Go中Context生命周期控制问题


在 Go 中处理Context管道时需要遵守三个主要规则:

  • 只有入口点函数应该创建新的Context,
  • Context仅沿着调用链传递,
  • 并且在函数返回后不存储Context或以其他方式使用它们。

上下文Context是 Go 的基本构建块之一。任何对该语言有粗略经验的人都可能遇到过它,因为它是传递给接受Context的函数的第一个参数。

我认为Context的目的有两个:

  1. 通过信号提供 跨 API 边界的控制流 机制。
  2. 跨 API 边界携带请求范围的数据。

这篇文章将重点关注利用控制流操作的Context上下文的良好实践。

首先要遵循几条经验法则。

  • 只有入口点函数(调用链顶端的函数)才应创建空上下文(即 context.Background())。例如,main()、TestXxx()。HTTP 库会为每个请求创建一个自定义Context,你应该访问并传递它。当然,中链函数如果需要共享数据或对其调用的函数进行流程控制,也可以创建子Context来传递。
  • Context(只能)在调用链中向下传递。如果你不在入口点函数中,而你需要调用一个需要Context的函数,那么你的函数应该接受一个Context并将其传递给它。但如果由于某种原因,您目前无法访问调用链顶端的上下文怎么办?在这种情况下,可以使用 context.TODO()。这表示Context尚未可用,需要进一步处理。也许你所依赖的另一个库的维护者需要扩展他们的函数以接受Context,这样你就可以反过来传递Context了。当然,函数不应该返回Context。

Context文档指出:
不要将Context存储在结构类型中;相反,将 Context 显式传递给每个需要它的函数。

我以为我已经含蓄地理解了这一点,而且听起来很容易遵守。因此,本周早些时候,当我收到一条关于代码审查的评论告诉我“不要存储上下文”时,我感到惊讶和困惑,因为我的结构中没有上下文!

我做错了什么?让我来设置上下文(双关语)。如果您只想要第三条规则(没有序言),请跳到下一部分。

想象一个长时间运行的例程,它向某个源发出请求并将其接收到的数据转发到 PubSub 服务。它会一直这样做,直到调用者告诉例程停止。这个相对常见的系统可能看起来像这样:

type Worker struct {
  quit chan struct{}
  // internal details
}

// New configures and returns a Worker.
func New(ctx context.Context, ...) (*Worker, error)

func (w *Worker) Run(ctx context.Context)

func (w *Worker) Stop()

这很好。

然而,当我以为我可以简化事情时,我知道

  •  该例程的调用者总是希望异步运行该例程(我编写了唯一的调用者),并且
  •  一旦例程启动,调用者需要做的唯一操作就是停止例程。

于是,我想出了这个办法:

type worker struct {
  quit chan struct{}
  // other internal details
}

func Start(ctx context.Context, ...) (cancel func()){
 
// Configure setup. Details elided.
  w := &worker{...}

  go w.run(ctx context.Context)
  return w.stop
}

func (w *worker) run(ctx context.Context) {
  ticker := time.NewTicker(time.Minute)
  defer ticker.Stop()
  for {
    select {
    case <- w.quit:
     
// perform cleanup
    case <-ticker.C:
      cctx, cancel := context.WithTimeout(ctx, 30 * time.Second)
      w.doWork(cctx)
      cancel()
    }
  }
}

func (w *worker) stop() {
  close(w.quit)
}

现在,大多数经验丰富的 Go 开发人员都会跳出来告诉你,库启动自己的 goroutines 是一种反模式。

最佳实践告诉我们,你应该同步执行你的工作,让调用者决定他们是否想要异步执行。

尽管知道这一点,但我还是想:"我正在编写调用程序,不会有问题的"。

现在,我不再需要先调用 New(),然后再调用 Run(),而只需调用 Start(),它将返回一个取消函数。

而且,除了 Start() 之外,我再也不需要导出任何东西了(我最喜欢小巧的 API 表面了)。

这样做之后,我意识到 "哦......我需要确保我也尊重上下文取消"。于是我对 run() 进行了这样的修改:

func (w *worker) run(ctx context.Context) {
  ticker := time.NewTicker(time.Minute)
  defer ticker.Stop()
  for {
    select {
    case <- w.quit:
      // perform cleanup
    case <- ctx.Done():
     
// perform cleanup
    case <-ticker.C:
     
// do work
    }
  }
}

同样,这本应是另一个迹象,表明我的黑客技术并非如此天才。我用同样的逻辑来处理上下文取消和停止调用。不过,我还是对自己的工作太满意了,所以我把清理逻辑抽象到了自己的方法中,然后继续前进。

总之,你能发现我是如何存储Context的吗,尽管我只是将其传递给函数,而从未将其放入结构体中?
问题在于:Start() 获取Context,将其传递给一个 goroutine,然后返回。即使在返回后,传递给它的Context仍在使用,这就打破了生命周期的预期,就像我把它藏在结构体中一样。

解决办法:
可取消context 是一种极好的反转控制机制。我不需要创建一个自定义的 Stop 函数。

type worker struct {
  // internal details. no stop channel.
}

// Start configures and runs the worker.
// Blocks until context cancellation.
func Start(ctx context.Context, ...){
 
// Configure setup. Details elided.
  w := worker{...}
 
// blocking call to run
  w.run(ctx context.Context)
}

func (w *worker) run(ctx context.Context) {
  ticker := time.NewTicker(time.Minute)
  defer ticker.Stop()
  for {
    select {
    case <- ctx.Done:
     
// perform cleanup
    case <-ticker.C:
     
// do work
    }
  }
}

规则 3:不要存储Context
该规则的核心是:
当函数采用上下文参数时,该上下文只能在调用期间使用,而不是在返回后使用。

基本原理是,一旦函数返回,调用者通常会取消上下文。然后,使用该上下文进行的任何调用都将在开始之前被取消,从而导致错误。这些可能是一些最隐蔽的错误的根本原因,因此最好消除这种可能性。