诡异的执行结果,有哪位 Golang Go语言大神来给瞧瞧?
先说一下具体背景,本人在刷题,有一道题是要求使用三个协程依次循环输出 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 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中,这个过程另一协程也会去读,同样把值赋值给寄存器,这个寄存器是一样的?共享的?所以就被覆盖了?感觉有这个可能。
诡异的执行结果,有哪位 Golang Go语言大神来给瞧瞧?
更多关于诡异的执行结果,有哪位 Golang Go语言大神来给瞧瞧?的实战教程也可以访问 https://www.itying.com/category-94-b0.html
i = i + 1 不是原子操作
更多关于诡异的执行结果,有哪位 Golang Go语言大神来给瞧瞧?的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
太长,懒得看,我给你个简单点的思路,3 个 chan ,
初始往 A 写入,A 消费到后输出 A ,然后写入 B ,B 消费后写入 C
感觉是并发调度问题?
是的,我自己也试了一下go<br>package main<br><br>import (<br> "fmt"<br> "time"<br>)<br><br>func main() {<br> i := 0<br><br> go func() {<br> for j := 0; j < 1000000; j++ {<br> i = i - 1<br> }<br> }()<br><br> go func() {<br> for j := 0; j < 1000000; j++ {<br> i = i + 1<br> }<br> }()<br><br> time.Sleep(time.Second * 2)<br><br> fmt.Println(i)<br>}<br>
这段代码输出结果不为 0
可能存在获取自旋锁后被调度,这样就可能会有多个获取到锁的情况
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 包,传入指针给协程
不过感觉还是有点问题,i = i+1 不是原子操作一般是值两个协程同时进行 i = i+1 才会有丢失更新问题。
但如果是一个进行 i = i+1 ,另一个进行 if i == 3 操作,会有影响吗?我自己另外敲了一段,发现没影响go<br>package main<br><br>import (<br> "fmt"<br> "time"<br>)<br><br>func main() {<br> i := 0<br><br> go func() {<br> for j := 0; j < 10000; j++ {<br> if i < 10 {<br> fmt.Print("f")<br> }<br> }<br> }()<br><br> go func() {<br> for j := 0; j < 10000; j++ {<br> i = i + 1<br> }<br> }()<br><br> time.Sleep(time.Second * 2)<br><br> fmt.Println(i)<br>}<br>
以上结果一直都是 10000 ,说明没影响。
开头写的那个自旋锁,是能保证只有一个协程进行 i = i+1 的,和这个例子很像,那这样就不应该有诡异的问题的!
所以问题到底是啥!
这个我知道,其它解法不是问题,为什么会有这个诡异的结果才是我想问的问题。
https://go.dev/play/p/MUTu5YM-Irz
看起来你并不太理解各种锁的作用。
-race 参数可以在运行时加入竞争检测,能告诉你代码写得对不对。
没啥诡异的,多线程入门必经之路,建议找点操作系统层面的并发机制看一看,pthread 什么的
自旋锁是用来在两个真并行 cpu 上阻止彼此同时进入临界区的,要实现自旋锁的必要条件是
你需要一条
1. 原子的
2. 同时具备读和写两个操作的
3. 在当前 cpu 的当前指令周期结束前阻止其它所有 CPU 访问同名寄存器的
单个 cpu 指令
在非 cpu 层面是无论如何实现不了「自旋锁」的,务必明确
然后说代码,取模的过程和打印的过程和自增的过程都不原子,都没有锁
也就是说,有可能发生
1. 使用了线程 1 副本的 i 算取模
2. 打印了线程 2 已经自增了的 i 值
3. i 被改成了线程 3 得到的 i+1 ,其值等于…… 可以等于任何数。因为有可能 i+1 之后线程就卡住了,一直没加回来
反正一个不存在任何同步机制(你写的代码就是)的多线程并发+并行环境,临界区内的数据会被改成什么样几乎是无法预知的。
> 一个协程把 i 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中
连这个都无法保证的,怎么猜? cpu 频率快慢都完全有可能影响读写的时序。分析不出来任何名堂的
i = i+1 不是原子操作,也没有锁,每个 goroutine 执行时随机的
谢谢大神。
搜索关键字 go 内存模型
非要用数字来当成锁只能用原子性判断下
var i uint64 = 0
for atomic.LoadUint64(&i)%3 != 2 {}
// 输出
atomic.AddUint64(&i, 1)
不过这样加锁实际上不合理,正常情况下不会这样写代码
请问你是在哪刷题呢
go compiler 自作多情而已
我尝试输出汇编代码,发现加不加 runtime.Gosched(),都没有偷工减料。
我直接让 协程 A 、协程 B 执行一遍就跳出,此时 i 2 ,满足 协程 C 执行条件,但 协程 C 就是不输出东西,此时 CPU 也是占用很大,说明 协程 C 是有在执行的。
可能是 for i%3 != 2 { 这里有问题,汇编有没有看到跳转语句,罗里吧嗦一堆看不太懂。
倒是 if i >= 1 { break 整个去掉,或者只把这个 break 去掉,那么程序也能按期待的运行。
不研究了,总之加 runtime.Gosched() 就没错了
没懂为啥把“E”去掉就死循环了,我本地跑没有复现,跑的结果是好的,ABCABC……
go<br>func main() {<br> wg := sync.WaitGroup{}<br> wg.Add(3)<br> a, b, c := make(chan int, 1), make(chan int, 1), make(chan int, 1)<br> p := func(cur, next chan int, v byte) {<br> defer wg.Done()<br> for i := 0; i < 100; i++ {<br> <-cur<br> fmt.Printf("%c", v)<br> next <- 1<br> }<br> }<br> a <- 1<br> go p(a, b, 'A')<br> go p(b, c, 'B')<br> go p(c, a, 'C')<br> wg.Wait()<br>}<br>
这样写显然存在 data race ,修好了应该没问题
go<br>package main<br><br>func main() {<br> chs := []chan struct{}{<br> make(chan struct{}), make(chan struct{}), make(chan struct{}),<br> }<br> next := make(chan struct{})<br> for i := 0; i < len(chs); i++ {<br> go func(i int) {<br> for range chs[i] {<br> b := byte('A' + i)<br> print(string(b))<br> if i != len(chs)-1 {<br> chs[i+1] <- struct{}{}<br> } else {<br> next <- struct{}{}<br><br> }<br> }<br> }(i)<br> }<br> for i := 0; i < 10; i++ {<br> chs[0] <- struct{}{}<br> <-next<br> }<br>}<br>
for i%3 !=2 被编译器优化后不会每次循环再 load i.
可以在循环体里或者 fmt.Println(“K”)那里放一个空函数, 或者编译时-gcflags="-N"禁用部分优化都能避免 case3 的死循环.
你的代码中每个协程里 load 或 store i 的地方都应该用 atomic.Load/Store 操作, 不仅是为了暗示编译器不能优化该处
load/store 操作(类似于其它语言的 volatile 语义), 同时也避免乱序出现匪夷所思的输出.
i = i + 1 不是原子的, i 可能变成任何值
<br>package main<br><br>import "fmt"<br><br>func main() {<br> intCh := make(chan int)<br> exit := make(chan bool)<br><br> a := func() {<br> fmt.Print("A")<br> }<br><br> b := func() {<br> fmt.Print("B")<br> }<br><br> c := func() {<br> fmt.Print("C")<br> }<br><br> go func() {<br> for i := 1; i < 10; i++ {<br> intCh <- i<br> }<br> close(intCh)<br> }()<br><br> go func() {<br> for {<br> select {<br> case i := <-intCh:<br> if i == 0 {<br> exit <- true<br> } else {<br> switch i % 3 {<br> case 1:<br> a()<br> case 2:<br> b()<br> case 0:<br> c()<br> }<br> }<br><br> }<br> }<br> }()<br><br> <-exit<br>}<br>
= = 感觉用 chan 会更好点。。waitgroup = = 这个 总感觉 在控制多个 routine 上费劲。
当然可以,我很乐意帮你看看这个诡异的执行结果。在 Go 语言中,遇到执行结果不符合预期的情况,通常可能是由以下几个原因造成的:
-
变量作用域和生命周期:检查所有变量的声明位置和使用范围,确保没有变量在未被正确初始化或已经被释放的情况下被使用。
-
并发问题:如果你的程序使用了 goroutines 和 channels,那么可能存在竞态条件或数据竞争。使用
go run -race
命令可以帮助检测这些问题。 -
指针和引用:确保你没有错误地传递或修改了指针所指向的数据。有时候,指针的误用会导致数据被意外修改。
-
类型转换和断言:检查所有的类型转换和类型断言,确保它们都是合法的,并且处理了所有可能的错误情况。
-
函数返回值:确认所有函数的返回值都被正确处理了,特别是那些可能返回错误或多重值的函数。
-
错误处理:检查你的错误处理逻辑,确保所有可能的错误都被捕获并妥善处理了。
为了更具体地帮助你,我需要看到具体的代码片段和期望与实际的执行结果。不过,以上这些检查点通常可以作为你调试和排查问题的起点。如果可能的话,尝试简化你的代码到一个最小可复现问题的示例,这样更容易定位问题所在。