前言
go 中常常使用 context 来作为协程之间传递消息。使用场景常常有:
传递一个任务的元信息:在微服务中,常常将 traceID 写入在上下文的键值对中,用于追踪一个请求的调用链。
传递取消信号:父协程并发开启子协程执行任务,但是父协程也许会检测到什么信号(时间截止、任务结束),这个时候父协程可以使用 context 的取消功能结束所有子协程。
在使用的过程中,我常常有疑问:
不同函数之间传递的 context 对象是值引用还是指针引用
父子协程同时访问 context 并发安全吗
父协程执行取消函数做了什么?子协程开启的孙协程也会终止吗?
带着问题,我阅读了 context 的源码(go 版本 1.19),了解了 context 的设计。
类型
上下文一共有以下 4 种类型:
emptyCtx
:最基础的 context,只是表征一个类型
valueCtx
:带键值对的context
cancelCtx
:带取消功能的context
timerCtx
:带定时取消功能的 context
在这 4 中类型中,最重要的是 valueCtx
、cancelCtx
,两者可以构建 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 参数的函数.
从规范来讲上,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 组成的关系是一颗逆向树。
如何从 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
的疑问了:
不同函数之间传递的 ctx 是值引用还是指针引用?
指针引用
父子协程同时访问 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
的控制动作是自顶向下的:
父协程是如何通过 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 包的主要源码解读,主要是分析了 valueCtx
和 cancelCtx
的视图,解答了开始提出的几个问题。