问一个并发程序可见性的问题, 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"的呢?

12562 次点击
所在节点    Go 编程语言
111 条回复
rockyliang
2023-12-13 13:55:16 +08:00
@codehz 并发有三大问题:原子性、有序性、可见性。你说的执行顺序,我觉得是属于有序性的范畴。现在主要是在讨论可见性,而可见性问题是由于 CPU 各个核心缓存不一致 导致的
cyrivlclth
2023-12-13 13:57:28 +08:00
@rockyliang 不对,可见性,是 go 内存模型去解决的。这里的 channel 就是个互斥锁啊。。。你非要吧 channel 和你所说的修改共享变量一起讲,那就没办法给你说清楚。。。

也不用去猜测了。。。源码就在那里。。。
cyrivlclth
2023-12-13 14:00:24 +08:00
momocraft
2023-12-13 14:02:12 +08:00
基于消息通信的同步
Masoud2023
2023-12-13 14:07:24 +08:00
确实是**相当于**读写屏障,想不到除了这个词之外更好的向 Java 崽解释这段代码的词汇了。

但是实际上并不是,这种协程语言(或者协程操作)一般都跟协程有一些比较大的区别。

不能按照线程的方式去理解协程。
Masoud2023
2023-12-13 14:12:24 +08:00
不要总是拿你 Java 的理解往 Go 上面去套用。
codehz
2023-12-13 14:17:50 +08:00
@rockyliang 可见性问题只是 java 自己内存模型里的东西,也不是啥放之四海而皆准的标准。。。事实上除了 java 之外,根本没有别的地方在用这个概念( gpu 相关的倒是有,但是那里的可见性是另一个概念)
本质上 MESI 协议就是让你根本无法察觉缓存的存在,你如果真的有仔细阅读 MESI 协议,就会发现,无论出现什么情况,它都不能被用户态观测到“不一致”的情况,只有有一个核心写入了它自己本地的缓存,另一个核心立刻就会观测并 invalidate 缓存,不可能出现读取到的局部缓存和全局不一致的场景。。。
Frankcox
2023-12-13 14:19:03 +08:00
@lesismal #59 确实我没注意,以为你说的只是协程的顺序问题。但是我还是不太明白为什么”无缓冲的 chan 的接收在该 chan 上相应的发送完成之前进行同步。“解答了 OP 的问题,这句话也并没有说出“进行同步”的具体步骤逻辑,而是只是说了"chan 在语言层面通过锁、调度器做了对应处理"
lesismal
2023-12-13 14:27:09 +08:00
@rockyliang
如果只是并行 cpu cache 的问题,相当于跟 golang 语言无关、跟 chan 也无关,直接看这种吧:
https://www.51cto.com/article/716546.html

@Frankcox #68 嗯嗯
baiyi
2023-12-13 14:29:12 +08:00
我觉得楼主想要问的是这篇文章里的内容: https://fanlv.fun/2020/06/09/golang-memory-model/
Ericcccccccc
2023-12-13 14:30:59 +08:00
搜 happen before
sunny352787
2023-12-13 14:39:35 +08:00
CPU <— > 寄存器<— > 缓存<— >内存

在使用高级语言编写程序的时候,锁的影响范围是内存这一级别,包括 atomic 处理数据也是保证内存数据一致
而多线程语句会在 CPU 这个层级进行,所以如果不加锁或者其他类型的“屏障”,即便是 i++这种看起来最简单的语句在汇编层面也是好几句处理,这就会出现 CPU0 和 CPU1 拿到的数据不一致的问题
具体到这段代码来看,channel 事实上已经在“内存”这一级别进行了锁操作,那么在不同的 CPU 上获取到的锁之前的数据当然就是一样的,因为都是从内存重新拿的

顺便,这里也能看到锁这类操作“很重”的原因,就是要经历上面内存->缓存->寄存器->CPU 这一整套过程
codehz
2023-12-13 14:40:23 +08:00
当然仔细探究的话还有 storebuffer 和 loadbuffer 的相关的东西,但那都是基于推测执行的,也就是说它甚至连本地的缓存都没进,只是相当于“推迟”了写入/读取的副作用,最后积累一批之后再统一让副作用生效,它甚至没到缓存这一侧,而且也不能说是指令执行已经完成了(因为还没写入/读取成功,有数据依赖的指令就会被阻止),当 storebuffer 被实际应用到本地缓存的时候,还是会遵循 mesi 协议去同步其他 cpu 的缓存状态
当然考虑到 store forwarding 的存在,load 请求可能会直接从 storebuffer 里取,跳过了所有其他缓存,但这仍然算是顺序问题,因为这整个过程都是发生在同一个核心里的,副作用最终还是会被应用,无论如何它都不属于多线程的可见性问题
infinityv
2023-12-13 14:40:50 +08:00
在非缓冲 channel 中,数据的发送 (c <- 0) 必须等待接收操作 (<-c) 准备就绪。这意味着,发送操作会阻塞,直到有一个 goroutine 尝试从这个 channel 接收数据。一旦接收操作开始执行,发送操作就可以完成,数据才会被传输。在这个过程中,发送和接收操作是同步发生的,但是发送操作没有必要在时间上先于接收操作发生。(我觉得这点很重要,楼上很多说 c<- 优先于 c<-0 我觉得不对 这本身就是一个同步的过程)

a = "hello, world" happens-before <-c (在 goroutine f 中),因为它们是按顺序执行的。另一方面,c <- 0 happens-before print(a)(在主 goroutine 中),因为它们也是按顺序执行的。由于<-c 和 c <- 0 是配对的接收和发送操作,它们会同步进行,因此一旦数据通过非缓冲 channel 被发送,我们就知道接收操作已经开始(或完成),接收操作之前的所有操作,包括 a = "hello, world",都已经发生了。

综上,对于非缓冲 channel ,不能说发送操作在时间上一定先于接收操作,而是发送操作需要等待接收操作准备就绪,然后两者才能同步进行。

所以,非常明显的结论是,在你的代码中,一旦主 goroutine 执行到 print(a) 时,a = "hello, world" 明显且一定已经发生了( channel 的发送接收操作已经同步完了)。
leonshaw
2023-12-13 14:42:30 +08:00
@rockyliang 这是几个不同层面的问题。
一是根据 Go 内存模型,这段代码保证顺序是 写 a (sequenced before) send (synchronized before) receive (sequenced before) 读 a ,所以最终结果是 写 a happens before 读 a
二是多核架构的内存模型里,CPU 都会提供显式或者隐式的同步指令,用正确的指令就能完成多核之间的同步。
三是硬件实现,这个不同架构会有比较大区别,非专业没必要深究。

关于协程和线程,协程最终是在线程里执行的,只要有竞争的协程有可能在两个线程中同时执行,就需要引入线程的同步机制。换句话说,代码最终是在 CPU 核心执行的,只要有竞争的代码有可能在两个核中同时执行,就需要多核同步机制。
leonshaw
2023-12-13 14:51:10 +08:00
#75 写反了,应该是 receive (synchronized before) completion of send

同时还有 send is synchronized before the completion of receive ,channel 是双向同步点。
aecra
2023-12-13 14:53:23 +08:00
Java 到底搞了些什么玩意,写代码也是有边界的啊,写个业务代码还去考虑 CPU 缓存?那要不要考虑不同 CPU 架构的差异呢?
CRVV
2023-12-13 14:58:10 +08:00
这些可见性的问题,实际上属于 Instruction Set Architecture (ISA),不属于编程语言。
每个 CPU 指令集都定义了自己的 CPU 上哪些内存操作在什么情况下可见,都不一样的,这是问题非常复杂。

一个高级程序设计语言,像 C 这种的,当然不需要程序员去处理不同 CPU 的不同行为,不然它就不叫 高级 语言了。
对这些内存操作的行为,高级语言必须有统一的定义,让这个语言写的程序在不同 ISA 上能得到相同的结果。这个定义叫做 memory model ,比如
https://en.cppreference.com/w/c/language/memory_model
https://en.wikipedia.org/wiki/Java_memory_model
https://go.dev/ref/mem

存在 cache 的情况确实会有不可见的问题(考虑一下 CPU 上没有 cache ,所有 core 直接写到主内存上的情况)。但你看 Go memory model 里面根本没出现过 cache 这个词。
就是说 Go 的这套定义里面,就没有 cache 这个东西。Go 的定义里面,程序的行为就像没有 cache 一样(因为他的文档里就没写 cache 的事),所以只要一个 write 发生在 read 之前,且这个 write 之后 read 之前没有发生其它的 write ,这个 write 就被这个 read 可见。
所以这个文档全在讨论顺序的问题。

楼主发的代码显然有明确的执行顺序,这个顺序显然满足上面的要求,所以内存操作是可见的。


顺便一说,程序员不需要知道 ISA 上的这些定义,这本来就是个高端问题。但是 x86 上的内存操作的行为特别简单,所以总有人写代码的时候依赖于这些 x86 的行为,实际上写的代码都是 undefined behavior ,还觉得自己特别厉害,写文章讲解什么内存可见性的问题,估计楼主是这种东西的受害者。
codehz
2023-12-13 15:03:43 +08:00
单从 store forwarding 角度来说,虽然实际上你可以观测到变量的读取直接从本地 store buffer 里提取之前写入的值来实现跳过全局缓存,但逻辑上仍然属于内存序问题
举例
线程 1
C = -1;

A = 0;
B = 0;
if(B == 1) C = A;
线程 2
A = 1;
B = 1;
在 x86 上可以观测到 C 的值可以是-1 0 1 (完全 TSO 的设备上不可能能是-1 )
即使线程 2 并没有交换 A B 的写入顺序,但这里可以“理解”为线程 1 交换了 C=A 和 if 的执行顺序,C 先=A ,然后再判定 B 是否==1
而高级语言的内存屏障也正是为了这个场景而设计的,通过保证副作用发生的顺序来避免出现意料之外的情况
cenbiq
2023-12-13 15:23:05 +08:00
@BeautifulSoap 看评论产生了和你同样的疑惑和同样的理解,梳理一遍应该是分两种情况:1.异步不等于一定执行在不同的线程中,这样就不存在可见性问题。2.如果运行在不同线程中,那么 go 根据 happens-before 原则在 chan 的实现中避免了可见性问题。

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

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

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

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

© 2021 V2EX