5-5.调度-抢占
单核CPU,开两个 goroutine,其中一个死循环,会怎么样?
答案是:死循环的 goroutine 卡住了,但是完全不影响另一个 goroutine 和主 goroutine 的运行。
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 中加入一个死循环会怎么样呢?
这段代码在 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时,就发出抢占请求。
实现机制
编译阶段,编译器会在一些有明显消耗的函数头部插入一些栈增长检测代码,用来做扩栈和调度相关的判断。
函数执行时,通过检测
g.stackguard0 == 0,来判断是否要扩栈。该标志也用来判断是否该做调度,如果g.stackguard0 == stackPreempt,则执行调度。(在 Go1.14 里使用g.preempt字段代替)调度时还会检查
gcwating标志,如果gcwating == 1, (表示GC正在等待执行STW操作),便会让出当前P
因此,对于非函数调用 (例如某个
G执行的是简单for循环),即使设置了抢占标志,该G也不会执行栈扩容检测,就会一直霸占M,无法完成调度
Go 1.14 信号式抢占
程序启动时,会注册函数,监听
SIGURG信号并绑定处理方法runtime.doSigPreempt需要调度时,发送
SIGURG信号监听函数收到
SIGURG信号,执行调度schedule()
调度时机
Go后台监控runtime.sysmon检测超时发送抢占信号Go GC栈扫描发送抢占信号对
GC Root进行标记的时候会扫描G的栈,扫描之前会调用suspendG挂起G的执行才进行扫描会将处于运行状态的
G的preemptStop标记成为true,并调用runtime.preemptM会调用runtime.signalM向线程发送信号SIGURG扫描完毕之后再次调用
resumeG恢复执行。
Go GC STW的时候调用preemptall抢占所有P,让其暂停
源码
sysmon
调度器在启动的时候会启动一个单独的后台监控线程 sysmon,它负责所有的监控工作,其中一项就是抢占,发现满足抢占条件的 G 时,就发出抢占请求。
sysmon 每隔段时间就会检查一次,间隔时间从 20us 递增,最多 10ms。成功调度后会恢复回 20us。
如果 sysmon 发现一个协程执行超过 10ms 了,就会发出抢占信号 SIGURG。
此外,程序在启动时会启动函数监听 SIGURG。收到这个信号后,执行 runtime.schedule() 函数触发调度。
发送调度信号
preemptone() 里修改了 G 的 preempt 字段为可被抢占,然后调用 signalM 发出信号量
接收调度信号
程序初始化时,会注册函数监听 SIGURG 信号
收到信号后的处理
在 asyncPreempt2() 里,如果是 GC 触发的抢占,就执行 preemptPark,否则执行 gopreempt_m。里面最终都会调用 schedule()
preemptPark 方法会解绑 M 和 G 的关系,封存当前协程,执行 runtime.schedule() 来重新调度,获取可执行的协程,至于被抢占的协程后面会去重启。
goschedImpl 操作就简单的多,把当前协程的状态从 _Grunning 正在执行改成 _Grunnable 可执行,使用 globrunqput 方法把抢占的协程放到全局队列里,根据 MPG 的协程调度设计,全局队列要后于本地队列被调度。
参考
Last updated