Vis/skjul meny Yingchi Blog

理解 Golang Context 机制

在使用 Golang 的一些框架的时候,比如 Gin,每一个请求的 Handler 方法总是需要传递进去一个 context 对象,然后很多请求数据,比如请求参数,路径变量等都可以从中读出来,其实在这个使用过程中已经大体理解了这个 context 是个什么东西,但是对于其中的一些细节包括具体的使用方式还是缺乏了解,因此本文就针对 golang 里面的 context 概念进行简单的探讨。

Goroutine 并发控制的几种方式

我们知道 Golang 是一门擅长高并发的编程语言,可以通过 Goroutine 快速地创建并发任务,但是如何有效地管理这些执行的 Goroutine 是一个值得思考的问题。通常我们有下面几种方式实现 Goroutine 的控制:

  • 使用 WaitGroup,根 goroutine 通过 add() 来记录已经开启的 Goroutine 数量,通过 wait() 来等待执行任务的 goroutine 的 done(),实现同步的工作;
  • 使用 for/select + stop channel,通过向 stop channel 中传递 stop signal 实现 goroutine 的结束;
  • 使用 Context, 可以控制具有复杂层级关系的 goroutine 任务,此时使用前两种方式实现会比较复杂,使用 context 会更优雅;

Context 原理概述

Goroutine 的创建和调用关系往往是有着层级关系的,顶部的 Goroutine 应有办法主动关闭其下属的 Goroutine 的执行。为了实现这种关系,Context 结构也应该像一棵树,叶子节点须总是由根节点衍生出来的。

第一个创建 Context 的 goroutine 被称为 root 节点:root 节点负责创建一个实现 Context 接口的具体对象,并将该对象作为参数传递至新拉起的 goroutine 作为其上下文。下游 goroutine 继续封装该对象并以此类推向下传递。

Context 可以安全的被多个 goroutine 使用。开发者可以把一个 Context 传递给任意多个 goroutine 然后 cancel 这个 context 的时候就能够通知到所有的 goroutine。

Context 接口源码

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}
  • Done 方法在 Context 被取消或超时时返回一个 close 的 channel,close 的 channel 可以作为广播通知,告诉给 context 相关的函数要停止当前工作然后返回。

  • Err 方法返回 context 为什么被取消。

  • Deadline 返回 context 何时会超时。

  • Value 返回 context 相关的数据

根 Context 创建方法

顶部的Goroutine应有办法主动关闭其下属的Goroutine的执行(不然程序可能就失控了)。为了实现这种关系,Context结构也应该像一棵树,叶子节点须总是由根节点衍生出来的。

有两种方式创建根 Context:

  1. context.Background()
  2. context.TODO()

context.Background()

创建根 context 的第一种方式。

在顶层 goroutine 中通过调用 context.Background() 可以返回一个空 Context,这个 Context 是 所有 Context 的 root,不能够被cancel。

ctx, cancel := context.WithCancel(context.Background())

context.TODO()

创建根 context 的第二种方式。

一般情况使用 Background() 方法创建根 context。TODO() 用于当前不确定使用何种 context,留待以后调整。

注意:不要传递 nil 的 context,在不确定使用何种 context 的时候应该使用 context.TODO()

子 Context 派生方法

父 context 被 cancel,那么它的派生 context 都会收到 cancel 信号,即 Done() 返回的 channel 读到数据。

有四种方法派生 context :

  1. func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  2. func WithValue(parent Context, key, val interface{}) Context
  3. func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
  4. func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

context.WithCancel()

最常用的一种 context 派生方式,接收一个父 context(可以是 background context,或其它 Context),返回一个派生的 context 以及一个用于控制的 cancel 函数对象。

WithCancel 返回一个继承的 context,这个 context 在父 context 的 Done 被关闭时关闭自己的 Done 通道,或者在自己被 Cancel 的时候关闭自己的 Done。(注意:读关闭的 channel 返回类型零值

WithCancel 同时还返回一个取消函数 cancel,这个 cancel 用于取消当前的 Context,在父任务执行 cancel() 时,接收 context 的所有 goroutine 会从 Done() 返回的通道中读取到值从而退出。

func job() {
    ctx, cancel := context.WithCancel(context.Background())
    go doSomething(ctx)

    time.Sleep(5 * time.Second)
    cancel()
}

func doSomething(ctx context.Context) {
    for {
        time.Sleep(1 * time.Second)
        select {
        case <-ctx.Done():
            fmt.Println("done")
            return
        default:
            fmt.Println("working")
        }
    }
}

context.WithValue()

派生一个携带信息的 context 用于传递。

比如在 Request 中携带认证信息,携带用户数据等。

WithValue(parent Context, key, val interface{}) 方法的参数包含三个部分:

  • parent,用于派生子 context 的父 context;
  • key,携带信息的 key,interface{} 类型;
  • value,携带信息的 value,interface{} 类型,通常在接收到信息后通过断言(.(T))将 value 转换成正确的类型使用;

接收 context 携带的信息可以使用 ctx.Value(K) 接收到 value(interface{}类型

context.WithTimeout()

派生一个带有超时机制的 context。

达到 Timeout 时长后,该 context 以及该 context 的子 context 会收到 cancel 信号退出。

当然,如果在 Timeout 时长内调用 cancel,则会提前发送 cancel 信号退出。

ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)

context.WithDeadline()

派生一个带有绝对时限的 context,与 WithTimeout() 作用基本相同,仅仅是时间设定方式上不同。

达到 deadline 设定的时间后,该 context 以及该 context 的子 context 会收到 cancel 信号退出。

当然,如果在 deadline 之前调用 cancel,则会提前发送 cancel 信号退出。

ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(5*time.Second))

层级 Context 间的传递与控制

  • 生命周期:Context 对象的生命周期一般仅为一个请求的处理周期。即针对一个请求创建一个 Context 变量(它为 Context 树的根);在请求处理结束后,撤销此 ctx 变量,释放资源。
  • 传递方式:每次创建一个 Goroutine,要么将原有的 Context 传递给 Goroutine,要么创建一个子 Context 并传递给 Goroutine。
  • 安全读写:Context能灵活地存储不同类型、不同数目的值,并且使多个 Goroutine 安全地读写其中的值。
  • 控制权:当通过父 Context 对象创建子 Context 对象时,可同时获得子 Context 的一个 Cancel 函数对象,这样就获得了对子任务的控制权。

使用原则

  • 传递 Context 时,不应把 Context 放入 struct,而应该显式地传入函数,并且放在参数列表第一个位置,通常命名为 ctx;
  • 不要传递 nil 的 Context,在不确定的时候应该传递 context.TODO()
  • 使用 context 的 Value 相关方法时只应该用于传递和请求相关的元数据,不要用它传递一些可选参数;
  • 同一个 context 可以传递到不同的 goroutine 中,且在多个 goroutine 可以安全访问;

参考