32 context.Context类型 我们在上篇文章中讲到了

sync.WaitGroup 类型:一个可以帮我们实现一对多goroutine协作流程的同步工具。

**在使用

WaitGroup 值的时候,我们最好用“先统一

Add ,再并发

Done ,最后

Wait ”的标准模式来构建协作流程。**

如果在调用该值的

Wait 方法的同时,为了增大其计数器的值,而并发地调用该值的

Add 方法,那么就很可能会引发panic。

这就带来了一个问题,如果我们不能在一开始就确定执行子任务的goroutine的数量,那么使用

WaitGroup 值来协调它们和分发子任务的goroutine,就是有一定风险的。一个解决方案是:分批地启用执行子任务的goroutine。

前导内容:WaitGroup值补充知识

我们都知道,

WaitGroup 值是可以被复用的,但需要保证其计数周期的完整性。尤其是涉及对其

Wait 方法调用的时候,它的下一个计数周期必须要等到,与当前计数周期对应的那个

Wait 方法调用完成之后,才能够开始。

我在前面提到的可能会引发panic的情况,就是由于没有遵循这条规则而导致的。

只要我们在严格遵循上述规则的前提下,分批地启用执行子任务的goroutine,就肯定不会有问题。具体的实现方式有不少,其中最简单的方式就是使用

for 循环来作为辅助。这里的代码如下: func coordinateWithWaitGroup() { total := 12 stride := 3 var num int32 fmt.Printf(“The number: %d [with sync.WaitGroup]\n”, num) var wg sync.WaitGroup for i := 1; i <= total; i = i + stride { wg.Add(stride) for j := 0; j < stride; j++ { go addNum(&num, i+j, wg.Done) } wg.Wait() } fmt.Println(“End.”) }

这里展示的

coordinateWithWaitGroup 函数,就是上一篇文章中同名函数的改造版本。而其中调用的

addNum 函数,则是上一篇文章中同名函数的简化版本。这两个函数都已被放置在了demo67.go文件中。

我们可以看到,经过改造后的

coordinateWithWaitGroup 函数,循环地使用了由变量

wg 代表的

WaitGroup 值。它运用的依然是“先统一

Add ,再并发

Done ,最后

Wait ”的这种模式,只不过它利用

for 语句,对此进行了复用。

好了,至此你应该已经对

WaitGroup 值的运用有所了解了。不过,我现在想让你使用另一种工具来实现上面的协作流程。

**我们今天的问题就是:怎样使用

context 包中的程序实体,实现一对多的goroutine协作流程?**

更具体地说,我需要你编写一个名为

coordinateWithContext 的函数。这个函数应该具有上面

coordinateWithWaitGroup 函数相同的功能。

显然,你不能再使用

sync.WaitGroup 了,而要用

context 包中的函数和

Context 类型作为实现工具。这里注意一点,是否分批启用执行子任务的goroutine其实并不重要。

我在这里给你一个参考答案。 func coordinateWithContext() { total := 12 var num int32 fmt.Printf(“The number: %d [with context.Context]\n”, num) cxt, cancelFunc := context.WithCancel(context.Background()) for i := 1; i <= total; i++ { go addNum(&num, i, func() { if atomic.LoadInt32(&num) == int32(total) { cancelFunc() } }) } <-cxt.Done() fmt.Println(“End.”) }

在这个函数体中,我先后调用了

context.Background 函数和

context.WithCancel 函数,并得到了一个可撤销的

context.Context 类型的值(由变量

cxt 代表),以及一个

context.CancelFunc 类型的撤销函数(由变量

cancelFunc 代表)。

在后面那条唯一的

for 语句中,我在每次迭代中都通过一条

go 语句,异步地调用

addNum 函数,调用的总次数只依据了

total 变量的值。

请注意我给予

addNum 函数的最后一个参数值。它是一个匿名函数,其中只包含了一条

if 语句。这条

if 语句会“原子地”加载

num 变量的值,并判断它是否等于

total 变量的值。

如果两个值相等,那么就调用

cancelFunc 函数。其含义是,如果所有的

addNum 函数都执行完毕,那么就立即通知分发子任务的goroutine。

这里分发子任务的goroutine,即为执行

coordinateWithContext 函数的goroutine。它在执行完

for 语句后,会立即调用

cxt 变量的

Done 函数,并试图针对该函数返回的通道,进行接收操作。

由于一旦

cancelFunc 函数被调用,针对该通道的接收操作就会马上结束,所以,这样做就可以实现“等待所有的

addNum 函数都执行完毕”的功能。

问题解析

context.Context 类型(以下简称

Context 类型)是在Go 1.7发布时才被加入到标准库的。而后,标准库中的很多其他代码包都为了支持它而进行了扩展,包括:

os/exec 包、

net 包、

database/sql 包,以及

runtime/pprof 包和

runtime/trace 包,等等。

Context 类型之所以受到了标准库中众多代码包的积极支持,主要是因为它是一种非常通用的同步工具。它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。

更具体地说,

Context 类型可以提供一类代表上下文的值。此类值是并发安全的,也就是说它可以被传播给多个goroutine。

由于

Context 类型实际上是一个接口类型,而

context 包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。

Context 类型的值(以下简称

Context 值)是可以繁衍的,这意味着我们可以通过一个

Context 值产生出任意个子值。这些子值可以携带其父值的属性和数据,也可以响应我们通过其父值传达的信号。

正因为如此,所有的

Context 值共同构成了一颗代表了上下文全貌的树形结构。这棵树的树根(或者称上下文根节点)是一个已经在

context 包中预定义好的

Context 值,它是全局唯一的。通过调用

context.Background 函数,我们就可以获取到它(我在

coordinateWithContext 函数中就是这么做的)。

这里注意一下,这个上下文根节点仅仅是一个最基本的支点,它不提供任何额外的功能。也就是说,它既不可以被撤销(cancel),也不能携带任何数据。

除此之外,

context 包中还包含了四个用于繁衍

Context 值的函数,即:

WithCancel 、

WithDeadline 、

WithTimeout 和

WithValue 。

这些函数的第一个参数的类型都是

context.Context ,而名称都为

parent 。顾名思义,这个位置上的参数对应的都是它们将会产生的

Context 值的父值。

WithCancel 函数用于产生一个可撤销的

parent 的子值。在

coordinateWithContext 函数中,我通过调用该函数,获得了一个衍生自上下文根节点的

Context 值,和一个用于触发撤销信号的函数。

WithDeadline 函数和

WithTimeout 函数则都可以被用来产生一个会定时撤销的

parent 的子值。至于

WithValue 函数,我们可以通过调用它,产生一个会携带额外数据的

parent 的子值。

到这里,我们已经对

context 包中的函数和

Context 类型有了一个基本的认识了。不过这还不够,我们再来扩展一下。

知识扩展

问题1:“可撤销的”在

context 包中代表着什么?“撤销”一个

Context 值又意味着什么?

我相信很多初识

context 包的Go程序开发者,都会有这样的疑问。确实,“可撤销的”(cancelable)这个词在这里是比较抽象的,很容易让人迷惑。我这里再来解释一下。

这需要从

Context 类型的声明讲起。这个接口中有两个方法与“撤销”息息相关。

Done 方法会返回一个元素类型为

struct{} 的接收通道。不过,这个接收通道的用途并不是传递元素值,而是让调用方去感知“撤销”当前

Context 值的那个信号。

一旦当前的

Context 值被撤销,这里的接收通道就会被立即关闭。我们都知道,对于一个未包含任何元素值的通道来说,它的关闭会使任何针对它的接收操作立即结束。

正因为如此,在

coordinateWithContext 函数中,基于调用表达式

cxt.Done() 的接收操作,才能够起到感知撤销信号的作用。

除了让

Context 值的使用方感知到撤销信号,让它们得到“撤销”的具体原因,有时也是很有必要的。后者即是

Context 类型的

Err 方法的作用。该方法的结果是

error 类型的,并且其值只可能等于

context.Canceled 变量的值,或者

context.DeadlineExceeded 变量的值。

前者用于表示手动撤销,而后者则代表:由于我们给定的过期时间已到,而导致的撤销。

你可能已经感觉到了,对于

Context 值来说,“撤销”这个词如果当名词讲,指的其实就是被用来表达“撤销”状态的信号;如果当动词讲,指的就是对撤销信号的传达;而“可撤销的”指的则是具有传达这种撤销信号的能力。

我在前面讲过,当我们通过调用

context.WithCancel 函数产生一个可撤销的

Context 值时,还会获得一个用于触发撤销信号的函数。

通过调用这个函数,我们就可以触发针对这个

Context 值的撤销信号。一旦触发,撤销信号就会立即被传达给这个

Context 值,并由它的

Done 方法的结果值(一个接收通道)表达出来。

撤销函数只负责触发信号,而对应的可撤销的

Context 值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,

Context 值对此并没有任何的约束。

最后,若再深究的话,这里的“撤销”最原始的含义其实就是,终止程序针对某种请求(比如HTTP请求)的响应,或者取消对某种指令(比如SQL指令)的处理。这也是Go语言团队在创建

context 代码包,和

Context 类型时的初衷。

如果我们去查看

net 包和

database/sql 包的API和源码的话,就可以了解它们在这方面的典型应用。

问题2:撤销信号是如何在上下文树中传播的?

我在前面讲了,

context 包中包含了四个用于繁衍

Context 值的函数。其中的

WithCancel 、

WithDeadline 和

WithTimeout 都是被用来基于给定的

Context 值产生可撤销的子值的。

context 包的

WithCancel 函数在被调用后会产生两个结果值。第一个结果值就是那个可撤销的

Context 值,而第二个结果值则是用于触发撤销信号的函数。

在撤销函数被调用之后,对应的

Context 值会先关闭它内部的接收通道,也就是它的

Done 方法会返回的那个通道。

然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会如法炮制,把撤销信号继续传播下去。最后,这个

Context 值会断开它与其父值之间的关联。

(在上下文树中传播撤销信号)

我们通过调用

context 包的

WithDeadline 函数或者

WithTimeout 函数生成的

Context 值也是可撤销的。它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销。这里定时撤销的功能是借助它们内部的计时器来实现的。

当过期时间到达时,这两种

Context 值的行为与

Context 值被手动撤销时的行为是几乎一致的,只不过前者会在最后停止并释放掉其内部的计时器。

最后要注意,通过调用

context.WithValue 函数得到的

Context 值是不可撤销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。

问题 3:怎样通过

Context 值携带数据?怎样从中获取数据?

既然谈到了

context 包的

WithValue 函数,我们就来说说

Context 值携带数据的方式。

WithValue 函数在产生新的

Context 值(以下简称含数据的

Context 值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。

原因很简单,当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。不过,这种

Context 值并不是用字典来存储键和值的,后两者只是被简单地存储在前者的相应字段中而已。

Context 类型的

Value 方法就是被用来获取数据的。在我们调用含数据的

Context 值的

Value 方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。

如果其父值中仍然未存储相等的键,那么该方法就会沿着上下文根节点的方向一路查找下去。

注意,除了含数据的

Context 值以外,其他几种

Context 值都是无法携带数据的。因此,

Context 值的

Value 方法在沿路查找的时候,会直接跨过那几种值。

如果我们调用的

Value 方法的所属值本身就是不含数据的,那么实际调用的就将会是其父辈或祖辈的

Value 方法。这是由于这几种

Context 值的实际类型,都属于结构体类型,并且它们都是通过“将其父值嵌入到自身”,来表达父子关系的。

最后,提醒一下,

Context 接口并没有提供改变数据的方法。因此,在通常情况下,我们只能通过在上下文树中添加含数据的

Context 值来存储新的数据,或者通过撤销此种值的父值丢弃掉相应的数据。如果你存储在这里的数据可以从外部改变,那么必须自行保证安全。

总结

我们今天主要讨论的是

context 包中的函数和

Context 类型。该包中的函数都是用于产生新的

Context 类型值的。

Context 类型是一个可以帮助我们实现多goroutine协作流程的同步工具。不但如此,我们还可以通过此类型的值传达撤销信号或传递数据。

Context 类型的实际值大体上分为三种,即:根

Context 值、可撤销的

Context 值和含数据的

Context 值。所有的

Context 值共同构成了一颗上下文树。这棵树的作用域是全局的,而根

Context 值就是这棵树的根。它是全局唯一的,并且不提供任何额外的功能。

可撤销的

Context 值又分为:只可手动撤销的

Context 值,和可以定时撤销的

Context 值。

我们可以通过生成它们时得到的撤销函数来对其进行手动的撤销。对于后者,定时撤销的时间必须在生成时就完全确定,并且不能更改。不过,我们可以在过期时间达到之前,对其进行手动的撤销。

一旦撤销函数被调用,撤销信号就会立即被传达给对应的

Context 值,并由该值的

Done 方法返回的接收通道表达出来。

“撤销”这个操作是

Context 值能够协调多个goroutine的关键所在。撤销信号总是会沿着上下文树叶子节点的方向传播开来。

含数据的

Context 值可以携带数据。每个值都可以存储一对键和值。在我们调用它的

Value 方法的时候,它会沿着上下文树的根节点的方向逐个值的进行查找。如果发现相等的键,它就会立即返回对应的值,否则将在最后返回

nil 。

含数据的

Context 值不能被撤销,而可撤销的

Context 值又无法携带数据。但是,由于它们共同组成了一个有机的整体(即上下文树),所以在功能上要比

sync.WaitGroup 强大得多。

思考题

今天的思考题是:

Context 值在传达撤销信号的时候是广度优先的,还是深度优先的?其优势和劣势都是什么?

戳此查看Go语言专栏文章配套详细代码。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Go%e8%af%ad%e8%a8%80%e6%a0%b8%e5%bf%8336%e8%ae%b2/32%20context.Context%e7%b1%bb%e5%9e%8b.md