Go 调度器的一个无法执行陷阱
注意: 这篇文章的答案可以有正确的结果,但解题思路是不对的,正确的思路请参考 关于线程同步操作的一道面试题
2020年03月16日补充说明
Go 1.14 加入了基于信号的抢占式调度,这个问题在 go 1.14 上已经复现不了了。参考 go1.14基于信号的抢占式调度实现原理
背景说明
前两天遇到了这样一道题目:
编写一个程序,开启 3 个线程A,B,C,这三个线程的输出分别为 A、B、C,每个线程将自己的 输出在屏幕上打印 10 遍,要求输出的结果必须按顺序显示。如:ABCABCABC….
注意:
- 输出要在各自的线程中输出,不能在主线程中输出
错误答案
当时想到一种思路是使用 Go atomic 包中提供的原子操作来完成上述功能。
即每个Goroutine原子性地获得i的值,如果符合i % 3 == threadNum的条件,则执行操作,否则作自旋。代码如下:
package main
import (
"fmt"
"sync/atomic"
)
var (
end = make(chan struct{})
i int32
)
func threadPrint(threadNum int32, threadName string) {
for {
v := atomic.LoadInt32((*int32)(&i))
if v >= 30 {
break
}
if v%3 == threadNum {
fmt.Printf("%d: %s\n", i, threadName)
atomic.AddInt32((*int32)(&i), int32(1))
} else {
continue
}
}
end <- struct{}{}
}
func main() {
names := []string{"A", "B", "C"}
for idx, name := range names {
go threadPrint(int32(idx), name)
}
for _ = range names {
<-end
}
}
这个程序当时跑的是没有问题的。我把答案发到 V2EX 论坛上之后, V友 @whoisghost 指出了问题
把 names 再追加 “ D ”, “E”, 把 3 => 5, 30 => 50, 还能正常运行吗?
我照着它的说明试了一下,发现程序会阻塞起来。后来我去查了一下资料,才了解到 Go 的调度器中还有一个隐藏的陷阱。在了解这个陷阱之前,我们需要先了解一下操作系统的线程调度器和 Go 的调度器。
操作系统线程调度器
操作系统线程调度器的执行逻辑如下:
- 操作系统的调度器维护了一组线程的信息,这些线程分别处于
running,runnable,non-runnable的状态。 - 当一个线程在一个 CPU 核心上运行超过一个时间片以后,它就会被系统时钟中断给中断掉。
- 被中断的线程会保存它的上下文信息,并执行中断处理函数。
- 中断处理函数会将执行权转交给操作系统的调度器,操作系统的调度器会调取其他的线程来这个 CPU 核心上运行。
Go 调度器
Go 语言中使用Goroutine来实现并发,Goroutine类似于线程,但它又是非常轻量的。一个创造成千上万个Goroutine的程序很常见,但是创造成千上万个线程的程序却很少见。
Goroutine是在用户层实现的,当 Go 程序启动的时候,Go 的运行时会创建GOMAXPROCS个系统级线程,然后Goroutine就被它在这GOMAXPROCS个系统级线程上调度。
Go 语言实现了一个协同式的,部分中断调度器(Golang implements a co-operative partially preemptive scheduler.)。它没有基于时钟中断来实现调度,但调度器可以在系统级线程上并行地运行多个 Goroutine。
在runtime提供的构造体,库,系统调用函数中,Go 添加了钩子函数,这些钩子函数能够协同式地启动 Go 的调度器。
Go 通过这种方式来将执行权切换到 Go 调度器,从而避免通过时钟中断来将执行权切换到 Go 调度器。runtime提供的这些函数也成为了进入 Go 调度器的入口。
但是如果我们在Goroutine中没有调用 runtime 的任何函数会发生什么情况呢?
代码错误原因简析
在上述代码中,我们启动了5个 Goroutine,在我的电脑上GOMAXPROCS 是4。
也就是说有4个Goroutine会各自占用一个系统级线程进行自旋操作,但因为它们没有调用runtime中的函数,所以它们并不会主动将执行权交给 Go 调度器。
这样始终有一个Goroutine无法获得执行的机会,整个程序也就被阻塞住了。
解决方案
在生产环境中,我们遇到上述错误的机会很少。因为我们的程序基本都会执行runtime中提供的一些功能,例如channel,系统调用, fmt.Sprint, Mutex, time.Sleep等。
例如如果我们在上述代码第加入一句time.Sleep(0),程序就不会阻塞了,因为time.Sleep中包含的钩子函数启动了 Go 调度器,第五个 goroutine 有了执行的机会。
|
|
如果我们在生产环境中遇到了这个问题的话,正确的做法应该是加入一句 runtime.Gosched(),如下所示:
|
|
这样当Goroutine自旋的时候,就会主动地去启动 Go 调度器,让其他Goroutine获得执行的机会。