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

2022-06-01 00:22:35 +08:00
 cocong

先说一下具体背景,本人在刷题,有一道题是要求使用三个协程依次循环输出 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 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中,这个过程另一协程也会去读,同样把值赋值给寄存器,这个寄存器是一样的?共享的?所以就被覆盖了?感觉有这个可能。

3613 次点击
所在节点    Go 编程语言
27 条回复
ruanimal
2022-06-01 00:33:53 +08:00
i = i + 1 不是原子操作
FrankAdler
2022-06-01 00:34:29 +08:00
太长,懒得看,我给你个简单点的思路,3 个 chan ,
初始往 A 写入,A 消费到后输出 A ,然后写入 B ,B 消费后写入 C
luguhu
2022-06-01 00:36:02 +08:00
感觉是并发调度问题?
cocong
2022-06-01 00:36:59 +08:00
@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
2022-06-01 00:37:42 +08:00
可能存在获取自旋锁后被调度,这样就可能会有多个获取到锁的情况
FrankAdler
2022-06-01 00:46:43 +08:00
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
2022-06-01 00:47:26 +08:00
不过感觉还是有点问题,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
2022-06-01 00:48:27 +08:00
@FrankAdler 这个我知道,其它解法不是问题,为什么会有这个诡异的结果才是我想问的问题。
GeruzoniAnsasu
2022-06-01 00:57:25 +08:00

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


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




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

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


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

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


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


> 一个协程把 i 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中
连这个都无法保证的,怎么猜? cpu 频率快慢都完全有可能影响读写的时序。分析不出来任何名堂的
wqtacc
2022-06-01 01:15:46 +08:00
i = i+1 不是原子操作,也没有锁,每个 goroutine 执行时随机的
cocong
2022-06-01 01:52:36 +08:00
@GeruzoniAnsasu 谢谢大神。
gamexg
2022-06-01 07:12:39 +08:00
搜索关键字 go 内存模型
virusdefender
2022-06-01 08:22:17 +08:00
这种可能的并发问题先直接 go run -race ,大部分直接就报错了
rekulas
2022-06-01 08:37:01 +08:00
非要用数字来当成锁只能用原子性判断下
var i uint64 = 0
for atomic.LoadUint64(&i)%3 != 2 {}
// 输出
atomic.AddUint64(&i, 1)
不过这样加锁实际上不合理,正常情况下不会这样写代码
Askiz
2022-06-01 08:47:49 +08:00
请问你是在哪刷题呢
MoYi123
2022-06-01 09:53:05 +08:00
其实你的代码除了性能比较差, 没什么大毛病吧.
自旋的时候如果失败了, 调一下 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
2022-06-01 10:35:27 +08:00
xfriday
2022-06-01 10:35:42 +08:00
go compiler 自作多情而已
cocong
2022-06-01 13:12:06 +08:00
@xfriday 我尝试输出汇编代码,发现加不加 runtime.Gosched(),都没有偷工减料。

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

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

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

不研究了,总之加 runtime.Gosched() 就没错了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/856589

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX