概要

Go 1.7 标准库引入 context,中文译作”上下文“,Go 语言的 context 包要用来在 Goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、K-V 等。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。

为什么要有 context ?

在 Go 里,我们不能直接杀死协程,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。

context 用来解决 Goroutine 之间退出通知、元数据传递的功能。

使用场景

传递共享数据

例如,第一次调用 process 函数时,ctx 是一个空的 context,自然取不出来 traceID。第二次,通过 WithValue 函数创建了一个 context,并赋上了 traceID 这个 key,自然就能取出来传入的 value 值。 现实场景中可能是从一个 HTTP 请求中通过获取到的 Request ID,一般在中间层将请求 ID 保存在 context 中,后续可以取出来在使用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
    ctx := context.Background()
    process(ctx)

    ctx = context.WithValue(ctx, "traceID", "123456")
    process(ctx)
}

func process(ctx context.Context) {
	traceId, ok := ctx.Value("traceID").(string)
	if ok {
		fmt.Printf("process over. traceID=%s\n", traceId)
	} else {
		fmt.Printf("process over. no traceID\n")
	}
}

取消耗时操作

超时取消

context.WithCancel 或者 context.WithTimeout 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数(CancelFunc)。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
	defer cancel()
	foo(ctx)
}

func foo(ctx context.Context) {
	ctx, cancel := context.WithTimeout(ctx, time.Second*4)
	defer cancel()

	resp := make(chan struct{}, 1)
	// 处理逻辑
	go func() {
		// 处理耗时
		time.Sleep(time.Second * 10)
		resp <- struct{}{}
	}()

	// 超时机制
	select {
	case <-ctx.Done():
		fmt.Println("ctx timeout")
		fmt.Println(ctx.Err())
	case v := <-resp:
		fmt.Printf("request completed, result: %v\n", v)
	}
	return
}

设计原理

Context 是一个 interface,在 Go 语言里面,interface 是一个使用非常广泛的结构,它可以接纳任何类型。

1
2
3
4
5
6
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline() 可以获取设置的截止时间。
  • Done() 方法返回一个只读的 chan,类型为 struct{},在 Goroutine 中,如果该方法返回的 chan 可以读取,则意味着 parent context 已经发起了取消请求,收到这个信号后,就应该做清理操作,然后退出 Goroutine,释放资源。之后,Err 方法会返回一个错误,告知为什么 Context 被取消。
  • Err() 返回取消的错误原因,因为什么 Context 被取消。
  • Value(key interface{}) interface{} 获取该 Context 上绑定的值,是一个键值对,所以要通过一个 Key 才可以获取对应的值,这个值一般是线程安全的。

context 包中有四种结构实现了 Context interface, 分别是 emptyCtx, cancelCtx, timerCtx, valueCtx,细看这类数据结构设计思路, 只记录父亲是谁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// emptyCtx 永远不会被取消的 context
type emptyCtx int

// cancelCtx 可被取消的 context
type cancelCtx struct { Context ... }

// timerCtx 带计时器和截止日期的 cancelCtx
type timerCtx struct { cancelCtx ... }

// valueCtx 储存键值对 context
type valueCtx struct {
    Context
    key, val interface{}
}

cancelCtx 的具体实现如下:

1
2
3
4
5
6
7
8
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     chan struct{}         // created lazily, closed by first cancel call
	children map[canceler]struct{} // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call
}

取消函数实现如下,首先会关闭 done 的 channel,通知其他协程,然后递归取消子节点,再从父节点中移除自己。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
	// 关闭 channel,通知其他协程
	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)
	}
}

timerCtx 的 cancel 是在 cancelCtx 上的一个封装,cancel 会先调用自身对象的 cancel 方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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.Background 只应用在最高等级,作为所有派生 context 的根。
  • context.TODO 应用在不确定要使用什么的地方,或者当前函数以后会更新以便使用 context。
  • context 取消是建议性的,这些函数可能需要一些时间来清理和退出。
  • context.Value 应该很少使用,它不应该被用来传递可选参数。这使得 API 隐式的并且可以引起错误。取而代之的是,这些值应该作为参数传递。
  • 不要将 context 存储在结构中,在函数中显式传递它们,最好是作为第一个参数。
  • 永远不要传递不存在的 context。相反,如果您不确定使用什么,使用一个 TODO context。
  • Context 结构没有取消方法,因为只有派生 context 的函数才应该取消 context。