原文链接:Context Control in Go | Best practices for handling context plumbing.
作者:Horace
在 Go 中处理上下文管道时,需要遵守三条主要规则:
- 只有入口点函数才能创建新的上下文;
- 上下文只能在调用链中向下传递;
- 在函数返回后,不要存储上下文或以其他方式使用它们。
上下文是 Go 的基础构件之一。对 Go 语言稍有了解的人都可能接触过它,因为它是传递给接受上下文的函数的第一个参数。我认为上下文有两个目的:
- 通过信号提供跨 API 边界的控制流机制。
- 跨应用程序接口(API)边界传输具有请求范围的数据
本篇文章将重点介绍利用上下文进行控制流操作的最佳实践。
有几条经验法则可供参考
- 只有入口点函数(位于调用链顶端的函数)才应创建空上下文(即
context.Background()
)。例如,main()
、TestXxx()
。HTTP 库会为每个请求创建一个自定义上下文,你应该访问并传递它。当然,中链函数如果需要共享数据或对其调用的函数进行流程控制,也可以创建子上下文来传递。 - 上下文(只能 )在调用链中向下传递。如果你不在入口点函数中,而你需要调用一个需要上下文的函数,那么你的函数应该接受上下文并将其传递下去。但如果由于某种原因,您目前无法访问调用链顶端的上下文怎么办?在这种情况下,可以使用
context.TODO()
。这表示上下文尚未可用,需要进一步处理。也许你所依赖的另一个库的维护者需要扩展他们的函数以接受上下文,这样你就可以反过来传递上下文了。当然,函数不应该返回上下文。
在处理上下文时,有三条关键的经验法则。上述前两条相对简单。第三条规则是我撰写这篇文章的原因,因为我在本周遇到了它。
故事线
上下文文档指出:
不要将
Context
保存在结构类型中;相反,应将Context
明确传递给每个需要它的函数。
我以为我已经大概理解了这一点,而且听起来也很容易遵守。因此,本周早些时候,当我收到一条关于代码审查的评论,告诉我 "不要存储上下文 "时,我感到非常惊讶和困惑。
我的第一反应是 "我没有存储上下文......"。我的结构中没有上下文!
我做错了什么?让我来介绍一下背景。如果你只想了解第三条规则,而不需要前言,请跳到下一节。
注意:下面讨论的代码示例是我所面临问题的简化近似值。虽然示例应该没有问题,但可能会有错别字。
想象一个长时间运行的例程,它向某个源发出请求并将其接收到的数据转发到 PubSub 服务。它会一直这样做,直到调用者告诉例程停止。这个相对常见的系统可能看起来像这样:
go
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()
这很好。然而,我(以我无穷的智慧)认为我可以简化事情。我知道
- 该例程的调用者总是希望异步运行该例程(我编写了唯一的调用者),并且
- 一旦例程启动,调用者需要做的唯一操作就是停止例程。
于是,我想出了这个办法:
go
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()
进行了这样的修改:
go
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
}
}
}
同样,这本应是另一个迹象,表明我的黑客技术并非如此天才。我用同样的逻辑来处理上下文取消和停止调用。不过,我还是对自己的工作太满意了,所以我把清理逻辑抽象到了自己的方法中,然后继续前进。
总之,你能发现我是如何存储上下文的吗,尽管我只是将其传递给函数,而从未将其放入结构体中? 问题在于,Start()
会获取上下文,将其传递给一个 goroutine,然后返回。即使在返回后,传递给它的上下文仍在使用,这就打破了生命周期的预期,就像我把它藏在结构体中一样。
于是,我用橡皮鸭调试法评估了我的代码:
我: 那么,这是否意味着我必须扔掉这一切,重新开始?
橡皮鸭: 其中有一些有趣的想法。只要稍加调整就能奏效。首先,不要再忽视最佳实践,让工作同步进行。
我: 有道理。调用者只需多写两个字符就能实现异步--工作量并不大。但等等!如果 Start()
是阻塞的,那么调用者将如何访问 Stop()
?我不得不回到 New() -> [Run, Stop]
的方式...
橡皮鸭: 那么,目前你们有两种停止机制,它们的工作原理完全相同。
我: 你说得对!可取消上下文是一种极好的反转控制机制。我不需要创建一个自定义的 Stop
函数。
go
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:不要存储上下文
该规则的核心内容是:
当函数接受一个上下文参数时,该上下文只应在调用期间使用,而不应在函数返回后使用。
理由是,一旦函数返回,调用者通常会取消上下文。那么,使用该上下文进行的任何调用在开始之前就会被取消,从而导致错误。这些错误可能是最难以确定根本原因的,因此最好排除这种可能性。
关于此主题的其他参考资料: