在 Go 中处理Context管道时需要遵守三个主要规则:
- 只有入口点函数应该创建新的Context,
- Context仅沿着调用链传递,
- 并且在函数返回后不存储Context或以其他方式使用它们。
上下文Context是 Go 的基本构建块之一。任何对该语言有粗略经验的人都可能遇到过它,因为它是传递给接受Context的函数的第一个参数。
我认为Context的目的有两个:
- 通过信号提供 跨 API 边界的控制流 机制。
- 跨 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 { |
这很好。
然而,当我以为我可以简化事情时,我知道
- 该例程的调用者总是希望异步运行该例程(我编写了唯一的调用者),并且
- 一旦例程启动,调用者需要做的唯一操作就是停止例程。
于是,我想出了这个办法:
type worker struct { |
现在,大多数经验丰富的 Go 开发人员都会跳出来告诉你,库启动自己的 goroutines 是一种反模式。
最佳实践告诉我们,你应该同步执行你的工作,让调用者决定他们是否想要异步执行。
尽管知道这一点,但我还是想:"我正在编写调用程序,不会有问题的"。
现在,我不再需要先调用 New(),然后再调用 Run(),而只需调用 Start(),它将返回一个取消函数。
而且,除了 Start() 之外,我再也不需要导出任何东西了(我最喜欢小巧的 API 表面了)。
这样做之后,我意识到 "哦......我需要确保我也尊重上下文取消"。于是我对 run() 进行了这样的修改:
func (w *worker) run(ctx context.Context) { |
同样,这本应是另一个迹象,表明我的黑客技术并非如此天才。我用同样的逻辑来处理上下文取消和停止调用。不过,我还是对自己的工作太满意了,所以我把清理逻辑抽象到了自己的方法中,然后继续前进。
总之,你能发现我是如何存储Context的吗,尽管我只是将其传递给函数,而从未将其放入结构体中?
问题在于:Start() 获取Context,将其传递给一个 goroutine,然后返回。即使在返回后,传递给它的Context仍在使用,这就打破了生命周期的预期,就像我把它藏在结构体中一样。
解决办法:
可取消context 是一种极好的反转控制机制。我不需要创建一个自定义的 Stop 函数。
type worker struct { |
规则 3:不要存储Context
该规则的核心是:
当函数采用上下文参数时,该上下文只能在调用期间使用,而不是在返回后使用。
基本原理是,一旦函数返回,调用者通常会取消上下文。然后,使用该上下文进行的任何调用都将在开始之前被取消,从而导致错误。这些可能是一些最隐蔽的错误的根本原因,因此最好消除这种可能性。