关于 golang 官网一段代码的疑惑

257 天前
 rockyliang

Golang 官网一段关于同步错误的代码示例:

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

官网对这段代码的解析原文:As before, there is no guarantee that, in main, observing the write to done implies observing the write to a, so this program could print an empty string too. Worse, there is no guarantee that the write to done will ever be observed by main, since there are no synchronization events between the two threads. The loop in main is not guaranteed to finish.


print(a)可能会输出一个空字符串这个比较好理解,因为 CPU 指令重排的原因,done = true有可能先于a = "hello world"语句执行,所以出现了这种情况。

但是 for 语句有可能会陷入死循环的原因我理解不了,原因是什么?

我猜测的原因是(有可能不正确): done = true的修改 CPU 只写入到了本地核心的 store buffer ,没有同步给其它 CPU 核心,导致不同核心的缓存数据不一致,接而导致运行在其它核心的 main 协程看不到这个修改,所以陷入了死循环

希望有大神能解析下原因,谢谢~

4331 次点击
所在节点    Go 编程语言
29 条回复
cloudzhou
257 天前
你的答案就是对的,涉及到内存同步
lasuar
257 天前
按你的猜测,难道永远都不会同步了吗?
zdking08135
257 天前
这里是 guarantee to ,意思是不是所有的平台上,都可以保证循环会结束。
因为编译器最多只能做到更新对应内存,对具体平台的多核架构下的一致性无法保证,这个是 CPU 架构设计者应该考虑的事情。

实际上不论 amd64 或者 arm ,还是其他的什么 CPU ,只要是真实的 CPU ,都会考虑&解决这个问题,
最多是这个修改延迟多久被其他核可见。所以,只要代码在目前主流 CPU 上正常运行,一般都会结束的。
xhd2015
257 天前
@zdking08135 hi ,你说的这个有相关文档吗?我理解其实是因为 CPU 的缓存满了之后,会自己丢弃,从而刷新旧值。但你说的这个“一般都会结束”,是指 cpu 有定期回刷的策略?
GopherDaily
257 天前
没有强制的同步指令的话,无法保证 main 能看到 done 的修改,但是实际上一般结果会看到

实际上,Go 还在其他文章/代码里面提到过另外一种情况:
CPU 级别的过度优化,CPU 判断出一直在比较一个变量,一旦把它加载到特定寄存器后就省略了读取的指令

上述两种都是极度理论化的假设,没必要深究
leonshaw
257 天前
是编译器优化,cpu 没有这么做的
rockyliang
257 天前
@lasuar 正常来说,就算 CPU 有 store buffer 和 Invalid queue ,对变量的修改应该也会同步给其它 CPU 核心,只是看时间上延迟多久而已。永远不同步的话说不过去,会给编程带来很大麻烦,但是官网文档又确实说了有可能会陷入死循环
sztink
257 天前
OP 的猜测是一种可能情况:done=true 没有同步到其他核心导致了死循环。所以 go 官方提供了 atomic.Storexxx()函数来保证原子性和可见性。

Storexxx()函数底层使用 XCHGQ 指令,该指令默认带有 lock 前缀指令的功能,会强制其他核心重新刷新缓存。我们常说的 atomic 包提供了原子操作,其实它也保证了可见性。对于 64 系统下,只要内存对齐(不会发生撕裂写),简单的赋值语句是原子性的( mov 指令),但他没法保证可见性,即当前核心的写入,没有立即被其他核心可见,所以你得需要 Storexxx()语句。

atomic 包的 Storexxx()函数和 Addxxx() 函数底层指令可以比较看看:
https://go.godbolt.org/z/o5jn4GG5K // Storexxx 使用 XCHGQ 指令,该指令默认有 lock 功能。
https://go.godbolt.org/z/rc5Y37q3P // Addxx 使用 lock 指令 + XADD 指令。多了 lock 前缀指令,来保证可见性。
rockyliang
257 天前
@leonshaw 如果是编译器优化,这种情况应该能稳定复现才对?我试了很多次都没能复现出死循环这种现象,甚至连输出空字符串的现象也复现不出来
leonshaw
257 天前
@rockyliang 编译器只是有可能但实际并没有做这个优化。
keakon
256 天前
1. Go 目前没有做这种编译器优化,具体你可以用 go tool compile -S main.go 来看看。
2. 常见的平台都会进行同步,x86 和 arm 基本都能在 100ms 内感知到,但不排除存在某些奇怪的平台。
3. 某些时候为了性能,我会用这种方式来达到最终一致性的同步,而不是 atomic 。
sztink
256 天前
编译器优化也是有可能的,不同版本 go 编译器编译有差异的。编译优化可以看看这篇文章:Golang 编译器优化那些事: https://fanlv.fun/2021/12/18/golang-complier-optimize/
iseki
256 天前
@keakon
Go 竟然连这么基本的优化都没做😫,循环里面是空的,都不用往别处看就可以知道这个条件绝对不会被破坏啊😐 Go 真是有点···
kuanat
256 天前
我来尝试用最直白的方式解释一下吧,

原文 The Go Memory Model 是严谨准确的,只是涉及到专业术语,没有相应的理论背景很难理解。

第一个容易误解的概念是 guaranteed 。编程语言定义规范( spec ),编译器负责在目标平台上完成实现。保证( guaranteed )的意思就是,在满足特定条件的情况下,无论编译器如何完成,都要确保最终的结果符预期。

看上去和什么都没说一样对吧?结合相反的概念就明白了,这个相反的概念叫 undefined 。也就是 C 时代大名鼎鼎的 undefined behavior 那个 undefined 。因为编程语言没明确要求,编译器想怎么干就怎么干,也可以什么都不干。

所以 guaranteed/undefined 的实质区别就是,前者以确定的方式干了点什么,从而保证了结果和预期的一致性。至于具体干了什么后面再说。

接下来的概念叫内存模型( memory model ),这是一组对规范( spec )的抽象而形成的规则。毕竟代码写起来可以五花八门,但内在逻辑是相似且有限的。但是如何用形式化( formal )的方式来精准定义这些逻辑呢?

从最简单的例子开始,假如只有单线程,那么执行顺序就是代码的书写顺序。如果是多线程呢?(这里还是取最简单的情形来简化分析)

思考一下就会发现,不确定性的来源主要是两类:一类是对于同一(共享)变量(内存)的读/写并行操作,另一类是写/写操作。这些的潜在冲突被叫做竞态( race )。

设想最简单的读写冲突场景,读和写操作分别位于两个独立的并行执行序列中。如果预期的结果是先读后写,那什么都不用做,这两个指令不会相互影响;但是反过来,预期先写后读,且要保证( guaranteed )读到写的结果,就需要编译器做点什么了。

这里“读到写的结果”有个专门的名词叫可见性( visibility ),编译器具体怎么做不重要,重要的是编译器做了一件事,在写操作完成读操作发生之前,使得写操作的结果对于读操作可见。(要么共享一份副本,要么两份独立的副本但同步成为相同的结果)

最后的任务就是,编程语言在规范( spec )中定义,在什么情况下编译器需要保证可见性( visibility )。即判断开发者按照直觉写出的代码,它的预期执行逻辑应该是什么样的。

这背后的代码逻辑关系被定义成了一个特殊的术语 happens-before ,满足 w(rite) happens-before r(ead) 的情形下,就要保证 w 的结果对 r 可见。这里其实是个反向的过程,既然某些代码和逻辑按照开发者的预期应该满足可见性,那就定义它要满足 happens-before 关系。(这里为了方便理解,对定义进行了简化)

这是最容易被误解的术语,它和指令执行的先后顺序无关,不是时间上的先后概念。(关于这一点如果不理解,建议从基础开始学习内存模型理论。)编译器的工作从来都不是对指令执行流程进行排序,只要能保证可见性,指令顺序是不影响预期结果的。

这个逻辑关系比较重要的特性是它具有传递性。我这里用 A <= B 代表 A happens-before B ,那么 A <= B 且 B <= C 可以推导出 A <= C 。另一点比较重要的是,happens-before 强制要求编译器保证可见性,但是可见性并不能反推出 happens-before 关系。

最终的结果就是,Golang 的设计者希望,当开发者用 XXX 的方式写代码的时候,编译器能产生符合预期的结果。于是就定义 XXX 这些写法,就是 happens-before 关系,编译器必须对其可见性结果做出保证。Golang 规范中定义了几种 happens-before 关系,这里就不一一列举了。

--------手动分割线--------

如果重新表述一下示例代码的问题,就是判断 [1] `a = "hello, world"` 和 [4] `print(a)` 是否满足 happens-before 关系,[2] `done = true` 和 [3] `for !done {}` 是否满足 happens-before 关系,从而判定可见性。

很遗憾根据 Golang 的规范,这里仅有 [1] <= [2] 和 [3] <= [4],不存在其他 happens-before 关系。(读 The Go Memory Model 原文可以看到采用 channel 通信后,比如把 [2] 和 [3] 分别替换为 `c <- 0` 和 `<-c`,由于语言规范层面上建立了两个 goroutine 相关指令间的 happens-before 关系,有 [2] <= [3],从而依靠传递性保证了 [1] <= [4],于是保证了不会有死循环,赋值也总能得到正确的输出)

既然不存在 happens-before 关系,那么编译器就没有必要保证 `done = true` 的结果对于 `for !done {}` 可见。缺少了这一层保证,那编译器的某种实现使得 `for !done {}` 一直读到 `done = true` 可见之前的结果也就不奇怪了。因为 `done = true` 对于 `done` 的操作可能完全不会让 `for !done {}` 知道。

--------手动分割线--------

看到这里你肯定会感到疑惑,为什么 Golang 不对前面这种情况做精确定义?

我的理解是,这个思路 C 当年用过了,然后发现太复杂且不符合直觉的情形太多,于是抽象了诸如 mutex/semaphore 等等原语来降低开发者的心智负担,帮助写出正确的代码。结果并没有那么理想,理解这个背景对于大多数非科班的开发者要求还是太高了。Golang 走相同的路不会有更好的结果。

Golang 的设计目标中很重要的一点是简单。希望即便开发者完全不懂内存模型,也能凭直觉写出正确的并行代码。原文的示例非常明显了,不要用 C 的思路去写并行代码,而是用 Golang 提供的 channel 机制,新机制有着严格且准确的 happens-before 逻辑,同时符合直觉。
kingcanfish
256 天前
也有可能是编译器的优化,可以看看 russ cox 的这篇文章 https://research.swtch.com/plmm

First, if x and done are ordinary variables, then thread 2's loop may never stop. A common compiler optimization is to load a variable into a register at its first use and then reuse that register for future accesses to the variable, for as long as possible. If thread 2 copies done into a register before thread 1 executes, it may keep using that register for the entire loop, never noticing that thread 1 later modifies done.

一种常见的编译器优化是在变量首次使用时将其加载到寄存器中,然后尽可能长时间地重用该寄存器,以便将来访问该变量。如果线程 2 在线程 1 执行之前将 done 复制到一个寄存器中,它可能会在整个循环中一直使用该寄存器,永远不会注意到线程 1 后来修改了 done 。

然后 russ cox 的内存模型文章一共三篇 ,看完这三篇就对内存模型有飞跃一般的认识了 https://research.swtch.com/mm
doraemonki
256 天前
@kuanat 老哥,既然 golang 保证了 [1] <= [2] 和 [3] <= [4],在代码里面当[3]可以跳出循环时,[2]一定执行了,那么可以推出[1]也执行了,那为什么还会存在输出空字符串的可能呢
hallDrawnel
256 天前
@kingcanfish 这个文章真不错
kuanat
256 天前
@doraemonki #16

“[2]一定执行了,那么可以推出[1]也执行了“ => 这句话不正确。

参考"happens-before 和指令执行的先后顺序无关,不是时间上的先后概念"这一部分。这句话解释起来就比较麻烦了,建议去看内存模型相关的文档,一般都是有示例来辅助理解的。happens-before 和时间上执行先后顺序无关。

[1]<=[2] 和 [3]<=[4] 这两个条件不能通过传递性获得任何 [1] 与 [4] 的关系。具体到这个例子,时间顺序上按 [2],[3],[4],[1] 的先后执行,恰好编译器使得 [2] 的结果对 [3]可见,这样是不违反规范的。

参考“编译器的工作从来都不是对指令执行流程进行排序,只要能保证可见性,指令顺序是不影响预期结果的。”

尽管 [1]<=[2] 但 [1] 和 [2] 的时间先后对二者都没有影响,[3] 和 [4] 相互也没有影响,他们都没有操作相同的内存变量。换句话说,由代码书写先后推导出的 [1]<=[2] 和 [3]<=[4] 是没有用的额外条件。这四条指令可以按任意时间顺序执行,都满足规范。
Steaven
256 天前
1.21 版本就可能出现死循环
rockyliang
256 天前
@kuanat 老哥厉害,码了这么多字,辛苦了,感谢已发送~

然后我还想追问一下 [既然不存在 happens-before 关系,那么编译器就没有必要保证 `done = true` 的结果对于 `for !done {}` 可见。] 这句话

既然不保证,那就是 done 的修改对于 main 协程来说有可能可见,也有可能不可见,那么这个“可能”是基于什么因素来决定的? CPU 架构吗?比如用架构 A 的时候就可见,用架构 B 的时候就不可见?还是说,这种“可能“是完全随机的?

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

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

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

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

© 2021 V2EX