也许是 Go Context 最佳实践

共 6814字,需浏览 14分钟

 ·

2021-06-13 00:39

最早 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 是一个接口

  1. Deadline ctx 如果在某个时间点关闭的话,返回该值。否则 ok 为 false
  2. Done 返回一个 channel, 如果超时或是取消就会被关闭,实现消息通讯
  3. Err 如果当前 ctx 超时或被取消了,那么 Err 返回错误
  4. 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, 但是 rootctx 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)
 }
}

可以通过 cancelCtxcancel 看到原理,级联 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 ctxcancel 函数。然后 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 nilfalse
 }
  ......
 return p, true
}

通过源码可知,parent 引用 child 有两种方式,官方 cancelCtx 类型的是用 map 保存。但是非官方的需要开启 goroutine 去监测。本来业务代码己经 goroutine 满天飞了,不加节制的使用只会增加系统负担。

另外听说某大公司嫌弃这个 map, 想要使用数组重写一版:(

原则

最后来总结下 context 使用的几个原则:

  1. 除了框架层不要使用 WithValue 携带业务数据,这个类型是 interface{}, 编译期无法确定,运行时 assert 有开销。如果真要携带也要用 thread-safe 的数据
  2. 一定不要打印 context, 尤其是从 http 标准库派生出来的,谁知道里面存了什么
  3. context 做为第一个参数传给函数,而不是当成结构体的成员字段来使用(虽然 etcd 代码也这么用)
  4. 尽可能不要自定义用户层 context,除非收益巨大
  5. 异步 goroutine 逻辑使用 context 时要清楚谁还持有,会不会提前超时
  6. 派生出来的 child ctx 一定要配合 defer cancel() 使用,释放资源

小结

这次分享就这些,以后面还会分享更多的内容,如果感兴趣,可以关注并转发(:

参考资料

[1]

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,



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 102
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报