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