Context 是 Go 中一个比较独特而常用的概念,用好了往往能事半功倍。但如果不知其然而滥用,则往往变成 "为赋新词强说愁",轻则影响代码结构,重则埋下许多bug。Context 本质上是一种在 API 间树形嵌套调用时传递信号的机制。本文将从接口、派生、源码分析、使用等几个方面来逐一解析 Context。
概述
Context 是 Go 中一个比较独特而常用的概念,用好了往往能事半功倍。但如果不知其然而滥用,则往往变成 "为赋新词强说愁",轻则影响代码结构,重则埋下许多bug。
Golang 使用树形派生的方式构造 Context,通过在不同过程 [1] 中传递 deadline 和 cancel 信号,来管理处理某个任务所涉及到的一组 goroutine 的生命周期,防止 goroutine 泄露。并且可以通过附加在 Context 上的 Value 来传递/共享一些跨越整个请求间的数据。
Context 最常用来追踪 RPC/HTTP 等耗时的、跨进程的 IO 请求的生命周期,从而让外层调用者可以主动地或者自动地取消该请求,进而告诉子过程回收用到的所有 goroutine 和相关资源。
Context 本质上是一种在 API 间树形嵌套调用时传递信号的机制。本文将从接口、派生、源码分析、使用等几个方面来逐一解析 Context。
Context 接口
Context 接口如下:
// Context 用以在多 API 间传递 deadline、cancelation 信号和请求的键值对。 // Context 中的方法能够安全的被多个 goroutine 并发调用。 type Context interface { // Done 返回一个只读 channel,该 channel 在 Context 被取消或者超时时关闭 Done() <-chan struct{} // Err 返回 Context 结束时的出错信息 Err() error // 如果 Context 被设置了超时,Deadline 将会返回超时时限。 Deadline() (deadline time.Time, ok bool) // Value 返回关联到相关 Key 上的值,或者 nil. Value(key interface{}) interface{} }
上面是简略注释,接口详细信息可以访问 Context 的 godoc。
Done()
方法返回一个只读的 channel,当 Context 被主动取消或者超时自动取消时,该 Context 所有派生 Context 的 done channel 都被 close 。所有子过程通过该字段收到 close 信号后,应该立即中断执行、释放资源然后返回。Err()
在上述 channel 被 close 前会返回 nil,在被 close 后会返回该 Context 被关闭的信息,error 类型,只有两种,被取消或者超时:
var Canceled = errors.New("context canceled") 2var DeadlineExceeded error = deadlineExceededError{}
Deadline()
如果本 Context 被设置了时限,则该函数返回ok=true
和对应的到期时间点。否则,返回ok=false
和 nil。Value()
返回绑定在该 Context 链(我称为回溯链,下文会展开说明)上的给定的 Key 的值,如果没有,则返回 nil。注意,不要用于在函数中传参,其本意在于共享一些横跨整个 Context 生命周期范围的值。Key 可以是任何可比较的类型。为了防止 Key 冲突,最好将 Key 的类型定义为非导出类型,然后为其定义访问器。看一个通过 Context 共享用户信息的例子:
package user 2 3import "context" 4 5// User 是要存于 Context 中的 Value 类型. 6type User struct {...} 7 8// key 定义为了非导出类型,以避免和其他 package 中的 key 冲突 9type key int 10 11// userKey 是 Context 中用来关联 user.User 的 key,是非导出变量 12// 客户端需要用 user.NewContext 和 user.FromContext 构建包含 13// user 的 Context 和从 Context 中提取相应 user 14var userKey key 15 16// NewContext 返回一个带有用户值 u 的 Context. 17func NewContext(ctx context.Context, u *User) context.Context { 18 return context.WithValue(ctx, userKey, u) 19} 20 21// FromContext 从 Context 中提取 user,如果有的话.2func FromContext(ctx context.Context) (*User, bool) { u, ok := ctx.Value(userKey).(*User) return u, ok }
Context 派生
Context 设计之妙在于可以从已有 Context 进行树形派生,以管理一组过程的生命周期。我们上面说了单个 Context 实例是不可变的,但可以通过 context 包提供的三种方法:WithCancel
、 WithTimeout
和 WithValue
来进行派生并附加一些属性(可取消、时限、键值),以构造一组树形组织的 Context。
当根 Context 结束时,所有由其派生出的 Context 也会被一并取消。也就是说,父 Context 的生命周期涵盖所有子 Context 的生命周期。
context.Background()
通常用作根节点,它不会超时,不能被取消。
// Background 返回一个空 Context。它不能被取消,没有时限,没有附加键值。Background 通常用在 // main函数、init 函数、test 入口,作为某个耗时过程的根 Context。 func Background() Context
WithCancel
和 WithTimeout
可以从父 Context 进行派生,返回受限于父 Context 生命周期的新 Context。
通过 WithCancel
从 context.Background()
派生出的 Context 要注意在对应过程完结后及时 cancel,否则会造成 Context 泄露。
使用 WithTimeout
可以控制某个过程的处理时限。具体过程为,到点后, Context 发送信号到 Done Channel,子过程检测到 Context Done Channel [2] 中的信号,会立即退出。
// WithCancel 返回一份父 Context 的拷贝,和一个 cancel 函数。当父 Context 被关闭或者 // 此 cancel 函数被调用时,该 Context 的 Done Channel 会立即被关闭. func WithCancel(parent Context) (ctx Context, cancel CancelFunc) // 调用 CancelFunc 取消对应 Context. type CancelFunc func() // WithTimeout 返回一份父 Context 的拷贝,和一个 cancel 函数。当父 Context 被关闭、 // cancel 函数被调用或者设定时限到达时,该 Context 的 Done Channel 会立即关闭。在 cancel 函数 // 被调用时,如果其内部 timer 仍在运行,将会被停掉。 func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithValue
可以给 Context 附加上整个处理过程中的键值。
// WithValue 返回一个父 Context 的副本,并且附加上给定的键值对. func WithValue(parent Context, key interface{}, val interface{}) Context
Context 源码解析
Go context 使用嵌入类,以类似继承的方式组织几个 Context 类:emptyCtx
、valueCtx
、 cancelCtx
、timerCtx
。
形象的来说,通过嵌入的方式,Go 对树形组织的 Context 体系中的每个 Context 节点都构造了一个指向父亲实例"指针"。从另一个角度来说,这是一种经典代码组织模式——组合模式,每一层只增量 or 覆盖实现自己所关注的功能,然后通过路由调用来复用已有的实现。
空实现 emptyCtx
emptyCtx
实现了一个空的 Context
,所有接口函数都是空实现。
type emptyCtx int func (*emptyCtx) Deadline() (deadline time.Time, ok bool) { return } func (*emptyCtx) Done() <-chan struct{} { return nil // 返回 nil,从语法上说是空实现,从语义上说是该 Context 永远不会被关闭。 } //... 其他的省略,类似都是满足语法要求的空函数体 func (e *emptyCtx) String() string { switch e {3 case background: return "context.Background" case todo: return "context.TODO" } return "unknown empty Context" }
context.Background()
和 context.TODO()
返回的都是 emptyCtx
的实例。但其语义略有不同。前者做为 Context 树的根节点,后者通常在不知道用啥时用。
var ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo }
附加单键值 valueCtx
valueCtx
嵌入了一个 Context
接口以进行 Context 派生,并且附加了一个 KV 对。从 context.WithValue
函数可以看出,每附加一个键值对,都得套上一层新的 valueCtx
。在使用 Value(key interface)
接口访问某 Key 时,会沿着 Context 树回溯链不断向上遍历所有 Context 直到 emptyCtx
:
- 如果遇到
valueCtx
实例,则比较其 key 和给定 key 是否相等 - 如果遇到其他 Context 实例,就直接向上转发。但这里有个特例,为了获取给定 Context 所有祖先节点中最近的
cancelCtx
,go 用了一个特殊的 key:cancelCtxKey
,遇到该 key 时,cancelCtx 会返回自身。这个在cancelCtx
实现中会提到。
对于其他的接口调用(Done
, Err
, Deadline
),会路由到嵌入的 Context
上去。
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) } func WithValue(parent Context, key, val interface{}) Context { if key == nil { panic("nil key") } if !reflectlite.TypeOf(key).Comparable() {8 panic("key is not comparable") } return &valueCtx{parent, key, val} // 附加上 kv,并引用父 Context }
可取消的 cancelCtx
context 包中核心实现在 cancelCtx
中,包括构造树形结构、进行级联取消。
type cancelCtx struct { Context mu sync.Mutex // 保证下面三个字段的互斥访问 done chan struct{} // 惰式初始化,被第一个 cancel() 调用所关闭 children map[canceler]struct{} // 被第一个 cancel() 调用置 nil err error // 被第一个 cancel() 调用置非 nil } func (c *cancelCtx) Value(key interface{}) interface{} { if key == &cancelCtxKey { return c } return c.Context.Value(key) } func (c *cancelCtx) Done() <-chan struct{} { c.mu.Lock() if c.done == nil { c.done = make(chan struct{}) } d := c.done c.mu.Unlock() return d }
Value()
函数的实现有点意思,遇到特殊 key:cancelCtxKey
时,会返回自身。这个其实是复用了 Value 函数的回溯逻辑,从而在 Context 树回溯链中遍历时,可以找到给定 Context 的第一个祖先 cancelCtx
实例。
children
保存的是子树中所有路径向下走的第一个可以 cancel 的 Context (实现了 canceler
接口,比如 cancelCtx
或 timerCtx
节点),可以参考后面的图来形象理解。
下面将逐一详细说明。
回溯链
回溯链是各个 context 包在实现时利用 go 语言嵌入(embedding)的特性来构造的,主要用于:
Value()
函数被调用时沿着回溯链向上查找匹配的键值对。- 复用
Value()
的逻辑查找最近cancelCtx
祖先,以构造 Context 树。
在 valueCtx
、cancelCtx
、timerCtx
中只有 cancelCtx
直接(valueCtx
和 timerCtx
都是通过嵌入实现,调用该方法会直接转发到 cancelCtx
或者 emptyCtx
)实现了非空 Done()
方法,因此 done := parent.Done()
会返回第一个祖先 cancelCtx
中的 done channel。但如果 Context 树中有第三方实现的 Context 接口的实例时,parent.Done()
就有可能返回其他 channel。
因此,如果 p.done != done
,说明在回溯链中遇到的第一个实现非空 Done()
Context 是第三方 Context ,而非 cancelCtx
。
// parentCancelCtx 返回 parent 的第一个祖先 cancelCtx 节点 func parentCancelCtx(parent Context) (*cancelCtx, bool) { done := parent.Done() // 调用回溯链中第一个实现了 Done() 的实例(第三方Context类/cancelCtx) if done == closedchan || done == nil { return nil, false } p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) // 回溯链中第一个 cancelCtx 实例 if !ok { return nil, false } p.mu.Lock() ok = p.done == done p.mu.Unlock() if !ok { // 说明回溯链中第一个实现 Done() 的实例不是 cancelCtx 的实例 return nil, false } return p, true }
树构建
Context 树的构建是在调用 context.WithCancel()
调用时通过 propagateCancel
进行的。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { c := newCancelCtx(parent) propagateCancel(parent, &c) return &c, func() { c.cancel(true, Canceled) } }
Context 树,本质上可以细化为 canceler
(*cancelCtx
和 *timerCtx
)树,因为在级联取消时只需找到子树中所有的 canceler
,因此在实现时只需在树中保存所有 canceler
的关系即可(跳过 valueCtx
),简单且高效。