Goroutine‘饿死‘现场:一个让资深Go工程师都懵圈的并发Bug
左手不必知道右手在做什么…并发这玩意儿的问题在于,当好几件事同时发生时,你很难搞清楚到底发生了什么。用 Go 写并发程序很简单,但理解这些程序的行为(或者更常见的是,异常行为)就是另一回事了。还是那个老问题。我们知道go关键字会让这两个函数并发执行,所以直觉告诉我们运行程序时应该会看到 A 和 B 的问候消息交替出现。但实际情况呢?main函数)欢快地跑完了全程,而 goroutine B(gor
…左手不必知道右手在做什么…
并发这玩意儿的问题在于,当好几件事同时发生时,你很难搞清楚到底发生了什么。用 Go 写并发程序很简单,但理解这些程序的行为(或者更常见的是,异常行为)就是另一回事了。
还是那个老问题。看看这段代码:
package main
import (
"fmt"
)
func goroutineB() {
for i := range 10 {
fmt.Println("Hello from goroutine B!", i)
}
}
func main() {
go goroutineB()
for i := range 10 {
fmt.Println("Hello from goroutine A!", i)
}
}
我们知道 go 关键字会让这两个函数并发执行,所以直觉告诉我们运行程序时应该会看到 A 和 B 的问候消息交替出现。但实际情况呢?我们只看到:
Hello from goroutine A! 0
Hello from goroutine A! 1
Hello from goroutine A! 2
Hello from goroutine A! 3
...
Hello from goroutine A! 9
Goroutine A(main 函数)欢快地跑完了全程,而 goroutine B(goroutineB 函数)似乎根本就没启动。搞什么鬼?
饥饿(Starvation)
Goroutine 有三种状态:运行中(running)、阻塞(blocked)或就绪(ready)。所以在 main 开始的时候,确实 只有 goroutine A 一个线程,毫不意外它正处于运行状态。
然后我们遇到了 go 语句:
这句话是在请求调度器创建一个新 goroutine,任务是运行 goroutineB 函数。但问题是,goroutine A 现在正在运行,而且暂时不需要阻塞,所以 go 语句只是把这个新 goroutine 丢进了就绪队列。
记住,除非当前运行的 goroutine 结束或者主动阻塞,否则调度器都懒得看就绪队列一眼。而 goroutine A 还有正经事要干——打印它那 10 条消息。
当 goroutine A 跑完 for 循环,它也就跑出了 main 函数的范围,然后——砰!程序结束了!可怜的 goroutine B 还在就绪队列里排队等着上场呢,这个机会再也等不到了(用行话说,它饿死了)。
新 Goroutine 的真相
我们已经学到了关于 Go 并发的有用知识。你可能以为程序会等到所有 goroutine 都跑完才结束,但事实并非如此。
相反,只要有某个 goroutine 跑到了 main 函数的末尾,程序就立马退出。如果还有 goroutine 在就绪队列或阻塞队列里排队?那太糟糕了,它们会随着程序一起被终结。
如果一个正在运行的 goroutine 有活干却从不阻塞,也从不主动让出 CPU,那其他 goroutine 就永远没机会运行。换句话说,Go 的多任务完全是协作式的(这里有些小细节,但我们暂时不必纠结)。
你也许会理所当然地认为 go 语句会暂停当前 goroutine 并立即启动新的那个,但事实也不是这样。相反,go 语句只是把新任务加到就绪队列里,让它在某个未来的时间点运行(如果它还有机会的话)。
你可以把 go 关键字理解为:不是"现在运行这个",而是"以后运行这个…也许吧!" 与此同时,当前 goroutine 会尽其所能继续执行。
成为调度器
顺便说一句,有个很有用的技巧可以帮你搞清楚并发程序为什么没按预期工作。我称之为**“成为调度器”**。逐行过一遍代码,就像刚才我们做的那样,每走一步都问问自己:现在有哪些 goroutine?它们各自是什么状态?找找 go 语句创建新 goroutine 的位置,还有导致状态转换的阻塞操作。
如果你认真系统地这样做,你就能理解任何并发程序的行为,最重要的是,它可能的异常行为。比如,我们就诊断出了"hello goroutine"程序的问题是饥饿:虽然我们创建了 goroutine B,但在程序结束前它根本没机会运行。
用 time.Sleep 让步
如果我们想看到两个 goroutine 都在运行的证据,就需要确保它们定期让出 CPU,让彼此有机会执行。最简单的方法是用 time 包里的 time.Sleep 函数让它们暂停一下:
time.Sleep(100 * time.Millisecond)
具体暂停多久并不重要:只要有暂停,就会阻塞当前 goroutine,触发调度器去运行就绪队列里的下一个任务。
看到 time.Sleep 这样的语句,你很容易误以为当前 goroutine 会霸占 CPU 空转,直到时间到了。但实际上并不会这样,因为在多任务系统中,那纯属浪费 CPU 时间。
只要就绪队列里还有其他 goroutine(意味着它们有正事要干),调度器就会让它们上场干活。
与此同时,调用 time.Sleep 的 goroutine 会被挂起(parked):它会加入阻塞队列,直到定时器到期。那时候它才会回到就绪队列。
交错的 Goroutine
下面是加了 time.Sleep 后的程序:
package main
import (
"fmt"
"time"
)
func goroutineB() {
for i := range 10 {
fmt.Println("Hello from goroutine B!", i)
time.Sleep(10 * time.Millisecond)
}
}
func main() {
go goroutineB()
for i := range 10 {
fmt.Println("Hello from goroutine A!", i)
time.Sleep(10 * time.Millisecond)
}
}
再跑一次:
Hello from goroutine A! 0
Hello from goroutine B! 0
Hello from goroutine B! 1
Hello from goroutine A! 1
Hello from goroutine B! 2
Hello from goroutine A! 2
Hello from goroutine A! 3
Hello from goroutine B! 3
...
Hello from goroutine A! 9
Hello from goroutine B! 9
这就对了。两个 goroutine 的消息交织在一起,说明它们的执行是重叠的。从这个输出可以看出,goroutine A 跑一两次循环,然后 goroutine B 上,再回到 A,如此往复。
每个 goroutine 在 CPU 上能跑多久取决于其他 goroutine(比如垃圾回收器)在做什么、你的 CPU 被其他程序占用得多忙,以及各种其他因素。我们无法预先知道这类程序的输出会是什么样,只知道两个 goroutine 会并发运行。
更多推荐



所有评论(0)