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

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

  •  1
     
  •   cocong ·
    hzh-cocong · 68 天前 · 3147 次点击
    这是一个创建于 68 天前的主题,其中的信息可能已经有所发展或是发生改变。

    先说一下具体背景,本人在刷题,有一道题是要求使用三个协程依次循环输出 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 条附言  ·  68 天前

    根据 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 条附言  ·  68 天前

    感谢 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  
       68 天前   ❤️ 1
    i = i + 1 不是原子操作
    FrankAdler
        2
    FrankAdler  
       68 天前
    太长,懒得看,我给你个简单点的思路,3 个 chan ,
    初始往 A 写入,A 消费到后输出 A ,然后写入 B ,B 消费后写入 C
    luguhu
        3
    luguhu  
       68 天前 via iPhone
    感觉是并发调度问题?
    cocong
        4
    cocong  
    OP
       68 天前
    @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
    luguhu
        5
    luguhu  
       68 天前 via iPhone
    可能存在获取自旋锁后被调度,这样就可能会有多个获取到锁的情况
    FrankAdler
        6
    FrankAdler  
       68 天前
    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
       68 天前
    不过感觉还是有点问题,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
       68 天前
    @FrankAdler 这个我知道,其它解法不是问题,为什么会有这个诡异的结果才是我想问的问题。
    GeruzoniAnsasu
        9
    GeruzoniAnsasu  
       68 天前

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


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




    没啥诡异的,多线程入门必经之路,建议找点操作系统层面的并发机制看一看,pthread 什么的
    GeruzoniAnsasu
        10
    GeruzoniAnsasu  
       68 天前   ❤️ 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  
       68 天前
    i = i+1 不是原子操作,也没有锁,每个 goroutine 执行时随机的
    cocong
        12
    cocong  
    OP
       68 天前
    @GeruzoniAnsasu 谢谢大神。
    gamexg
        13
    gamexg  
       68 天前
    搜索关键字 go 内存模型
    virusdefender
        14
    virusdefender  
       68 天前
    这种可能的并发问题先直接 go run -race ,大部分直接就报错了
    rekulas
        15
    rekulas  
       68 天前
    非要用数字来当成锁只能用原子性判断下
    var i uint64 = 0
    for atomic.LoadUint64(&i)%3 != 2 {}
    // 输出
    atomic.AddUint64(&i, 1)
    不过这样加锁实际上不合理,正常情况下不会这样写代码
    Askiz
        16
    Askiz  
       68 天前 via Android
    请问你是在哪刷题呢
    MoYi123
        17
    MoYi123  
       68 天前   ❤️ 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  
       68 天前   ❤️ 1
    xfriday
        19
    xfriday  
       68 天前
    go compiler 自作多情而已
    cocong
        20
    cocong  
    OP
       68 天前
    @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  
       67 天前
    没懂为啥把“E”去掉就死循环了,我本地跑没有复现,跑的结果是好的,ABCABC……
    LeegoYih
        22
    LeegoYih  
       67 天前
    ```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  
       67 天前 via Android
    这样写显然存在 data race ,修好了应该没问题
    wqtacc
        24
    wqtacc  
       67 天前
    ```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  
       66 天前
    for i%3 !=2 被编译器优化后不会每次循环再 load i.
    可以在循环体里或者 fmt.Println("K")那里放一个空函数, 或者编译时-gcflags="-N"禁用部分优化都能避免 case3 的死循环.

    你的代码中每个协程里 load 或 store i 的地方都应该用 atomic.Load/Store 操作, 不仅是为了暗示编译器不能优化该处
    load/store 操作(类似于其它语言的 volatile 语义), 同时也避免乱序出现匪夷所思的输出.
    lysS
        26
    lysS  
       61 天前
    i = i + 1 不是原子的, i 可能变成任何值
    wh1012023498
        27
    wh1012023498  
       50 天前
    ```
    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   ·   我们的愿景   ·   广告投放   ·   感谢   ·   实用小工具   ·   4154 人在线   最高记录 5497   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 07:30 · PVG 15:30 · LAX 00:30 · JFK 03:30
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.