Go 协程抢占机制
本文基于 Go 1.13,英文原文 Go: Goroutine and Preemption (opens in a new tab)
在 Go 1.14 中实现了一种异步的抢占机制,从而使本文中部分内容过时了。当然,我将会对这些部分进行标记,从而使本文对于理解异步抢占依然有用。
借助内部的调度程序, Go 实现了对 Goroutines 的管理。该调度器的旨在实现 Goroutines 之间的切换,从而保证它们都能够得到可运行的时间。但是,调度器可能需要抢占 Goroutines 。
Scheduler and preemption
让我们用一个简单的例子来看看调度器使如何工作的:为了便于阅读,这些示例中将不会使用原子操作。
func main() {
var total int
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
for j := 0; j < 1000; j++ {
total += readNumber()
}
wg.Done()
}()
}
wg.Wait()
}
//go:noinline
func readNumber() int {
return rand.Intn(10)
}
下图使 Traces 信息
我们清晰的看到调度程序在处理器上切换 Goroutines,给它们运行时间。为了改变运行时间,Go 会安排 Goroutine 在由于系统调用,通道阻塞,睡眠,等待互斥锁等原因而停止时停止运行。调度器受益于数字生成器中的互斥锁,从而为所有的 Goroutines 提供运行时间。如下图所示
但是,如果 Go 没有任何暂停, 它依然需要一种方法来停止正在运行的 Goroutine。这个动作称之为抢占,它允许调度器切换 Goroutines。任何运行时间超过 10ms 的 Goroutines 都会被标记为可抢占。然后,当 Goroutine的堆栈增加时,将在函数开始时进行抢占。
让我们看一个使用数字生成器,具有和前面例子一样的锁行为,但是该锁已经被修改为不再使用:
func main() {
var total int
var wg sync.WaitGroup
for i := gen(0); i < 20; i++ {
wg.Add(1)
go func(g gen) {
for j := 0; j < 1e7; j++ {
total += g.readNumber()
}
wg.Done()
}(i)
}
wg.Wait()
}
var generators [20]*rand.Rand
func init() {
for i := int64(0); i < 20; i++ {
generators[i] = rand.New(rand.NewSource(i).(rand.Source64))
}
}
type gen int
//go:noinline
func (g gen) readNumber() int {
return generators[int(g)].Intn(10)
}
下图是 Traces 信息
Goroutines 在函数入口还是被抢占了
这里的检查是由编译器自动添加的;下面是前面例子生成的汇编码 (opens in a new tab):
运行时通过在每个函数运行前插入指令来确保栈的增长。这也使得调度器在需要的时候可以运行。
大多数情况下,Goroutines 可以让调度器来调度它们,但是,如果使一个没有函数调用的循环的话,调度过程将没有机会运行。
Forcing preemption
让我们用一个简单的例子看看循环时如何阻塞住调度的:
func main() {
var total int
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
for j := 0; j < 1e6; j++ {
total ++
}
wg.Done()
}()
}
wg.Wait()
}
由于这里没有函数调用,并且 Goroutines 永远不会阻塞,因此调度器不会抢占它们。我们可以在 Traces 中看到
当然, Go 提供了一些解决方案来解决这个问题:
-
使用
runtime.Gosched()
强制调度器运行for j := 0; j < 1e8; j++ { if j % 1e7 == 0 { runtime.Gosched() } total ++ }
下面时新的 Traces:
-
使用允许循环被抢占的实验特性。通过使用
GOEXPERIMENT=preemptibleloops
重新构建 Go 工具链 (opens in a new tab) 或者是使用go build
编译时添加-gcflags -d=ssa/insert_resched_checks/on
选项来启用该特性。这样,不需要修改代码,新的 Traces 如下图
当启用循环中的抢占特性时,编译器将会在生成 SSA 代码时添加一个 pass:
这个 pass 将会添加调用调度器的指令
由于强制调度器触发的次数可能超过了必要的次数,这种方法可能会降低代码的速度。这里时这两个版本的基准测试结果:
name old time/op new time/op delta
Loop-8 2.18s ± 2% 2.05s ± 1% -6.23%
该问题已经被 Go 1.14 的异步抢占机制修复。当然,这里对两个方案的解释还是正确的。
runtime.GoSched()
可以被用来触发调度器,可抢占的循环选项依然是标准库的一部分。
Incoming improvements
到目前为止,调度器使用的协作式抢占技术可以覆盖大多数情况。但是,在一些特殊场景下,它可能会称为疼点。一个 “非协作式抢占 (opens in a new tab)” 的提案已经被提交了,该提案解决了文中提到的问题:
我建议 Go 实现非协作的抢占机制,这样可以允许 Goroutines 在任何时候都可以被抢占,而无需进行明确的检查。这种方法解决了延迟抢占问题,并且零运行时开销。
该提案中提出了几种各具优缺点的技术,这些技术可能会应用在下一版本的 Go 语言中。