Go 系列(二):context 源码分析
本文主要简单介绍了 Go 语言 (golang) 中的context
包。给出了 context 的基本用法和使用建议,并从源码层面对其底层结构和具体实现原理进行分析。
以下分析基于 Go 1.17.1
1. 概述
1.1 什么是 context
上下文 context.Context
在 Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。上下文与 Goroutine 有比较密切的关系,是 Go 语言中独特的设计,在其他编程语言中我们很少见到类似的概念。
context 用来解决 goroutine 之间退出通知、数据传递的功能。
注:这里的数据传递主要指全局数据,如 链路追踪里的 traceId 之类的数据,并不是普通的参数传递 (也非常不推荐用来传递参数)。
1.2 设计原理
因为context.Context
主要作用就是进行超时控制,然后外部程序监听到超时后就可以停止执行任务,取消 Goroutine。
网上有很多用 Context 来取消 Goroutine 的字眼,初学者 (比如笔者) 可能误会,以为 Context 可以直接取消 Goroutine。
实际,Context 只是完成了一个信号的传递,具体的取消逻辑需要由程序自己监听这个信号,然后手动处理。
Go 语言中的 Context 通过构建一颗 Context 树,从而将没有层级的 Goroutine 关联起来。如下图所示:
所有 Context 都依赖于 BackgroundCtx 或者 TODOCtx,其实这二者都是一个 emptyCtx,只是语义上不一样。
在超时或者手动取消的时候信号都会从最顶层的 Goroutine 一层一层传递到最下层。这样该 Context 关联的所有 Goroutine 都能收到信号,然后进入自定义的退出逻辑。
比如这里手动取消了 ctxB1,然后 ctxB1 的两个子 ctx(C1 和 C2) 也会收到取消信号,这样 3 个 Goroutine 都能收到取消信号进行退出了。
1.3 使用场景
最常见的就是 后台 HTTP/RPC Server。
在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据, 具体如下图:
而客户端一般不会无限制的等待,都会被请求设定超时时间,比如 100ms。
比如这里 GoroutineA 消耗 80ms,GoroutineB3 消耗 30ms,已经超时了,那么后续的 GoroutineCDEF 都没必要执行了,客户端已经超时返回了,服务端就算计算出结果也没有任何意义了。
所以这里就可以使用 Context 来在多个 Goroutine 之间进行超时信号传递。
同时引入超时控制后有两个好处:
- 1)客户端可以快速返回,提升用户体验
- 2)服务端可以减少无效的计算
2. 使用案例
2.1 WithCancel
返回一个可以手动取消的 Context,手动调用 cancel() 方法以取消该 context。
1 | // 启动一个 worker goroutine 一直产生随机数,知道找到满足条件的数时,手动调用 cancel 取消 ctx,让 worker goroutine 退出 |
2.2 WithDeadline & WithTimeout
可以自定义超时时间,时间到了自动取消 context。
其实 WithTimeout 就是对 WithDeadline 的一个封装:
1 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { |
1 | // 启动一个 worker goroutine 一直产生随机数,直到 ctx 超时后退出 |
在这个案例中,因为限制了超时时间,所以并不是每次都能找到满足条件的 r 值。
2.3 WithValue
可以传递数据的 context,携带关键信息,为全链路提供线索,比如接入 elk 等系统,需要来一个 trace_id,那 WithValue 就非常适合做这个事。
1 | // 通过 ctx 进行超时控制的同时,在 ctx 中存放 traceId 进行链路追踪。 |
为了进行超时控制,本就需要在多个 goroutine 之前传递 ctx,所以把 traceId 这种信息存放到 ctx 中是非常方便的。
3. 源码分析
Context 在 Go 1.7 版本引入标准库中,主要内容可以概括为:
- 1 个接口
- Context
- 4 种实现
- emptyCtx
- cancelCtx
- timerCtx
- valueCtx
- 6 个方法
- Background
- TODO
- WithCancel
- WithDeadline
- WithTimeout
- WithValue
整体类图如下:
3.1 1 个接口
1 | type Context interface { |
Context 是一个接口,定义了 4 个方法,它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。
- Done(): 返回一个 只读channel,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。
- Err(): 返回一个错误,表示 channel 被关闭的原因。例如是被取消,还是超时。
- Deadline(): 返回 context 的截止时间,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。
- Value(key):返回 key 对应的 value,是协程安全的
同时包中也定义了提供 cancel
功能需要实现的接口。这个主要是后文会提到的 “取消信号、超时信号” 需要去实现。
1 | // A canceler is a context type that can be canceled directly. The |
实现了上面定义的两个方法的 Context,就表明该 Context 是可取消的。源码中有两个类型实现了 canceler 接口:*cancelCtx 和 *timerCtx。注意是加了 * 号的,是这两个结构体的指针实现了 canceler 接口。
Context 接口设计成这个样子的原因:
“取消”操作应该是建议性,而非强制性
caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送“取消”信息,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法。
“取消”操作应该可传递
“取消”某个函数时,和它相关联的其他函数也应该“取消”。因此,Done() 方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。
3.2 4 种实现
为了更方便的创建 Context,包里定义了 Background 来作为所有 Context 的根,它是一个 emptyCtx 的实例。
3.2.1 emptyCtx
这也是最简单的一个 ctx
1 | type emptyCtx int |
空方法实现了 context.Context 接口,它没有任何功能。
Background 和 TODO 这两个方法都会返回预先初始化好的私有变量 background
和 todo
,它们会在同一个 Go 程序中被复用:
1 | var ( |
从源代码来看,context.Background 和 context.TODO 和也只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:
- context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生出来;
- context.TODO 应该仅在不确定应该使用哪种上下文时使用;
在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。
3.2.2 cancelCtx
这是一个带 cancel 功能的 context。
1 | type cancelCtx struct { |
同时 cancelCtx 还实现了 canceler 接口,提供了 cancel 方法,可以手动取消:
1 | type canceler interface { |
实现了上面定义的两个方法的 Context,就表明该 Context 是可取消的。
创建 cancelCtx 的方法如下:
1 | func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { |
这是一个暴露给用户的方法,传入一个父 Context(这通常是一个 background
,作为根节点),返回新建的 context,并通过闭包的形式,返回了一个 cancel 方法。
newCancelCtx
将传入的上下文包装成私有结构体context.cancelCtx
。
propagateCancel
则会构建父子上下文之间的关联,形成树结构,当父上下文被取消时,子上下文也会被取消:
1 | func propagateCancel(parent Context, child canceler) { |
上述函数总共与父上下文相关的三种不同的情况:
- 1)当
parent.Done() == nil
,也就是parent
不会触发取消事件时,当前函数会直接返回; - 2)当
child
的继承链包含可以取消的上下文时,会判断parent
是否已经触发了取消信号;- 如果已经被取消,
child
会立刻被取消; - 如果没有被取消,
child
会被加入parent
的children
列表中,等待parent
释放取消信号;
- 如果已经被取消,
- 3)当父上下文是开发者自定义的类型、实现了 context.Context 接口并在
Done()
方法中返回了非空的管道时;- 运行一个新的 Goroutine 同时监听
parent.Done()
和child.Done()
两个 Channel; - 在
parent.Done()
关闭时调用child.cancel
取消子上下文;
- 运行一个新的 Goroutine 同时监听
propagateCancel
的作用是在 parent
和 child
之间同步取消和结束的信号,保证在 parent
被取消时,child
也会收到对应的信号,不会出现状态不一致的情况。
1 | func parentCancelCtx(parent Context) (*cancelCtx, bool) { |
cancelCtx 的 done 方法肯定会返回一个 chan struct{}
1 | func (c *cancelCtx) Done() <-chan struct{} { |
c.done 是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建。再次说明,函数返回的是一个只读的 channel,而且没有地方向这个 channel 里面写数据。所以,直接调用读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。
1 | func (c *cancelCtx) Value(key interface{}) interface{} { |
所以这里parent.Value(&cancelCtxKey)
返回值就是 parent 内部的 cancelCtx。
parentCancelCtx 其实就是判断 parent context 里面有没有一个 cancelCtx,有就返回,让子 context 可以 “挂靠” 到 parent context 上,如果不是就返回 false,不进行挂靠,自己新开一个 goroutine 来监听。
最后再看一下比较重要的 cancel 方法。
1 | func (c *cancelCtx) cancel(removeFromParent bool, err error) { |
总体来看,cancel() 方法的功能就是关闭 channel:c.done;递归地取消它的所有子节点;从父节点从删除自己。达到的效果是通过关闭 channel,将取消信号传递给了它的所有子节点。goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中。
3.2.3 timerCtx
timerCtx 内部不仅通过嵌入 cancelCtx 的方式承了相关的变量和方法,还通过持有的定时器 timer
和截止时间 deadline
实现了定时取消的功能:
1 | type timerCtx struct { |
timerCtx.cancel 不仅调用了 cancelCtx.cancel 方法,还会停止持有的定时器减少不必要的资源浪费。
实际上对外提供了 WithTimeout 方法只是 WithDeadline 的封装:
1 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { |
和 WithCancel 大致逻辑是相同的,除了多了一个 timer 来定时取消:
1 | c.timer = time.AfterFunc(dur, func() { |
里面的一个 deadLine 判断也比较有意思:
1 | if cur, ok := parent.Deadline(); ok && cur.Before(deadline){ |
如果父节点 context 的 deadline 早于本次创建子节点的 deadline ,那就没必要给子节点创建一个 timerCtx 了,因为根据 deadline 来看,父节点肯定会早与这个子节点取消,而父节点取消后,子节点也会跟着被取消,所以没必要给子节点创建 timer,直接创建一个 cancelCtx 将子节点挂到父节点上就行了,效果是一样的,还剩下一个 timer。
1 | type valueCtx struct { |
3.2.4 valueCtx
valueCtx 则是多了 key、val 两个字段来存数据。
1 | func WithValue(parent Context, key, val interface{}) Context { |
直接基于 parent 构建了一个 valueCtx,比较简单。注意点是这个方法对 key 的要求是**可比较的 (comparable)**,因为之后需要通过 key 取出 context 中的值,可比较是必须的。
取值的过程,实际上是一个递归查找的过程:
1 | func (c *valueCtx) Value(key interface{}) interface{} { |
如果 key 和当前 ctx 中存的 value 一致就直接返回,没有就去 parent 中找。最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。
因为这里要比较两个 key 是否一致,所以创建的时候必须要求 key 是 comparable。
类似于一个链表,其实效率是很低的,不建议用来传参数。
4. 使用建议
在官方博客里,对于使用 context 提出了几点建议:
- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.
- Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
- Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
- The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.
翻译过来就是:
- 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一参数,而且一般都命名为 ctx。
- 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。
- 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。
- 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。