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

go 一个线程写, 另外一个线程读, 为什么不能保证最终一致性?

  •  2
     
  •   xiadada · 2018-06-28 15:29:32 +08:00 · 6225 次点击
    这是一个创建于 2121 天前的主题,其中的信息可能已经有所发展或是发生改变。
    package main
    
    import (
        "fmt"
        "runtime"
        "sync"
        "time"
    )
    
    var lock sync.RWMutex
    var i = 0
    
    func main() {
        runtime.GOMAXPROCS(2)
        go func() {
            for {
                fmt.Println("i am here", i)
                time.Sleep(time.Second)
            }
        }()
        for {
            i += 1
        }
    }
    

    结果始终是 0, 考虑 cpu cache 一致性的话, 过一段时间就会看到变量发生了变化啊?

    第 1 条附言  ·  2018-06-28 16:33:16 +08:00
    感谢 @Monad 大神,还有另一位提出 race 的小伙伴

    我终于解决这个问题了.

    这段代码跟 cpu cache 没什么关系, 主要是被编译器错误的优化掉了.

    go tool objdump -s "main\.main" example
    可以看到根本没有执行 add 操作.

    但是,当我们用 go build -race 去编译的时候

    o build -race example.go
    go tool objdump -s "main\.main" example

    清晰的 example.go:18 0x10e820a 83c077 ADDL $0x77, AX

    这种情况下, cache 的一致性协议会让其他 go 协程最终读到变化的值.

    至于内存屏障是说, 在 cache 最终一致性之前, cpu 为了性能, 有一个缓存, 这个时候是有脏读出现的. 如果你并发去写, 不用内存屏障, 就会遇到问题.(大概是这么个意思)
    42 条回复    2019-04-27 16:07:07 +08:00
    Monad
        1
    Monad  
       2018-06-28 15:32:45 +08:00
    编译器是可以优化的, 并且没有 memory barrier
    wkc
        2
    wkc  
       2018-06-28 15:41:54 +08:00   ❤️ 1
    `go run -race a.go` 就能看到预期变化了
    xiadada
        3
    xiadada  
    OP
       2018-06-28 15:49:03 +08:00
    @wkc 确实,还没这么用过
    xiadada
        4
    xiadada  
    OP
       2018-06-28 15:50:03 +08:00
    @Monad 我看了一下汇编代码, 确实是被编译器编译掉了, 他可能认为`i`没有变化, 根本没有 Add 过.
    zsxzy
        5
    zsxzy  
       2018-06-28 16:09:14 +08:00
    没有 volatile , 这个怎么解决
    xiadada
        6
    xiadada  
    OP
       2018-06-28 16:16:17 +08:00
    @Monad 再请教一下啊, 内存屏障和 cache 一致性有关系吗? 网上说写屏障会发消息让其他 cache 失效, 如果不设置屏障, 难道不会达成最终一致性吗?
    xiadada
        7
    xiadada  
    OP
       2018-06-28 16:16:50 +08:00
    @zsxzy go 用更上一层的读写锁
    Monad
        8
    Monad  
       2018-06-28 16:20:52 +08:00
    @xiadada #6 在 i386 和 x86_64 上应该是会的 其它架构我就不清楚了 这里的主要原因其实还是没有屏障导致编译器优化掉了
    polythene
        9
    polythene  
       2018-06-28 16:22:00 +08:00
    @wkc 请教一下,这里的“-race ”做了什么神奇的操作导致 i 发生了变化?
    rrfeng
        10
    rrfeng  
       2018-06-28 16:31:48 +08:00
    看不懂……有没有人详细解释一下
    lostsquirrelX
        11
    lostsquirrelX  
       2018-06-28 16:50:45 +08:00
    按按 go tour 的说法有两种方式
    一种是 chan
    一种是 把你的变量和锁放在一个结构体里面
    finalsatan
        12
    finalsatan  
       2018-06-28 18:52:44 +08:00
    seaswalker
        13
    seaswalker  
       2018-06-28 18:58:49 +08:00 via iPhone
    需要一个 compiler 屏障就行了呗
    scnace
        14
    scnace  
       2018-06-28 19:06:23 +08:00
    没看懂这个 lock 定义在这干啥。。。
    WildCat
        15
    WildCat  
       2018-06-28 19:08:23 +08:00
    @scnace +1 同样没看懂
    CRVV
        16
    CRVV  
       2018-06-28 19:11:47 +08:00   ❤️ 1
    @polythene
    楼主发的代码包含 data race,-race 打开了 data race detector,用来检查这个错误,为了检查错误关了相关的编译器优化

    @lostsquirrelX
    变量和锁不用放在一个结构体里,随便怎么放都行

    曾经有一个和这事相关的 bug
    https://github.com/golang/go/issues/19182
    scnace
        17
    scnace  
       2018-06-28 19:13:16 +08:00
    https://golang.org/doc/articles/race_detector.html 其实 Go 文档还是挺详细的。。。
    xfriday
        18
    xfriday  
       2018-06-28 21:23:57 +08:00
    在 i +=1 下面添加一行 runtime.Gosched() 结果就是你期望的
    gabon
        19
    gabon  
       2018-06-28 23:15:36 +08:00 via Android
    volatile
    yangxin0
        20
    yangxin0  
       2018-06-28 23:33:18 +08:00
    这个要从 memory model 说起。i += 1 其实是两个指令:
    mov i, %eax
    add %eax, 1
    所以当你在 for { i += 1}的时候存在两个 instructions, 而另一个读取 print i 的时候可能在 mov 之后也可能在 add 之后。所以你这个一致性要是不增加 memory fence 基本无解。

    解法有几种:
    1、原子 add
    2、chan 传递数据
    3、mutex 或者 rwlock
    yangxin0
        21
    yangxin0  
       2018-06-28 23:39:19 +08:00
    gcc 有一个__sync_add_and_fetch 就主要用了 memory fence 和 instruction reorder 技术来保证 memory model 的一致性。
    tempdban
        22
    tempdban  
       2018-06-28 23:58:12 +08:00 via Android
    @yangxin0
    mov i, %eax
    add %eax, 1
    mov %eax, I
    yangxin0
        23
    yangxin0  
       2018-06-28 23:58:53 +08:00
    @tempdban 噗写漏了。
    tempdban
        24
    tempdban  
       2018-06-28 23:59:48 +08:00 via Android
    内存屏障是解决顺序一致性的问题,怎么到了楼上的说法怎么全是解决 cache 一致性了。
    tempdban
        25
    tempdban  
       2018-06-29 00:00:17 +08:00 via Android
    @yangxin0 内存屏障不是解决 cache 一致性问题的
    yangxin0
        26
    yangxin0  
       2018-06-29 00:06:24 +08:00
    我的理解他这个问题就是一个顺序一致性问题,读 thread 读取 i 的时候,写 thread 可能正在进行一个非原子的+=1,这里就出现不一致。
    cholerae
        27
    cholerae  
       2018-06-29 00:15:47 +08:00
    有 race 的 Go 程序的行为是未定义行为,理论上出现什么情况都是正常的,你这个示例程序极好地显示了这一点。所以讨论为什么出现这种现象实际上没有任何意义,不要依赖这种行为。理论上这个程序一运行就自动打开一个游戏也是合理的,好像有一个版本的 GCC 对待未定义行为就是这样做的。
    conoha
        28
    conoha  
       2018-06-29 00:16:41 +08:00
    @CRVV 大牛~
    styx
        29
    styx  
       2018-06-29 00:27:03 +08:00
    @tempdban 是对的。x86 的 mfence 只解决 read-after-write 可能出现的 speculative/reorder 的情景,用于保证 sequential consistency。至于 @yangxin0 说的,跟 sequential consistency 没有关系,而且“另一个读取 print i 的时候可能在 mov 之后也可能在 add 之后”是完全合适也正确的访存行为。
    tempdban
        30
    tempdban  
       2018-06-29 00:51:02 +08:00
    @yangxin0 看来我说的不够详细,内存屏障是解决 LOAD/STORE 乱序的问题。
    例如这种情况:
    a = (char *) melloc();
    dev.buff = a;
    mb();
    dev.flag = 1;
    很好理解吧,填 buff,置 flag。
    另一个线程发现 dev.flag == 1 就开始取 buff。
    但是 cpu 的执行单元是乱序的(注意:假定编译器得到的顺序是对的,这里还有个 Optimization Barrier 的问题),如果不加屏障就可能是这样:
    dev.flag = 1;
    dev.buff = a;
    另一个线程发现 flag 置 1 了去读 buff,此时 buff 指针可能还没来得及填,直接一个段错误歇菜了。
    内存屏障实际作用是:保证 MFENCE 指令前的 LOAD/STORE,一定在 MFENCE 指令之后的 LOAD/STORE 指令之前完成。
    回到你的理解:
    写 thread 可能正在进行一个非原子的+=1
    首先他只有一个线程在加,就算不是原子加那也不会影响别人读数,最多读的不是准确值,但是绝不会一直是 0。
    要是有多个线程再加同一个数,就算不是原子加,最后肯定有 core 会成功写到 cache 上的,也不会一直是 0。

    题主说的真没错,不是什么高深的问题,就仅仅是编译器把
    i += 1
    给优化掉了。
    仅此而已。
    styx
        31
    styx  
       2018-06-29 01:08:41 +08:00
    @tempdban 唉,前面还说你结论是对的。你的这个例子确实是错的,你这里两个都是 store,x86 的 TSO 是保证 store 顺序的,所以另一个线程看到了 flag==1 一定能看到 buff==a,因为 store buffer 是按顺序刷到 cache 里去的。正确的关于 mfence 的例子应该是:
    Thread 1:
    a = 1
    // mfence
    if (b == 0) {
    enter_critical_section()
    }

    Thread 2:
    b = 1
    // mfence
    if (a == 0) {
    enter_critical_section()
    }

    如果不加 fence,则会出现两个线程同时进入 critical section 的情景,这是 Dijkstra 最早提出的 mutex 方法。

    ---
    当然我们都走远了,题主的问题是一个简单的问题。
    styx
        32
    styx  
       2018-06-29 01:10:50 +08:00   ❤️ 1
    @tempdban 抱歉,应该说在 x86 下你的例子是不会跑错的,因为 TSO。在 ARM 下你的例子应该就是合适的。
    tempdban
        33
    tempdban  
       2018-06-29 01:21:11 +08:00
    @styx 是我考虑的不仔细,随手写的确实没考虑 x86 STORE 保序的问题。
    tempdban
        34
    tempdban  
       2018-06-29 01:31:34 +08:00 via Android
    @styx 你不说我还真记不起来 TSO 这个事,得好好谢谢你。
    styx
        35
    styx  
       2018-06-29 01:54:15 +08:00 via Android
    @tempdban 其实也不是记着 tso,因为 x86 的 tso 只允许 R-A-W 这一种 reorder,所以这种 sequential consistency violation 的例子是比较唯一的,就是各种 mutex 嘛。反倒是理解 store buffer 和 speculation execution 比较重要。
    yangxin0
        36
    yangxin0  
       2018-06-29 10:25:56 +08:00
    @styx
    @tempdban 多谢回复,我再去看看相关资料。
    xiadada
        37
    xiadada  
    OP
       2018-06-29 11:49:18 +08:00
    @cholerae 是的, 我在 https://stackoverflow.com 也问了这个问题, 就被他们这么教育了, 我对未定义行为的认识不够, 不过了解一下到底为什么这种未定义之后, 到底发生了什么, 为什么会这样, 还是挺好玩的, 要不然难受的慌.
    conoha
        38
    conoha  
       2018-06-29 12:19:27 +08:00
    @xiadada 为什么都在关心 happens before...? happens before 发生在 i =0; x= i * 4; 值有依赖的情况,@CRVV 发的 github 才是正解啊,修 bug 前这个 routine 直接没有被调度到
    xiadada
        39
    xiadada  
    OP
       2018-06-29 14:28:24 +08:00
    @conoha 不是一码事啊老哥, 用 atomic 还打印 0 说明是程序 bug. 我没有用, 会出现竞态, 编译器直接把 Add 这个操作优化没了. 不是没有调度的问题

    ```
    package main

    import (
    "fmt"
    "os"
    "runtime"
    "time"
    )

    var a uint64 = 0

    func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))

    go func() {
    for {
    a += 1
    // just do something
    _ = make(chan os.Signal)
    }
    }()

    for {
    fmt.Println(a)
    time.Sleep(time.Second)
    }
    }

    ```
    加一句 make, 就可以不是 0 了, 难道加了 make 就会解决调度问题?
    xiadada
        40
    xiadada  
    OP
       2018-06-29 14:29:51 +08:00   ❤️ 1
    Looking at the assembly, the increment (and in fact the whole for loop) has been (over-)optimized away.
    reus
        41
    reus  
       2018-06-30 17:18:16 +08:00
    for 循环当作 dead code 优化掉了
    ms2008
        42
    ms2008  
       2019-04-27 16:07:07 +08:00
    @xiadada 能贴个 stackoverflow 的链接吗?
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   4917 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 38ms · UTC 05:38 · PVG 13:38 · LAX 22:38 · JFK 01:38
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.