深入理解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.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整体类图

源码分析

以下内容的理解, 大部分来自于 context.go 源代码的注释说明, 期间部分内容是自己实际使用过程中的一些理解

Context接口分析

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
// 可以传递 deadline / cancellation signal / other values API操作
// Context 相关方法可以被多个goroutines同时调用
type Context interface {
// Deadline 返回当前协程完成时间, 代表当前 context 应该被取消掉.
// 在没有设置具体的deadline时间时, 返回 ok==false , 连续多次调用此方法返回相同的结果
Deadline() (deadline time.Time, ok bool)

// Done 当当前任务完成时, 代表当前的context应该被取消掉, 此时会返回一个 被关闭的 channel
// 如果当前context永远不被取消, Done 可能返回nil.
// 连续多次调用此方法返回相同的结果
// 在 CancelFunc 被触发并返回之后, 会一步的关闭 Dine channel
// WithCancel 在 cancel 方法被调用时关闭 channel
// WithDeadline 在超过截止时间时关闭 channel
// WithTimeout 在过去 timeout 事件之后, 关闭 chan
// https://blog.golang.org/pipelines 查看更多使用示例
Done() <-chan struct{}

// Err 如果 Done Channel 没有被关闭, 将会返回 nil
// 如果 Done Channel 已经被关闭, 返回 non-nil 错误
// 一旦 返回 non-nil 错误 , 代表以后的全部调用均会返回次错误
Err() error

// Value 将会返回 当前 key 在 context 中对应的值, 如果不存在当前key, 将会返回 nil
// 此方法应用于传输过程和API边界的请求域的数据, 不应该用于传输函数的参数
Value(key interface{}) interface{}
}
  • 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
2
3
4
5
// 可以被直接取消的 context, *cancelCtx 和 *timerCtx 实现此接口
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
  • 实现了上面定义的两个方法的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// emptyCtx 是一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (*emptyCtx) Done() <-chan struct{} {
return nil
}

func (*emptyCtx) Err() error {
return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
return nil
}
1
2
3
4
5
6
7
8
9
10
11
12
13
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
// background 通常用在 main 函数中,作为所有 context 的根节点。
func Background() Context {
return background
}

// todo 通常用在并不知道传递什么 context的情形。
func TODO() Context {
return todo
}
  • emptyCtx 被包装成 background 以及 todo , 分别通过 Background 以及 TODO 获取
  • background 通常用在 main 函数中,作为所有 context 的根节点。
  • todo 通常用在并不知道传递什么 context的情形。例如,调用一个需要传递 context 参数的函数,你手头并没有其他 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位子”,最终要换成其他 context

cancelCtx

这是一个可以取消的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context

mu sync.Mutex
done atomic.Value // chan struct{}, 惰性创建, 调用 Done 时才创建, 第一次调用 cancel 时被关闭
children map[canceler]struct{} // 首次创建为nil
err error // set to non-nil by the first cancel call
}

func (c *cancelCtx) Value(key interface{}) interface{} {
if key == &cancelCtxKey {
return c
}
return c.Context.Value(key)
}
  • Done()的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}

c.done 是 懒汉式 创建,只有调用了 Done() 方法的时候才会被创建。再次说明,函数返回的是一个 只读的 channel ,而且没有地方向这个 channel 里面写数据。所以,直接调用读这个 channel,协程会被 block 住。一般通过 搭配 select 来使用。一旦关闭,就会 立即读出零值

  • Err()的实现
1
2
3
4
5
6
func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}

直接返回 error

  • String()的实现
1
2
3
4
5
6
7
8
9
func contextName(c Context) string {
if s, ok := c.(stringer); ok {
return s.String()
}
return reflectlite.TypeOf(c).String()
}
func (c *cancelCtx) String() string {
return contextName(c.Context) + ".WithCancel"
}

返回 类型.WithCancel

  • 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
28
// cancel 关闭 c.done , 取消全部 c.children, 如果全部取消成功, 将当前ctx也从它的父节点上移除
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 // 已经被关闭了
}
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: 持有父节点所的时候,获得子节点的锁.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()

if removeFromParent {
removeChild(c.Context, c)
}
}
  • 判断传入的err是否为nil, 若为 nil, 说明不满足取消条件
  • 判断当前 context 的 error 是否为nil , 若不为nil, 说明已经处理过了
  • 关闭channel , err 赋值
  • 递归 取消所有子节点
  • 从当前节点的父节点中,移除当前字节点
  • goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中

WithCancel()的实现

创建一个可取消的context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 固定的取消类型
var Canceled = errors.New("context canceled")

// WithCancel 使用新的 Done channel 生成一个parent context的副本
// 当 cancel() 函数被调用时 或者 parent context Done channel被关闭时, 当前的 Done channel 会被关闭
// 取消 context 会释放其关联的资源, 所以代码层面, 应该在context完成时, 尽快的调用 cancel 函数
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.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
  • 这是一个暴露给用户的方法,传入一个父 Context(这通常是一个 background,作为根节点),返回新建的 context,新 context 的 done channel 是新建的(前文讲过)
  • 当 WithCancel 函数返回的 CancelFunc 被调用或者是父节点的 done channel 被关闭(父节点的 CancelFunc 被调用),此 context(子节点) 的 done channel 也会被关闭。
  • 注意传给 WithCancel 方法的参数,前者是 true,也就是说取消的时候,需要将自己从父节点里删除。第二个参数则是一个固定的取消错误类型 Canceled

调用子节点 cancel 方法的时候,传入的第一个参数 removeFromParent 是 false。

两个问题需要回答:

  1. 什么时候会传 true?
  2. 为什么有时传 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 的境地,会有问题的。

cancel context示意图

如上左图,代表一棵 context 树。当调用左图中标红 context 的 cancel 方法后,该 context 从它的父 context 中去除掉了:实线箭头变成了虚线。且虚线圈框出来的 context 都被取消了,圈内的 context 间的父子关系都荡然无存了。

此时需要重点关注 propagateCancel() 的实现

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
39
40
41
42
43

// 曾经创建过的 goroutine 数量 ; 测试使用.
var goroutines int32

// propagateCancel 当父节点取消时, 安排取消子节点.
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // 父节点未被取消
}

select {
case <-done:
// 父节点已被取消
child.cancel(false, parent.Err())
return
default:
}

if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// 父节点已经被取消了,当前节点(子节点)也要取消
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)
// 如果没有找到可取消的父 context。新启动一个协程监控父节点或子节点取消信号
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}

这个方法的作用就是向上寻找可以“挂靠”的“可取消”的 context,并且“挂靠”上去。这样,调用上层 cancel 方法的时候,就可以层层传递,将那些挂靠的子 context 同时“取消”。

那么,为什么会有 else 描述的情况发生? else 逻辑是指当前节点 context 没有向上找到可以取消的父节点,那么就要再启动一个协程监控父节点或者子节点的取消动作。

这里就有疑问了,既然 没找到 可以取消的父节点,那 case <-parent.Done() 这个 case 就永远不会发生,所以可以忽略这个 case;而 case <-child.Done() 这个 case 又啥事不干。那这个 else 不就多余了吗?

既然有这段逻辑代码, 显然上面的想法必然是错误的, 首先关注下 parentCancelCtx() 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// closedchan 是可重复使用的 closed channel.
var closedchan = make(chan struct{})

// parentCancelCtx 返回一个根 *cancelCtx.
// 通过搜索 parent.Value(&cancelCtxKey) 查找最深层的 *cancelCtx,
// 并且检测 parent.Done() 和 *cancelCtx 是否匹配.
// (If not, the *cancelCtx
// has been wrapped in a custom implementation providing a
// different done channel, in which case we should not bypass it.)
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
}

p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) , 说明只支持 *cancelCtx, 而实现了次接口的只有 cancelCtxtimerCtx*valueCtx 。若是把 Context 内嵌到一个类型里,就识别不出来了。

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
// TestCancelCtx_UDC 测试自定义context
//
// Author : go_developer@163.com<白茶清欢>
//
// Date : 12:46 下午 2021/12/2
func TestCancelCtx_UDC(t *testing.T) {
childCancel := true

parentCtx, parentFunc := WithCancel(Background())
mctx := MyContext{parentCtx}

childCtx, childFun := WithCancel(mctx)

if childCancel {
childFun()
} else {
parentFunc()
}

fmt.Println("parent context => ", parentCtx, reflect.TypeOf(parentCtx).String())
fmt.Println("my context => ", mctx, reflect.TypeOf(mctx).String())
fmt.Println("child context => ", childCtx, reflect.TypeOf(childCtx).String())

// 防止主协程退出太快,子协程来不及打印
time.Sleep(5 * time.Second)
}

上述测试用例输出结果(测试源代码参照参考资料2)

三个打印结果依次为:

1
2
3
context.Background.WithCancel
{context.Background.WithCancel}
{context.Background.WithCancel}.WithCancel

果然,mctx 和正常的 parentCtx 不一样,因为它是一个 自定义的结构体类型

else 这段代码说明,如果把 ctx 强行 塞进一个结构体,并用它 作为父节点 ,调用 WithCancel 函数构建子节点 context 的时候,Go 会新启动一个协程来监控取消信号。

另外, select 语句里的两个 case 其实都不能删。

1
2
3
4
5
6
7
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
  • 第一个 case 说明当父节点取消,则取消子节点。如果去掉这个 case,那么父节点取消的信号就不能传递到子节点。
  • 第二个 case 是说如果子节点自己取消了,那就退出这个 select,父节点的取消信号就不用管了。如果去掉这个 case,那么很可能父节点一直不取消,这个 goroutine 就泄漏了。当然,如果父节点取消了,就会重复让子节点取消,不过,这也没什么影响嘛。

timerCtx

timerCtx 基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 context。

1
2
3
4
5
6
7
// timerCtx 包含 timer 和 deadline. 嵌入了 cancelCtx 实现 Done 和 Err
// 通过停止计时器实现 cancelCtx.cancel.
type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}

取消的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 直接调用 cancelCtx 的取消方法
c.cancelCtx.cancel(false, err)
if removeFromParent {
// 从父节点中删除子节点
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
// 关掉定时器,这样,在deadline 到来时,不会再次取消
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}

创建 timerCtx 的方法

1
2
3
4
// 函数直接调用了 WithDeadline,传入的 deadline 是当前时间加上 timeout 的时间
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}

WithDeadline() 的实现

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
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// 如果父节点 context 的 deadline 早于指定时间。直接构建一个可取消的 context。
// 原因是一旦父节点超时,自动调用 cancel 函数,子节点也会随之取消。
// 所以不用单独处理子节点的计时器时间到了之后,自动调用 cancel 函数
return WithCancel(parent)
}

// 构建 timerCtx
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 挂靠到父节点上
propagateCancel(parent, c)
// 计算当前距离 deadline 的时间
dur := time.Until(d)
if dur <= 0 {
// 已超时,直接取消
c.cancel(true, DeadlineExceeded)
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// dur 时间后,timer 会自动调用 cancel 函数。自动取消
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}

也就是说仍然要把子节点挂靠到父节点,一旦父节点取消了,会把取消信号向下传递到子节点,子节点随之取消。

c.timer 会在 d 时间间隔后,自动调用 cancel 函数,并且传入的错误就是 DeadlineExceeded , 也就是超时错误。

核心逻辑即为:

1
2
3
c.timer = time.AfterFunc(d, func() {
c.cancel(true, DeadlineExceeded)
})

如果要创建的这个子节点的 deadline 比父节点要晚,也就是说如果父节点是时间到自动取消,那么一定会取消这个子节点,导致子节点的 deadline 根本不起作用,因为子节点在 deadline 到来之前就已经被父节点取消了。

valueCtx

1
2
3
4
type valueCtx struct {
Context
key, val interface{}
}

其实现了两个方法

1
2
3
4
5
6
7
8
9
10
func (c *valueCtx) String() string {
return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

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

创建 valueCtx 的函数

1
2
3
4
5
6
7
8
9
10
11
12
func WithValue(parent Context, key, val interface{}) 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}
}

对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。

WithValue数据传递图

值的指向关系和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点。通过 WithValue 函数,可以创建层层的 valueCtx,存储 goroutine 间可以共享的变量。取值的过程,实际上是 一个递归查找的过程

1
2
3
4
5
6
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
  • 它会顺着链路 一直往上找 ,比较当前节点的 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 可能并不完美,但它确实简洁高效地解决了问题。

参考资料


深入理解GO-Context
http://www.zhangdeman.cn/archives/97ffd046.html
作者
白茶清欢
发布于
2021年11月29日
许可协议