golang 异步抢占例子,继上篇文章《golang 非协作式抢占》添加一些手操事例
一定要go1.14以后的版本,本文版本
go version go1.15.1 linux/amd64
源码cat main.go
package main
import (
"fmt"
"golang.org/x/sys/unix"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
for {
time.Sleep(time.Second * 5)
go func() {
fmt.Printf("tid %d\n", unix.Gettid())
for {
}
}()
}
time.Sleep(time.Hour)
}
编译
go build -o main main.go
运行
两个shell终端,一个用于执行,一个查看strace
关闭异步抢占
第一个终端执行 GODEBUG=asyncpreemptoff=1 ./main
可以看到输出了一个tid 25674
第二个终端执行 strace -p 25674
,可以看到显示
开启异步抢占
第一个终端执行 ./main
,可以看到输出了一个tid 3141
第二终端执行strace -p 3141
可以看到右侧显示大量的SIGURG信号
注意到右侧的si_pid 与 tid不同,也就是虽然 GMP中虽然 P被设置了1,但是 M这个时候数量是多于 P的。 发送的信号之后主要做的是保存 ctx,接着 M与P depatch掉。(
GC
通常发生在饥饿态。当然如果搞复杂一点,也可以弄一个 GC
想要 stop the word,但是同步抢占无法 depatch其中一个P。我们知道 stop the word,所有的M需要与 P depath,然后进入idle
列表。
这种无法depathc
会拉长整个 GC时间,甚至是挂起整个进程。上篇《golang 非协作抢占》有链接可以看一下奇葩的事例。所以异步抢占,是有利于 GC的,但是整体来说,基于信号的方式,性能非常差。
调试器
对于调试器来说,会拦截到的本该进程接受到的SIGURG
,所以需要对其忽略,这个是golang异步占用的原提议提到的(看我上篇文章)
golang的dlv https://github.com/go-delve/delve/issues/1754 会收到影响
比方说我写的go调试器toy,里面需要将https://github.com/chainhelen/godbg/pull/6 信号忽略
tight loop
另外需要注意的是可能有人说,上面这个例子for
是个死循环,正常不会写这种代码。
但是实际生产环境,由于编译器不添加 gcflag='-N -l'
,SSA阶段会做大量的指令和内联优化,
是有可能把函数调用优化成上述的代码,当然这是另一种go用法的失误(或者说go设计者并不希望你这么用,详细可以看上篇《golang 非协作抢占》里面有几个链接都是比较好的事例)。
其他BUG
正常运行的时候,
GODEBUG=asyncpreemptoff=1
开关关闭,就是说不允许异步抢占(进程自己不发送SIGURG emit
),那么就不会发生协程异步抢占了吗?哈哈,有人就是手贱,手动发送。https://github.com/golang/go/issues/38531还有一些跑满CPU的bug,https://github.com/golang/go/issues/37741
- 还有有人提议把runtime 自己产生的信号,过滤掉,不要发送到用户态的代码 https://github.com/golang/go/issues/37942
代码位置
下面是分析源码,不喜欢看的可以跳过。 先找一下发送信号的位置。先看最最基本的,某个G占用超过10ms的情况,由 monitor 协程发起的。
发送方
// src/runtime/proc.go
func sysmon() {
...
// retake P's blocked in syscalls
// and preempt long running G's
if retake(now) != 0 {
idle = 0
} else {
idle++
}
...
}
上面的这个retake
对所有的G进行重整
// src/runtime/proc.go
func retake(now int64) uint32 {
for i := 0; i < len(allp); i++ {
...
if s == _Prunning || s == _Psyscall {
// Preempt G if it's running for too long.
t := int64(_p_.schedtick)
// 这地方就是协程自己调度了,没有被抢占
// 所以G对应的schedtick和schedwhen跟监控的不一致,需要重新更新一些monitor的数值
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
// 如果超过了10ms就需要进行抢占了
preemptone(_p_)
// In case of syscall, preemptone() doesn't
// work, because there is no M wired to P.
sysretake = true
}
}
...
}
}
上面遍历所有的P,monotor发现上次我就监控过你,这次发现你了,然后还时间间隔超了10ms,那对不起了,我要执行 preemptone(_p_)
了。
func preemptone(_p_ *p) bool {
...
// 注意这个两个属性其实是重复的,就是说作用是一样的,都是为了标记当前是在抢占中
// 可以看一下preempt的注解
gp.preempt = true
gp.stackguard0 = stackPreempt
// Request an async preemption of this P.
if preemptMSupported && debug.asyncpreemptoff == 0 {
_p_.preempt = true
preemptM(mp)
}
...
}
在之前的版本协作式抢占中,这个标记设置好了,就只能干等着,直到 morestask (函数调用)或者gopark(IO操作,例如channel之类)才行。但是这里面明显就能看到有一个 preemptM信号通知线程。
接收方
./src/runtime/signal_unix.go
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
if sig == sigPreempt && debug.asyncpreemptoff == 0 {
// Might be a preemption signal.
doSigPreempt(gp, c)
// Even if this was definitely a preemption signal, it
// may have been coalesced with another signal, so we
// still let it through to the application.
}
}
上面是发送信号的地方,sigPreempt
是一个常量,该文件能看到 const sigPreempt = _SIGURG
./src/runtime/signal_unix.go
// doSigPreempt handles a preemption signal on gp.
func doSigPreempt(gp *g, ctxt *sigctxt) {
// Check if this G wants to be preempted and is safe to
// preempt.
if wantAsyncPreempt(gp) {
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(funcPC(asyncPreempt), newpc)
}
}
// Acknowledge the preemption.
atomic.Xadd(&gp.m.preemptGen, 1)
atomic.Store(&gp.m.signalPending, 0)
}
这里面可以看到需要判断异步安全点
的(就是上篇那个那个,自己看),然后返回一个可以植入的pc
寄存器位置。
./src/runtime/preempt.go
func isAsyncSafePoint(gp *g, pc, sp, lr uintptr) (bool, uintptr) {
....
// 上面别的地方check还挺简单的,就不贴了。
// 但是这里面我一开始没看懂第二个条件。
// 因为我看到asyncPreemptStack的赋值 var asyncPreemptStack = ^uintptr(0)
// 是-1的补码,就是说如果是无符号,那么就是最大的整数
// 所以怎么可能两个数相减超过最大(二进制全1)
// 后来发现func init()函数里面对asyncPreemptStack重新赋值了。。。。
// 实际第二个条件它的含义是检查的是当前G是否还够栈插入异步抢占栈空间。
// 而且第一个条件是不能省掉的,仅用第二个条件因为可能相减(无符号)存在溢出
// (在不stack split的情况下)
//
// ——ho |
// | |
// bp——---- | |
// | | |
// | | |
// | | |
//sp ——-- | |
// | \ | /
// | \|/
// | stack递减方向
// —— lo
//
if sp < gp.stack.lo || sp-gp.stack.lo < asyncPreemptStack {
return false, 0
}
// 底下还有一些判断,大多数跟编译器生成的信息有关,主要跟pcdata、funcdata有关
}
./src/runtime/preempt_amd64.s
TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
MOVQ AX, 0(SP)
MOVQ CX, 8(SP)
MOVQ DX, 16(SP)
MOVQ BX, 24(SP)
...
CALL ·asyncPreempt2(SB)
...
MOVQ 16(SP), DX
MOVQ 8(SP), CX
MOVQ 0(SP), AX
这部分是汇编实现的,我个人电脑是amd64/linux,所以看的是这个文件,这里面其实就是保存了所有的寄存器,然后偷偷在中间调用了asyncPreempt2(这个是golang实现的),这样让其进行了调度。再偷偷恢复了自己的寄存器。
后面代码就有点小复杂,而且并不是异步抢占的核心逻辑。看代码主要要看三个函数
func preemptPark(gp *g)
func gopreempt_m(gp *g)
func schedule()
补一张我画的天师神图,希望一路驱魔斩妖保平安
注意
golang的异步信号是针对(target)线程(thread)级别的,通常由sysmon
发起抢占信号。就是说对应GMP模型中的M,而最终发生切换动作的其实是 G。
参考:
https://www.jianshu.com/p/604d277cbc6f
https://chai2010.cn/advanced-go-programming-book/ch3-asm/ch3-06-func-again.html
https://medium.com/a-journey-with-go/go-asynchronous-preemption-b5194227371c