先说一下具体背景,本人在刷题,有一道题是要求使用三个协程依次循环输出 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 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中,这个过程另一协程也会去读,同样把值赋值给寄存器,这个寄存器是一样的?共享的?所以就被覆盖了?感觉有这个可能。
根据 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 新版对于每一个协程都有一个时间限制,应该不会饿死才对,所以问题到底是啥?求大神解答。
感谢 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 不过我输出汇编代码后并没有发现有偷工减料的地方,具体以后有时间再研究了。
1
ruanimal 2022-06-01 00:33:53 +08:00 1
i = i + 1 不是原子操作
|
2
FrankAdler 2022-06-01 00:34:29 +08:00
太长,懒得看,我给你个简单点的思路,3 个 chan ,
初始往 A 写入,A 消费到后输出 A ,然后写入 B ,B 消费后写入 C |
3
luguhu 2022-06-01 00:36:02 +08:00 via iPhone
感觉是并发调度问题?
|
4
cocong OP @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 |
5
luguhu 2022-06-01 00:37:42 +08:00 via iPhone
可能存在获取自旋锁后被调度,这样就可能会有多个获取到锁的情况
|
6
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 包,传入指针给协程 |
7
cocong OP 不过感觉还是有点问题,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 的,和这个例子很像,那这样就不应该有诡异的问题的! 所以问题到底是啥! |
8
cocong OP @FrankAdler 这个我知道,其它解法不是问题,为什么会有这个诡异的结果才是我想问的问题。
|
9
GeruzoniAnsasu 2022-06-01 00:57:25 +08:00
https://go.dev/play/p/MUTu5YM-Irz 看起来你并不太理解各种锁的作用。 -race 参数可以在运行时加入竞争检测,能告诉你代码写得对不对。 没啥诡异的,多线程入门必经之路,建议找点操作系统层面的并发机制看一看,pthread 什么的 |
10
GeruzoniAnsasu 2022-06-01 01:13:44 +08:00 2
自旋锁是用来在两个真并行 cpu 上阻止彼此同时进入临界区的,要实现自旋锁的必要条件是
你需要一条 1. 原子的 2. 同时具备读和写两个操作的 3. 在当前 cpu 的当前指令周期结束前阻止其它所有 CPU 访问同名寄存器的 单个 cpu 指令 在非 cpu 层面是无论如何实现不了「自旋锁」的,务必明确 然后说代码,取模的过程和打印的过程和自增的过程都不原子,都没有锁 也就是说,有可能发生 1. 使用了线程 1 副本的 i 算取模 2. 打印了线程 2 已经自增了的 i 值 3. i 被改成了线程 3 得到的 i+1 ,其值等于…… 可以等于任何数。因为有可能 i+1 之后线程就卡住了,一直没加回来 反正一个不存在任何同步机制(你写的代码就是)的多线程并发+并行环境,临界区内的数据会被改成什么样几乎是无法预知的。 > 一个协程把 i 拿出来,加一后再放回去,这个拿出来是赋值给寄存器,寄存器加一后再拷贝到栈中 连这个都无法保证的,怎么猜? cpu 频率快慢都完全有可能影响读写的时序。分析不出来任何名堂的 |
11
wqtacc 2022-06-01 01:15:46 +08:00
i = i+1 不是原子操作,也没有锁,每个 goroutine 执行时随机的
|
12
cocong OP @GeruzoniAnsasu 谢谢大神。
|
13
gamexg 2022-06-01 07:12:39 +08:00
搜索关键字 go 内存模型
|
14
virusdefender 2022-06-01 08:22:17 +08:00
这种可能的并发问题先直接 go run -race ,大部分直接就报错了
|
15
rekulas 2022-06-01 08:37:01 +08:00
非要用数字来当成锁只能用原子性判断下
var i uint64 = 0 for atomic.LoadUint64(&i)%3 != 2 {} // 输出 atomic.AddUint64(&i, 1) 不过这样加锁实际上不合理,正常情况下不会这样写代码 |
16
Askiz 2022-06-01 08:47:49 +08:00 via Android
请问你是在哪刷题呢
|
17
MoYi123 2022-06-01 09:53:05 +08:00 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() } |
18
xfriday 2022-06-01 10:35:27 +08:00 1
|
19
xfriday 2022-06-01 10:35:42 +08:00
go compiler 自作多情而已
|
20
cocong OP @xfriday 我尝试输出汇编代码,发现加不加 runtime.Gosched(),都没有偷工减料。
我直接让 协程 A 、协程 B 执行一遍就跳出,此时 i 2 ,满足 协程 C 执行条件,但 协程 C 就是不输出东西,此时 CPU 也是占用很大,说明 协程 C 是有在执行的。 可能是 for i%3 != 2 { 这里有问题,汇编有没有看到跳转语句,罗里吧嗦一堆看不太懂。 倒是 if i >= 1 { break 整个去掉,或者只把这个 break 去掉,那么程序也能按期待的运行。 不研究了,总之加 runtime.Gosched() 就没错了 |
21
zealllot 2022-06-01 16:02:04 +08:00
没懂为啥把“E”去掉就死循环了,我本地跑没有复现,跑的结果是好的,ABCABC……
|
22
LeegoYih 2022-06-01 18:31:35 +08:00
```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() } ``` |
23
kiwi95 2022-06-01 18:55:12 +08:00 via Android
这样写显然存在 data race ,修好了应该没问题
|
24
wqtacc 2022-06-02 00:01:36 +08:00
```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 } } ``` |
25
katsusan 2022-06-03 10:16:15 +08:00
for i%3 !=2 被编译器优化后不会每次循环再 load i.
可以在循环体里或者 fmt.Println("K")那里放一个空函数, 或者编译时-gcflags="-N"禁用部分优化都能避免 case3 的死循环. 你的代码中每个协程里 load 或 store i 的地方都应该用 atomic.Load/Store 操作, 不仅是为了暗示编译器不能优化该处 load/store 操作(类似于其它语言的 volatile 语义), 同时也避免乱序出现匪夷所思的输出. |
26
lysS 2022-06-08 14:06:50 +08:00
i = i + 1 不是原子的, i 可能变成任何值
|
27
wh1012023498 2022-06-18 23:10:56 +08:00
```
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 上费劲。 |