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 · 12199 次点击
    这是一个创建于 371 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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  
    Evrins
        101
    Evrins  
       2023-12-13 22:58:32 +08:00   ❤️ 1
    @rockyliang flag = false 的 写操作被 golang 优化了, 因为后面没有读操作了, 后面如果再打印 flag 的话就预期一样了

    ```go
    func Test_goroutine_value(t *testing.T) {
    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
    fmt.Printf("flag: %v\n", flag)
    time.Sleep(time.Second) // 很重要, 不然会爆炸
    continue
    }
    }
    ```

    另外一方面 golang 协程的范式应该通过 channel 在不同的 goroutine 之间传递数据, 而不是共享内存

    `Do not communicate by sharing memory; instead, share memory by communicating.`
    nuk
        102
    nuk  
       2023-12-13 23:08:06 +08:00
    这个其实是锁的基础,基于执行时间先后的同步,默认情况下处理器是需要支持的,当然也有不支持的情况。
    假如有 A ,B ,C 三个指令,如果 ABC 按照时间顺序依次执行,依然会产生不同的结果,那么锁就失去了意义。
    因为乱序执行或者缓存同步,确实可能会导致的这样的情况,比如 C 执行时 B 的结果还无法被观察到,但是设计锁的时候就要考虑到这些情况。
    iseki
        103
    iseki  
       2023-12-13 23:08:32 +08:00 via Android
    @rockyliang Java 即使不使用 volatile ,使用 Lock 或者 synchronize 也会有一样的效果。
    Java 对此的描述是 happens-before ,不是粗暴的 volatile 就可见别的不可见。
    iseki
        104
    iseki  
       2023-12-13 23:10:06 +08:00 via Android
    volatile 所谓的可见性只是被用户总结出来的效果之一,显然不是说 volatile 只能保证这个。
    Gaas6lt
        105
    Gaas6lt  
       2023-12-13 23:17:43 +08:00 via iPhone
    难道不是因为这个 chan 是不带 buffer 的吗
    rockyliang
        106
    rockyliang  
    OP
       2023-12-13 23:20:25 +08:00
    @lesismal @codehz @Evrins 好吧,原来是编译器的问题。感觉多线程并发这块水真的有点深,原子性、内存可见性、指令重排、CPU 的 MESI 缓存一致性协议、内存屏障...涉及到一大堆东西,还要预防编译器的自动优化问题,防止出现预期之外的情况,后面这些再逐块深入研究了,哈哈哈哈...最后再次感谢你们帮忙分析,感谢已经发送~
    lesismal
        107
    lesismal  
       2023-12-13 23:20:29 +08:00
    @codehz #100

    其实就是语言定位、取舍问题。c/cpp 这些是要把底层能力尽量留给开发者、开发者可以“肆意”掌控和进行性能优化,编译器自己优化性能的效率比肉眼要高得多。golang 的定位本来也不是像 c/cpp 那样极致性能与控制力,而是尽量在工程上让开发者能够舒服地做业务逻辑,所以写 go 也不需要考虑那么多。
    zacard
        108
    zacard  
       2023-12-14 10:09:41 +08:00
    因为 channel 的同步机制是通过读写屏障,而读写屏障不是只保障 channel 里面的数据可见,它的原理是写的时候通过失效 cpu 缓存的数据,读的时候防止重排保障读到写屏障之前的数据更新。因此即使处于另一个核心的线程,由于缓存的数据失效了,会去读主存的最新数据,顺带就把最新赋值的 a 给读出来了。

    java 中有很多类库直接使用了这个技巧来减少重复的同步消耗,例如 FutureTask:
    // 源码第 92 行
    private volatile int state;

    // 源码第 104 行
    private Object outcome; // non-volatile, protected by state reads/writes

    第 2 个变量的定义没有加 voliatile ,然后可以安全的在并发中使用
    CRVV
        109
    CRVV  
       2023-12-15 07:49:16 +08:00
    @rockyliang

    程序员写代码不需要懂这么多的东西,如果你想深究这些知识,当然可以学,这些东西都还挺有意思的,但这些知识和 “写正确的代码” 不相关。让程序员不需要懂这些东西就能写代码,就是所谓 高级语言 的功能。

    计算机这东西在各个领域都是分层的,设计 HTTP 协议的人不需要懂 IP 协议要怎么工作,他只需要懂 TCP 协议就行。CPU 指令和编程语言也是类似的情况,写代码的人只需要懂编程语言,不需要懂 CPU 的工作方式,不需要懂编译器的实现细节。
    重复一下,你想学当然可以学,但这些知识不能帮你把代码写对。把代码写对需要的是编程语言本身的知识。

    你举的例子说 flag = false 没有生效,这件事情的原因,如果非要深究到底,那确实是这一句被编译器优化掉了。
    编译器把它优化掉了,这个叫 实现细节,编译器优化掉它,是因为根据编程语言的 spec ,flag = false 这一句可以被优化掉。编译器可以优化掉它,也可以不优化。
    经常出现的一种情况是,程序员写了一段带有 undefined behavier 的代码,跑了一下发现一切正常,就认为代码是对的,之后升级了编译器程序就挂了。

    从编程语言的角度来说 flag = false 没有被执行到的原因,或者说编译器可以把它优化掉的原因是
    这两个 for 循环执行在不同的 goroutine 上,而且 Go 没有保证 goroutine 的执行顺序,也不保证 goroutine 被执行到。
    for flag print sleep 的那个循环一直占用着 CPU ,sleep 的实现是忙等,而后面的 for 循环从来都没有执行到,这是一种符合 spec 的行为。
    或者 for flag print sleep 的循环要执行了一亿亿亿次以后才会执行到后面的 for ,这也符合 spec
    两个 goroutine 同时对一个变量做读写操作,这个叫 data race ,当然是 undefined behavier

    两个线程不能同时读写同一个变量,这个算基础知识吧
    EchoGroot
        110
    EchoGroot  
       2023-12-15 17:16:53 +08:00
    @CRVV #78 同意大佬的见解,编程语言已经封装好了这块。Go 中满足 happen befor 就可见了,不用太关心底层的缓存一致性问题。不过我认为 java 中的可见性得考虑,例如使用 volatile 。
    lesismal
        111
    lesismal  
       2023-12-15 17:53:10 +08:00
    @CRVV @EchoGroot #109

    > for flag print sleep 的那个循环一直占用着 CPU ,sleep 的实现是忙等,而后面的 for 循环从来都没有执行到,这是一种符合 spec 的行为

    sleep 的实现是忙等,这个不对吧?它可不是纯 cpu spin 那种吧? print 也是有 io 的,也不是导致后面的 for 循环从来没有执行到的原因吧?
    所以虽然后面的 for 被优化掉了,但我并不清楚具体什么原因导致的优化

    > 两个 goroutine 同时对一个变量做读写操作,这个叫 data race ,当然是 undefined behavier
    > 两个线程不能同时读写同一个变量,这个算基础知识吧

    这个说法片面了吧~
    data race 可能会造成不一致、undefined behavior ,但如果正确使用、并不会造成 ub 。
    我代码里一些 flag 就是 data race 的,为了性能,一些简单的地方没必要都加锁,atomic 也是多余
    1  2  
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5563 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 46ms · UTC 03:34 · PVG 11:34 · LAX 19:34 · JFK 22:34
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.