context 包定义了 Context 类型,通过该类型可以实现在不同流程间设置 deadline、通知取消信号、设置每请求关联值等功能。context 包简化了 Go 并发编程中不同 API/流程间的信息传递(取消信号、超时信号、每请求关联数据等都可以认为是要传递的信息)。
这篇文章主要介绍 context 包的使用及内部原理。
context 库简介
context 包最基本的使用逻辑是:服务器收到请求后应该创建一个 Context,之后发起后续调用时都应该将 Context 作为参数进行传递,而且整个函数调用链都需要传递 Context。传递的 Context 可以是同一个,也可以是通过 WithCancel、WithDeadline、WithTimeout、WithValue 等函数创建的子 Context。当某个 Contex 取消后,它的所有子 Context 也会被取消。
context 包的基本原理
context 包定义了 Context 这个接口类型,它的定义如下:
1 | type Context interface { |
Deadline()
方法用于返回该 Context 的截止时间。如果ok
返回值为 false,则表示该 Context 没有设置 deadlineDone()
方法返回一个 channel,当 Context 被取消时,该函数所返回的 channel 就会被关闭。如果该 Context 无法被取消,则 Done() 返回 nilErr()
方法返回该 Context 的错误状态信息- 如果
Done()
所返回的 channel 还没有关闭,则返回 nil - 如果
Done()
所返回的 channel 已经关闭,则返回一个非 nil 的错误信息。其中Canceled
表示该 Context 是被取消,DeadlineExceeded
表示已经到达截止时间
- 如果
Value()
方法返回该 Context 上指定 key 所关联的值。通过该方法可以实现每请求关联值
功能
所有的具体 Context 类型都需要实现上述接口类型。
Background()/TODO()
在收到请求时,我们需要创建一个 Root Context,或者称为顶级 Context。可以通过 Backgroud()
函数创建这个 Root Context。该 Context 其实没有任何功能,它不能被取消、没有 deadline、也不带任何值信息,它的主要作用就是作为整个 Context 树的根节点。
我们从 Background 及其相关实现也可以确认这一点:
1 | type backgroundCtx struct{ emptyCtx } |
下面是 Background()
的一个测试用例,由于其 Done()
函数返回的 channel 是 nil,所以正确情况下 由于 <-c.Done()
应该会被阻塞,因此 select 选择 default 分支。
1 | func TestBackground(t *testing.T) { |
TODO()
函数返回的 Context 其实和 Background()
的返回值底层类型是一样的,只不过 TODO()
函数的名字提示我们,在不知道使用什么 Context 时,可以先使用 TODO()
函数返回的 Context 作为一个占位符。
1 | type todoCtx struct{ emptyCtx } |
WithCancel()
WithCancel()
创建一个可手动取消的 Context。该函数接受一个 Context 参数作为父 Context,并返回一个新的 Context 以及一个 cancel 函数。通过调用该 cancel 函数,可以手动取消该新的 Context。另外如果 parent Context 被取消了,那么该子 Context 也会被取消(取消信号会沿着 Context 树一直传递下去)。Context 被取消之后,都可以通过 Context->Done()
函数的返回 channel 得到通知。
1 | type CancelFunc func() |
所以 Cancel Context
的基本用法是:
Cancel Context
的生产者通过调用 cancel 函数来向Cancel Context
的消费者通知取消信号Cancel Context
的消费者通过Context->Done()
函数返回的 channel 来接收取消信号
如下测试代码展示了 WithCancel()
的基本用法:
1 | package main |
1 | # go run main.go |
可以看到,在调用 c1 的 cancel 函授后,它的子孙 Context 都自动被取消了。
WithCancelCause
函数类似于 WithCancel 函数,但是它返回的取消函数类型为 CancelCauseFunc,而不是 CancelFunc。调用该类型的 cancel 函数时可以传递一个 error 参数用于记录 Context 取消的原因。之后可以通过 Cause(ctx)
获取取消原因。如果调用 cancel 函数时 error 参数设置为 nil,则 Cause(ctx)
返回默认值 Canceled
。
1 | type CancelCauseFunc func(cause error) |
WithDeadline()
WithDeadline()
函数创建一个具有 deadline 时间的 Context。该函数接受一个 Context 参数作为父 Context,并返回一个新的 Context 以及一个 cancel 函数。在如下任意时机,该 Context 的 Done()
函数所返回的 channel 都会得到通知:
- 手动调用返回的 cancel 函数
- 到达了 deadline 所指定的时间
- parent Context 的
Done()
函数所返回的 channel 被关闭
值得注意是,如果 parent Context 本身也有 deadline,且 parent 的 deadline 时间早于 WithDeadline()
函数中所指定的 deadline 时间,那么 WithDeadline(parent, deadline)
函数中所指定的新 deadline 没有作用,其创建的 Context 等同于 WithCancel(parent)
所创建的 context(内部也是这样实现的)。
WithDeadlineCause()
函数类似于 WithDeadline()
函数,但是它额外接收一个 error 类型的 cause 参数用于设置 deadline 超时后的原因。返回的 cancel 函数则不能设置 cause。
1 | func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) |
WithTimeout()
WithTimeout()
也是创建一个具有超时功能的、可取消 Context,但该函数是通过 time.Duration
类型的参数来设置超时时间:
1 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) |
WithTimeout(parent, timeout)
函数实际上等价于 WithDeadline(parent, time.Now().Add(timeout))
。因此该函数返回的 Context 在功能特性上也和 WithDeadline()
返回的 Context 完全一致,这里就不再赘述。
WithTimeoutCause()
函数类似于 WithTimeout()
函数,但是它额外接收一个 error 类型的 cause 参数用于设置超时原因。返回的 cancel 函数不能则设置 cause。
AfterFunc()
AfterFunc()
函数可以提供更高级的功能,当所指定的 ctx 完成后(手动取消或者超时取消),它可以在独立的 goroutine 中执行指定的函数 f()
:
1 | func AfterFunc(ctx Context, f func()) (stop func() bool) |
AfterFunc()
返回一个 stop
函数,通过该函数可以取消 ctx
与 f
的关联。调用 stop 函数后:
- 如果返回值为 true,表示成功取消
ctx
与f
的关联,f 函数不会有运行时机 - 如果返回值为 false,则表示 ctx 已经 done,f 函数已经开始运行;或者
ctx
和f
已经没有关联联系了
context 包的使用规范
根据标准库建议,应用程序在使用 context 包时应该遵循以下规则,这样能保证不同 go package 间接口的一致性、也可以让静态分析工具检查 context 的传播是否正确:
- 不要在结构体类型中保存 Context,而应该显式将 Context 作为函数参数传递给每个需要它的函数。Context 应该作为第一个参数,通常命名为 ctx
1 | func DoSomething(ctx context.Context, arg Arg) error { |
- 不要传递 nil Context,即使函数允许该参数值。当不确定使用哪一个 Context 时,可以先试用
context.TODO()
- 仅将
Value Context
用于在 API 间传递每请求关联值
,不要将它用作传递函数的可选参数 - 同一个 Context 可以传递到运行在不同 goroutine 的函数里,Context 对于多个 goroutine 同时使用是安全的
context 包的实现原理
接下来介绍 context 包的代码实现原理。下图展示了 context 包中的的核心数据结构定义:
Context
接口类型对Context 功能
进行了统一抽象,所有具体的 Context 类型都会实现该接口emptyCtx
是一种 dummy Context 类型,Background()
和TODO()
函数返回的 Context 都是基于emptyCtx{}
类型实现的WithCancel
函数返回的 Context 类型就是cancelCtx
,它实现了可取消 Context 功能WithDeadline
和WithTimeout
函数返回的 Context 类型都是timerCtx
,它实现了具有超时时间的、可取消 Context。从timerCtx
结构的定义也可以看出,其通过内嵌cancelCtx
类型来实现 cancel 相关功能cancelCtx
和timerCtx
都实现了 canceler 接口
valueCtx
WithValue()
函数创建的 Context 类型就是 valueCtx
,它自己实现了 Value()
方法以提供 每请求关联值
的功能,而将其他方法的实现都委托给了其父 Context。valueCtx
的定义如下:
1 | type valueCtx struct { |
其 Value()
方法的实现如下,其核心逻辑是判断待查找的 key 和当前 valueCtx 的 key 是否相等。如果相等,则返回所对应的 val,否则会沿着 Context
树一直向上对其父 Context 进行 key 查找,直到查找成功或者遇到根节点。
1 | // valueCtx 实现的 Value 方法 |
正是由于这种向上递归查找特性,使得 valueCtx
携带的 key-value
键值对呈现出了一种 继承
特征,即可以通过后代 Context 查找其祖先 Context 中携带的 key-value
键值对。如下是一个示例:
1 | package main |
1 | # go run main.go |
可以看到,即使整个 Context 树中除了包含 valueCtx
类型,还存在 cancelCtx
类型、自定义 Context 类型,整个 key-value
键值对的查找仍能正常进行。
cancelCtx
/timerCtx
cancelCtx
/timerCtx
都是可取消 Context。cancelCtx
可通过 cancel()
函数手动通知取消信号,而 timerCtx
除了可以手动取消外,在超时之后也会自动通知取消信号。这点重点介绍 canctlCtx
是如何实现取消功能的。
当一个 cancelCtx
被取消时,我们不仅需要关闭其 Done()
方法返回的 channel,还需要向下沿着 Context 将其所有子孙 Context 都取消。cancelCtx
通过如下两种方式来实现该特性的:
- 如果 parent 本身是通过
WithCancel()
/WithTimeout()
等创建的*cancelCtx
类型(或者可以完全转换为一个*cancelCtx
类型,但没有重新实现Done()
方法),那么就在 parent cancelCtx 的children
字段中记录其所有可取消的子 Context。这样当 parent 被取消后,只需要遍历 children 中的所有可取消 Context,调用其cancel()
方法即可 - 否则其他情况(例如完全自定义的 Context 类型)下,就会额外创建一个 goroutine 来监控 parent 的
Done()
channel,这样当 parent 被取消时,触发自己的取消流程
上述逻辑的实现是在 propagateCancel()
函数中实现的。该函数通过 parentCancelCtx()
判断其 parent 是否为 cancelCtx
类型(或者可以完全转换为一个 *cancelCtx
类型):
1 | // cancelCtx 的 Value 函数实现 |
propagateCancel()
之所以要分两种情况来处理,就是为了尽量减少 goroutine 的创建。接下来再看 cancelCtx
是如何取消的,其核心逻辑位于 cancelCtx.cancel()
方法中:
1 | func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) { |
明白了 cancelCtx
的实现原理之后,timerCtx
的实现就非常简单了。因为其本身就通过内嵌 cancelCtx
类型来实现取消功能,只不过 tiemrCtx
在创建时会额外启动一个定时器,在指定的时间超时后自动取消自己:
1 | func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) { |
总结
context
包的源码虽然不多,但是其实现还是非常精巧的,还需要多用、多看,才能深入掌握 context
包的设计思想及实现原理。