诡异的执行结果,有哪位 Go 大神来给瞧瞧? - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
cocong

诡异的执行结果,有哪位 Go 大神来给瞧瞧?

  •  1
     
  •   cocong
    hzh-cocong 2022 年 6 月 1 日 4318 次点击
    这是一个创建于 1426 天前的主题,其中的信息可能已经有所发展或是发生改变。

    先说一下具体背景,本人在刷题,有一道题是要求使用三个协程依次循环输出 ABCABCABCABCABC 。

    以下这种实现方式会出现非常诡异的结果:

    package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup = sync.WaitGroup{} wg.Add(1) // var ch chan bool = make(chan bool) var i int = 0 go func() { for { // 自旋锁 for i%3 != 0 { } fmt.Print("A", i) i = i + 1 } }() go func() { for { // 自旋锁 for i%3 != 1 { } fmt.Print("B", i) i = i + 1 } }() go func() { for { // 限制循环次数,避免一直死循环 if i >= 3 { fmt.Print("E", i, "\n") i = 2 break } // 这段如果注释掉,就只会输出 AB 然后一直死循环 fmt.Print("[K]") // 自旋锁 for i%3 != 2 { } fmt.Print("C", i) i++ } wg.Done() }() // ch <- true wg.Wait() } 

    上面三个协程使用一个变量来模拟锁,当变量的值和自身对应,即和 3 取余后比较与第 N (取 0 、1 、2 )个协程相等,就说明该协程获取到锁,于是输出对应的字母,然后通过将变量的值增加的方式来模拟释放锁。

    如果直接运行上面那段代码,有时候会输出

    [K]A0B1C2E3 A3A3B4 

    为了方便查找问题,在输出字母的时候也会同时输出 i 的值,可以看到有两个 A3 ,问题是每次协程输出字母后 i 的值都会自增,理论上不可能出现两个 A3 ,但显示就是这么诡异。

    还有,代码注释里面又说到,如果把 fmt.Print("[K]"),注释掉,就只会输出 A0B1 ,然后一直陷入死循环。真实诡异!

    这还没完,如果把 if i >= 3 { 这段用来限制循环次数的代码放到 fmt.Print("C", i) 下面,那一切又恢复正常了。负负得正?诡异的诡异为正常?

    本人的 Go 版本为 1.18.1 ,切换到 1.14.15 也是有同样的问题。

    个人猜测是 i = i + 1 的问题,于是在 i = i + 1 后也再输出 i 的值,发现 i 的值并有增加,这样看来确实是它的问题,问题这没道理啊!虽说三个协程存在并发问题,但在操作 i 时只有一个协程在操作,其它都是在读,不应该会影响才对。难道真的有影响?一个协程把 i 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中,这个过程另一协程也会去读,同样把值赋值给寄存器,这个寄存器是一样的?共享的?所以就被覆盖了?感觉有这个可能。

    第 1 条附言    2022 年 6 月 1 日

    根据 V 友们的评论目前已经解决了一大半的问题了。

    1、首先是为什么会出现两个 A3,即

    [K]A0B1C2E3 A3A3B4 

    这个是我自己挖的坑,仔细看下面这段代码

     // 限制循环次数,避免一直死循环 if i >= 3 { fmt.Print("E", i, "\n") i = 2 // 坑在这里,进来时 i = 3,然后 i 又被改为 2,所以才出现两个 A3 break } 

    2、如果是一个协程进行 i = i+1 ,另一个协程进行 if i == 3 操作,会有影响吗?

    每个协程所使用的寄存器都是独立的,协程在切换的时候也会保存这些寄存器的值,所以不是共享的,所以 i = i + 1 结果将是正确的。

    3、如果把 fmt.Print("[K]"),注释掉,就只会输出A0B1 ,然后一直陷入死循环(此时没有任何输出)。

    由 2 可知,代码里的自旋锁是没有问题的,因此 i = i+1 也是正确的。最后本人在测试时发现,在输出 A0B1 后,i 的值为2,因此 协程 A 和 协程 B 都处于自旋中,所以不会有内容输出。但是对于协程 C 来说,此时 i 的值为 2,不满足 i%3 != 2,即 协程 C 拿到了锁,此时应该输出 C。但实际情况是 C 无动于衷。

    这就是当前还无法解答的问题,本人在 协程 A 和 协程 B 自选时打印 i 的值,确实是一致打印 i = 2,所以为何 i = 2 时协程 C 不输出东西呢?当我在 协程 C 的自选内也加上打印 i 的值后,诡异的是 协程 C 能输出东西了,程序能够正常停止了。

    这有点像 薛定乐的猫,你不观察你就不知道 i 的值,但只要你观察(打印 i 的值),就会发生坍塌,程序能够正常停止。

    个人猜测这个应该和协程的调度有关,有可能 协程 C 被饿死了。不过 Go 新版对于每一个协程都有一个时间限制,应该不会饿死才对,所以问题到底是啥?求大神解答。

    第 2 条附言    2022 年 6 月 1 日

    感谢 V 友们的帮助,最后一个问题可能和编译器优化有关,暂时不研究了。

    总之,在自旋锁里加上 runtime.Gosched() 让协程主动让出 CPU 就没问题了。

    package main import ( "fmt" "runtime" "sync" ) func main() { var wg sync.WaitGroup = sync.WaitGroup{} wg.Add(1) var i int = 0 go func() { for { // 自旋锁 for i%3 != 0 { // 让出 CPU runtime.Gosched() } fmt.Print("A") i = i + 1 } }() go func() { for { // 自旋锁 for i%3 != 1 { // 让出 CPU runtime.Gosched() } fmt.Print("B") i = i + 1 } }() go func() { for { // 限制循环次数,避免一直死循环 if i/3 >= 30 { fmt.Print("E", i, "\n") break } // 自旋锁 for i%3 != 2 { // 让出 CPU runtime.Gosched() } fmt.Print("C\n") i++ } wg.Done() }() wg.Wait() } 

    编译器优化的问题可以看 V 友(xfriday)提供的:https://github.com/golang/go/issues/40572 不过我输出汇编代码后并没有发现有偷工减料的地方,具体以后有时间再研究了。

    27 条回复    2022-06-18 23:10:56 +08:00
    ruanimal
        1
    ruanimal  
       2022 年 6 月 1 日   1
    i = i + 1 不是原子操作
    FrankAdler
        2
    FrankAdler  
       2022 年 6 月 1 日
    太长,懒得看,我给你个简单点的思路,3 个 chan ,
    初始往 A 写入,A 消费到后输出 A ,然后写入 B ,B 消费后写入 C
    awah
        3
    awah  
       2022 年 6 月 1 日 via iPhone
    感觉是并发调度问题?
    cocong
        4
    cocong  
    OP
       2022 年 6 月 1 日
    @ruanimal 是的,我自己也试了一下

    ```go
    package main

    import (
    "fmt"
    "time"
    )

    func main() {
    i := 0

    go func() {
    for j := 0; j < 1000000; j++ {
    i = i - 1
    }
    }()

    go func() {
    for j := 0; j < 1000000; j++ {
    i = i + 1
    }
    }()

    time.Sleep(time.Second * 2)

    fmt.Println(i)
    }
    ```
    这段代码输出结果不为 0
    awah
        5
    awah  
       2022 年 6 月 1 日 via iPhone
    可能存在获取自旋锁后被调度,这样就可能会有多个获取到锁的情况
    FrankAdler
        6
    FrankAdler  
       2022 年 6 月 1 日
    func abc() {
    ca := make(chan struct{}, 1)
    cb := make(chan struct{}, 1)
    cc := make(chan struct{}, 1)

    ca <- struct{}{}
    num := 0

    for {
    select {
    case <-ca:
    fmt.Print("A")
    cb <- struct{}{}
    continue

    case <-cb:
    fmt.Print("B")
    cc <- struct{}{}
    continue

    case <-cc:
    fmt.Print("C")
    ca <- struct{}{}
    num++
    if num > 100 {
    os.Exit(1)
    }
    continue
    }
    }
    }

    你可以自行加工下改成 3 个协程,如果不想用我的思路,非常要变量、锁啥的,用 sync 包,传入指针给协程
    cocong
        7
    cocong  
    OP
       2022 年 6 月 1 日
    不过感觉还是有点问题,i = i+1 不是原子操作一般是值两个协程同时进行 i = i+1 才会有丢失更新问题。

    但如果是一个进行 i = i+1 ,另一个进行 if i == 3 操作,会有影响吗?我自己另外敲了一段,发现没影响

    ```go
    package main

    import (
    "fmt"
    "time"
    )

    func main() {
    i := 0

    go func() {
    for j := 0; j < 10000; j++ {
    if i < 10 {
    fmt.Print("f")
    }
    }
    }()

    go func() {
    for j := 0; j < 10000; j++ {
    i = i + 1
    }
    }()

    time.Sleep(time.Second * 2)

    fmt.Println(i)
    }
    ```
    以上结果一直都是 10000 ,说明没影响。

    开头写的那个自旋锁,是能保证只有一个协程进行 i = i+1 的,和这个例子很像,那这样就不应该有诡异的问题的!

    所以问题到底是啥!
    cocong
        8
    cocong  
    OP
       2022 年 6 月 1 日
    @FrankAdler 这个我知道,其它解法不是问题,为什么会有这个诡异的结果才是我想问的问题。
    GeruzoniAnsasu
        9
    GeruzoniAnsasu  
       2022 年 6 月 1 日

    https://go.dev/play/p/MUTu5YM-Irz


    看起来你并不太理解各种锁的作用。
    -race 参数可以在运行时加入竞争检测,能告诉你代码写得对不对。




    没啥诡异的,多线程入门必经之路,建议找点操作系统层面的并发机制看一看,pthread 什么的
    GeruzoniAnsasu
        10
    GeruzoniAnsasu  
       2022 年 6 月 1 日   2
    自旋锁是用来在两个真并行 cpu 上阻止彼此同时进入临界区的,要实现自旋锁的必要条件是

    你需要一条
    1. 原子的
    2. 同时具备读和写两个操作的
    3. 在当前 cpu 的当前指令周期结束前阻止其它所有 CPU 访问同名寄存器的
    单个 cpu 指令


    在非 cpu 层面是无论如何实现不了「自旋锁」的,务必明确

    然后说代码,取模的过程和打印的过程和自增的过程都不原子,都没有锁
    也就是说,有可能发生
    1. 使用了线程 1 副本的 i 算取模
    2. 打印了线程 2 已经自增了的 i 值
    3. i 被改成了线程 3 得到的 i+1 ,其值等于…… 可以等于任何数。因为有可能 i+1 之后线程就卡住了,一直没加回来


    反正一个不存在任何同步机制(你写的代码就是)的多线程并发+并行环境,临界区内的数据会被改成什么样几乎是无法预知的。


    > 一个协程把 i 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中
    连这个都无法保证的,怎么猜? cpu 频率快慢都完全有可能影响读写的时序。分析不出来任何名堂的
    wqtacc
        11
    wqtacc  
       2022 年 6 月 1 日
    i = i+1 不是原子操作,也没有锁,每个 goroutine 执行时随机的
    cocong
        12
    cocong  
    OP
       2022 年 6 月 1 日
    @GeruzoniAnsasu 谢谢大神。
    gamexg
        13
    gamexg  
       2022 年 6 月 1 日
    搜索关键字 go 内存模型
    virusdefender
        14
    virusdefender  
       2022 年 6 月 1 日
    这种可能的并发问题先直接 go run -race ,大部分直接就报错了
    rekulas
        15
    rekulas  
       2022 年 6 月 1 日
    非要用数字来当成锁只能用原子性判断下
    var i uint64 = 0
    for atomic.LoadUint64(&i)%3 != 2 {}
    // 输出
    atomic.AddUint64(&i, 1)
    不过这样加锁实际上不合理,正常情况下不会这样写代码
    Askiz
        16
    Askiz  
       2022 年 6 月 1 日 via Android
    请问你是在哪刷题呢
    MoYi123
        17
    MoYi123  
       2022 年 6 月 1 日   1
    其实你的代码除了性能比较差, 没什么大毛病吧.
    自旋的时候如果失败了, 调一下 runtime.Gosched() ,不然会长时间在死循环里.

    package main

    import (
    "fmt"
    "runtime"
    "sync"
    )

    func main() {
    var wg = sync.WaitGroup{}
    wg.Add(1)
    var i = 0

    go func() {
    for i < 6 {
    // 自旋锁
    for i%3 != 0 {
    runtime.Gosched()
    }
    fmt.Print("A", i)
    i = i + 1
    }
    }()
    go func() {
    for i < 6 {
    // 自旋锁
    for i%3 != 1 {
    runtime.Gosched()
    }
    fmt.Print("B", i)
    i = i + 1
    }
    }()
    go func() {
    for i < 6 {
    // 自旋锁
    for i%3 != 2 {
    runtime.Gosched()
    }
    fmt.Print("C", i)
    i++
    }
    wg.Done()
    }()
    wg.Wait()
    }
    xfriday
        18
    xfriday  
       2022 年 6 月 1 日   1
    xfriday
        19
    xfriday  
       2022 年 6 月 1 日
    go compiler 自作多情而已
    cocong
        20
    cocong  
    OP
       2022 年 6 月 1 日
    @xfriday 我尝试输出汇编代码,发现加不加 runtime.Gosched(),都没有偷工减料。

    我直接让 协程 A 、协程 B 执行一遍就跳出,此时 i 2 ,满足 协程 C 执行条件,但 协程 C 就是不输出东西,此时 CPU 也是占用很大,说明 协程 C 是有在执行的。

    可能是 for i%3 != 2 { 这里有问题,汇编有没有看到跳转语句,罗里吧嗦一堆看不太懂。

    倒是 if i >= 1 { break 整个去掉,或者只把这个 break 去掉,那么程序也能按期待的运行。

    不研究了,总之加 runtime.Gosched() 就没错了
    zealllot
        21
    zealllot  
       2022 年 6 月 1 日
    没懂为啥把“E”去掉就死循环了,我本地跑没有复现,跑的结果是好的,ABCABC……
    LeegoYih
        22
    LeegoYih  
       2022 年 6 月 1 日
    ```go
    func main() {
    wg := sync.WaitGroup{}
    wg.Add(3)
    a, b, c := make(chan int, 1), make(chan int, 1), make(chan int, 1)
    p := func(cur, next chan int, v byte) {
    defer wg.Done()
    for i := 0; i < 100; i++ {
    <-cur
    fmt.Printf("%c", v)
    next <- 1
    }
    }
    a <- 1
    go p(a, b, 'A')
    go p(b, c, 'B')
    go p(c, a, 'C')
    wg.Wait()
    }
    ```
    kiwi95
        23
    kiwi95  
       2022 年 6 月 1 日 via Android
    这样写显然存在 data race ,修好了应该没问题
    wqtacc
        24
    wqtacc  
       2022 年 6 月 2 日
    ```go
    package main

    func main() {
    chs := []chan struct{}{
    make(chan struct{}), make(chan struct{}), make(chan struct{}),
    }
    next := make(chan struct{})
    for i := 0; i < len(chs); i++ {
    go func(i int) {
    for range chs[i] {
    b := byte('A' + i)
    print(string(b))
    if i != len(chs)-1 {
    chs[i+1] <- struct{}{}
    } else {
    next <- struct{}{}

    }
    }
    }(i)
    }
    for i := 0; i < 10; i++ {
    chs[0] <- struct{}{}
    <-next
    }
    }
    ```
    katsusan
        25
    katsusan  
       2022 年 6 月 3 日
    for i%3 !=2 被编译器优化后不会每次循环再 load i.
    可以在循环体里或者 fmt.Println("K")那里放一个空函数, 或者编译时-gcflags="-N"禁用部分优化都能避免 case3 的死循环.

    你的代码中每个协程里 load 或 store i 的地方都应该用 atomic.Load/Store 操作, 不仅是为了暗示编译器不能优化该处
    load/store 操作(类似于其它语言的 volatile 语义), 同时也避免乱序出现匪夷所思的输出.
    lysS
        26
    lysS  
       2022 年 6 月 8 日
    i = i + 1 不是原子的, i 可能变成任何值
    wh1012023498
        27
    wh1012023498  
       2022 年 6 月 18 日
    ```
    package main

    import "fmt"

    func main() {
    intCh := make(chan int)
    exit := make(chan bool)

    a := func() {
    fmt.Print("A")
    }

    b := func() {
    fmt.Print("B")
    }

    c := func() {
    fmt.Print("C")
    }

    go func() {
    for i := 1; i < 10; i++ {
    intCh <- i
    }
    close(intCh)
    }()

    go func() {
    for {
    select {
    case i := <-intCh:
    if i == 0 {
    exit <- true
    } else {
    switch i % 3 {
    case 1:
    a()
    case 2:
    b()
    case 0:
    c()
    }
    }

    }
    }
    }()

    <-exit
    }
    ```

    = = 感觉用 chan 会更好点。。waitgroup = = 这个 总感觉 在控制多个 routine 上费劲。
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     916 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 68ms UTC 19:34 PVG 03:34 LAX 12:34 JFK 15:34
    Do have faith in what you're doing.
    ubao msn 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