深入理解GO-Context
什么是context
context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。与它协作的 API 都可以由外部控制执行“取消”操作
Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是
goroutine 的上下文
,包含 goroutine 的运行状态、环境、现场
等信息。context 主要用来在 goroutine 之间传递上下文信息,包括:
取消信号(WithCancel)
、超时时间(WithTimeout)
、截止时间(WithDeadline)
、k-v(WithValue)
等。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了
并发控制和超时控制
的标准做法。
为什么要有context
context 用来解决 goroutine 之间退出通知、元数据传递的问题。
Go 常用来写后台服务,通常只需要几行代码,就可以搭建一个 http server。
在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据……
这些 goroutine 需要共享这个请求的基本数据,例如登陆的 token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。这时,所有正在为这个请求工作的 goroutine 需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关的资源。
context 包就是为了解决上面所说的这些问题而开发的: 在 一组 goroutine 之间传递共享的值、取消信号、deadline……
用简练一些的话来说,在Go 里,我们 不能直接杀死协程
,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。
context 概览
此为个人理解, 部分内容可能不准确或者有误, 有疑问可查看源码中的英文注释, 说明较为详细
查阅的源码版本为 1.17.2
, 其context.go结构如下:
声明 | 类型 | 作用 |
---|---|---|
Context | 接口约束 | 定义了四个方法约束 , 分别为: 1. Deadline() (deadline time.Time, ok bool) 2. Done() <-chan struct{} 3. Err() error 4. Value(key interface{}) interface{} |
emptyCtx | Context接口的一个实现 | 本质是一个 空的context , 不会超时, 不会取消 |
CancelFunc | 取消函数 | 调用后, 会取消调用链上进行中 goroutine 的执行 |
canceler | Context接口的一个实现 | context的取消接口, 定义了两个方法, 分别为: 1. cancel(removeFromParent bool, err error) 2. Done() <-chan struct{} |
cancelCtx | 可被取消的context | 当其被取消时, 会取消调用链上进行中 goroutine 的执行 |
timerCtx | 可超时的context | 当其超时时, 会取消调用链上进行中 goroutine 的执行 |
valueCtx | 携带数据的context | 可通过设置数据, 在父子协程中共享数据 |
Background | 函数 - 生成context | 返回一个空的context(emptyContext) , 通常作为空context |
TODO | 函数 - 生成context | 返回一个空的context(emptyContext) , 通常用于重构 , 不确定此时 context 是什么, 用于占位 |
WithCancel | 函数 - 生成一个可取消的context | 基于父 context , 生成一个可取消的context, 同时返回可取消的context与取消函数 |
newCancelCtx | 函数 | 创建一个可取消的context, WithCancel 内部会调用 |
propagateCancel | 函数 | 当父节context被取消时,向下传递context的取消关系 |
parentCancelCtx | 函数 | 找到第一个可取消的父节点 |
removeChild | 函数 | 移除父 context 的子节点 |
WithDeadline | 函数 | 创建一个具有截止时间的context |
WithTimeout | 函数 | 创建一个具有超时时间的context |
WithValue | 函数 | 创建一个存储kv的context |
整体类图如下 :
源码分析
以下内容的理解, 大部分来自于 context.go
源代码的注释说明, 期间部分内容是自己实际使用过程中的一些理解
Context接口分析
1 |
|
Done() 返回一个 channel,可以表示 context
被取消的信号
:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读
的channel。 读一个关闭的 channel 会读出相应类型的零值
。并且源码里没有
地方会向这个 channel 里面塞入值。换句话说,这是一个receive-only
的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。Err() 返回一个错误,表示 channel 被关闭的原因。例如是被取消,还是超时。
Deadline() 返回 context 的
截止时间
,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。Value() 获取之前设置的 key 对应的 value。
canceler分析
1 |
|
- 实现了上面定义的两个方法的 Context,就表明该 Context 是可取消的。
- 源码中有两个类型实现了 canceler 接口:
*cancelCtx
和*timerCtx
。注意是加了 * 号的,是这两个结构体的指针实现了 canceler 接口。
Context接口设计成这个样子的原因
取消操作
应该是建议性的, 而非强制性的caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送“取消”信息,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法。
取消操作
应该是可传递了“取消”某个函数时,和它相关联的其他函数也应该“取消”。因此,Done() 方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。
emptyCtx
emptyCtx 实际上是一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。
源码中定义了 Context
接口之后, 同时给出具体实现, 实现如下:
1 |
|
1 |
|
- emptyCtx 被包装成
background
以及todo
, 分别通过Background
以及TODO
获取 background
通常用在 main 函数中,作为所有 context 的根节点。todo
通常用在并不知道传递什么 context的情形。例如,调用一个需要传递 context 参数的函数,你手头并没有其他 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位子”,最终要换成其他 context
cancelCtx
这是一个可以取消的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。
1 |
|
- Done()的实现
1 |
|
c.done 是 懒汉式
创建,只有调用了 Done() 方法的时候才会被创建。再次说明,函数返回的是一个 只读的 channel
,而且没有地方向这个 channel 里面写数据。所以,直接调用读这个 channel,协程会被 block
住。一般通过 搭配 select
来使用。一旦关闭,就会 立即读出零值
。
- Err()的实现
1 |
|
直接返回 error
- String()的实现
1 |
|
返回 类型.WithCancel
- cancel()的实现
1 |
|
- 判断传入的err是否为nil, 若为 nil, 说明不满足取消条件
- 判断当前 context 的 error 是否为nil , 若不为nil, 说明已经处理过了
- 关闭channel , err 赋值
递归
取消所有子节点- 从当前节点的父节点中,移除当前字节点
- goroutine 接收到取消信号的方式就是 select 语句中的读
c.done
被选中
WithCancel()的实现
创建一个可取消的context
1 |
|
- 这是一个暴露给用户的方法,传入一个父 Context(这通常是一个 background,作为根节点),返回新建的 context,新 context 的 done channel 是新建的(前文讲过)
- 当 WithCancel 函数返回的 CancelFunc 被调用或者是父节点的 done channel 被关闭(父节点的 CancelFunc 被调用),此 context(子节点) 的 done channel 也会被关闭。
- 注意传给 WithCancel 方法的参数,前者是 true,也就是说取消的时候,需要将自己从父节点里删除。第二个参数则是一个固定的取消错误类型 Canceled
调用子节点 cancel 方法的时候,传入的第一个参数 removeFromParent 是 false。
两个问题需要回答:
- 什么时候会传 true?
- 为什么有时传 true,有时传 false?
当 removeFromParent
为 true 时,会将当前节点的 context 从父节点 context 中删除, 具体的实现逻辑参见: removeChild
, 移除的关键逻辑为: delete(p.children, child)
, 即: 将当前context从父context的子节点列表中移除
调用 WithCancel() 方法的时候,也就是 新创建一个可取消的 context 节点时
,返回的 cancelFunc 函数会传入 true。这样做的结果是:当调用返回的 cancelFunc 时,会将这个 context 从它的父节点里“除名”,因为父节点可能有很多子节点,你自己取消了,所以我要和你断绝关系,对其他人没影响。
在取消函数内部,我知道,我所有的子节点都会因为我的一:c.children = nil 而化为灰烬。我自然就没有必要再多做这一步,最后我所有的子节点都会和我断绝关系,没必要一个个做。另外,如果遍历子节点的时候,调用 child.cancel 函数传了 true,还会造成同时遍历和删除一个 map 的境地,会有问题的。
如上左图,代表一棵 context 树。当调用左图中标红 context 的 cancel 方法后,该 context 从它的父 context 中去除掉了:实线箭头变成了虚线。且虚线圈框出来的 context 都被取消了,圈内的 context 间的父子关系都荡然无存了。
此时需要重点关注 propagateCancel()
的实现
1 |
|
这个方法的作用就是向上寻找可以“挂靠”的“可取消”的 context,并且“挂靠”上去。这样,调用上层 cancel 方法的时候,就可以层层传递,将那些挂靠的子 context 同时“取消”。
那么,为什么会有 else 描述的情况发生? else 逻辑是指当前节点 context 没有向上找到可以取消的父节点,那么就要再启动一个协程监控父节点或者子节点的取消动作。
这里就有疑问了,既然 没找到
可以取消的父节点,那 case <-parent.Done() 这个 case 就永远不会发生,所以可以忽略这个 case;而 case <-child.Done() 这个 case 又啥事不干。那这个 else 不就多余了吗?
既然有这段逻辑代码, 显然上面的想法必然是错误的, 首先关注下 parentCancelCtx()
的实现
1 |
|
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
, 说明只支持 *cancelCtx
, 而实现了次接口的只有 cancelCtx
, timerCtx
, *valueCtx
。若是把 Context 内嵌到一个类型里,就识别不出来了。
1 |
|
三个打印结果依次为:
1 |
|
果然,mctx 和正常的 parentCtx 不一样,因为它是一个 自定义的结构体类型
。
else 这段代码说明,如果把 ctx 强行
塞进一个结构体,并用它 作为父节点
,调用 WithCancel 函数构建子节点 context 的时候,Go 会新启动一个协程来监控取消信号。
另外, select 语句里的两个 case 其实都不能删。
1 |
|
- 第一个 case 说明当父节点取消,则取消子节点。如果去掉这个 case,那么父节点取消的信号就不能传递到子节点。
- 第二个 case 是说如果子节点自己取消了,那就退出这个 select,父节点的取消信号就不用管了。如果去掉这个 case,那么很可能父节点一直不取消,这个 goroutine 就泄漏了。当然,如果父节点取消了,就会重复让子节点取消,不过,这也没什么影响嘛。
timerCtx
timerCtx 基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 context。
1 |
|
取消的实现
1 |
|
创建 timerCtx 的方法
1 |
|
WithDeadline() 的实现
1 |
|
也就是说仍然要把子节点挂靠到父节点,一旦父节点取消了,会把取消信号向下传递到子节点,子节点随之取消。
c.timer 会在 d 时间间隔后,自动调用 cancel 函数,并且传入的错误就是 DeadlineExceeded
, 也就是超时错误。
核心逻辑即为:
1 |
|
如果要创建的这个子节点的 deadline 比父节点要晚,也就是说如果父节点是时间到自动取消,那么一定会取消这个子节点,导致子节点的 deadline 根本不起作用,因为子节点在 deadline 到来之前就已经被父节点取消了。
valueCtx
1 |
|
其实现了两个方法
1 |
|
创建 valueCtx 的函数
1 |
|
对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。
值的指向关系和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点。通过 WithValue 函数,可以创建层层的 valueCtx,存储 goroutine 间可以共享的变量。取值的过程,实际上是 一个递归查找的过程
1 |
|
- 它会顺着链路
一直往上找
,比较当前节点的 key 是否是要找的 key,如果是,则直接返回 value。否则,一直顺着 context 往前,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。 - 因为查找方向是往上走的,所以,
父节点没法获取子节点存储的值,子节点却可以获取父节点的值
- WithValue 创建 context 节点的过程实际上就是
创建链表节点
的过程。两个节点的 key 值是可以相等的,但它们是两个不同的 context 节点。查找的时候,会向上查找到最后一个挂载
的 context 节点,也就是离得比较近的一个父节点 context。所以,整体上而言,用 WithValue 构造的其实是一个低效率的链表
。
context真这么无敌么
context 解决了 cancelation
问题, 从功能与能力的角度来看, ctx 很好,很实用, 但在使用过程中,还是有一些掣肘的。
Go 官方建议我们把 Context 作为函数的第一个参数,甚至连名字都准备好了。这造成一个后果:因为我们 想控制所有的协程的取消动作
,所以需要在几乎所有的函数里加上一个 Context 参数。很快,我们的代码里,context 将像病毒一样扩散的到处都是。
另外,经过上文的源码分析, 像 WithCancel、WithDeadline、WithTimeout、WithValue 这些创建函数,实际上是创建了一个个的链表结点而已。我们知道,对链表的操作,通常都是 O(n) 复杂度的,效率不高。
总结
- context 主要用于在 goroutine 之间传递取消信号、超时时间、截止时间以及一些共享的值等。它并不是太完美,但几乎成了并发控制和超时控制的标准做法。
- 使用上,先创建一个根节点的 context,之后根据库提供的四个函数创建相应功能的子节点 context。由于它是并发安全的,所以可以放心地传递。
- 当使用 context 作为函数参数时,直接把它放在第一个参数的位置,并且命名为 ctx。另外,不要把 context 嵌套在自定义的类型里,虽然这么用是可以的,但是不推荐。
- context 可能并不完美,但它确实简洁高效地解决了问题。