Go Context底层原理

前言

go 中常常使用 context 来作为协程之间传递消息。使用场景常常有:

  • 传递一个任务的元信息:在微服务中,常常将 traceID 写入在上下文的键值对中,用于追踪一个请求的调用链。
  • 传递取消信号:父协程并发开启子协程执行任务,但是父协程也许会检测到什么信号(时间截止、任务结束),这个时候父协程可以使用 context 的取消功能结束所有子协程。

在使用的过程中,我常常有疑问:

  • 不同函数之间传递的 context 对象是值引用还是指针引用
  • 父子协程同时访问 context 并发安全吗
  • 父协程执行取消函数做了什么?子协程开启的孙协程也会终止吗?

带着问题,我阅读了 context 的源码(go 版本 1.19),了解了 context 的设计。

类型

上下文一共有以下 4 种类型:

  • emptyCtx:最基础的 context,只是表征一个类型
  • valueCtx:带键值对的context
  • cancelCtx:带取消功能的context
  • timerCtx:带定时取消功能的 context

在这 4 中类型中,最重要的是 valueCtxcancelCtx,两者可以构建 context 包的几乎所有功能。

context 包定义了一组接口:

1
2
3
4
5
6
type Context interface {
	Deadline() (deadline time.Time, ok bool) // 用于子协程获取deadline
	Done() <-chan struct{} // 用于子协程获取管道的取消信号
	Err() error // 返回上下文被取消或超时时的错误信息
	Value(key any) any // 用于获取键值对
}

只要实现这一组接口就算就能算作 Context 类型。

Background

Background :一个不懂 context 使用方法的新手用来作为调用函数的默认 context 参数的函数.

新手与context

从规范来讲上,Backgroud 方法返回的 context 应该作为一个任务(请求)的根上下文。源码中的 Backgroud 方法返回一个初始化时生成的 emptyCtx

1
2
3
4
5
6
7
8
9
type emptyCtx int

var (  
    background = new(emptyCtx)  
)  
 
func Background() Context {  
    return background  
}

emptyCtx 在后文中的键值对 context 中实际上是作为子 context 往上回溯所有 context 的时的终结标志

valueCtx

先来看带键值对的 ctx,因为带取消功能的 ctx 依赖带键值对的 ctx。

结构如下,实际上就是一个链表:

1
2
3
4
type valueCtx struct {  
    Context  // 指向父context的指针
    key, val any  // 键值对
}

如何获得一个在现有 ctx 基础之上添加一个新键值对的 ctx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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}
}

创建一个新的 valueCtx,valueCtx.Context 指向父 ctx。在每个子任务的眼中,ctx 是一个链表,但是在整体视图中,ctx 组成的关系是一颗逆向树。

valueCtx整体视图

如何从 ctx 中获取存储的键值对信息:

 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
32
func value(c Context, key any) any {  
    for {  
       switch ctx := c.(type) {  
       case *valueCtx:  
          if key == ctx.key {  
             return ctx.val  
          }  
          c = ctx.Context  
       case *cancelCtx:  
          if key == &cancelCtxKey {  
             return c  
          }  
          c = ctx.Context  
       case *timerCtx:  
          if key == &cancelCtxKey {  
             return &ctx.cancelCtx  
          }  
          c = ctx.Context  
       case *emptyCtx:  
          return nil  
       default:  
          return c.Value(key)  
       }    
    }
}

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

可以先假设所有的 ctx 都是由 emptyCtx 添加键值对形成,那么获取目标键值对的过程就是从树的底部(子任务)往根节点遍历链表的过程,如果中途获取到目标键值对则返回,如果没有最后会遇到 emptyCtx,终止遍历返回空。(这反映了 Background 方法的重要性)

另外还值得一提的是,valueCtx 并没有设计更改设置好的键的值的 api,这是为了并发安全。

现在可以解答关于 valueCtx 的疑问了:

  1. 不同函数之间传递的 ctx 是值引用还是指针引用? 指针引用
  2. 父子协程同时访问 context 并发安全吗? 由于 valueCtx 是一个不可变对象,一旦构建之后,不能更改原有键的值,所以父子协程同时遍历键值对的时候不会设计到读写冲突。

cancelCtx

结构:

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

	mu       sync.Mutex            // protects following fields
	done     atomic.Value          // of 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
}

如何获得一个在现有 ctx 基础之上添加取消控制的 ctx:

1
2
3
4
5
6
7
8
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) }
}

创建一个 cancelCtx,然后关键是 propagateCancel(parent, &c)

 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
32
33
34
35
36
37
38
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}

	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

	if p, ok := parentCancelCtx(parent); ok {
		p.mu.Lock()
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

首先检查父 ctx 是否结束,然后调用 parentCancelCtx(parent):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
	done := parent.Done()
	if done == closedchan || done == nil {
		return nil, false
	}
	p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
	if !ok {
		return nil, false
	}
	pdone, _ := p.done.Load().(chan struct{})
	if pdone != done {
		return nil, false
	}
	return p, true
}

可以看到还是先检查父 ctx 是否结束(为了并发安全),然后调用 parent.Value(&cancelCtxKey).(*cancelCtx),这一调用可以理解为将找到最近(向根节点遍历)的带有取消功能的 ctx。如果没有,说明创建的 cancelCtx 并不需要向父 ctx (整个链路)注册,因为创建的 cancelCtx 没有任何函数、协程控制。如果有,那么需要将指向最近的带有取消功能的 ctx 指针返回,用于注册创建的 cancelCtx

看到这里,可以整理一下 cancelCtx 的整体视图(下图),cancelCtx 的遍历动作视图仍是 valueCtx 的配图,但是 cancelCtx 的控制动作是自顶向下的:

cancelCtx的整体视图

父协程是如何通过 ctx 控制管辖的子协程的?

ctx, cancel := context.WithCancel(context.Background()) 创建子 ctx 的同时,会分发给父协程一个 cancel 函数。父协程可以调用这一函数取消所有子协程。cancel 的源码如下:

 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
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
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	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)
	}
}

对于第一个参数 removeFromParent,只有当当前 ctx 主动调用分发的 cancel 函数,才会为 true(分发的 cancel 函数:func() { c.cancel(true, Canceled) })。这一参数为 true,将会在最近的带有取消功能的 ctx 注销当前的 ctx。比如,在 cancelCtx 整体视图中,如果节点 2 调用 cancel 函数,节点 1 中的 map 将删除节点 2 。调用 cancel 函数,传到的控制信息是自顶向下的(深度优先搜索),会给所有的节点发送取消信号,接受到取消信号的 cancelCtx 会继续向下传到取消信号……

总结

以上便是 context 包的主要源码解读,主要是分析了 valueCtxcancelCtx 的视图,解答了开始提出的几个问题。

0%