Go在1.14之前没有真正的实时抢占机制,而是一套协作式抢占(cooperative preemption)。1.14开始使用非协作式抢占(non-cooperative preemption),通过堆栈和寄存器来保存抢占上下文,避免对抢占不友好的函数导致GC STW延长
本篇是对深度探索Go语言:抢占式调度 的记录
问题
1 | package main |
如果是Go 1.14及以上版本,确实会如预期,一直输出递增的数字;
但对于Go 1.13及以下版本,居然会发生阻塞。
1.13及以前
使用gvm或Goland自带的版本切换功能,切回到Go 1.13.5
执行代码, 数字打印一段时间后,程序就“静止不动“了。
机器是多核CPU,执行top命令,发现程序仍然在运行中。
但却没有继续输出,说明负责输出数字的goroutine阻塞了
通过dlv进行调试:
拿到程序对应的进程id,执行
dlv attach pid
通过grs查看当前所有的协程
*代表当前调试工具绑定到了该协程。 可通过
gr 6 切换到6号协程;
然后可以继续 grs,
通过bt命令查看栈回溯,探究究竟阻塞在哪里
可以看到实际阻塞发生在runtime.futex这里,再往上倒,会找到runtime.gcStart。阻塞发生在gcStart所在文件的第1287行。
定位到这一行,发现是在执行STW时发生了阻塞。
GC开始前需要STW来 进行开启写屏障等准备工作. 所以STW就是要抢占所以的P,让它们暂时放下手中的活儿,让GC得以正常工作。
而我们(继续执行for{})的1号协程没能被抢占,一直在执行。而STW一直在等待它让出,这样就陷入了僵局
为何如此?
STW时,GC需要抢占所有的P,但这不是值日生喊一嗓子就能清场的问题。
所以它会记录下自己要等待多少个P让出(sched.stopwait=gomaxprocs).
当该值减为0,目的就达到了
对于当前P,以及陷入系统调用中的P(_Psyscall),还有空闲状态的P(_Pidle),直接将它们设置为**_Pgcstop**状态即可。
对于还有g在运行的p,则会将对应的g.stackguard0设置为一个特殊标识runtime.stackPreempt,告诉它GC在等待你让出呢
此外还会设置一个gcwaiting标识 (sched.gcwaiting=1)。
接下来就通过这两个标识符的配合,来实现运行中的p的抢占。
怎么实现呢?
goroutine在创建之初,栈的大小是固定的,为了防止栈溢出的情况,编译器会在有明显栈消耗的函数头部插入一些检测代码。通过g.stackguard0来判断是否需要进行栈增长
但如果g.stackguard0被设置为特殊标识runtime.stackPreempt, 便不会去执行栈增长,而是去执行一次调度(schedule())。 在调度执行时,会检测gcwaiting标识。若发现GC在等待执行,便会让出当前p,将其置为_Pgcstop状态。
1号协程之所以没有退出,是因为空的for循环并没有调用函数,也就没机会执行栈增长检测代码。所以它并不知道GC在等待它让出
依赖栈增长检测代码的方式,不算是真正的抢占式调度。
Go 1.14中迎来了真正的抢占式调度
1.14:
依赖栈增长检测代码的抢占代码的,遇到没有函数调用的情况就会出现问题。
在Go 1.14中,实现了基于信号的真正的抢占式调度。
因为基于信号实现,所以也称为异步抢占
函数preemptone用来抢占一个p,在1.13中,preemptone主要用来设置g.preempt=true,并将g.tsackguard0设置为特殊标识(stackPreempt);
而在1.14中,增加了最后的if语句块,第一个判断用来确认当前硬件环境是否支持这种异步抢占,这个常量值(preemptMSupported)是在编译期间就确定的。
第二个判断用来检测用户是否允许开启异步抢占(默认允许)。 可通过GODEBUG环境变量来禁用异步抢占。
这两条验证都通过,则将p.preempt字段设置为true,实际的抢占操作交由preemptM函数来完成。
该函数的主要逻辑是,通过runtime.signalM函数,向指定M发送sigPreempt信号---- 会调用操作系统中信号相关的系统调用,将指定信号发送给目标线程。 信号发出去了,异步抢占的前一半工作就算是完成了
接收到信号的工作线程 会调用对应的信号handler来处理。Go语言中的信号交由runtime.sighandler来处理。
runtime.sighandler在确认信号为sigPreempt以后,会调用doSigPreempt函数。
doSigPreempt函数会首先确认runtime是否要对指定的g进行异步抢占----首先指定的g与其对应p的preempt字段都要为true,且指定的g还要处在_Grunning状态;还要确认在当前位置打断g并执行异步抢占是安全的----1.指定的g可以挂起并安全的扫描它的栈和寄存器,并且当前被打断的位置并没有打断写屏障; 2.指定的g还有足够的栈空间来注入一个异步抢占函数调用(asyncPreempt); 3.这里可以安全的和runtime进行交互,主要是确定当前并没有持有runtime相关的锁,继而不会在后续尝试获得锁时造成死锁
这三点都ok,就可以放心的通过pushCall向g的执行上下文中注入异步抢占函数调用了。
被注入的异步抢占函数(asyncPreempt)是一个汇编函数,它会先把各个寄存器的值保存在栈上,也就是先保存现场到栈上,然后调用runtime.asyncPreempt2函数,这个函数最终会去执行schedule()
使用dlv验证是否如此
Preempt! vt. 抢占,先占;先取;以先买权获得
- 程序启动时,在注册 _SIGURG 信号的处理函数 runtime.doSigPreempt;
- 此时有一个 M1 通过 signalM 函数向 M2 发送中断信号 _SIGURG;
- M2 收到信号,操作系统中断其执行代码,并切换到信号处理函数runtime.doSigPreempt;
- M2 调用 runtime.asyncPreempt 修改执行的上下文,重新进入调度循环进而调度其他 G;
原文链接: https://dashen.tech/2010/03/07/Go语言抢占式调度/
版权声明: 转载请注明出处.