GoLang之Context基础篇

GoLang之Context基础篇

注:本文基于Windos系统上Go SDK v1.18进行讲解
“一个接口,四种方法,六个函数”

1.一个接口

golang的context包定义了Context类型,根据官方文档的说法,该类型被设计用来在API边界之间以及过程之间传递截止时间、取消信号及其他与请求相关的数据。Context实际上是一个接口,提供了4个方法:
1.Deadline用来返回ctx的截止时间,ok为false表示没有设置。达到截止时间的ctx会被自动Cancel掉;
2.如果当前ctx是可取消的,Done返回一个chan用来监听,否则返回nil。当ctx被Cancel时,返回的chan会同时被close掉,也就变成“有信号”状态;
3.如果ctx已经被canceled,Err会返回执行Cancel时指定的error,否则返回nil;
4.Value用来从ctx中根据指定的key提取对应的value;

//context/context.go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

2.四种实现

golang对Context接口的具体实现基于如下几种类型:
1.emptyCtx类型只是实现了Context接口,几个方法都只是简单的返回nil、false等,实际上什么也不做。Background和TODO返回的Context,实际上都是emptyCtx;
2.cancelCtx类型可以说是最核心的一个类型,它实现了Cancel操作和信号机制,以及Context父子关系关联,从而支持在父Context Cancel时同步Cancel所有子Context;
3.timerCtx类型用来支持Deadline、Timeout,Cancel操作依赖于内置的cancelCtx;
4.valueCtx类型用来支持key、val打包,结合内置的Context字段,实际上构造了一个单链表节点;

//context/context.go
type emptyCtx int

type cancelCtx struct {
    Context
    mu       sync.Mutex            // 保护其他字段
    done     chan struct{}         // 延迟创建,被第一个cancel close
    children map[canceler]struct{} // 被第一个cancel设为nil
    err      error                 // 被第一个cancel赋值(非nil)
}

type timerCtx struct {
    cancelCtx
    timer *time.Timer // 被cancelCtx.mu保护(并发保护)
    deadline time.Time
}

type valueCtx struct {
    Context
    key, val interface{}
}

3.六个函数

3.1六个函数

为了方便我们使用Context,context包还实现了一组函数:

//context/context.go
func Background() Context
func TODO() Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

3.2Background

//context/context.go
var (
  background = new(emptyCtx)
  todo       = new(emptyCtx)
)

func Background() Context {
  return background
}

Background函数会创建一个没有Deadline、没有Value,也不能被Cancel的emptyCtx。通常在一个请求的初始化阶段用Background()创建最顶层的根Context。

ctx := context.Background()

ctx是Context类型,也就是一个非空接口,它的结构如下图所示:

在这里插入图片描述

3.3TODO

//context/context.go
func TODO() Context {
  return todo
}

TODO函数和Background一样,也会创建一个emptyCtx,官方文档建议在"本来应该使用外层传递的ctx,而外层却没有传递"的地方使用,就像函数名的含义一样,留下一个TODO。

3.4WithCancel

//context/context.go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel函数会基于传入的parent创建一个可以Cancel的ctx,与cancel函数一起返回。调用cancel函数就会将这个新的ctx Cancel掉,所有基于此ctx创建的子孙Context也会一并被Cancel掉。

func main() {
    var wg sync.WaitGroup
    ctx := context.Background()
    ctx1, cancel = context.WithCancel(ctx)

    wg.Add(1)
    go func() {
        defer wg.Done()
        tick := time.NewTicker(300 * time.Millisecond)
        for {
            select {
            case <-ctx1.Done():
                fmt.Println(ctx1.Err())
                return
            case t := <-tick.C:
                fmt.Println(t.Nanosecond())
            }
        }
    }()
    time.Sleep(time.Second)
    cancel()
    wg.Wait()
}

上面的示例把Background()获取的ctx包装为可Cancel的ctx1,它的结构如下图所示:(示例中拆分成ctx和ctx1单纯为了图好画-_-!)

在这里插入图片描述

通过ctx1.Done()获取一个channel,监听这个channel可以获得ctx1的Cancel通知,还可以通过ctx.Err()获取ctx1被取消时写入的错误信息。

3.5WithDeadline

//context/context.go
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline内部会创建一个可以Cancel的ctx,并且为它设置一个超时时间,然后与一个cancel函数一并返回。如果用户代码没有主动调用cancel函数,则ctx会在超时时间到达后自动Cancel。

如果需要给任务设定一个截止时间,就可以用WithDeadline创建一个timerCtx。用wg来等待goroutine返回,若没有达到截止时间就完成了任务,defer cancel()也会把ctx2取消掉。


func main() {
    var wg sync.WaitGroup
    ctx := context.Background()
    ctx2, cancel := context.WithDeadline(ctx,time.Now().Add(time.Second))
    defer cancel()

    wg.Add(1)
    go func() {
        defer wg.Done()
        tick := time.NewTicker(300 * time.Millisecond)
        for {
            select {
            case <-ctx2.Done():
                fmt.Println(ctx2.Err())
                return
            case t := <-tick.C:
                fmt.Println(t.Nanosecond())
            }
        }
    }()
    wg.Wait()
}

上面的示例中传递给WithDeadline函数的,是一个没有截止时间且不可取消的Context。WithDeadline内部会基于ctx构建一个cancelCtx,所以ctx2的动态类型是*timerCtx结构如下图所示:

在这里插入图片描述

如果WithDeadline函数接收到的是一个有截止时间的Context,那就要比较一下这两个截止时间了。例如下面再基于ctx2创建一个Context:

ctx3, cancel := context.WithDeadline(ctx2, time.Now().Add(time.Second*2))

如果ctx3要设定的截止时间早于ctx2,那么就会构造一个timerCtx,并且timerCtx.cancelCtx是基于ctx2构建的。

在这里插入图片描述

如果ctx3要设定的截止时间比ctx2还晚,那么就没必要给它加定时器和截止时间了。只会用WithCancel函数基于ctx2构造一个cancelCtx,所以ctx3的动态类型是*cancelCtx。

在这里插入图片描述

上面的ctx3是基于ctx2创建的,而ctx2又是基于ctx创建的。这样层层包装的结果就是,这些Context会形成树状结构,子节点是基于父节点的包装,每个节点都可能有零个或多个子节点。

在这里插入图片描述

对于可取消的Context而言,也就是实现了context.canceler接口的类型,还有一个关键点,就是它们会被注册到距离当前Context节点最近的、可取消的祖先节点中。注册位置就在cancelCtx结构体的children map中。而*cancelCtx和*timerCtx都实现了canceler接口,所以ctx3会被注册到ctx2中。

在这里插入图片描述

这样如果ctx2先取消,就可以根据children map这里的记录,取消自己子节点中所有可Cancel的Context。

在这里插入图片描述

3.6WithTimeout

//context/context.go
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeout只是WithDeadline的一个wrapper,接受一个time.Duration类型的参数,使用当前时间加上这个时间段来设置Deadline。

3.7WithValue

//context/context.go
func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

WithValue将传入的parent Context和keyval打包成一个新的ctx,再借助valueCtxValue方法就可以通过Context传递数据了。

使用Context传递数据要特别注意:
1.Context是本着不可改变(immutable)的模式设计的,所以不要试图修改Context里保存的数据。
2.为了避免后续包装的键值对覆盖先前的值,最好不要直接使用string、int这类基础类型,而是用自定义类型包装一下。
下面我们来测试一下上述第二点:


package main

import (
    "context"
    "fmt"
)

var keyA string = "keyA"
var keyC string = "keyA"

func main() {
    ctx := context.Background()  
    ctx1 := context.WithValue(ctx, keyA, "valueA")
    fmt.Println("In ctx:")
    fmt.Println("keyA => ", ctx1.Value(keyA))

    ctx2 := context.WithValue(ctx1, keyC, "valueC")
    fmt.Println("In ctx2:")
    fmt.Println("keyA => ", ctx2.Value(keyA))
    fmt.Println("keyC => ", ctx2.Value(keyC))    
    return

运行结果如下,ctx2中用相等的key覆盖了ctx1中的值。

In ctx:
keyA =>  valueA
In ctx2:
keyA =>  valueC
keyC =>  valueC

我们先来看一下ctx1和ctx2的结构,valueCtx中key和val都是interface{}类型,空接口类型的结构是一个动态类型元数据指针,外加一个指向动态值的unsafe.Pointer。

在这里插入图片描述

再结合valueCtx.Value方法看一下键值对查找过程:

func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key)
}

可以看到,通过ctx2.Value("keyA")查找val时,通过比较key,会直接锁定ctx2中保存的val,所以发生了Context间Value的覆盖。可以通过自定义key类型来避免这个问题。

type akeytype string
type ckeytype string
var keyA akeytype = "keyA"
var keyC ckeytype = "keyA"
......

执行结果如下:


In ctx:
keyA =>  valueA
In ctx2:
keyA =>  valueA
keyC =>  valueC

这一次通过ctx2.Value(keyA)查找val时,因为ctx2中存储的key与keyA类型不相符,所以会把查找请求委托给ctx2的parent,也就是ctx1。通过这样一级一级的向上查找,实际上形成了一个链表结构。

在这里插入图片描述

WithCancel、WithDeadline和WithValue函数,内部就是分别构造了cancelCtx、timerCtx和valueCtx这3种结构,最终所有的ctx将构成类似一棵树的结构,在根节点执行cancel就可以Cancel整个棵树,所以非常适合用来控制请求处理。
Go语言的http和sql包中都有Context的应用,这些待到“应用篇”再接着聊~


版权声明:本文为weixin_52690231原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。