go1.14 版本实现了基于信号的抢占式调度,可以在 goroutine 执行时间过长的时候强制让出调度,执行其他的 goroutine。接下来看看具体怎么实现的。基于 go1.15 linux amd64。
先看一个常规的例子
func main() {
// 只有一个 P 了,目前运行主 goroutine
runtime.GOMAXPROCS(1)
// 创建一个 G1 , runtime.newproc -> runqput(_p_, newg, true) 放入到 P.runnext 字段
go f1()
// 因为只有一个 P 主 goroutine继续运行
// 创建一个 G2 , 同上面,也是加入到队列头部,这时候本地队列的顺序就是 G2 G1
go f2()
// 等待 f1 f2的执行, gopark 主 goroutine, GMP 调度可运行的 G
// 按顺序调用 G2 G1
// 所以不管执行多少次,结果都是
// This is f2
// This is f1
// success
time.Sleep(100 * time.Millisecond)
fmt.Println("success")
}
如果将 runtime.GOMAXPROCS(1) 改成 runtime.GOMAXPROCS(4) 即多核CPU,你就会发现 f1 和 f2 交替执行,没有明确的先后。因为此时有4个 P,两个 M 可以分别运行自己的 P 并行执行 f1 和 f2。
既然每次都是 f2 先执行,那在 f2 中加入一个死循环会怎么样呢?
func f1() {
fmt.Println("This is f1")
}
func f2() {
// 死循环
for {
}
fmt.Println("This is f2")
}
func main() {
runtime.GOMAXPROCS(1)
go f1()
go f2()
time.Sleep(100 * time.Millisecond)
fmt.Println("success")
}
// This is f1
// success
这段代码在 go 1.14 中可以正常执行,但 go 1.13 中则会卡住。因为之前版本在执行栈容量检查时才会检查是否需要抢占,对于 f2 里这种简单的 for 循环不会触发栈容量检查,就会导致不触发抢占。
强制抢占
操作系统使用的是强制抢占,即线程执行超过一定时间后,不管是否执行完,强制换上别的线程,以实现公平
Go 1.2 协作式调度 / 请求式抢占
Go 的抢占调度实现原理就是给 G 里放了一个标记字段,标记它是否可以被抢占。执行前检查这个字段,如果为 true,就表示可以暂停它,让出当前 CPU 给别的协程
Go 调度在 go1.2 实现了抢占,应该更精确的称为请求式抢占,那是因为 Go 调度器的抢占和 OS 的线程抢占比起来很柔和,不暴力,不会说线程时间片到了,或者更高优先级的任务到了,强制暂停当前任务给其他的任务。
抢占请求需要满足 2个条件中的1个:
G进行系统调用超过20us
G运行超过10ms。
调度器在启动的时候会启动一个单独的线程 sysmon,它负责所有的监控工作,其中1项就是抢占,发现满足抢占条件的 G 时,就发出抢占请求。
preemptPark 方法会解绑 M 和 G 的关系,封存当前协程,执行 runtime.schedule() 来重新调度,获取可执行的协程,至于被抢占的协程后面会去重启。
// runtime/proc.go
// preemptPark parks gp and puts it in _Gpreempted.
func preemptPark(gp *g) {
casGToPreemptScan(gp, _Grunning, _Gscan|_Gpreempted) // 修改 G 的状态为抢占中
dropg() // 解绑 M 和 G
casfrom_Gscanstatus(gp, _Gscan|_Gpreempted, _Gpreempted) // 修改 G 的状态为被抢占
schedule() // 调度
}
// 解绑 M 和 G
func dropg() {
_g_ := getg()
setMNoWB(&_g_.m.curg.m, nil) // 将 G 的 M 设置为 nil
setGNoWB(&_g_.m.curg, nil) // 将 M 的 curg 设置为 nil
}