聊一聊 go 的 context

266 阅读3分钟

什么是 context

    通常 server 接收到一个请求之后会创建一个 goroutine 来处理任务。处理请求时, 会创建额外的 goroutine 来访问下游服务「rpc、mysql、redis...」。针对同一个请求产生的不同 goroutines, 他们之间会传递变量「token, timeout...」。当请求超时或取消, 对应的 goroutines 都应该及时的被取消, 释放系统资源。

    在 1.7 版本中, go 将 context 包加入到官方库。通过 context 实现不同 goroutines 之前传递变量, 取消信号, 超时信息。

类型定义

    Context 本身是一个接口, 提供了 Done()、Err()、Deadline()、Value() 方法。Context 并没有提供 Cancel 方法, 这是因为 Context 提供了 Done 方法, 接收一个 channel。Context 不应该同时提供 Cancel 和 Done 方法, 可以通过 context.WithCancel 来取消一个新的 context。

Done():
返回一个 channel, 这个 channel 会在 ctx 完成或取消后关闭, 重复调用返回的是同一个 channel Err():
返回 ctx 结束的具体原因, 如果 ctx 没有被关闭, 返回 nil。
Deadline():
返回 ctx 结束的具体时间, 也就是任务什么时候被截止; 如果 ctx 没有设置截止时间, 返回 ok: false
Value():
返回 ctx 中指定 key 的值。

// A Context carries a deadline, cancellation signal, and request-scoped values// across API boundaries. Its methods are safe for simultaneous use by multiple// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled// or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel// is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

context 派生

    context 包提供了方法派生不同的 ctx. 可以派生的逻辑看作一个树。当跟节点取消之后, 派生出来的所有 ctx 都会取消。 BackGround() 返回一个空 ctx, 通常作为 ctx 的根节点, 对于不同请求生成的 ctx 都应该基于它来派生。 WitchCancel 和 WithTimeout 可以派生出 ctx, 它可以比父 ctx 要更早取消。在 http 请求中, 当 request 超时, 可以及时取消派生出的 ctx。

// Background returns an empty Context. It is never canceled, has no deadline,// and has no values. Background is typically used in main, init, and tests,// and as the top-level Context for incoming requests.
func Background() Context

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithCancel returns a copy of parent whose Done channel is closed as soon as// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// WithTimeout returns a copy of parent whose Done channel is closed as soon as// parent.Done is closed, cancel is called, or timeout elapses. The new// Context's Deadline is the sooner of now+timeout and the parent's deadline, if// any. If the timer is still running, the cancel function releases its// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

怎么使用 context

    go 的官方也给我推荐了使用 context 的一些基本原则:

  • 不应该在 struct 里存储 ctx, 而应该以传参的形式使用 ctx, 将 ctx 作为第一个参数「why:大概是说通过穿参的形式可以做到方法粒度的控制 ctx 里的数据, 超时, 取消信号; 将 ctx 放到 struct 上, 容易造成混淆, 无法确定 ctx 应该作用到哪些方法上; 当然我们在基础库中也看到有 ctx 存到 sturct 中, 这更多的是兼容性考虑」

  • 不要传递一个 nil 的 ctx, 即使函数允许这么做。如果我们不知道传递什么, 可以传递一个 context.TODO()

  • ctx 传递的 value 应该是请求粒度的参数「token、rip」, value 应该是不被修改的, 不应该传递业务逻辑处理的参数 ctx 可以在不同的 goroutines 之间传递, 是并发安全的

服务端处理示例

在服务端开发场景中, 框架通常会在 ctx 存储一些数据「logid, trace_id...」, 便于将同一个 request 的调用链路串起来; 同时需要设置超时时长, 当 request 处理超时, 及时取消对应的子任务, 释放资源

1.创建 context

针对每个请求均需要创建一个 context, 这里创建一个指定超时 5s 和携带 reqID 的 ctx。提供函数, 支持从 ctx 里获取 reqID。针对 ctx 的 key, 应该是不可导出的, 避免 key 冲突。

const DefaultTimeOut = 5 * time.Second
const KReqID = "reqID"

func genReqCtx(ctx context.Context, reqNum int) (context.Context, context.CancelFunc) {
   // 设置 5s 的超时时间
   reqCtx, cancel := context.WithTimeout(ctx, DefaultTimeOut)
   //  添加 reqID
   reqCtx = context.WithValue(reqCtx, KReqID, reqNum)
   return reqCtx, cancel
}

func getReqID(ctx context.Context) (int, error) {
   reqId, ok := ctx.Value(KReqID).(int)
   if ok {
      return reqId, nil
   }
   return 0, errors.New("invalid reqID")
}
  1. 编写业务代码

在 bizHandler 里编写处理逻辑, 通过 select 来判断 ctx 是否结束或子任务执行完毕, 及时退出任务。

func bizHandler(ctx context.Context) error {
   reqID, err := getReqID(ctx)
   if err != nil {
      return err
   }

   fmt.Printf("start processing reqNum: %d \n", reqID)
   errChan := make(chan error)
   // 启动 goroutine 进行计算
   go func() {
      errChan <- calculate(ctx, reqID)
   }()

   select {
   // 超时退出
   case <-ctx.Done():
      // 需要等待 errChan 关闭, 不然会泄露 goroutine
      <-errChan
      return ctx.Err()
      // rpc 出错退出
   case e := <-errChan:
      return e
   }
}
  1. 编写子任务代码

模拟子任务处理逻辑

func calculate(ctx context.Context, reqId int) error {
   fmt.Printf("start calculate reqID: %d \n", reqId)
   for {
      select {
      // 超时或取消, 退出计算
      case <-ctx.Done():
         fmt.Printf("cancel calculate reqID: %d, err: %v \n", reqId, ctx.Err())
         return ctx.Err()
      // 计算
      default:
         fmt.Printf("calculate reqID: %d \n", reqId)
         time.Sleep(time.Second * 2)
      }
   }
}
  1. 函数入口

编写 mian 函数, 模拟不同的 req 请求

func main() {
   // 创建 root ctx
   root := context.Background()
   for reqNum := 0; reqNum < 2; reqNum++ {
      // 创建 goroutine, 处理 req 请求
      go func(rn int) {
         reqCtx, cancel := genReqCtx(root, rn)
         defer cancel()
         if err := bizHandler(reqCtx); err != nil {
            fmt.Printf("process reqNum: %d meet err:%v \n", rn, err)
         } else {
            fmt.Printf("process reqNum: %d success\n", rn)
         }
      }(reqNum)
   }
   time.Sleep(time.Second * 10)
   runtime.GC()
   // 是否有 goroutine 泄露
   fmt.Println(runtime.NumGoroutine())
   time.Sleep(time.Hour)
}

总结

    ctx 核心要解决的问题其实还是不同 goroutines 之间传递信号, 及时告诉子 goroutines 取消相关操作。但是, 按照官方的推荐, 我们每个函数第一个参数都为 ctx, 这会导致 ctx 像病毒一样不断扩散; 同时, ctx 里面可以携带数据, 在开发过程中, 我们很难知道 ctx 里都携带了哪些数据, 很难做相应处理。

    从开发者视角来看, 我们可以使用 ctx 很好的设置 timeout, 通知 goroutines 及时取消。应尽量避免在 ctx 里传递非通用的数据。