V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
rockyliang
V2EX  ›  Go 编程语言

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

  •  1
     
  •   rockyliang · 2023-12-13 10:22:14 +08:00 · 12417 次点击
    这是一个创建于 383 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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"的呢?

    1  2  
    codehz
        1
    codehz  
       2023-12-13 10:28:45 +08:00   ❤️ 6
    channel 这里就用作读写屏障了
    sunny352787
        2
    sunny352787  
       2023-12-13 10:34:52 +08:00   ❤️ 1
    哪有那么复杂,就是 c 在这里锁住了当前线程不执行 print 而已,你这里的 c 是阻塞 channel ,不是非阻塞 channel ,阻塞 channel 的读取和输入你可以看作是一个操作或者类似一次函数调用,没有读取就会卡在输入这端
    jonsmith
        3
    jonsmith  
       2023-12-13 10:41:55 +08:00
    这个无缓冲的 channel 是同步的、会阻塞,如果缓存 channel 效果就不一样了。
    leonshaw
        4
    leonshaw  
       2023-12-13 10:45:49 +08:00
    原文解释的还不够清楚吗?
    rockyliang
        5
    rockyliang  
    OP
       2023-12-13 10:49:17 +08:00
    @sunny352787 可是就算 channel 阻塞结束了,main 协程也不一定能够打印出 "hello, world" 吧?因为 golang 是多线程,多线程并发就会有可见性问题,一个线程修改了共享变量,这个修改对于其它线程来说不一定能观测到
    AnroZ
        6
    AnroZ  
       2023-12-13 10:55:43 +08:00
    这个思考挺有意思的。
    我猜是 channel 阻塞恢复后会触发一次协程的上下文同步,即使跑在不同核不同物理线程上也是被强制同步了一次,所以这个例子是可以保证打印出"hello, world" 的。
    InDom
        7
    InDom  
       2023-12-13 11:02:58 +08:00
    既然是无缓冲的,而且 chan 的特性是会阻塞。

    也就是,不管是谁先执行的,都会等 main 把 c <- 0 并且 g f() 里面取到到 <-c 以后在继续执行。

    这里有疑问的无非是 main 比 f 先执行,那么 c <-0 会等待,等 f 执行完以后才会继续。
    kiddingU
        8
    kiddingU  
       2023-12-13 11:05:39 +08:00
    搞明白这里 channel 的作用你就知道为啥了
    kiddingU
        9
    kiddingU  
       2023-12-13 11:08:14 +08:00
    <-ch 读阻塞 ,go f ,必定是先执行 a 的赋值了,c <- 0 写入之后才能执行到 print
    rockyliang
        10
    rockyliang  
    OP
       2023-12-13 11:08:18 +08:00
    @InDom 谢谢回答,我知道无缓冲的 channel 会阻塞,但我关注的重点是多线程并发的可见性问题,具体可以看 #5 的回复
    PTLin
        11
    PTLin  
       2023-12-13 11:15:24 +08:00
    了解原子变量的话,把 channel 读写的地方当作有一致内存序就好了。
    lifei6671
        12
    lifei6671  
       2023-12-13 11:16:35 +08:00
    感觉你想知道的是 golang 中的内存一致性模型是怎么实现的。
    golang 中经常讨论的一个概念是 happens before ,
    这段代码就完美诠释了 happens before 的精髓,其中针对一个无缓冲的 channel 来说,它的时序依赖如下:
    如果 ch 是一个 unbuffered channel 则,ch<-val > val <- ch
    也就是从 channel 接受数据 happens before 往 channel 写数据。根据 happens before 的传导性就可以推断出,后面的读 a 变量 happens before 写 a 变量。
    wei2629
        13
    wei2629  
       2023-12-13 11:17:43 +08:00
    协程无法保证顺序,chan 不就是 为了保证 print(a) 在 f() 之后执行吗? a = "hello, world" 修改了内存, 当 print 打印内存 就没问题了。
    sunny352787
        14
    sunny352787  
       2023-12-13 11:18:25 +08:00
    老弟啊,咱们写程序修改的变量都是在内存里的呀,谁去管缓存干什么了,修改了那对所有的线程都是可见的呀,多线程会出现的问题是修改一个变量的顺序,而不是修改完不给别人...
    yph007595
        15
    yph007595  
       2023-12-13 11:19:10 +08:00
    @rockyliang #5 一个线程修改了共享变量,为什么对其他现成不一定能观测到?如果观测不到,那程序不乱套了么,都不能保证程序的正确性了。
    xuanbg
        16
    xuanbg  
       2023-12-13 11:19:28 +08:00
    要是程序需要这么写才能正常运行,那这个程序就是臭狗屎。无缓冲的 channel 会阻塞,和多线程并发的可见性是两个特性,千万别搅在一起用。
    godgrp
        17
    godgrp  
       2023-12-13 11:20:16 +08:00
    线程、协程不同的吧
    ZField
        18
    ZField  
       2023-12-13 11:24:14 +08:00
    @yph007595 #15 大胆猜测下他的思考是基于 jvm 的本地内存,在本地内存里面会有变量的副本,所以会存在可见性的问题
    cyrivlclth
        19
    cyrivlclth  
       2023-12-13 11:24:23 +08:00
    #5 说的依据是什么?其他线程能不能观测到和这个问题没啥关系吧,他们又不是不可预见的发生顺序,而是加了阻塞 channel ,在 go 中,修改全局变量一定发生在 print 之前。
    rockyliang
        20
    rockyliang  
    OP
       2023-12-13 11:28:28 +08:00   ❤️ 1
    @yph007595 #15 因为 CPU 有多个核心,每个核心都有独立的缓存,线程 A 修改了共享变量,那么这个修改只会存储到它自己所在的核心,跑在其它 CPU 核心的线程不一定能知道这个修改,比如 java 语言提供的 volatile 关键字,就是通过禁用 CPU 缓存来解决这个可见性问题的(最后一句关于 java 的话来自 ChatGPT )
    cyrivlclth
        21
    cyrivlclth  
       2023-12-13 11:28:33 +08:00
    @yph007595 我也觉得#5 说的怪怪的,这像是某个其他语言的特性?搜了下,只能搜到 java 的 volatile 。
    lifei6671
        22
    lifei6671  
       2023-12-13 11:30:01 +08:00
    @ZField 应该不是,明显讨论的是多核 CPU 下,某个核心修改了变量,是如何将变量同步到全部核心缓存的问题。按照 op 的理解,cpu0 修改了 a 变量,必须有一种机制将 a 变量的值同步到其他 cpu 缓存,否则其他 cpu 核心读到的还是旧变量。
    pkoukk
        23
    pkoukk  
       2023-12-13 11:30:57 +08:00
    你用过带 await 语法的语言么?
    这相当于 await f(),同步等待这个异步函数执行完成
    rockyliang
        24
    rockyliang  
    OP
       2023-12-13 11:32:44 +08:00
    @cyrivlclth #21 所以可见性问题只存在于 Java 语言吗
    pkoukk
        25
    pkoukk  
       2023-12-13 11:35:10 +08:00
    另外,不要用共享内存去理解 channel ,channel 的设计是有顺序的
    https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-channel/
    xiaxiaocao
        26
    xiaxiaocao  
       2023-12-13 11:35:17 +08:00
    楼里没听说过的可以 Google 下 The Go Memory Model ,官方有文章讲述的,和 Java 的内存模型也比较类似。
    无缓冲的 chan 的入队和出队语义上是保证 happens before 关系的。具体到实现里面可能是有个锁/条件变量,也可能是其他的机制,比如在同一个 P 上调度的时候可能就不需要锁。
    xiaxiaocao
        27
    xiaxiaocao  
       2023-12-13 11:36:27 +08:00
    @rockyliang 可见性问题存在于所有语言,只是有些人不知道就以为不存在
    ZField
        28
    ZField  
       2023-12-13 11:38:06 +08:00
    @lifei6671 #22 = =我提到的就是这个意思,java 里面 JMM 模型中的 volatile 关键字就是做这件事的,强制同步,不然就会出现他所提到的可见性的问题。所以我才会猜测他的想法是基于 jvm
    xiaxiaocao
        29
    xiaxiaocao  
       2023-12-13 11:39:04 +08:00
    @rockyliang 可见性问题存在于所有语言,只是有些人不知道就以为不存在
    @xiaxiaocao 啊,我这里说的不对,一些单线程的语言或者只能用单核的语言应该就没有可见性问题
    yph007595
        30
    yph007595  
       2023-12-13 11:43:44 +08:00
    @rockyliang #20 @cyrivlclth 不了解 java ,但 go 中没有 volatile 这个关键字,我认为 go 会帮我们处理好这其中的关系。即:如果一个共享变量 a, 两个线程 t1, t2, t1 肯定先运行,修改了 a, t2 后运行,那么 t2 读取的 a 肯定是修改后的。如果这不能保证,那我感觉我以前写的程序的运行的基石要坍塌了。。
    raynor2011
        31
    raynor2011  
       2023-12-13 11:44:31 +08:00
    chan, atomic,mutex 底层已经解决了可见性问题,所以 golang 一般不关心可见性问题,可以参考官方文档 https://go.dev/ref/mem
    cyrivlclth
        32
    cyrivlclth  
       2023-12-13 11:45:14 +08:00
    @rockyliang
    @xiaxiaocao 不是,可见性问题是都有的。

    就像楼上说的,可见性你看 go 语言的内存管理就行。channel 的作用,你看 https://github.com/golang/go/blob/41d8e61a6b9d8f9db912626eb2bbc535e929fefc/src/runtime/chan.go#L32 这里就行,有互斥锁的
    rockyliang
        33
    rockyliang  
    OP
       2023-12-13 11:46:34 +08:00
    @ZField #28 对,java 的 volatile 关键字,我看网上资料说是通过禁用 CPU 缓存来解决可见性问题的,但 golang 的 channel 是不是通过同样的方式去解决的就不知道了,还是说可见性问题只存在 java ?但这个貌似也说不通,只要是多线程并发,应该就会有可见性问题
    charslee013
        34
    charslee013  
       2023-12-13 11:48:48 +08:00
    > 因为 golang 是多线程,多线程并发就会有可见性问题,一个线程修改了共享变量,这个修改对于其它线程来说不一定能观测到

    不要被 Java 多线程带偏了,Golang 是多协程而不是多线程,Golang 中唯一使用多线程场景是 GMP 中的 M 用来跟内核的线程进行绑定

    这里首先是 `a = "hello, world"` 执行后,主协程中的 `c <- 0` 阻塞到 f() 协程执行到 `<-c` 后才会继续下去,此刻无论如何 a 变量已经被安全的修改了
    xiaxiaocao
        35
    xiaxiaocao  
       2023-12-13 11:49:31 +08:00
    @yph007595 go 里需要用 atomic.Load/atomic.Store....
    当然不用程序常常还是能正常跑的,因为要出现可见问题的几率有点低
    xiaxiaocao
        36
    xiaxiaocao  
       2023-12-13 11:51:10 +08:00
    @rockyliang volatile 只是一种方式。基本上同步操作,比如锁、信号量、线程 start/join 、阻塞队列入队/出队,语义上都是保证 happends before 关系,这一点 java 和 go ,和其他语言都是类似的
    rockyliang
        37
    rockyliang  
    OP
       2023-12-13 11:51:19 +08:00
    @raynor2011
    @cyrivlclth

    非常感谢,我先消化下你们发出来的链接内容
    pkoukk
        38
    pkoukk  
       2023-12-13 11:51:24 +08:00
    @rockyliang #5 channel 有锁啊,有锁还不能解决可见性问题么?那你用的 java 是通过啥解决的
    chaleaochexist
        39
    chaleaochexist  
       2023-12-13 11:51:51 +08:00
    不同 cpu 核心之间存在缓存一致性问题

    这个问题也不需要咱们考虑啊.
    那你用别的语言写并发程序也需要考虑这个问题了.

    我唯一能提示给你的是, channel 是一个有锁的队列.
    cyrivlclth
        40
    cyrivlclth  
       2023-12-13 11:54:38 +08:00
    @yph007595 啊,你这里说的先后,这段代码就是用阻塞的 channel 去保证这个执行的先后顺序的,如果没有这个 channel ,那就不能保证这两个协程谁先执行哪里了。
    yph007595
        41
    yph007595  
       2023-12-13 11:55:24 +08:00 via iPhone   ❤️ 1
    @xiaxiaocao atomic 还是为了解决线程竞争的问题吧,up 这个例子里,已经通过 channel 保证没有竞态了。
    dobelee
        42
    dobelee  
       2023-12-13 11:56:27 +08:00
    过度解读了。。修改变量后才写通道解除阻塞的。
    xiaxiaocao
        43
    xiaxiaocao  
       2023-12-13 11:58:19 +08:00
    @yph007595 我回的不是楼主的
    llhhss
        44
    llhhss  
       2023-12-13 11:59:18 +08:00
    你发的这个链接里就有解释啊
    > 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
        45
    ArianX  
       2023-12-13 12:02:28 +08:00
    @xiaxiaocao 这里 f 和 main 可能跑在不同的 cpu 上,并且读写 a 在各自 cpu 上的缓存。但是一旦使用 chan 之后,根据 go 的 memory model 里面 happens before 关系,是不是就可以确保在持续看起来 写 a 发生在 print(a) 之前(写 chan 发生在读 chan 之前)?从而使用 chan 的时候,这里面可能隐含了底层 cpu 层面的缓存同步语意。这样整个程序就是遵循 happens before 关系,不需要使用 atomic 来读写 a
    xiaxiaocao
        46
    xiaxiaocao  
       2023-12-13 12:02:33 +08:00
    @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
        47
    xiaxiaocao  
       2023-12-13 12:04:23 +08:00 via iPhone
    @ArianX 是的
    ArianX
        48
    ArianX  
       2023-12-13 12:06:08 +08:00
    通过 memory model ,go 应该保证了在 写 chan 之前的所有内存操作,对于 读 chan 之后的内存操作都是可见的
    ryalu
        49
    ryalu  
       2023-12-13 12:06:17 +08:00
    @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
        50
    BeautifulSoap  
       2023-12-13 12:09:57 +08:00 via Android   ❤️ 9
    看下来,这个帖子暴露出了两个严重问题,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
        51
    codehz  
       2023-12-13 12:14:39 +08:00   ❤️ 1
    @yph007595 不是观测不到,最终肯定是能观测到的
    主要的问题是过程中,可能会出现另一个线程观察到的修改顺序和你在源代码里看到的顺序不一致
    也就是一个线程你明明先改 a 再改 b ,但另一个线程可能会先看的 b 被改,然后 a 才改,你去检测 b 来确保同步的话就会出现问题
    这时候才需要引入所谓的内存序和读写屏障的概念
    一方面阻止了编译器调换顺序(编译器默认只需要保证在一个线程里的“执行效果”一致,调换顺序不影响结果的话就可以换)
    另一方面也阻止了 cpu 的一些优化(和编译器类似,但不可由开发者直接控制,你写汇编也没用),这方面 x86 不做类似优化
    java 和 msvc (仅限编译到 x86 )里的 volatile 关键字除了能保证修改一定反映到内存上以外,也会防止编译器调换前后的读写顺序,以及放置内存栅栏
    但其他平台和编译器就没做这个了
    然后如果用了锁,不管什么平台还是语言,都会放内存屏障,所以肯定是能保证顺序的
    go 里 channel 也起到类似作用,写入 channel 时会保证所有之前发生的副作用都确实已经发生了;同理从 channel 中取值也会保证所有后续操作和副作用都不会在之前运行
    这里和缓存毫无关系,事实上 mesi 协议就是为了确保缓存对开发者不可见,而实际上也确实做到了这个目标(除了性能上的差异外无法观测到影响)
    在多线程问题上讨论 mesi 根本毫无意义
    lesismal
        52
    lesismal  
       2023-12-13 12:22:04 +08:00   ❤️ 3
    官网里这个例子前面那句写的很清楚了:
    > 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
        53
    ylc  
       2023-12-13 12:34:53 +08:00 via Android
    @BeautifulSoap #50
    既然有可能在不同的 CPU 执行的情况,那为啥这个假设又是错误的?
    main 挂起之前 f 已经在执行了,main 挂起了之后去抢 f 过来执行吗?不然如果他们在不同的 CPU 执行的话即使 main 挂起了他们依然在不同的 CPU 吧
    BeautifulSoap
        54
    BeautifulSoap  
       2023-12-13 13:05:43 +08:00   ❤️ 1
    @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
        55
    Mitt  
       2023-12-13 13:19:58 +08:00
    😹 帖子下面回复的都是对 channel 的一般理解,并没有 get 到主题想问的意思,我也对这个好奇
    Frankcox
        56
    Frankcox  
       2023-12-13 13:25:45 +08:00   ❤️ 1
    @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
        57
    rockyliang  
    OP
       2023-12-13 13:29:32 +08:00
    @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
        58
    codehz  
       2023-12-13 13:39:41 +08:00
    @rockyliang 不对,不要牵扯到缓存,你直接当缓存不存在,这些问题都是一样的逻辑,你这里引入所谓缓存失效一类的,只是烟雾弹,实际上的问题是 cpu 根本就不一定按源码顺序执行效果,没有缓存的情况下一样会引起问题
    lesismal
        59
    lesismal  
       2023-12-13 13:46:59 +08:00
    @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
        60
    codehz  
       2023-12-13 13:48:08 +08:00
    https://zhuanlan.zhihu.com/p/413889872
    这里有个文章解释了 cpu 的这种乱序执行的行为
    可以看出和一些编译器的优化也是类似的,只是不受程序员的直接控制,只能通过插入内存屏障来解决
    rockyliang
        61
    rockyliang  
    OP
       2023-12-13 13:55:16 +08:00
    @codehz 并发有三大问题:原子性、有序性、可见性。你说的执行顺序,我觉得是属于有序性的范畴。现在主要是在讨论可见性,而可见性问题是由于 CPU 各个核心缓存不一致 导致的
    cyrivlclth
        62
    cyrivlclth  
       2023-12-13 13:57:28 +08:00   ❤️ 1
    @rockyliang 不对,可见性,是 go 内存模型去解决的。这里的 channel 就是个互斥锁啊。。。你非要吧 channel 和你所说的修改共享变量一起讲,那就没办法给你说清楚。。。

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

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

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

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

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

    顺便,这里也能看到锁这类操作“很重”的原因,就是要经历上面内存->缓存->寄存器->CPU 这一整套过程
    codehz
        73
    codehz  
       2023-12-13 14:40:23 +08:00
    当然仔细探究的话还有 storebuffer 和 loadbuffer 的相关的东西,但那都是基于推测执行的,也就是说它甚至连本地的缓存都没进,只是相当于“推迟”了写入/读取的副作用,最后积累一批之后再统一让副作用生效,它甚至没到缓存这一侧,而且也不能说是指令执行已经完成了(因为还没写入/读取成功,有数据依赖的指令就会被阻止),当 storebuffer 被实际应用到本地缓存的时候,还是会遵循 mesi 协议去同步其他 cpu 的缓存状态
    当然考虑到 store forwarding 的存在,load 请求可能会直接从 storebuffer 里取,跳过了所有其他缓存,但这仍然算是顺序问题,因为这整个过程都是发生在同一个核心里的,副作用最终还是会被应用,无论如何它都不属于多线程的可见性问题
    infinityv
        74
    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
        75
    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
        76
    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
        77
    aecra  
       2023-12-13 14:53:23 +08:00 via Android
    Java 到底搞了些什么玩意,写代码也是有边界的啊,写个业务代码还去考虑 CPU 缓存?那要不要考虑不同 CPU 架构的差异呢?
    CRVV
        78
    CRVV  
       2023-12-13 14:58:10 +08:00   ❤️ 20
    这些可见性的问题,实际上属于 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
        79
    codehz  
       2023-12-13 15:03:43 +08:00   ❤️ 1
    单从 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
        80
    cenbiq  
       2023-12-13 15:23:05 +08:00
    @BeautifulSoap 看评论产生了和你同样的疑惑和同样的理解,梳理一遍应该是分两种情况:1.异步不等于一定执行在不同的线程中,这样就不存在可见性问题。2.如果运行在不同线程中,那么 go 根据 happens-before 原则在 chan 的实现中避免了可见性问题。
    CSM
        81
    CSM  
       2023-12-13 15:43:02 +08:00
    推荐阅读:Memory Barriers: a Hardware View for Software Hackers
    GopherDaily
        82
    GopherDaily  
       2023-12-13 16:43:34 +08:00
    搜 MemoryModel ,类似 https://go.dev/ref/mem
    Java Memory Model 做为入门读物最佳
    rockyliang
        83
    rockyliang  
    OP
       2023-12-13 16:44:17 +08:00
    感谢所有大佬的热心回复,这个帖子就先讨论到这吧,我要先整理和消化下相关知识,thanks~~
    bitmin
        84
    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
        85
    shermie  
       2023-12-13 18:02:13 +08:00
    比起数据不一致问题,我觉得问为什么不死锁更有意义
    nextvay
        86
    nextvay  
       2023-12-13 18:15:57 +08:00
    无缓冲区 channel ,必须先异步执行 f() 进行消费
    再接着才会执行 main 里的写入啊
    所以 f 肯定执行完毕了,才会执行 print
    HelloAmadeus
        87
    HelloAmadeus  
       2023-12-13 18:40:51 +08:00   ❤️ 1
    有两个知识点:
    1. 无缓冲的 channel 保证 print 的时候,a=hello world 已经发生了
    2. channel 实现有内存屏障,所以在 print 时候已经能读到 a=hello world 对内存的修改
    ppto
        88
    ppto  
       2023-12-13 18:49:49 +08:00
    如果没有 channel 当协程(执行时间很长)在不同的核心上调度,如何确保 a 内存可见的。有大神给介绍一下嘛。
    HelloAmadeus
        89
    HelloAmadeus  
       2023-12-13 18:53:10 +08:00
    cpu 读内存没有可见性问题,只有顺序问题,如果说 vpu 缓存设计的多核读出来的内存值不一样了,那就是 CPU 的 bug ,经典的内存屏障是为了让多核在某一时刻在某一个 CPU 指令那里强制同步,比如说经典的 i++ 问题,cpu 会分成好几个指令,为了让所有的 cpu 在执行 i++前后都同步一次,就加内存屏障指令,保证这几个指令在所有核上执行到这里的时候,只有一个核能完整执行 i+o
    dyllen
        90
    dyllen  
       2023-12-13 19:07:56 +08:00
    f 里面执行到<-c 的时候会等待,main 里面执行到 c <- 0 的时候 f 等待就会结束了,f 执行也结束了,因为 f 里面的这个等待能确保 a 能被赋值到。
    如果没有 c 这个 channel ,可能的结果是 f 还没执行,main 就已经结束了,a 还是空字符串,结果会不确定。
    defage
        91
    defage  
       2023-12-13 19:08:16 +08:00   ❤️ 1
    cpu cache 有个内存屏障的概念,有个 MESI 协议。就是用来干这事了,上层编译器肯定是需要利用这些硬件标准指令的,以达到多个 cpu 核心之间的 cache 是可跨 cpu 观测的
    dyllen
        92
    dyllen  
       2023-12-13 19:12:27 +08:00
    根本没有什么可见性的问题,一个协程修改了,另一个协程里面能立马读到新的值。
    RedisMasterNode
        93
    RedisMasterNode  
       2023-12-13 19:39:01 +08:00
    @dyllen 他就是想知道为什么,特别是两个协程跑在不同线程上时,一个协(线)程修改了值之后这个值在多级缓存、内存如何扩散和同步和保证一致的。

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

    你说的是表现,他问的是原理,虽然帖子表述本身也很有问题,这跟 Golang 关联只能说 55 开,每个语言都可以这么问,每个语言都会分成内核(或者硬件)和编程语言层级的实现原理。
    RedisMasterNode
        94
    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
        95
    codehz  
       2023-12-13 21:20:11 +08:00
    @RedisMasterNode 其实 sleep10 秒也未必能保证()
    多线程编程里,就是要假设任意一个线程可以被饿死任意长的时间,任何使用硬编码数字的 sleep 方法都是不能保证的
    rockyliang
        96
    rockyliang  
    OP
       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
        97
    lesismal  
       2023-12-13 22:40:56 +08:00   ❤️ 2
    @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
        98
    lesismal  
       2023-12-13 22:47:21 +08:00   ❤️ 1
    @rockyliang #96

    汇编在这里,去掉了 fmt 换成了 println 免得太长影响视线:
    https://gist.github.com/lesismal/3673322106d032abc10a2a06ee138f9b
    codehz
        99
    codehz  
       2023-12-13 22:50:07 +08:00
    @rockyliang 立刻就被观测到的一个前提是,真的有去生成读取的代码,虽然我在任何在线 go playground/godbolt 都没能复现这种情况(),但是这个读取的消除理论上是可以做的,毕竟 go 的文档也没说要保证生成读取的副作用,不能开局读一个值,然后就不管了
    (用 c/c++可以在 godbolt 里看到这种情况,直接把有条件循环变成死循环)
    codehz
        100
    codehz  
       2023-12-13 22:55:00 +08:00
    @lesismal 不过 c++内存模型(和 rust 等一些“现代语言”)的巧妙之处就在于,它把硬件的内存模型和编译的优化模型都统一的结合在一起了,只要保证最终目标(执行结果和内存模型预测的一致),就可以完全无视代码本身的执行逻辑,编写顺序
    然后一旦加入了屏障,就同时修改编译的优化逻辑,以及加入硬件相关的屏障代码
    1  2  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1004 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 21:44 · PVG 05:44 · LAX 13:44 · JFK 16:44
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.