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

2023-12-13 10:22:14 +08:00
 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"的呢?

12565 次点击
所在节点    Go 编程语言
111 条回复
CSM
2023-12-13 15:43:02 +08:00
推荐阅读:Memory Barriers: a Hardware View for Software Hackers
GopherDaily
2023-12-13 16:43:34 +08:00
搜 MemoryModel ,类似 https://go.dev/ref/mem
Java Memory Model 做为入门读物最佳
rockyliang
2023-12-13 16:44:17 +08:00
感谢所有大佬的热心回复,这个帖子就先讨论到这吧,我要先整理和消化下相关知识,thanks~~
bitmin
2023-12-13 17:03:05 +08:00
感谢 OP 发的帖子

感谢大佬提供的文章
https://juejin.cn/post/6911126210340716558
https://www.51cto.com/article/716546.html
https://fanlv.fun/2020/06/09/golang-memory-model/

刚好无聊学学 golang ,学了很多知识点

golang 实现了 Happens Before 语义的几个地方 init 函数、goruntine 的创建、goruntine 的销毁、channel 通讯、锁、sync 、sync/atomic

LOCK prefix 和 XCHG 指令前缀提供了强一致性的内(缓)存读写保证,Mutex 锁基于 Atomic 来实现 Happens Before 语义,Atomic 的 API 对应了这些汇编指令

引用 3.2 Golang Happen Before 语义继承图,贴近来有点乱,来自 https://fanlv.fun/2020/06/09/golang-memory-model/

+----------+ +-----------+ +---------+
| sync.Map | | sync.Once | | channel |
++---------+++---------+-+ +----+----+
| | | |
| | | |
+------------+ | +-----------------+ | |
| | | | +v--------+ | |
| WaitGroup +---+ | RwLock| Mutex | | +------v-------+
+------------+ | +-------+---------+ | | runtime lock |
| | +------+-------+
| | |
| | |
| | |
+------+v---------------------v +------v-------+
| LOAD | other atomic action | |runtime atomic|
+------+--------------+-------+ +------+-------+
| |
| |
+------------v------------------v+
| LOCK prefix |
+--------------------------------+
shermie
2023-12-13 18:02:13 +08:00
比起数据不一致问题,我觉得问为什么不死锁更有意义
nextvay
2023-12-13 18:15:57 +08:00
无缓冲区 channel ,必须先异步执行 f() 进行消费
再接着才会执行 main 里的写入啊
所以 f 肯定执行完毕了,才会执行 print
HelloAmadeus
2023-12-13 18:40:51 +08:00
有两个知识点:
1. 无缓冲的 channel 保证 print 的时候,a=hello world 已经发生了
2. channel 实现有内存屏障,所以在 print 时候已经能读到 a=hello world 对内存的修改
ppto
2023-12-13 18:49:49 +08:00
如果没有 channel 当协程(执行时间很长)在不同的核心上调度,如何确保 a 内存可见的。有大神给介绍一下嘛。
HelloAmadeus
2023-12-13 18:53:10 +08:00
cpu 读内存没有可见性问题,只有顺序问题,如果说 vpu 缓存设计的多核读出来的内存值不一样了,那就是 CPU 的 bug ,经典的内存屏障是为了让多核在某一时刻在某一个 CPU 指令那里强制同步,比如说经典的 i++ 问题,cpu 会分成好几个指令,为了让所有的 cpu 在执行 i++前后都同步一次,就加内存屏障指令,保证这几个指令在所有核上执行到这里的时候,只有一个核能完整执行 i+o
dyllen
2023-12-13 19:07:56 +08:00
f 里面执行到<-c 的时候会等待,main 里面执行到 c <- 0 的时候 f 等待就会结束了,f 执行也结束了,因为 f 里面的这个等待能确保 a 能被赋值到。
如果没有 c 这个 channel ,可能的结果是 f 还没执行,main 就已经结束了,a 还是空字符串,结果会不确定。
defage
2023-12-13 19:08:16 +08:00
cpu cache 有个内存屏障的概念,有个 MESI 协议。就是用来干这事了,上层编译器肯定是需要利用这些硬件标准指令的,以达到多个 cpu 核心之间的 cache 是可跨 cpu 观测的
dyllen
2023-12-13 19:12:27 +08:00
根本没有什么可见性的问题,一个协程修改了,另一个协程里面能立马读到新的值。
RedisMasterNode
2023-12-13 19:39:01 +08:00
@dyllen 他就是想知道为什么,特别是两个协程跑在不同线程上时,一个协(线)程修改了值之后这个值在多级缓存、内存如何扩散和同步和保证一致的。

> 一个协程修改了,另一个协程里面能立马读到新的值。

你说的是表现,他问的是原理,虽然帖子表述本身也很有问题,这跟 Golang 关联只能说 55 开,每个语言都可以这么问,每个语言都会分成内核(或者硬件)和编程语言层级的实现原理。
RedisMasterNode
2023-12-13 19:44:50 +08:00
另外楼主这个问题个人觉得不要用 channel 在里面做混淆比较好,很显然误导了一部分人的思考方向。如果:
1. 把 f() 中的 <-c 去掉
2. 把 main() 中的 c <- 0 改成 time.Sleep(10 秒)

应该就更贴近本意了。思考一下,time.Sleep 在这里的作用是什么,它对确保正常打印出 "hello, world" 有帮助吗?有,但是跟线程间的数据一致性没关系,那问题就可以很简单地转变为如何保障 bla bla bla 了,也就是作者上面跟别人讨论的各种缓存、锁、内存管理。
codehz
2023-12-13 21:20:11 +08:00
@RedisMasterNode 其实 sleep10 秒也未必能保证()
多线程编程里,就是要假设任意一个线程可以被饿死任意长的时间,任何使用硬编码数字的 sleep 方法都是不能保证的
rockyliang
2023-12-13 21:35:38 +08:00
@dyllen

"根本没有什么可见性的问题,一个协程修改了,另一个协程里面能立马读到新的值",关于这个我可以给你一段代码例子:
```go
func main() {
flag := true

// 协程 A
go func() {
fmt.Printf("Goroutine start\n")
for flag {
fmt.Printf("Goroutine flag: %v\n", flag)
time.Sleep(time.Second * 1)
continue
}
fmt.Printf("Goroutine finish\n")
}()

for {
flag = false
continue
}
}
```
上面这段代码在我的机器上执行,每次都是不断的输出 "Goroutine flag: true",说明 main 协程 中的 flag = false 赋值语句没有生效,或者说生效了,但是新的值没有被 协程 A 观测到。

然后还想问下 @codehz 大佬,上面这段代码之所以出现这样的运行结果,是不是也是因为指令重排导致的呢?
lesismal
2023-12-13 22:40:56 +08:00
@rockyliang #96

看了下汇编,for 循环里不断设置 flag 的语句这块生成的汇编只有一条 NOPL (空指令)汇编、应该是被编译器优化掉了。
随便在 for 循环里加个 print 之类的,都可以让 flag=false 生效。

OP 的疑问其实是 CPU 指令自己的事情,并不算是编程语言相关的,所以我再提一下这个帖子,建议 OP 先看下:
https://www.51cto.com/article/716546.html
还有例如这个帖子:
https://xiaolincoding.com/os/1_hardware/cpu_mesi.html#mesi-%E5%8D%8F%E8%AE%AE

CPU 多核缓存一致性由 CPU 保证。一些人举例子的 i++是属于多个并发各自执行一组指令时各组指令自己的原子性问题、和 cache 一致性其实应该是无关的。

很多认为的可见性的不一致问题,应该是编译器优化导致的,例如:
#96
c++ const 变量通过指针强改内容但其他用到 const 变量的地方仍然是旧值、因为编译器认为常量直接类似宏替换了,需要 volatile 指定每次读内存主要也是为了告诉编译器不要优化这里的内容
lesismal
2023-12-13 22:47:21 +08:00
@rockyliang #96

汇编在这里,去掉了 fmt 换成了 println 免得太长影响视线:
https://gist.github.com/lesismal/3673322106d032abc10a2a06ee138f9b
codehz
2023-12-13 22:50:07 +08:00
@rockyliang 立刻就被观测到的一个前提是,真的有去生成读取的代码,虽然我在任何在线 go playground/godbolt 都没能复现这种情况(),但是这个读取的消除理论上是可以做的,毕竟 go 的文档也没说要保证生成读取的副作用,不能开局读一个值,然后就不管了
(用 c/c++可以在 godbolt 里看到这种情况,直接把有条件循环变成死循环)
codehz
2023-12-13 22:55:00 +08:00
@lesismal 不过 c++内存模型(和 rust 等一些“现代语言”)的巧妙之处就在于,它把硬件的内存模型和编译的优化模型都统一的结合在一起了,只要保证最终目标(执行结果和内存模型预测的一致),就可以完全无视代码本身的执行逻辑,编写顺序
然后一旦加入了屏障,就同时修改编译的优化逻辑,以及加入硬件相关的屏障代码

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

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

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

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

© 2021 V2EX