前言
在 Go runtime 调度器精讲(七):案例分析 一文我们介绍了一个抢占的案例。从案例分析抢占的实现,并未涉及到源码层面。本文将继续从源码入手,看 Go runtime 调度器是如何实现抢占逻辑的。
sysmon 线程
还记得 Go runtime 调度器精讲(四):运行 main goroutine 一文我们蜻蜓点水的提了一嘴 sysmon 线程,它是运行在系统栈上的监控线程,负责监控 goroutine 的状态,并且做相应处理。当然,也负责做抢占的处理,它是本讲的重点。

sysmon 的创建在 src/runtime/proc.go:sysmon:
1// The main goroutine.
2func main() {
3 ...
4 if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
5 systemstack(func() {
6 newm(sysmon, nil, -1)
7 })
8 }
9 ...
10}
sysmon 不需要和 P 绑定,作为监控线程运行在系统栈。进入 sysmon:
1func sysmon() {
2 ...
3 idle := 0 // how many cycles in succession we had not wokeup somebody
4 delay := uint32(0)
5
6 for {
7 if idle == 0 { // start with 20us sleep...
8 delay = 20 //
9 } else if idle > 50 { // start doubling the sleep after 1ms...
10 delay *= 2
11 }
12 if delay > 10*1000 { // up to 10ms
13 delay = 10 * 1000
14 }
15 usleep(delay) // 休眠 delay us
16
17 // retake P's blocked in syscalls
18 // and preempt long running G's
19 if retake(now) != 0 {
20 idle = 0
21 } else {
22 idle++
23 }
24 ...
25 }
26}
省略了很多和抢占无关的内容,和抢占相关的是 retake 函数,进入 retake:
1func retake(now int64) uint32 {
2 n := 0
3 lock(&allpLock)
4
5 // 。。。
6 for i := 0; i < len(allp); i++ {
7 if pp == nil {
8 // This can happen if procresize has grown
9 // allp but not yet created new Ps.
10 continue
11 }
12
13 pd := &pp.sysmontick // 用于 sysmon 线程记录被监控 p 的系统调用次数和调用时间
14 s := pp.status
15 sysretake := false
16 if s == _Prunning || s == _Psyscall { // 如果 P 是 _Prunning 或者 _Psyscall,则对 P 进行处理
17 // Preempt G if it's running for too long.
18 t := int64(pp.schedtick) // P 的 schedtick 用于记录 P 被调度的次数
19 if int64(pd.schedtick) != t {
20 pd.schedtick = uint32(t) // 如果系统监控和调度次数不一致,则更新系统监控的调度次数和调度时间点
21 pd.schedwhen = now
22 } else if pd.schedwhen+forcePreemptNS <= now { // forcePreemptNS 为 10ms,如果 P 的 goroutine 运行时间超过 10ms 则对 P 发起抢占
23 preemptone(pp) // 抢占 P
24 // In case of syscall, preemptone() doesn't
25 // work, because there is no M wired to P.
26 sysretake = true // 设置 retake 标志为 true
27 }
28 }
29 ...
30 }
31 unlock(&allpLock)
32 return uint32(n)
33}
这里重点在如果 P 的 goroutine 运行时间过长,则进入 preemptone(pp) 抢占 P,也就是抢占运行时间过长的 goroutine。
抢占运行时间过长的 goroutine
进入 preemptone:
1func preemptone(pp *p) bool {
2 mp := pp.m.ptr() // P 绑定的线程
3 if mp == nil || mp == getg().m {
4 return false
5 }
6 gp := mp.curg // 线程运行的 goroutine,就是该 goroutine 运行过长的
7 if gp == nil || gp == mp.g0 {
8 return false
9 }
10
11 gp.preempt = true // 设置抢占标志位为 true
12
13 // Every call in a goroutine checks for stack overflow by
14 // comparing the current stack pointer to gp->stackguard0.
15 // Setting gp->stackguard0 to StackPreempt folds
16 // preemption into the normal stack overflow check.
17 gp.stackguard0 = stackPreempt // 官方的注释已经很清晰了,设置 goroutine 的 stackguard0 为 stackPreempt,stackPreempt 是一个比任何栈都大的数
18
19 // Request an async preemption of this P.
20 if preemptMSupported && debug.asyncpreemptoff == 0 { // 是否开启异步抢占,这里我们先忽略
21 pp.preempt = true
22 preemptM(mp)
23 }
24
25 return true
26}
可以看到,preemptone 主要是更新了 goroutine 的 gp.stackguard0,为什么更新这个呢?
主要是在下一次调用函数时,调度器会根据这个值判断是否应该抢占当前 goroutine。
我们看一个 goroutine 栈如下:
1func gpm() {
2 print("hello runtime")
3}
4
5func main() {
6 go gpm()
7 time.Sleep(1 * time.Minute)
8 print("hello main")
9}
给 goroutine 加断点,dlv 进入断点处:
1(dlv) b main.gpm
2Breakpoint 1 set at 0x46232a for main.gpm() ./main.go:5
3(dlv) c
4> main.gpm() ./main.go:5 (hits goroutine(5):1 total:1) (PC: 0x46232a)
5 1: package main
6 2:
7 3: import "time"
8 4:
9=> 5: func gpm() {
10 6: print("hello runtime")
11 7: }
12 8:
13 9: func main() {
14 10: go gpm()
15(dlv) disass
16TEXT main.gpm(SB) /root/go/src/foundation/gpm/main.go
17 main.go:5 0x462320 493b6610 cmp rsp, qword ptr [r14+0x10]
18 main.go:5 0x462324 762a jbe 0x462350
19 main.go:5 0x462326 55 push rbp
20 main.go:5 0x462327 4889e5 mov rbp, rsp
21=> main.go:5 0x46232a* 4883ec10 sub rsp, 0x10
22 main.go:6 0x46232e e82d28fdff call $runtime.printlock
23 ...
24 main.go:5 0x462350 e8abb1ffff call $runtime.morestack_noctxt
25 main.go:5 0x462355 ebc9 jmp $main.gpm
在 main.gpm 栈中,首先执行 cmp rsp, qword ptr [r14+0x10] 指令,这个指令的意思是将当前栈的栈顶和 [r14+0x10] 比较,[r14+0x10] 就是 goroutine 的 stackguard0 值。如果 rsp 大于 g.stackguard0 表示栈容量是足够的,如果小于 g.stackguard0 表示栈空间不足,需要执行 jbe 0x462350 跳转指令,调用 call $runtime.morestack_noctxt 扩栈。
这里如果 goroutine 是要被抢占的,那么 g.stackguard0 将被 sysmon 设置成很大的值。goroutine(中的函数) 在调用时,会执行 cmp rsp, qword ptr [r14+0x10] 指令比较栈顶指针和 g.stackguard0。因为栈顶 rsp 肯定小于 g.stackguard0,调用 call $runtime.morestack_noctxt 扩栈。
1// morestack but not preserving ctxt.
2TEXT runtime·morestack_noctxt(SB),NOSPLIT,$0
3 MOVL $0, DX
4 JMP runtime·morestack(SB)
5
6TEXT runtime·morestack(SB),NOSPLIT|NOFRAME,$0-0
7 ...
8 // runtime.morestack 内容很多,这里只挑重点和抢占相关的 runtime.newstack 介绍
9 BL runtime·newstack(SB)
10 ...
进入 runtime.newstack:
1func newstack() {
2 thisg := getg()
3 ...
4 gp := thisg.m.curg
5 ...
6 stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
7 preempt := stackguard0 == stackPreempt // 如果 gp.stackguard0 == stackPreempt,则设置抢占标志 preempt == true
8 if preempt {
9 if !canPreemptM(thisg.m) { // 判断是否可以抢占
10 // Let the goroutine keep running for now.
11 // gp->preempt is set, so it will be preempted next time.
12 gp.stackguard0 = gp.stack.lo + stackGuard // 如果不能抢占,恢复 gp.stackguard0 为正常值
13 gogo(&gp.sched) // never return // gogo 执行 goroutine
14 }
15 }
16 ...
17 if preempt { // 执行到这里,说明 goroutine 是可以抢占的,再次判断抢占标志是否为 true
18 if gp == thisg.m.g0 {
19 throw("runtime: preempt g0")
20 }
21 if thisg.m.p == 0 && thisg.m.locks == 0 {
22 throw("runtime: g is running but p is not")
23 }
24
25 ...
26
27 if gp.preemptStop { // 判断抢占类型是否是 preemptStop,这个类型和 GC 有关,这里我们不讨论
28 preemptPark(gp) // never returns
29 }
30
31 // Act like goroutine called runtime.Gosched.
32 gopreempt_m(gp) // never return // 重点看 gopreempt_m 进行的抢占
33 }
34 ...
35}
newstack 会执行抢占逻辑,如注释所示,经过层层执行,调用 gopreempt_m 抢占运行时间过长的 goroutine:
1func gopreempt_m(gp *g) {
2 goschedImpl(gp)
3}
4
5func goschedImpl(gp *g) {
6 status := readgstatus(gp) // 获取 goroutine 的状态
7 if status&^_Gscan != _Grunning {
8 dumpgstatus(gp)
9 throw("bad g status")
10 }
11 casgstatus(gp, _Grunning, _Grunnable) // 这时候 goroutine 还是运行的,更新 goroutine 的状态为 _Grunnable
12 dropg() // 调用 dropg 解除线程和 goroutine 的绑定
13 lock(&sched.lock)
14 globrunqput(gp) // 将 goroutine 放到全局可运行队列中,因为 goroutine 运行时间够长了,不会放到 P 的本地队列中,这也是一种惩罚机制吧
15 unlock(&sched.lock)
16
17 schedule() // 线程再次进入调度逻辑,运行下一个 _Grunnable 的 goroutine
18}
至此,我们知道对于运行时间过长的 goroutine 是怎么抢占的。
再次梳理下执行流程:
sysmon监控线程发现运行时间过长的 goroutine,将 goroutine 的 stackguard0 更新为一个比任何栈都大的 stackPreempt 值- 当线程进行函数调用时,会比较栈顶 rsp 和 g.stackguard0 检查 goroutine 栈的栈空间。
- 因为更新了 goroutine 栈的
stackguard0,线程会走到扩展逻辑,进入根据 preempt 标志位,执行对应的抢占调度。
小结
本讲介绍了 sysmon 线程,顺着 sysmon 线程介绍了抢占运行时间过长的 goroutine 的实现方式。下一讲会继续介绍 sysmon 线程和抢占系统调用时间过长的 goroutine。