也许是 Go Context 最佳实践
最早 context
是独立的第三方库,后来才移到标准库里。关于这个库该不该用有很多争义,比如 Context should go away for Go 2[1]. 不管争义多大,本着务实的哲学,所有的开源项目都重度使用,当然也包括业务代码。
但是我发现并不是每个人都了解 context
, 从去年到现在就见过两次因为错误使用导致的问题。每个同学都会踩到坑,今天分享下 context
库使用的 Dos and Don'ts
原理
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Context
是一个接口
Deadline
ctx 如果在某个时间点关闭的话,返回该值。否则 ok 为 falseDone
返回一个 channel, 如果超时或是取消就会被关闭,实现消息通讯Err
如果当前 ctx 超时或被取消了,那么Err
返回错误Value
根据某个 key 返回对应的 value, 功能类似字典
目前的实现有 emptyCtx
, valueCtx
, cancelCtx
, timerCtx
. 可以基于某个 Parent
派生成 Child Context
func WithValue(parent Context, key, val interface{}) Context
func WithCancel(parent Context) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
这是四个常用的派生函数,WithValue
包装 key/value 返回 valueCtx
, 后三个返回两个值 Context
是 child ctx, CancelFunc
是取消该 ctx 的函数。基于这个特性呢,经过多次派生,context
是一个树形结构
context tree
如上图所示,是一个多叉树。如果 root
调用 cancel 函数那么所有 children
也都会级联 cancel, 因为保存 children
的是一个 map, 也就无所谓先序中序后序了。如果 ctx 1-1
cancel, 那么他的 children
都会 cancel, 但是 root
与 ctx 1-2
则不会受影响。
业务代码当调用栈比较深时,就会出现这个多叉树的形状,另外 http 库己经集成了 context
, 每个 endpoint 的请求自带一个从 http 库派生出来的 child
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 // already canceled
}
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
可以通过 cancelCtx
的 cancel
看到原理,级联 cancel 所有 children
场景
来看一下使用场景吧,以一个标准的 watch etcd
来入手
func watch(ctx context.Context, revision int64) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for {
rch := watcher.Watch(ctx, watchPath, clientv3.WithRev(revision))
for wresp := range rch {
......
doSomething()
}
select {
case <-ctx.Done():
// server closed, return
return
default:
}
}
}
首先基于参数传进来的 parent ctx
生成了 child ctx
与 cancel
函数。然后 Watch
时传入 child ctx
, 如果此时 parent ctx
被外层 cancel 的话,child ctx
也会被 cancel, rch
会被 etcd clientv3
关闭,然后 for 循环走到 select 逻辑,此时 child ctx
被取消了,所以 <-ctx.Done()
生效,watch
函数返回。
其于 context
可以很好的做到多个 goroutine 协作,超时管理,大大简化了开发工作。
Bad Cases
那我们看几个错误使用 context
的案例,都非常经典
1. 打印 ctx
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
以 WithCancel
为例子,可以看到 child
同时引用了 parent
, 而 propagateCancel
函数的存在,parent
也会引用 child
(当 parent 是 cancelCtx 类型时).
如果此时打印 ctx
, 就会递归调用 String()
方法,就会把 key/value 打印出来。如果此时 value 是非线程安全的,比如 map, 就会引发 concurrent read and write
panic.
这个案例就是 http 标准库的实现 server.go:2906[2] 行代码,把 http server 保存到 ctx 中
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
最后调用业务层代码时把 ctx 传给了用户
go c.serve(connCtx)
如果此时打印 ctx, 就会打印 http srv
结构体,这里面就有 map. 感兴趣的可以做个实验,拿 ab 压测很容易复现。
2. 提前超时
func test(){
ctx, cancel := context.WithCancel(ctx)
defer cancel()
doSomething(ctx)
}
func doSomething(ctx){
go doOthers(ctx)
}
当调用栈较深,多人合作时很容易产生这种情况。其实还是没明白 ctx cancel 工作原理,异步 go 出去的业务逻辑需要基于 context.Background()
再派生 child ctx
, 否则就会提前超时返回
3. 自定义 ctx
理论上没必要自定义 ctx, 相比官方实现,自定义有个很大的开销在于 child
如何响应 parent cancel
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
......
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
......
} else {
......
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
......
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
......
return p, true
}
通过源码可知,parent
引用 child
有两种方式,官方 cancelCtx
类型的是用 map 保存。但是非官方的需要开启 goroutine 去监测。本来业务代码己经 goroutine 满天飞了,不加节制的使用只会增加系统负担。
另外听说某大公司嫌弃这个 map, 想要使用数组重写一版:(
原则
最后来总结下 context
使用的几个原则:
除了框架层不要使用 WithValue
携带业务数据,这个类型是interface{}
, 编译期无法确定,运行时 assert 有开销。如果真要携带也要用 thread-safe 的数据一定不要打印 context
, 尤其是从 http 标准库派生出来的,谁知道里面存了什么context
做为第一个参数传给函数,而不是当成结构体的成员字段来使用(虽然 etcd 代码也这么用)尽可能不要自定义用户层 context
,除非收益巨大异步 goroutine 逻辑使用 context
时要清楚谁还持有,会不会提前超时派生出来的 child ctx
一定要配合defer cancel()
使用,释放资源
小结
这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并转发(:
参考资料
Context should go away for Go 2: https://faiface.github.io/post/context-should-go-away-go2/,
[2]server.go: https://github.com/golang/go/blob/master/src/net/http/server.go#L2878,
推荐阅读