问一个并发程序可见性的问题, golang 语言

345 天前
 rockyliang

Go 官网有一段代码例子:

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

官网说使用了 channel 后,这段代码可以确保能正常打印出"hello, world",原因是什么?

这是我的理解(有可能不对,欢迎指正): 假设 [ f 协程] 运行在 cpu0 核心上, [ main 协程] 运行在 cpu1 核心上, [ f 协程] 修改完 a 变量后,由于不同 cpu 核心之间存在缓存一致性问题,这个修改对于 [ main 协程] 来说有可能是不可见的,也就是 [ main 协程] 有可能会打印出一个空字符串

那么,channel 在这段代码里发挥的作用是什么,它是怎么确保 [ main 协程] 可以正常打印出"hello, world"的呢?

11787 次点击
所在节点    Go 编程语言
111 条回复
yph007595
345 天前
@xiaxiaocao atomic 还是为了解决线程竞争的问题吧,up 这个例子里,已经通过 channel 保证没有竞态了。
dobelee
345 天前
过度解读了。。修改变量后才写通道解除阻塞的。
xiaxiaocao
345 天前
@yph007595 我回的不是楼主的
llhhss
345 天前
你发的这个链接里就有解释啊
> A send on a channel is synchronized before the completion of the corresponding receive from that channel.
> The write to a is sequenced before the send on c, which is synchronized before the corresponding receive on c completes, which is sequenced before the print.
然后前面有
> The synchronized before relation is a partial order on synchronizing memory operations
ArianX
345 天前
@xiaxiaocao 这里 f 和 main 可能跑在不同的 cpu 上,并且读写 a 在各自 cpu 上的缓存。但是一旦使用 chan 之后,根据 go 的 memory model 里面 happens before 关系,是不是就可以确保在持续看起来 写 a 发生在 print(a) 之前(写 chan 发生在读 chan 之前)?从而使用 chan 的时候,这里面可能隐含了底层 cpu 层面的缓存同步语意。这样整个程序就是遵循 happens before 关系,不需要使用 atomic 来读写 a
xiaxiaocao
345 天前
@yph007595 go 语言没有提供 volatile ,方案就是用 atomic ,上面说的 The Go Memory Model 里有说明:
Atomic Values
The APIs in the sync/atomic package are collectively “atomic operations” that can be used to synchronize the execution of different goroutines. If the effect of an atomic operation A is observed by atomic operation B, then A is synchronized before B. All the atomic operations executed in a program behave as though executed in some sequentially consistent order.

The preceding definition has the same semantics as C++’s sequentially consistent atomics and Java’s volatile variables.
xiaxiaocao
345 天前
@ArianX 是的
ArianX
345 天前
通过 memory model ,go 应该保证了在 写 chan 之前的所有内存操作,对于 读 chan 之后的内存操作都是可见的
ryalu
345 天前
@rockyliang #5 以下个人理解,可能有一点误解:
1. 对于 golang 用户来说应该不需要去管线程(以及多线程、共享变量等问题)的概念,在 golang 中你只要知道协程这个概念就行了,提到协程就不得不说 GMP 调度模型( https://www.yuque.com/aceld/golang/srxd6d )感兴趣可以去了解下。代码中 `go f() ` 是唤起一个协程 G 执行 f(), 在进程内部有可能是同一个线程 M 里的处理器 P 在执行。

2. 回到上面说的共享变量的问题(go 内部有共享空间读写锁,对于用户你只需要知道理解堆、栈就行),这在 go 中就涉及到逃逸分析的问题。 这里的 `var a string` 会被编译器自动分配到栈上以供多协程访问。这个可能会涉及到数据竞态(DATA RACE)的问题,上面代码比较简单所以没问题。

3. 上述代码中,channel 其实就类似于个读写锁的作用,保证 `print(a)` 是在 a 被赋值后执行。
BeautifulSoap
345 天前
看下来,这个帖子暴露出了两个严重问题,lz 表达能力和回帖的理解能力不足导致的交流效率低下,以及那么多人对并发编程最基本知识理解的匮乏,居然那么多人不知道并发的可见性问题,拜托这可是多线程/并发开发的基础知识

对于 lz 的疑问,要回答全面涉及几个方面知识
1. 首先 lz 假设的两个 go 协程分别跑在 cpu0 和 cpu1 上的前提是不正确的。他们可能会跑在同一个核心上,也可能跑在不同的核心上。当执行 c <- 0 将 main 阻塞之后,go 调度器可能会将 main 的执行挂起然后用同一个核心去执行 f() ,这时候程序的执行是完全单核的。当然也可能会开另一个线程执行 f()。具体行为由 go 来调度,用户层面没有感知

2. Go 协程自然会遇到内存可见性问题,但是 go 帮你解决了很多可见性问题,其中就包括 channel
https://juejin.cn/post/6911126210340716558
codehz
345 天前
@yph007595 不是观测不到,最终肯定是能观测到的
主要的问题是过程中,可能会出现另一个线程观察到的修改顺序和你在源代码里看到的顺序不一致
也就是一个线程你明明先改 a 再改 b ,但另一个线程可能会先看的 b 被改,然后 a 才改,你去检测 b 来确保同步的话就会出现问题
这时候才需要引入所谓的内存序和读写屏障的概念
一方面阻止了编译器调换顺序(编译器默认只需要保证在一个线程里的“执行效果”一致,调换顺序不影响结果的话就可以换)
另一方面也阻止了 cpu 的一些优化(和编译器类似,但不可由开发者直接控制,你写汇编也没用),这方面 x86 不做类似优化
java 和 msvc (仅限编译到 x86 )里的 volatile 关键字除了能保证修改一定反映到内存上以外,也会防止编译器调换前后的读写顺序,以及放置内存栅栏
但其他平台和编译器就没做这个了
然后如果用了锁,不管什么平台还是语言,都会放内存屏障,所以肯定是能保证顺序的
go 里 channel 也起到类似作用,写入 channel 时会保证所有之前发生的副作用都确实已经发生了;同理从 channel 中取值也会保证所有后续操作和副作用都不会在之前运行
这里和缓存毫无关系,事实上 mesi 协议就是为了确保缓存对开发者不可见,而实际上也确实做到了这个目标(除了性能上的差异外无法观测到影响)
在多线程问题上讨论 mesi 根本毫无意义
lesismal
345 天前
官网里这个例子前面那句写的很清楚了:
> A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.
> 无缓冲的 chan 的接收在该 chan 上相应的发送完成之前进行同步。

如果没有这个保证,c <- 0 可能先于 f()协程完成、所以很可能 a 还没被设置。
但有了这个保证,则例子中:
<-c 先于 c <- 0 完成;
a = "hello, world" 先于 <-c ,所以也先于 c <- 0;
print(a) 在 c <- 0 之后,所以 a = "hello, world" 先于 print(a),所以肯定打印了 hello world 。


本质上 OP 是迷惑为什么“无缓冲的 chan 的接收在该 chan 上相应的发送完成之前进行同步”。
这个可以自己啃下 runtime/chan.go 源码
ylc
345 天前
@BeautifulSoap #50
既然有可能在不同的 CPU 执行的情况,那为啥这个假设又是错误的?
main 挂起之前 f 已经在执行了,main 挂起了之后去抢 f 过来执行吗?不然如果他们在不同的 CPU 执行的话即使 main 挂起了他们依然在不同的 CPU 吧
BeautifulSoap
345 天前
@ylc
“既然有可能在不同的 CPU 执行的情况,那为啥这个假设又是错误的?” 因为你搞错了 go 线程啊,我在指正你的知识错误。
go 协程之所以叫“go 协程”而不是单纯的线程、协程,那是因为它就不是单纯的多线程,而是多线程+自动协程调度。上面我有段没说清楚,“当执行 c <- 0 将 main 阻塞之后,go 调度器可能会将 main 的执行挂起然后用同一个核心去执行 f()” ,这里我用了用同一个核心去执行这话容易可能引起误会,更准确的是 ”go 调度器可能会在当前线程中将 main() 的执行挂起,然后用同一个线程去执行 f() ,然后等 f() 阻塞后将 f() 的执行挂起回去执行 main() “。这里的任务调度都是 go 内部完成的而且自始至终都只有一个线程,而不是 go 在调度 main() 和 f() 两个线程,这点要搞清楚


"main 挂起之前 f 已经在执行了" 你这假设也是有问题的。Go 协程你是没法确保它什么时候被执行的,这在学 go 协程的时候对应教程应该都有说明的。f()有可能是在 main 挂起之前执行了,也有可能是在 main 挂起之后才执行,所有调度都是 go 调度器在控制。

“main 挂起了之后去抢 f 过来执行吗” 是的,你猜对了,Go 的线程调度模型就是一个线程空下来(或阻塞)的话,就挂起当前阻塞的任务,然后从待处理的任务里“偷”个任务过来在当前线程里执行。有兴趣可以找找 Go 协程的 G-M-P 模型,官方形容这种模型就是”鼹鼠从其他工人的推车上偷砖块”
Mitt
345 天前
😹 帖子下面回复的都是对 channel 的一般理解,并没有 get 到主题想问的意思,我也对这个好奇
Frankcox
345 天前
@lesismal 题主的意思应该不是协程执行的先后问题,而是,如果 main()和 f()如果分配在不同的 CPU 上执行,channel 确实确保了 a = "hello, world"在 print(a)之前执行,但是此时 f()是在比如 core1 上执行,a="hello, world"修改了其对应的 core1 的 L1, L2cache ,而 main()读的却是 core0 的 L1,L2 cache 里的 a 的值,OP 的疑问是如何确保如果两个协程在不同的 core 上执行,修改的值在不同的 core cache 上同步
rockyliang
345 天前
@BeautifulSoap
@xiaxiaocao
@codehz

首先非常感谢各位大佬的回答,因为我平时高并发做得比较少,所以这块知识有点混乱,再加上表达能力确实不太好,导致挺多人 Get 不到我疑惑的地方。

然后对于 @BeautifulSoap
1. 因为单线程程序没有并发可见性问题,所以我就先假设了两个协程分别跑在不同的 CPU 核心上
2. 谢谢你分享的这篇文章,纠正了我的一个误区:之前我以为 happens-before 规则只是一个事件发生顺序的一个规定,但其实它还包含了”可见性“的语义,A happens-before B 代表 A 对共享变量做出的修改,对于 B 来说是可见的。

然后 golang 的 channel 为了满足可见性要求,我猜底层应该会有以下操作:
1. 线程 A 修改共享变量,不能只将修改保存在线程所在 CPU 核心的缓存里,还要将它同步回内存
2. 如果其它 CPU 核心上也有此共享变量,需要将缓存里的变量设置为失效状态
3. 其它核心上的线程读取共享变量,因为所在核心的缓存状态已标记为失效,只能去内存里读,此时就能读到最新的变量值了

我这样理解对不对呢?
codehz
345 天前
@rockyliang 不对,不要牵扯到缓存,你直接当缓存不存在,这些问题都是一样的逻辑,你这里引入所谓缓存失效一类的,只是烟雾弹,实际上的问题是 cpu 根本就不一定按源码顺序执行效果,没有缓存的情况下一样会引起问题
lesismal
345 天前
@Frankcox #56

我也没有说是协程执行先后顺序的问题呀。。
可能我给那几个步骤排序,造成了你的误解、误以为我在说协程执行的先后顺序。
请仔细看下,我说的是不同协程里,golang 对 chan 的操作时序保证,这与 go f()是否被先调度执行是无关的

> OP 的疑问是如何确保如果两个协程在不同的 core 上执行,修改的值在不同的 core cache 上同步

不管你是单核心并发、还是多核心并发或并行,chan 都在语言层面通过锁、调度器做了这个对并发操作 chan 行为的顺序保证,就是官网里这句:A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.

所以 OP 把问题本身就搞混了
codehz
345 天前
https://zhuanlan.zhihu.com/p/413889872
这里有个文章解释了 cpu 的这种乱序执行的行为
可以看出和一些编译器的优化也是类似的,只是不受程序员的直接控制,只能通过插入内存屏障来解决

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/999936

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX