golang 异步抢占例子

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,可以看到显示

1599118327532

开启异步抢占

第一个终端执行 ./main,可以看到输出了一个tid 3141

第二终端执行strace -p 3141 可以看到右侧显示大量的SIGURG信号

1599118327532

注意到右侧的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

  1. 正常运行的时候,GODEBUG=asyncpreemptoff=1开关关闭,就是说不允许异步抢占(进程自己不发送SIGURG emit),那么就不会发生协程异步抢占了吗?哈哈,有人就是手贱,手动发送。https://github.com/golang/go/issues/38531

  2. 还有一些跑满CPU的bug,https://github.com/golang/go/issues/37741

  3. 还有有人提议把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。

参考:

<因goroutine运行时间过长而发生的抢占调度

https://www.jianshu.com/p/604d277cbc6f

https://golang.org/doc/asm

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

https://www.bookstack.cn/read/qcrao-Go-Questions/goroutine%20%E8%B0%83%E5%BA%A6%E5%99%A8-sysmon%20%E5%90%8E%E5%8F%B0%E7%9B%91%E6%8E%A7%E7%BA%BF%E7%A8%8B%E5%81%9A%E4%BA%86%E4%BB%80%E4%B9%88.md