Go 中 Context 包

上下文 context.Context 在 Go 语言中被用来设置截止日期、同步信号,传递请求相关值的结构体。上下文与 goroutine 有比较密切的关系,是 Go 语言中的独特设计。

使用场景

如下代码,每次请求,Handler 会创建一个 goroutine 来为其提供服务,而且连续请求3次,request 的地址也是不同的:

func main()  {
    http.HandleFunc("/", SayHello) // 设置访问的路由

    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
    fmt.Println(&request)
    writer.Write([]byte("Hello world"))
}

========================================================
$ curl http://localhost:8080/
0xc00012a030
0xc000010018
0xc000010028

而每个请求对应的 Handler,常会启动额外的的 goroutine 进行数据查询或 RPC 调用等。

而当请求返回时,这些额外创建的 goroutine 需要及时回收。而且,一个请求对应一组请求域内的数据可能会被该请求调用链条内的各 goroutine 所需要。

现在对上面代码在添加一点东西,当请求进来时,Handler 创建一个监控 goroutine,这样就会每隔1s打印一句 Current request is in progress

func main()  {
    http.HandleFunc("/", SayHello) // 设置访问的路由

    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
    fmt.Println(&request)

    go func() {
        for range time.Tick(time.Second) {
            fmt.Println("Current request is in progress")
        }
    }()

    time.Sleep(2 * time.Second)
    writer.Write([]byte("Hello world"))
}

这里假定请求需要耗时 2s,在请求 2s 后返回,原本是期望监控 goroutine 在打印 2 次 Current request is in progress后即停止。但运行发现,监控 goroutine 打印2次后,其仍不会结束,而会一直打印下去。

问题出在创建监控 goroutine 后,未对其生命周期作控制,下面使用 context 作一下控制,即监控程序打印前需检测 request.Context() 是否已经结束,若结束则退出循环,即结束生命周期:

func main()  {
    http.HandleFunc("/", SayHello) // 设置访问的路由

    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
    fmt.Println(&request)

    go func() {
        for range time.Tick(time.Second) {
            select {
            case <- request.Context().Done():
                fmt.Println("request is outgoing")
                return
            default:
                fmt.Println("Current request is in progress")
            }
        }
    }()

    time.Sleep(2 * time.Second)
    writer.Write([]byte("Hi"))
}

基于以上的需求,context 包应用而生。context 包可以提供一个请求从 API 请求边界到各 goroutine 的请求域数据传递、取消信号及截至时间等能力。

相关原理

在 Go 语言中 context 包允许传递一个 “context” 到程序当中。 Context 使用超时或截止日期(deadline)或通道来指示停止运行和返回。例如,如果正在执行一个 web 请求或运行一个系统命令,定义一个超时对生产级系统通常是个好主意。因为,如果依赖的 API 运行缓慢,我们不希望在系统上备份(back up)请求,因为它可能最终会增加负载并降低所有请求的执行效率。导致级联效应。这是超时或截止日期 context 就派上了用场。

相关接口方法

context.Context 是 Go 语言在 1.7 版本中引入标准库的接口,该接口定义了四个需要实现的方法,其中包括:

  • Deadline :返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  • Done :返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后关闭,多次调用 Done 方法会返回同一个 Channel;
  • Err :返回 context.Context 结束的原因,它只会在 Done 返回的 Channel 被关闭时才会返回非空的值;
    • 如果 context.Context 被取消,会返回 Canceled 错误;
    • 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  • Value :从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
type Context interface {
    Deadline() (deadline time.Time, ok bool)

    // 注意这是一个单向信道
    // 
    // 一个使用的 Demo:
    // func Stream(ctx context.Context, out chan<- Value) error {
    //  	for {
    //  		v, err := DoSomething(ctx)
    //  		if err != nil {
    //  			return err
    //  		}
    //  		select {
    //  		case <-ctx.Done():
    //  			return ctx.Err()
    //  		case out <- v:
    //  		}
    //  	}
    //  }
    Done() <-chan struct{}
    
    Err() error
    
    Value(key interface{}) interface{}
}

设计原理

Go 语言中的每一个请求的都是通过一个单独的 Goroutine 进行处理的,HTTP/RPC 请求的处理器往往都会启动新的 Goroutine 访问数据库和 RPC 服务,这时候可能会创建多个 Goroutine 来处理一次请求,而 Context 的主要作用就是在不同的 Goroutine 之间同步请求特定的数据、取消信号以及处理请求的截止日期。

每一个 Context 都会从最顶层的 Goroutine 一层一层传递到最下层,这也是 Golang 中上下文最常见的使用方式,如果没有 Context,当上层执行的操作出现错误时,下层其实不会收到错误而是会继续执行下去。

当最上层的 Goroutine 因为某些原因执行失败时,下层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当正确地使用 Context 时,就可以在下层及时停掉无用的工作减少额外资源的消耗。

这其实就是 Golang 中上下文的最大作用,在不同 Goroutine 之间对信号进行同步避免对计算资源的浪费,与此同时 Context 还能携带以请求为作用域的键值对信息。

例子

在下面的例子中,首先设置过期时间为 500 毫秒,设置超时时间为 1秒:

func main()  {
    ctx,cancel := context.WithTimeout(context.Background(),1*time.Second)
    defer cancel()
    go HelloHandle(ctx,500*time.Millisecond)
    select {
    case <-ctx.Done():
        fmt.Println("Hello handle ",ctx.Err())
    }
    
    time.Sleep(2 * time.Second)

}

func HelloHandle(ctx context.Context,duration time.Duration) {
    select {
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    case <-time.After(duration):
        fmt.Println("process request with",duration)
    }
}

因为有足够的时间处理修改请求,所以运行后的打印结果为:

process request with 500ms
Hello handle  context deadline exceeded

HelloHandle 函数并没有进入超时的 select 分支,但是 main 函数的 select 却会等待 context.Context 的超时并打印出 Hello Handle context deadline exceeded。如果将处理请求的时间增加至 2000 ms,程序就会因为上下文过期而被终止:

Hello handle  context deadline exceeded
context deadline exceeded

使用和相关源码分析

创建 context

context 包允许通过以下方式创建和获得 context:

  • context.Background():这个函数返回一个空 context。这只能用于高等级(在 main 或顶级请求处理中)。
  • context.TODO():这个函数也是创建一个空 context。也只能用于高等级或当不确定使用什么 context,或函数以后会更新以便接收一个 context 。这意味您(或维护者)计划将来要添加 context 到函数。

其实这两个函数都是通过 new(emptyCtx) 语句初始化的,它们是指向私有结构体 context.emptyCtx 的指针,这是最简单、最常用的上下文类型:

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

type emptyCtx int

// emptyCtx 实现了 context 接口(都是空实现)
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

func (e *emptyCtx) String() string {
    switch e {
    case background:
        return "context.Background"
    case todo:
        return "context.TODO"
    }
    return "unknown empty Context"
}

从源代码来看,context.Backgroundcontext.TODO 函数其实也只是互为别名,没有太大的差别。它们只是在使用和语义上稍有不同:

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生(Derived)出来。
  • context.TODO 应该只在不确定应该使用哪种上下文时使用;

在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。

context 的继承衍生

context 包还提供了 With 系列的函数作为根 context 的衍生:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个 With 函数,接收的都有一个 partent 参数,就是父 Context,也就是说需要基于这个父 Context 创建出子 Context,这种方式可以理解为子 Context 对父 Context 的继承,也可以理解为基于父 Context 的衍生。

通过这些函数,就创建了一颗 Context 树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个。

WithCancel 函数,传递一个父 Context 作为参数,返回子 Context,以及一个取消函数用来取消 Context。 WithDeadline 函数,和 WithCancel 差不多,它会多传递一个截止时间参数,意味着到了这个时间点,会自动取消 Context,当然也可以不等到这个时候,可以提前通过取消函数进行取消。

WithTimeoutWithDeadline 基本上一样,这个表示是超时自动取消,是多少时间后自动取消 Context 的意思。

WithValue 函数和取消 Context 无关,它是为了生成一个绑定了一个键值对数据的 Context,这个绑定的数据可以通过 Context.Value 方法访问到。

前三个函数都返回一个取消函数 CancelFunc,这是一个函数类型,它的定义非常简单。

// CancelFunc 不等待工作停止。
// 多个 goroutine 可以同时调用 CancelFunc。
// 第一次调用后,随后对 CancelFunc 的调用将不执行任何操作。
type CancelFunc func()

这就是取消函数的类型,该函数可以取消一个 Context,以及这个节点 Context 下所有的所有的 Context,不管有多少层级。

下面逐一分析每一个方法。

WithValue

context.WithValue 函数能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型:

func WithValue(parent Context, key, val interface{}) 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}
}

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

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

此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。不建议使用 context 值传递关键参数,而是函数应接收签名中的那些值,使其显式化。

context.valueCtx 结构体会将除了 Value 之外的 ErrDeadline 等方法代理到父上下文中,它只会响应 context.valueCtx.Value 方法。如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到在某个父上下文中返回 nil 或者查找到对应的值。

具体使用如下:

type key string

func main()  {
    ctx := context.WithValue(context.Background(),key("k"),"v")
    Get(ctx,key("k"))
    Get(ctx,key("ks"))
}

func Get(ctx context.Context,k key)  {
    if v, ok := ctx.Value(k).(string); ok {
        fmt.Println(v)
    }
}

key 不建议使用 string 或其他内置类型,所以建议自定义 key 类型

WithCancel

此函数创建从传入的父 context 派生的新 context。父 context 可以是后台 context 或传递给函数的 context。返回派生 context 和取消函数。只有创建它的函数才能调用取消函数来取消此 context。当然也可以传递取消函数,但是,强烈建议不要这样做。这可能导致取消函数的调用者没有意识到取消 context 的下游影响。可能存在源自此的其他 context,这可能导致程序以意外的方式运行。简而言之,永远不要传递取消函数。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

type cancelCtx struct {
    Context

    mu       sync.Mutex            // 用以保护下面的字段
    done     chan struct{}         // 懒加载创建,会在第一次调用 cancel 的时候关闭
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}
  • context.newCancelCtx 将传入的上下文包装成私有结构体 context.cancelCtx
  • context.propagateCancel 会构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消:
func propagateCancel(parent Context, child canceler) {
    done := parent.Done()
    if done == nil {
        return // 父上下文没有通道done,永远不会触发取消信号
    }

    select {
    case <-done:
        // 父上下文已被取消,取消 child 上下文
        child.cancel(false, parent.Err())
        return
    default:
    }

    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // 父上下文已经被取消
            child.cancel(false, p.err)
        } else {
            // 父上下文尚未取消,讲 child 加入到 parent 的 children 列表中,等待 parent 释放取消信号
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        // 默认情况下
        atomic.AddInt32(&goroutines, +1)
        // 运行一个 groutine 同时监听两个,当 parent 取消时调用 child.cancel 取消子上下文
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

上述函数总共与父上下文相关的三种不同的情况:

  1. parent.Done() == nil,也就是 parent 不会触发取消事件时,当前函数会直接返回;
  2. child 的继承链包含可以取消的上下文时,会判断 parent 是否已经触发了取消信号;
    • 如果已经被取消,child 会立刻被取消;
    • 如果没有被取消,child 会被加入 parentchildren 列表中,等待 parent 释放取消信号;
  3. 在默认情况下
    • 运行一个新的 Goroutine 同时监听 parent.Done()child.Done() 两个 Channel
    • parent.Done() 关闭时调用 child.cancel 取消子上下文;

context.propagateCancel 的作用是parentchild 之间同步取消和结束的信号,保证在 parent 被取消时,child 也会收到对应的信号,不会发生状态不一致的问题

另外,context.cancelCtx 结构体最重要的方法是 cancel,这个方法会关闭上下文中的 Channel 并向所有的子上下文同步取消信号:

// 实现了 canceler 接口
// 
// type canceler interface {
//	 cancel(removeFromParent bool, err error)
//	 Done() <-chan struct{}
// }
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    if c.done == nil {
        c.done = closedchan
    } else {
        close(c.done)
    }
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

WinCancel 的使用方法:

func main()  {
    ctx,cancel := context.WithCancel(context.Background())
    defer cancel()
    go Speak(ctx)
    time.Sleep(10*time.Second)
}

func Speak(ctx context.Context)  {
    for range time.Tick(time.Second){
        select {
        case <- ctx.Done():
            return
        default:
            fmt.Println("balabalabalabala")
        }
    }
}

这里使用 withCancel 创建一个基于 Background 的ctx,然后启动一个讲话程序,每隔1s说一话,main 函数在10s后执行 cancel,那么 speak 检测到取消信号就会退出。

WithDeadline

此函数返回其父项的派生 context,当截止日期超过或取消函数被调用时,该 context 将被取消。例如,可以创建一个将在以后的某个时间自动取消的 context,并在子函数中传递它。当因为截止日期耗尽而取消该 context 时,获此 context 的所有函数都会收到通知去停止运行并返回。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // 父 context 的截止日期早于传入参数的截止日期
        return WithCancel(parent)
    }
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    propagateCancel(parent, c)
    dur := time.Until(d)
    if dur <= 0 {
        c.cancel(true, DeadlineExceeded) // 当前时间已经过了设定的过期时间
        return c, func() { c.cancel(false, Canceled) }
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
        // 创建了一个定时器,当时间超过截止日期后定时器自动调用 cancel 方法
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

context.WithDeadline 也都能创建可以被取消的计时器上下文 context.timerCtx

context.WithDeadline 方法在创建 context.timerCtx 的过程中,判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel 方法同步取消信号。

context.timerCtx 结构体内部不仅通过嵌入了context.cancelCtx 结构体继承了相关的变量和方法,还通过持有的定时器 timer 和截止时间 deadline 实现了定时取消这一功能:

type timerCtx struct {
    cancelCtx
    timer *time.Timer // Under cancelCtx.mu.

    deadline time.Time
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
    return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
    c.cancelCtx.cancel(false, err)
    if removeFromParent {
        // Remove this timerCtx from its parent cancelCtx's children.
        removeChild(c.cancelCtx.Context, c)
    }
    c.mu.Lock()
    if c.timer != nil {
        // 停止持有的定时器以减少不必要的资源浪费
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}

context.timerCtx.cancel 方法不仅调用了 context.cancelCtx.cancel],还会停止持有的定时器减少不必要的资源浪费

使用例子:

func main() {
    now := time.Now()
    later, _ := time.ParseDuration("10s")

    ctx,cancel := context.WithDeadline(context.Background(),now.Add(later))
    defer cancel()
    go Monitor(ctx)

    time.Sleep(20*time.Second)
}

func Monitor(ctx context.Context) {
    select {
    case <- ctx.Done():
        fmt.Println(ctx.Err())
    case <-time.After(20*time.Second):
        fmt.Println("stop monitor")
    }
}

置一个监控 goroutine,使用 WithTimeout 创建一个基于 Background 的 ctx,其会当前时间的 10s 后取消。验证结果如下:10s,监控 goroutine 被取消了。

context deadline exceeded

WithTimeout

此函数类似于 context.WithDeadline。不同之处在于它将持续时间作为参数输入而不是时间对象。此函数返回派生 context,如果调用取消函数或超出超时持续时间,则会取消该派生 context。

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

使用示例:

func main() {

    ctx,cancel := context.WithTimeout(context.Background(),10*time.Second)
    defer cancel()
    go Monitor(ctx)

    time.Sleep(20*time.Second)
}

func Monitor(ctx context.Context) {
    select {
    case <- ctx.Done():
        fmt.Println(ctx.Err())
    case <-time.After(20*time.Second):
        fmt.Println("stop monitor")
    }
}

Context使用原则

  • context.Background 只应用在最高等级,作为所有派生 context 的根。
  • context 取消是建议性的,这些函数可能需要一些时间来清理和退出。
  • 不要把 Context 放在结构体中,要以参数的方式传递。
  • Context 作为参数的函数方法,应该把 Context 作为第一个参数,放在第一位。
  • 给一个函数方法传递 Context 的时候,不要传递 nil,如果不知道传递什么,就使用 context.TODO
  • Context 的 Value 相关方法应该传递必须的数据,不要什么数据都使用这个传递。context.Value 应该很少使用,它不应该被用来传递可选参数。这使得 API 隐式的并且可以引起错误。取而代之的是,这些值应该作为参数传递。
  • Context 是线程安全的,可以放心的在多个 goroutine 中传递。同一个 Context 可以传给使用其的多个 goroutine,且 Context 可被多个 goroutine 同时安全访问。
  • Context 结构没有取消方法,因为只有派生 context 的函数才应该取消 context。

Go 语言中的 context.Context 的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,虽然它也有传值的功能,但是这个功能我们还是很少用到。在真正使用传值的功能时我们也应该非常谨慎,使用 context.Context 进行传递参数请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

参考链接

详解Context包,看这一篇就够了!!!

Go语言设计与实现——6.1上下文Context