1.异步抢占
上一次我们介绍了go1.13中依赖栈增长检测代码的抢占方式,遇到没有函数调用的情况就会出现问题。幸运的是,在go1.14中,这一问题得到了解决。在linux 操作系统上,这种真正的抢占式调度是基于信号来实现的,所以也称为"异步抢占"。
2.异步抢占前半部分
函数preemptone用来抢占一个P,定位到该函数对比1.13和1.14的实现有何不同?在1.13中,preemptone函数主要负责设置g.preemapt=true,并将g.stackguard 0设置为特殊标识(stackPreempt)。而在1.14中增加了最后这个if 语句块,第一个判断用于确认当前硬件环境是否支持这种异步抢占。这个常量值(preemptMSupported)是在编译期间确定的。第二个判断(debug.asyncpreemptoff)用于检测用户是否允许开启异步抢占。当然,默认情况下是允许的,但是用户可以通过GODEBUG环境变量来禁用异步抢占。如果这两条验证都通过了,就将p.primate字段置为true
实际的抢占操作会交由preemptM函数来完成。定位到preemptM函数,它的主要逻辑是通过runtime.signalM函数,向指定M发送sigPreempt信号,怎么发送的呢?signalM函数这里会通过调用操作系统中信号相关的系统调用,将指定信号发送给目标线程
信号发出去了。异步抢占的前一半工作就算完成了
3.异步抢占后半部分
至于后一半工作就要由接收到信号的工作线程来完成了。线程接收到信号以后,会调用对应的信号handler 来处理,Go 语言中的信号交由runtime.sigHandler 来处理。signalhandler在确定信号为sigPreempt以后,会调用doSigPreempt函数,定位到这个函数,
重点在这里,它会首先确认runtime 是否要对指定的G 进行一步抢占。
通过什么来判断呢?首先指定的G与其对应P 的greempt的字段都要为true,而且指定的G 还要处在_Grunning状态。
然后还要确认在当前位置打断G并执行抢占是安全的。怎么确保安全性呢?第一,指定的G 可以挂起并安全的扫描它的栈和寄存器,并且当前被打断的位置并没有打断血写屏障。第二,指定的G 还有足够的栈空间来注入一个异步抢占函数调用(asyncPreempt)。第三,这里可以安全的和runtime 进行交互,主要就是确定当前并没有持有runtime 相关的锁。继而不会在后续尝试获得锁时造成死锁,
确认了要抢占这个G ,并且在此时抢占是安全的以后,就可以放心的通过pushCall 向G 的执行上下文中注入异步抢占函数调用了。
被注入的异步抢占函数是(asyncPreempt)一个汇编函数,它会先把各个寄存器的值保存在栈上,也就是先保存现场,然后调用runtime.asyncPreempt2函数。这个函数最终会去执行schedule()。
到这里,异步抢占就完成了。
4.go1.14来执行
下面我们用go1.14来执行。
这个例子可以看到,即使空的for 循环没有执行栈增长检测代码,也依然没有阻塞。
同样用调试工具dlv, attach 到当前进程。
在异步抢占函数这里设置断点,然后继续执行。
现在通过bt命令查看这个函数是谁调用的。栈回溯显示在main函数十二行,也就是说异步回调函数是这个空的for循环调用的,这只能是注入的呀。
所以即使空的for 循环没有被插入栈增长检测代码,在1.14中,通过注入异步回调函数的方式,同样能实现抢占式调度。