极客时间已完结课程限时免费阅读

32 | context.Context类型

32 | context.Context类型-极客时间

32 | context.Context类型

讲述:黄洲君

时长15:51大小7.26M

我们在上篇文章中讲到了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值的函数,即:WithCancelWithDeadlineWithTimeoutWithValue
这些函数的第一个参数的类型都是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值的函数。其中的WithCancelWithDeadlineWithTimeout都是被用来基于给定的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值在传达撤销信号的时候是广度优先的,还是深度优先的?其优势和劣势都是什么?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 20

提建议

上一篇
31 | sync.WaitGroup和sync.Once
下一篇
33 | 临时对象池sync.Pool
unpreview
 写留言

精选留言(45)

  • Cloud
    2018-10-24
    还没用过context包的我看得一愣一愣的
    共 4 条评论
    114
  • Spike
    2018-12-05
    https://blog.golang.org/pipelines https://blog.golang.org/context 要了解context的来源和用法,建议先阅读官网的这两篇blog
    61
  • 勉才
    2019-01-01
    context 树不难理解,context.Background 是根节点,但它是个空的根节点,然后通过它我们可以创建出自己的 context 节点,在这个节点之下又可以创建出新的 context 节点。看了 context 的实现,其实它就是通过一个 channel 来实现,cancel() 就是关闭该管道,context.Done() 在 channel 关闭后,会返回。替我们造的轮子主要实现两个功能:1. 加锁,实现线程安全;2. cancel() 会将本节点,及子节点的 channel 都关闭。
    37
  • Li Yao
    2018-11-16
    如果能举一个实际的应用场景就更好了,这篇看不太懂用途
    共 1 条评论
    34
  • 丁香与茉莉
    2018-11-30
    http://www.flysnow.org/2017/05/12/go-in-action-go-context.html
    共 3 条评论
    21
  • Timo
    2019-05-28
    Context 使用原则 不要把Context放在结构体中,要以参数的方式传递 以Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位。 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递 Context是线程安全的,可以放心的在多个goroutine中传递
    展开
    14
  • 鸠摩·智
    2020-09-05
    @郝老师 有几点疑问烦劳回答下,谢谢! 1、在coordinateWithContext的例子中,总共有12个子goroutine被创建,第12个即最后一个子goroutine在运行结束时,会通过计算defer表达式从而触发cancelFunc的调用,从而通知主goroutine结束在ctx.Done上获取通道的接收等待。我的问题是,在第12个子goroutine计算defer表达式的时候,会不会存在if条件不满足,未执行到cancelFunc的情况?或者说,在此时,第1到第11的子goroutine中,会存在自旋cas未执行完的情况吗?如果这种情况有,是否会导致主goroutine永远阻塞的情况? 2、在撤销函数被调用的时候,在当前context上,通过contex.Done获取的通道会马上感知到吗?还是会同步等待,使撤销信号在当前context的所有subtree上的所有context传播完成后,再感知到?还是有其他情况? 3、WithDeadline和WithTimeout的区别是什么?具体说,deadline是针对某个具体时间,而timeout是针对当前时间的延时来定义自动撤销时间吗? 感谢回复!
    展开

    作者回复: 首先要明确: 1. coordinateWithContext函数里的for语句是为了启用12个goroutine,但是这些go函数谁先执行谁后执行与这些goroutine的启用顺序而关。 2. addNum函数里的defer函数会在它后面的for语句执行完毕之后才开始执行。 所以,第一个问题的答案是:不会。因为总会有一个addNum函数把num的值变成12,然后执行deferFunc函数,并由于num==12而执行cancelFunc函数。 另外,这些go函数在调用addNum函数时会碰到自旋的情况(程序会打印出来),但是绝不会造成死锁,因为这些AddNum函数中的CAS操作早晚会执行成功。原子的Load+CAS外加for语句,相当于乐观锁,而且它们的操作都很“规矩”(都只是+1而已)。你也可以理解为把这12次“累加”串行化了,只不过大家是并发的,都在寻找自己“累加”成功的机会。 第二个问题:当前context会马上感知到,但前提是它是可撤销的。通过WithValue函数构造出来的context只会传递不会感知(通过匿名字段实现的)。 第三个问题:你已经把答案说出来了,我就不复述了。

    共 4 条评论
    7
  • 郝林
    2019-04-17
    我看不少读者都说写一篇难理解。可能的确如此,因为我假设你们已对context包有基本的了解。 不过没关系,你们有此方面的任何问题都可以通过以下三个途径与我讨论: 1. 直接在专栏文章下留言; 2. 在 GoHackers 微信群或者 BearyChat 中的 GoHackers 团队空间里艾特我; 3. 在知识星球中的 GoHackers VIP 社群里向我提问。
    展开
    共 5 条评论
    7
  • 憶海拾貝
    2018-11-15
    服务间调用通常要传递上下文数据,用带值Context在每个函数间传递是一种方式,但从Python过来的我觉得这对代码侵入性蛮大的。请问go中还有什么更好的办法传递上下文数据呢?
    7
  • Shawn
    2018-10-25
    看代码是深度优先,但是我自己写了demo,顺序是乱的,求老师讲解

    作者回复: 打印出来的顺序不定是正常的,因为goroutine会被实时调度啊,打印出来的顺序不一定就是真实顺序。每填语句执行完都可能被调度。

    6
  • Geek_f39659
    2019-11-08
    根据这句话:“A great mental model of using Context is that it should flow through your program. Imagine a river or running water. This generally means that you don’t want to store it somewhere like in a struct. Nor do you want to keep it around any more than strictly needed. Context should be an interface that is passed from function to function down your call stack, augmented as needed.” 感觉上Context设计上更像是一个运行时的动态概念,它更像是代表传统call stack 层层镶套外加分叉关系的那颗树。代表着运行时态的调用树。所以它最好就是只存在于函数体的闭包之中,大家口口相传,“传男不传女”。“因为我调用你,所以我才把这个传给你,你可以传给你的子孙,但不要告诉别人!”。所以最好不要把它保存起来让旁人有机会看得到或用到。 楼上有人提到这种风格的入侵性,我能理解你的感觉。但以我以前玩Node.js中的cls (continuation local storage)踩过的那些坑来看,我宁愿两害相权取其轻。这种入侵性至少是可控的,显式的。同步编程的世界我们只需要TLS(Thread local storage)就好了,但对应的异步编程的世界里玩cls很难搞的。在我来看,Context显然是踩过那些坑的老鸟搞出来的。
    展开
    5
  • mclee
    2022-02-11
    实测了下,context.WithValue 得到的新的 ctx 当其 parent context cancle 时也能收到 done 信号啊,并不是文中说的那样会跳过! package main import ( "context" "fmt" "time" ) func main() { ctx1, cancelFun := context.WithCancel(context.Background()) ctx2 := context.WithValue(ctx1, "", "") ctx3, _ := context.WithCancel(ctx1) go watch(ctx1, "ctx1") go watch(ctx2, "ctx2") go watch(ctx3, "ctx3") time.Sleep(2 * time.Second) fmt.Println("可以了,通知监控停止") cancelFun() //为了检测监控过是否停止,如果没有监控输出,就表示停止了 time.Sleep(5 * time.Second) } func watch(ctx context.Context, name string) { for { select { case <-ctx.Done(): fmt.Println(name,"监控退出,停止了...") return default: fmt.Println(name,"goroutine监控中...") time.Sleep(2 * time.Second) } } }
    展开

    作者回复: 我说的跳过是源码级别的跳过,是跳过value节点直接传到它下级的节点,因为value节点本身是没有timeout机制,无需让cancel信号在那里发挥什么作用。 在value节点上的Done()在源码级别实际上调用的并不是value节点自己的方法,而是它上级节点(甚至上上级)的方法。

    共 4 条评论
    4
  • Direction
    2020-10-22
    在 Go 服务中,每个传入的请求都在它自己的 goroutine 中处理。请求处理程序通常启动额外的 goroutine 来访问后端,如数据库和 RPC 服务。 处理请求的 goroutine 集通常需要访问特定于请求的值,例如最终用户的身份、授权令牌和请求的截止日期。当一个请求被取消或超时时(cancelFunc() or WithTimeout()),处理该请求的所 有 goroutines 应该快速退出(ctx.Done()),以便系统可以回收(reclaim)它们正在使用的任何资源。 感觉这个举例挺好的(参考至:https://blog.golang.org/context)
    展开
    4
  • 茴香根
    2019-07-25
    留言区很多人说Context 是深度优先,但是我在想每个goroutine 被调用的顺序都是不确定的,因此在编写goroutine 代码时,实际的撤销响应不能假定其父或子context 所在的goroutine一定先或者后结束。

    作者回复: 是的,这涉及到两个方面,需要综合起来看。

    4
  • Cutler
    2019-04-09
    cotext.backround()和cotext.todo()有什么区别

    作者回复: 很明显,context.Background()返回的是全局的上下文根(我在文章中多次提到),context.TODO()返回的是空的上下文(表明应用的不确定性)。

    共 3 条评论
    4
  • 属雨
    2018-10-25
    深度优先,看func (c *cancelCtx) cancel(removeFromParent bool, err error)方法的源代码。
    3
  • HOPE
    2018-11-12
    老师,请教个问题。我运行了多个goroutine,每个goroutine有不同的配置。我现在想修改运行时的goroutine的配置,使用context可以实现吗?或者可以用什么办法实现?
    共 1 条评论
    2
  • 罗志洪
    2018-10-24
    context树有点难理解。
    2
  • 鲲鹏飞九万里
    2023-01-09 来自北京
    老师,您还能看到我的留言吗,现在已经是2023年了。您看我下面的代码,比您的代码少了一句time.Sleep(time.Millisecond * 200), 之后,打印的结果就是错的,只打印了12个数,您能给解释一下吗。(我运行环境是:go version go1.18.3 darwin/amd64, 2.3 GHz 四核Intel Core i5) func main() { // coordinateWithWaitGroup() coordinateWithContext() } 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() { // 如果所有的addNum函数都执行完毕,那么就立即分发子任务的goroutine // 这里分发子任务的goroutine,就是执行 coordinateWithContext 函数的goroutine. if atomic.LoadInt32(&num) == int32(total) { // <-cxt.Done() 针对该函数返回的通道进行接收操作。 // cancelFunc() 函数被调用,针对该通道的接收会马上结束。 // 所以,这样做就可以实现“等待所有的addNum函数都执行完毕”的功能 cancelFunc() } }) } <-cxt.Done() fmt.Println("end.") } func addNum(numP *int32, id int, deferFunc func()) { defer func() { deferFunc() }() for i := 0; ; i++ { currNum := atomic.LoadInt32(numP) newNum := currNum + 1 // time.Sleep(time.Millisecond * 200) if atomic.CompareAndSwapInt32(numP, currNum, newNum) { fmt.Printf("The number: %d [%d-%d]\n", newNum, id, i) break } else { fmt.Printf("The CAS option failed. [%d-%d]\n", id, i) } } } 运行的结果为: $ go run demo01.go The number: 0 [with context.Context] The number: 1 [12-0] The number: 2 [1-0] The number: 3 [2-0] The number: 4 [3-0] The number: 5 [4-0] The number: 6 [9-0] The number: 7 [10-0] The number: 8 [11-0] The number: 10 [6-0] The number: 11 [5-0] The number: 9 [8-0] end.
    展开

    作者回复: 从 addNum 成功 +1,到它向标准输出打印内容,这中间是也是有延迟的,而且在那两行代码之间,Go语言的 runtime 也可能会进行调度。所以,部分数字的打印可能会出现乱序,或者直到程序运行结束也没来得及打印出来。加入 time.Sleep(time.Millisecond * 200) 就是为了能让 addNum 执行得慢一点,并且让 CAS 操作多重试几次,这样就更容易让数字的打印呈现出自然序。 要是没有 time.Sleep 的话,就有可能发生这种情况:某个 addNum 已经把 num 加到 12 了,并且已经执行了 cancelFunc,但是其他的 addNum 还没有来得及打印内容。 不过,即使在这种情况下,其他的数字都可能来不及打印,但是 12 肯定是会打印出来的。因为把数字添加到 12 的那个 addNum 一定就是执行 cancelFunc 函数的那个函数,并且打印语句肯定会在 cancelFunc 之前执行。12 可能会出现在“end.”之后,但是肯定是会打印出来的。 你这个打印结果是不是没有贴全?

    1
  • hunterlodge
    2021-05-05
    “由于Context类型实际上是一个接口类型,而context包中实现该接口的所有私有类型,都是基于某个数据类型的指针类型,所以,如此传播并不会影响该类型值的功能和安全。” 请问老师,这句话中的「所以」二字怎么理解呢?指针不是会导致数据共享和竞争吗?为什么反而是安全的呢?谢谢!

    作者回复: 你引用的这段话其实有两层含义: 1. 某个值的指针值的传递并不会导致源值(即指针指向的那个值)的拷贝。 2. 接口值里面其实会存储实际值的指针值,而不是实际值。所以在拷贝之后,其中的实际值依然是源值。 你看过sync包的文档吗?里面的同步工具大都不允许“使用之后的再传递”。 其实这种约束的原因就是,传递值会导致值的拷贝。如此一来,原值(即拷贝前的值)和拷贝值就是两个(几乎)不相干的值了。在两边分别对它们进行操作,也就起不到同步工具原有的作用了。 但如果是传递指针值的话,两边操作的仍然会是同一个源值,对吧?这就避免了同步工具的“操作失灵”的问题。 你说的“指针会导致数据共享和竞争”是另一个视角的问题。但是这两个问题的底层知识是一个,即:传递指针值的时候并不会拷贝源值,导致分别操作两个指针值相当于在操作同一个源值。 同步工具的内部自有避免竞争的手段,所以拷贝其指针值在大多数情况下是可以的。但是,最好还是不要拷贝它们的指针值(尤其是sync包中的那些工具),因为这样很可能会迷惑住读代码和后续写代码的人,导致理解错误或操作错误的概率很大。

    1