Context 到底是干什么的?
如果你从没接触过 Golang,那么按其它编程语言的经验来推测,多半会认为 Context 是用来读写一些请求级别的公共数据的,事实上 Context 也确实拥有这样的功能:
-
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 https://dave.cheney.net/2017/08/20/context-isnt-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 https://faiface.github.io/post/context-should-go-away-go2/」:用 Context 来读写一些请求级别的公共数据,本身就是一种拙劣的设计;而用 Context 来控制 goroutine 退出亦如此,正确的做法应该是在语言层面解决,不过关于这一点,只能寄希望于 Golang 2.0 能有所作为了。
从目前社区对 Context 的使用情况来看,基本上主要还是使用 Context 控制 goroutine 的退出,不管你喜不喜欢,Context 已经成为了一种事实标准。
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:
1type Request struct {2 // ...34 // ctx is either the client or server context. It should only5 // be modified via copying the whole Request using WithContext.6 // It is unexported to prevent people from using Context wrong7 // and mutating the contexts held by callers of the same request.8 ctx context.Context9}
go
一边说不要把 Context 放到 struct 里,另一方面却偏偏这么干,是不是自相矛盾?实际上,这是文档描述问题,按照惯用法,Context 应该作为方法的第一个参数,但是如果 struct 类型本身就是方法的参数的话,那么把 Context 放到 struct 里并无不妥之处,http.Request 就属于此类情况,关键在于只是传递 Context 不是存储 Context。
顺便说一句,把 Context 作为方法的第一个参数真是丑爆了!引用「Context should go away for Go 2」的话来说:「Context is like a virus」,看着代码想死的心都有了。
Context 控制 goroutine 的退出有什么好处?
我们知道 Context 是在 Golang 1.7 才成为标准库的,那么在没有 Context 的时候,人们是如何控制 goroutine 退出呢?下面举例同时运行多个 goroutines,看看如何退出:
1package main 2 3import ( 4 "fmt" 5 "sync" 6) 7 8func main() { 9 var wg sync.WaitGroup1011 do := make(chan int)12 done := make(chan int)1314 for i := 0; i < 10; i++ {15 wg.Add(1)1617 go func(i int) {18 defer wg.Done()1920 select {21 case <-do:22 fmt.Printf("Work: %d\\n", i)23 case <-done:24 fmt.Printf("Quit: %d\\n", i)25 }26 }(i)27 }2829 close(done)3031 wg.Wait()32}
代码里的 wg 之类的代码只是为了演示效果,可以忽视,只要关注 done 的使用就可以了,它用来控制什么时候关闭 goroutines,实际使用非常简单,只要调用 close 即可,所有的 goroutines 都会从 done 收到关闭的消息。如此说来,用 Context 控制 goroutine 的退出有什么好处?这是因为 Context 实现了继承,可以完成更复杂的操作,我们引用「如何正确使用 Context – Jack Lindamood https://blog.lab99.org/post/golang-2017-10-27-video-how-to-correctly-use-package-context.html」中的例子来说明一下:
1type userID string 2 3func tree() { 4 ctx1 := context.Background() 5 ctx2, _ := context.WithCancel(ctx1) 6 ctx3, _ := context.WithTimeout(ctx2, time.Second*5) 7 ctx4, _ := context.WithTimeout(ctx3, time.Second*3) 8 ctx5, _ := context.WithTimeout(ctx3, time.Second*6) 9 ctx6 := context.WithValue(ctx5, userID("UserID"), 123)1011 // ...12}
如此构造了 Context 继承链:

当 3s 超时后,ctx4 会被触发:

当 5s 超时后,ctx3 会被触发,不仅如此,其子节点 ctx5 和 ctx6 也会被触发,即便 ctx5 本身的超时时间还没到,但因为它的父节点已经被触发了,所以它也会被触发:

总体来说,Context 是一个实战派的产物,虽然谈不上优雅,但是它已经是社区里的事实标准。实际使用中,任何有可能「慢」的方法都应该考虑通过 Context 实现退出机制,以避免因为无法退出导致泄露问题,对于服务端编程而言,通常意味着你很多方法的第一个参数都会是 Context,虽然丑爆了,但在出现更好的解决方案之前,忍着!
