Go Context 最佳实践

639次阅读  |  发布于3年以前

去年写了[也许是 Context 最佳实践] , 回头看有些遗漏,重新编辑整理,总结截至 go 1.17 的最佳实践

尽管有人说Context should go away in GO2[1], 但是现有的代码中还是大量使用 Context, 并不是每个人都了解 Context, 从去年到现在就见过两次因为错误使用导致的问题。每个同学都会踩到坑,今天分享下 Context 库使用的 Dos and Don'ts

使用场景

Context 主要有以下三种使用场景

举一个 etcd watch 的例子,我们加深了解

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 关闭,然后 for 循环走到 select 逻辑,此时 child ctx 被取消了,所以 <-ctx.Done() 生效,watch 函数返回

其于 context 可以很好的做到多个 goroutine 协作,超时管理,大大简化了开发工作。这也是 Go 的魅力

原理

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

Context 是一个接口

目前的实现有 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)

经过多次派生后,ctx 是一个类似多叉树的结构。当 ctx-1 被 cancel 时,会级联 cancel 以 ctx-1 为根的整棵树,但是原来的 root, ctx2 ctx3 不受影响

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
 d, _ := c.done.Load().(chan struct{})
 if d == nil {
  c.done.Store(closedchan)
 } else {
  close(d)
 }
 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)
 }
}

首先检测 done channel, 如果有人监听,那么 close 掉,这时所有 wait 这个 ctx 的 goroutines 都会收到消息

然后遍历 children map, 依次 cancel 所有 child, 这里类似树的先序遍历。最后 removeFromParent 将自己从父节点中摘除

几个问题

打印 Ctx

WithCancel 为例子,可以看到 child 同时引用了 parent, 而 propagateCancel 函数的存在,parent 也会引用 child(当 parent 是 cancelCtx 类型时)

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}
}

如果此时打印 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 压测很容易复现

func stringify(v interface{}) string {
 switch s := v.(type) {
 case stringer:
  return s.String()
 case string:
  return s
 }
 return "<not Stringer>"
}

func (c *valueCtx) String() string {
 return contextName(c.Context) + ".WithValue(type " +
  reflectlite.TypeOf(c.key).String() +
  ", val " + stringify(c.val) + ")"
}

同时注意,后来 go 对此做了部份修复,一定程序上解决了问题。但也记住不要打印 ctx

Key/Value 类型不安全

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
 Context
 key, val interface{}
}

强烈不建义使用 Context 传递过多数据,这里可以看到 key/value 类型都是 interface{}, 编译期无法确定类型,运行期需要断言,有性能和安全问题

关闭底层连接

Context 超时会触发 http pool 关闭掉底层 connection, 导致连接频繁销重建,参考之前的文章[超时控制一个反例] 问题在于,要在哪层处理 tcp 无用数据,如果应用层读完再丢掉,此时连接还是可用的,但是操作系统 tcp stack 处理无用数据,那直接就 close. 而 grpc 就没这个问题,因为多路复用,每个请求都是虚拟的 stream, 如果超时,只需关闭 stream, 无需关闭底层 tcp 连接

双向链表

Context 派生层数比较多时,构成了一个双向链表,key/value 获取很有可能退化成 O(N) 操作,非常慢

type valueCtx struct {
 Context
 key, val interface{}
}

func WithValue(parent Context, key, val interface{}) Context {
 if parent == nil {
  panic("cannot create context from nil parent")
 }
 if key == nil {
  panic("nil key")
 }
 if !reflectlite.TypeOf(key).Comparable() {
  panic("key is not comparable")
 }
 return &valueCtx{parent, key, val}
}

func (c *valueCtx) Value(key interface{}) interface{} {
 if c.key == key {
  return c.val
 }
 return c.Context.Value(key)
}

每当添加一个 key/value 时都会生成新的 valueCtx, 查询时,如果当前 ctx 不存在 key, 则递归查询 c.Context

提前超时

func test(){
 ctx, cancel := context.WithCancel(ctx)
 defer cancel()

  doSomething(ctx)
}

func doSomething(ctx){
  go doOthers(ctx)
}

当调用栈较深,多人合作时很容易产生这种情况。其实还是没明白 ctx cancel 工作原理,异步 go 出去的业务逻辑需要基于 context.Background() 再派生 child ctx, 否则就会提前超时返回

另外大家容易忽略的点,默认情况下 grpc 会透传超时时间的,比如入口 A 服务调 B, 超时设置了 2s, B 如果用同一个 Context 去调下游 C, 那么超时就要减去 B 自己处理的时间。如果链路比较长,很可能到达 G 服务时就己经超时了

传递超时可以提前释放资源,否则入口超时了,后端还在处理请求

自定义 Ctx

非常不建义自定义 Context, 原因在于源码中处理是不同的

// 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 满天飞了,不加节制的使用只会增加系统负担

使用建议

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

小结

关于 Context 大家有什么看法,欢迎留言一起讨论,大牛多留言 ^_^

参考资料

[1]Context should go away in GO2: https://faiface.github.io/post/context-should-go-away-go2/,

[2]http server: https://github.com/golang/go/blob/master/src/net/http/server.go#L2878,

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8