Golang之Context的迷思
对我而言,Golang 中的 Context 一直是谜一样的存在,如果你还不了解它,建议阅读「快速掌握 Golang context 包,简单示例」,本文主要讨论一些我曾经的疑问。
Context 到底是干什么的?
如果你从没接触过 Golang,那么按其它编程语言的经验来推测,多半会认为 Context 是用来读写一些请求级别的公共数据的,事实上 Context 也确实拥有这样的功能,我曾写过一篇文章「在Golang的HTTP请求中共享数据」描述相关用法:
Value(key interface{}) interface{} WithValue(parent Context, key, val interface{}) Context不过除此之外,Context 还有一个功能是控制 goroutine 的退出:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)把两个毫不相干的功能合并在同一个包里,无疑增加了使用者的困扰,Dave Cheney 曾经吐槽:「Context isn’t for cancellation」,按他的观点:Context 只应该用来读写一些请求级别的公共数据,而不应该用来控制 goroutine 的退出,况且用 Context 来控制 goroutine 的退出,在功能上并不完整(没有确认机制),原文:
Context‘s most important facility, broadcasting a cancellation signal, is incomplete as there is no way to wait for the signal to be acknowledged.
此外,Michal Štrba 的观点更为尖锐:「Context should go away for Go 2」,用 Context 来读写一些请求级别的公共数据,本身就是一种拙劣的设计;而用 Context 来控制 goroutine 退出亦如此,正确的做法应该是在语言层面解决,不过关于这一点,只能寄希望于 Golang 2.0 能有所作为了。
从目前社区的使用主流情况来看,基本上不推荐用 Context 来读写一些请求级别的公共数据,主要还是使用 Context 控制 goroutine 的退出。
Context 一定是第一个参数么?
如果你用 Context 写过程序,那么多半看过文档上建议不要在 struct 里保存 Context,而应该显式的传递方法,并且作为方法的第一个参数:
Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter.
可是我们偏偏在标准库里就能看到一个反例 http.Request:
type Request struct { // ... // ctx is either the client or server context. It should only // be modified via copying the whole Request using WithContext. // It is unexported to prevent people from using Context wrong // and mutating the contexts held by callers of the same request. ctx context.Context }
一边说不要把 Context 放到 struct 里,另一方面却偏偏这么干,是不是自相矛盾?实际上,这是文档描述问题,按照惯用法,Context 应该作为方法的第一个参数,但是如果 struct 类型本身就是方法的参数的话,那么把 Context 放到 struct 里并无不妥之处,http.Request 就属于此类情况,关键在于只是传递 Context 不是存储 Context。
顺便说一句,把 Context 作为方法的第一个参数真是丑爆了!引用「Context should go away for Go 2」的话来说:「Context is like a virus」,你如果不相信可以看看标准库 database/sql 的 API 设计,我保证你想死的心都有了。
Context 控制 goroutine 的退出有什么好处?
我们知道 Context 是在 Golang 1.7 才成为标准库的,那么在此之前,人们是如何控制 goroutine 退出呢?下面举例看看如何退出多个 goroutines:
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup do := make(chan int) done := make(chan int) for i := 0; i < 10; i++ { wg.Add(1) go func(i int) { defer wg.Done() select { case <-do: fmt.Printf("Work: %d\n", i) case <-done: fmt.Printf("Quit: %d\n", i) } }(i) } close(done) wg.Wait() }
注意代码里的 done,它用来关闭 goroutines,实际使用非常简单,只要调用 close 即可,所有的 goroutines 都会收到关闭的消息。是不是很简单,如此说来,那为什么还要用 Context 控制 goroutine 的退出呢,它有什么特别的好处?实际上这是因为 Context 实现了继承,可以完成更复杂的操作,虽然我们自己编码也能实现,但是通过使用 Context,可以让代码更标准化一些,下面引用「如何正确使用 Context – Jack Lindamood」中的例子来说明一下:
type userID string func tree() { ctx1 := context.Background() ctx2, _ := context.WithCancel(ctx1) ctx3, _ := context.WithTimeout(ctx2, time.Second*5) ctx4, _ := context.WithTimeout(ctx3, time.Second*3) ctx5, _ := context.WithTimeout(ctx3, time.Second*6) ctx6 := context.WithValue(ctx5, userID("UserID"), 123) // ... }
如此构造了 Context 继承链:
当 3s 超时后,ctx4 会被触发:
当 5s 超时后,ctx3 会被触发,不急如此,其子节点 ctx5 和 ctx6 也会被触发,即便 ctx5 本身的超时时间还没到,但因为它的父节点已经被触发了,所以它也会被触发:
总体来说,Context 是一个实战派的产物,虽然谈不上优雅,但是它已经是社区里的事实标准。实际使用中,任何有可能「慢」的方法都应该考虑通过 Context 实现退出机制,以避免因为无法退出导致泄露问题,对于服务端编程而言,通常意味着你很多方法的第一个参数都会是 Context,虽然丑爆了,但在出现更好的解决方案之前,忍着!
文章来源:
Author:老王
link:https://huoding.com/2019/04/15/730