上下文context

上下文context是Go中较独特的设计,其他编程语言中较为少见,主要用于在Goroutine之间传递请求的截止时间、取消信号和其他跨API边界的值。

context.Context是Go在1.7版本中引入的标准库接口,定义了4个待实现的方法:

  1. Deadline:返回 context.Context 被取消的时间,也就是完成工作的截止日期(如果Context设置了截止时间,则返回ok=truedeadline返回该时间;如果没有设置截止时间,则返回ok=falsedeadline为空)

  2. Done:返回一个Channel(在当前工作完成或者上下文被取消后关闭),作为对Context关联函数的取消信号。当Channel关闭时,关联函数终止工作并返回。

    • 多次调用 Done 方法会返回同一个 Channel;(可以通过select监听该通道,在取消信号到来时,实现优雅退出)

      为什么Context不设置Cancel函数呢?

      Done通道设置为只读的原因一致:收到取消信号的函数callee,通常不是发送取消信号的函数caller。特别地,当一个父操作启动goroutines以执行子操作时,子操作不应该具备取消父操作的权力。

      caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送取消信号,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法,而是在canceler中定义。

  3. Err:返回 context.Context 结束的原因,只会Done 方法对应的 Channel 关闭时返回非空的值

    1. 如果 context.Context 被取消,会返回 Canceled 错误;
    2. 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  4. Value:从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;

1
2
3
4
5
6
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}

Context是线程安全的,可以被多个goroutine同时使用。

再来看看另一个接口canceler

1
2
3
4
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}

源码中*cancelCtx*timerCtx实现了canceler接口:对应的Context时可取消的。

设计原理

目的:在Goroutine构成的树形结构中,对信号进行同步处理,以减少计算资源的浪费。

Go Concurrency Patterns: Context

Go 服务的每一个请求都是通过单独的 Goroutine 处理的,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。当一个请求被取消或者超时,所有面向该请求工作的goroutine将快速退出,以便系统回收资源。

如下图所示,我们可能会创建多个 Goroutine 来处理一次请求,而 context.Context 的作用是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。

每一个 context.Context 都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context 可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层,停掉无用的工作以减少额外资源的消耗:

核心接口与类

context.cancelCtx

context.cancelCtx 结构体实现了接口canceler

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
44
45
46
47
48
49
50
51
52
53
54
55
56
type cancelCtx struct {
Context

mu sync.Mutex // 保护之后的字段
done chan struct{} // 懒汉式创建(调用Done方法时才创建),被第一个取消信号关闭
children map[canceler]struct{} // 第一个取消信号到来时,设置为nil
err error // 第一个取消信号到来时,设置为non-nil的值
}
// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
// 1. 加锁,保证修改cancelCtx内部字段时的并发安全
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // 该Context已经被其他协程取消,直接返回
}
c.err = err
// 2. 关闭done通道,通知其他协程
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
// 3. 遍历所有子Context:
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err) // 递归地取消所有子Context
}
c.children = nil // 子Context置空
// 4. 解锁
c.mu.Unlock()
// 5. 从父Context的children列表中,移除当前Context
if removeFromParent {
removeChild(c.Context, c)
}
}

// 调用了 Done() 方法的时候才会创建done通道
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{})
}

有一个关键问题:调用cancel方法时,如何设置参数removeFromParent呢?也即:什么时候传true?什么时候传false

看看removeChild函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// removeChild 将当前Context从它的父Context的children列表中删除
func removeChild(parent Context, child canceler) {
if s, ok := parent.(stopCtx); ok {
s.stop()
return
}
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}

什么时候会传true呢?调用WithCancel()时。

1
return &c, func() { c.cancel(true, Canceled) }

当新创建一个可取消的Context时,返回的cancelFunc需要传入true:当调用返回的cancelFunc时,将调用removeChild函数,将该Context从其父Contextchildren列表中删除。

以下是一棵Context树:

当调用标红Contextcancel方法后,该Context将从它的父Contextchildren列表中删除,实线变成虚线;虚线框内的Context也均被取消,父子关系消失。

派生的 Context

context 包提供了从现有的 Context 值派生新 Context 值的函数。这些派生的 Context 形成了一棵树:当一个 Context 被取消时,所有从它派生的 Context 也会被取消。

默认上下文:context.Background

Background是任何Context树的根,永远不会被取消,也没有ValueDeadline。(通常用于main, init和测试函数,或者作为传入请求的顶级Context

context 包中最常用的方法还是 context.Backgroundcontext.TODO,这两个方法都会返回预先初始化好的私有变量 backgroundtodo,它们会在同一个 Go 程序中被复用:

这两个私有变量都是通过 new(emptyCtx) 语句初始化的,它们是指向私有结构体 context.emptyCtx 的指针,这是最简单、最常用的上下文类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}

context.emptyCtx 通过空方法实现了 context.Context 接口中的所有方法,它没有任何功能。

从源代码看,context.Backgroundcontext.TODO 只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生出来;
  • context.TODO 应该仅在不确定应该使用哪种上下文时使用;

取消信号:context.WithCancel()

context.WithCancel 函数用于从父Context中衍生出一个新的子Context,并返回父Context的副本。一旦我们父ContextDone通道关闭/cancel被调用,父Context以及它的子Context都会被取消,所有的 Goroutine 都会同步收到这一取消信号。

看看context.WithCancel 函数的实现:

1
2
3
4
5
6
7
8
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 传入一个父Context(通常是一个background,作为根节点);返回新建的父Context副本
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
  1. context.newCancelCtx 将传入的Context包装成私有结构体 context.cancelCtx
  2. context.propagateCancel 会构建父子Context之间的关联,当父Context被取消时,子Context也会被取消;共包含3种情况:
    1. parent.Done()==nil时,也即parent不会触发取消信号时,当前函数直接返回;
    2. child的继承链包含可以取消的Context时,判断parent是否已经触发取消信号:
      • 如果已经被取消,child 会立刻被取消;
      • 如果没有被取消,child 会被加入 parentchildren 列表中,等待 parent 释放取消信号;
    3. 在默认情况下:
      • 运行一个新的 Goroutine 同时监听 parent.Done()child.Done() 两个 Channel:在 parent.Done() 关闭时调用 child.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
29
30
31
32
33
34
35
36
37
38
39
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // 1. 父Context不会触发取消信号
}
select {
case <-done:
// 如果父Context已经被取消,则取消子Context的任务
child.cancel(false, parent.Err())
return
default:
// 如果父Context没有取消信号,继续执行
}

// 2. 寻找需要取消的父Context:
if p, ok := parentCancelCtx(parent); ok {
// 获取父Context的取消信息,并加锁
p.mu.Lock()
if p.err != nil {
// 如果父Context已被取消,传递取消信号给子Context
child.cancel(false, p.err)
} else {
// 如果父Context没有被取消,将子Context挂到父Context的children列表中
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 3. 没有找到需要取消的父Context:启动一个goroutine同时监听父Context和子Context的取消信号
go func() {
select {
case <-parent.Done():
// 如果父Context被取消,则取消子Context
child.cancel(false, parent.Err())
case <-child.Done():
// 如果子Context自己取消,直接返回
}
}()
}
}
  1. propagateCancel方法的意义是什么呢?

​ 不断向上寻找可以“挂靠”的“可取消”Context,并“挂靠”上去。这样,调用上层cancel 方法的时候,就可以层层传递,将那些挂靠的子Context 同时取消。看看查找的关键函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
// 查找 parent.Value(&cancelCtxKey) 来获取最内层包裹的 *cancelCtx:验证parent.Done() 返回的 channel 是否与该 *cancelCtx 的 channel 匹配
// 如果不匹配,说明 *cancelCtx 被提供了不同 done channel 的自定义实现所包裹,这种情况下我们不应该绕过该包装层。
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
}
  1. 如果没有找到可“挂靠”的Context(第3种情况),那理论上case <-parent.Done()永远不会发生,岂非有些多余?

​ 进入Value函数看看:从context链中查找key对应的值:

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
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx: // 1. 值上下文
if key == ctx.key {
return ctx.val
}
c = ctx.Context // 未找到,继续向上层查找
case *cancelCtx: // 可取消上下文
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case withoutCancelCtx: // 无取消上下文
if key == &cancelCtxKey {
// This implements Cause(ctx) == nil
// when ctx is created using WithoutCancel.
return nil
}
c = ctx.c
case *timerCtx: // 定时器上下文
if key == &cancelCtxKey {
return &ctx.cancelCtx // 返回内嵌的 cancelCtx
}
c = ctx.Context
case backgroundCtx, todoCtx:
return nil
default:
return c.Value(key)
}
}
}

​ 发现Value只能识别一些原生的Context类型,如果采用自定义的Context类型作为父Context,不能匹配。此时Go 会新启动一个协程来监控取消信号。

  1. 为啥第3种情况里select语句的两个case都不能删?

    1
    2
    3
    4
    5
    select {
    case <-parent.Done():
    child.cancel(false, parent.Err())
    case <-child.Done():
    }
    1. 第一个`case`如果去掉,那么父`Context`的取消信号无法传递给子`Context`了;
    2. 第二个`case`是说如果子`Context`自行取消,则退出该`select`,不用再等父`Context`的取消信号(否则父`Context`一直不取消,造成goroutine泄漏)

定时取消信号:context.WithTimeout()

context.WithDeadline 在创建 context.timerCtx 的过程中判断了父ContextDeadline当前Deadline(新的 ContextDeadline 是当前时间加上超时时间,与父ContextDeadline做比较,取更早的一个);并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.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
29
30
31
32
33
34
35
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
// 将当前时间与超时时间相加,形成一个绝对的截止时间
return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 1. 检查父Context是否已设置截止时间cur:
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// 1.1 cur早于当前设置的截止时间d,直接调用WithCancel返回一个可取消的Context,无需使用定时器;
return WithCancel(parent)
}
// 1.2 cur晚于d:创建一个带有截止时间d的新Context
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 将parent的取消信号,传播给新创建的timerCtx
propagateCancel(parent, c)
// 2. 处理截止时间:计算当前时间到截止时间d的持续时间dur
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded) // 2.1 已经过了截止日期:立即取消Context
return c, func() { c.cancel(false, Canceled) }
}
// 2.2 尚未过截止时间:设置定时器,在dur后调用cancel,取消Context
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
// 3. 返回Context和取消函数
return c, func() { c.cancel(true, Canceled) }
}

context.timerCtx 内部不仅通过嵌入 context.cancelCtx 结构体继承了相关的变量和方法,还通过持有的定时器 timer 和截止时间 deadline 实现了定时取消的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.

deadline time.Time
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}

WithTimeout 非常适用于设置与后端服务器的请求超时。当希望在一定时间内没有响应就取消请求时,可以使用 WithTimeout

传值:context.WithValue()

context 包中的 context.WithValue 能从父Context中创建一个子Context,传值的子Context使用 context.valueCtx 类型:

1
2
3
4
5
6
7
8
9
10
// WithValue 返回父 Context 的副本,父 Context 的 Value 方法对于 key 返回 val
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}

context.valueCtx 结构体会将除了 Value 之外的 ErrDeadline 等方法代理到父上下文中,它只会响应 context.valueCtx.Value 方法:

1
2
3
4
5
6
7
8
9
10
11
type valueCtx struct {
Context // 父Context
key, val interface{}
}

func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key) // 向父Context中不断回溯查找
}

如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父Context中查找该键对应的值,直到某个父Context中返回 nil 或者查找到对应的值。

WithValue 允许将值与 Context 关联,这些值在请求的生命周期内是有效的,并且线程安全地共享。通常在 web 服务中,WithValue 被用来在请求处理中传递用户信息或其他与请求相关的数据。

一个栗子

创建一个过期时间为1s的Context,并传入handle函数,该方法使用500ms处理传入的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) // 创建一个1s超时的Context
defer cancel()

// 启动一个新的goroutine:执行handle函数
go handle(ctx, 500*time.Millisecond)
select {
case <-ctx.Done():
// 等待Context的取消信号
fmt.Println("main", ctx.Err())
}
}

func handle(ctx context.Context, duration time.Duration) {
select {
// 监听父Context(main中的ctx):如果父Context被取消,handle函数退出
case <-ctx.Done():
fmt.Println("handle", ctx.Err())
// 监听duration,等待500ms后打印消息
case <-time.After(duration):
fmt.Println("process request with", duration)
}
}
  1. 由于超时时间1s>处理时间500ms,因此打印内容为:

    1
    2
    3
    $ go run context.go
    process request with 500ms
    main context deadline exceeded

    handle 函数没有进入超时的 select 分支,但是 main 函数的 select 却会等待 context.Context 超时并打印出 main context deadline exceeded

  2. 如果设置处理时间=1500ms>超时时间1s,整个程序将因Contxt终止而终止,打印内容为:

    1
    2
    3
    $ go run context.go
    main context deadline exceeded
    handle context deadline exceeded

    注:有几率不打印"handle context deadline exceeded",因为main 协程已经退出了,handle 被强制退出了。此时需要main中sleep一会儿。

一些官方文档的建议

  1. 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一参数,而且一般都命名为 ctx;
  2. 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库准备好了一个 context:todo
  3. 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等;
  4. 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。

参考

Go 语言设计与实现

深度解密Go语言之context