go 一个线程写, 另外一个线程读, 为什么不能保证最终一致性? - V2EX
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 6924 次点击
    这是一个创建于 2662 天前的主题,其中的信息可能已经有所发展或是发生改变。
    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,这里就出现不一致。
    6ufq0VLZn0DDkL80
        27
    6ufq0VLZn0DDkL80  
       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     Solana     1094 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 32ms UTC 18:12 PVG 02:12 LAX 11:12 JFK 14:12
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86