Go 语言在 1.16 版本之后加入了新的内置数据结构 Context,虽然在代码中使用都比较简单,但语言内部还是做了许多区分,今天来分析一下 Context。
Context
虽然在使用 context 的时候,看起来都是 context.* 的结构,但 Go 语言内部做了这样一个区分:
具体说明如下:
- emptyCtx:所有 ctx 类型的根
- valueCtx:主要为了在 ctx 中嵌入上下文数据,一个简单的 k 和 v 结构,同一个 ctx 内只支持一对 kv,需要更多的 kv 的话,会形成树形结构。
- cancelCtx:取消程序的执行树
- timerCtx:在 cancelCtx 上包了一层,支持基于时间的 cancel
这里有个例子
1package main
2
3import (
4 "context"
5 "fmt"
6)
7
8type orderID int
9
10func main() {
11 var x = context.TODO()
12 x = context.WithValue(x, orderID(1), "1234")
13 x = context.WithValue(x, orderID(2), "2345")
14 x = context.WithValue(x, orderID(3), "3456")
15 fmt.Println(x.Value(orderID(2)))
16
17}
具体流程如下,前一个节点是后一个节点的 parent
valueCtx{ k: 3, v: 3456 } → valueCtx{ k: 2, v: 2345 } → valueCtx{ k: 1, v: 1234} → emptyCtx
看起来像是链表,但其实 Context 更像是一棵树,这里例子更直观些:
1package main
2
3import (
4 "context"
5 "fmt"
6)
7
8type orderID int
9
10func main() {
11 var x = context.TODO()
12 x = context.WithValue(x, orderID(1), "1234")
13 x = context.WithValue(x, orderID(2), "2345")
14
15 y = context.WithValue(x, orderID(3), "4567")
16 y = context.WithValue(x, orderID(4), "3456")
17
18 fmt.Println(x.Value(orderID(3)))
19 fmt.Println(y.Value(orderID(3)))
20}
x: valueCtx{ k: 3, v: 3456 } →
→ valueCtx{ k: 2, v: 2345 } → valueCtx{ k: 1, v: 1234} → emptyCtx
y: valueCtx{ k: 3, v: 4567 } →
这样看起来就更像树结构了。
我们在开发中可能会遇到, context 影响 goroutine,伪代码如下:
1func () {
2 go func1() {} ()
3 go func2() {
4 go func3() {} ()
5 }
6}
其实这也是一个树形结构,这里做了个简化就是:假设我们在每一个 goroutine 中都创建了和它对应的 context,那么可以认为我们的根就是对应的最外层的函数。内部每启动一个 goroutine ,就会对应以下这样的节点:
这种结构正好和我们程序执行的顺序是匹配的,最终生成一个 context 树。
假如我们想进一步,生成了 context 树之后,做些取消操作:比如 goroutine 都没执行完,不过想要取消下图红框所示 cancelCtx,意味着取消后,后续的子 goroutine 都需要取消,就需要调用最外部的 cancelCtx。就相当于,父节点取消时,可以传导到所有子节点中。
但这种方式也会有一种问题,context 的操作都需要在 goroutine 中去做配合的。也就是说我们在 goroutine 中一定要用 select 然后用 ctx.dump 的操作。如果不写就相当于完全不配合外部取消操作,也就不监听外部取消通知,理论上 goroutine 还是配合不了。所以,即便有了 context 这种侵入式用法,还是需要写一些 goroutine 处理的相关逻辑。虽然整体上不是很好用,但相比其他语言,我能够简单粗暴地中断某个执行还是很不错的,可以不用考虑执行现场中怎么恢复、怎么清理当时分配的资源。
Go 语言中,不仅有这种基于 context 和 goroutine 中 select ctx.dump 的中断方式,在内部还能够通过信号来中断。而且信号中断没有给用户暴露相关的手段,用户能够操作的也就只有常见的让程序直接停止和退出的情况。
这其实也是一种抢占式调度的方式,相当于可以从执行的汇编流中,任意的位置中断。然后把现场保存,在后续流程中如果需要就恢复回来。
最后说下 context 的社区现状。因为 context 不是很好用,社区中不少人在骂,这种侵入方式到底好不好。据说 Go 2.0.x 以后,用实现简单的 context 来做取消中断还是比较方便,而信号就没那么方便了。