golang 异步抢占例子

分享
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 调度器-sysmon 后台监控线程做了什么.md](https://www.bookstack.cn/read/qcrao-Go-Questions/goroutine 调度器-sysmon 后台监控线程做了什么.md)