《Go 语言运行时原理》2.3 抢占式调度与 sysmon

协作式抢占和异步抢占的区别、sysmon 干什么,站内专题讲过概念。本节只写增量:用两组真实实验量化抢占——关闭异步抢占后 GC 的 STW 从 0.3ms 暴涨到 1.8s,以及 GOMAXPROCS=1 下阻塞系统调用被 sysmon 夺回 P;再定位到 proc.go:sysmon/retake/preemptone 与 preempt.go:asyncPreempt。

前两节看的都是「goroutine 主动让出」——阻塞、退出、channel 等待。但如果一个 goroutine 不肯让出呢?一个不含函数调用的死循环,不会碰到任何协作式检查点。这一节讲 runtime 怎么对付这种「钉子户」:协作式抢占(设置 stackguard0)与异步抢占(发信号),以及这一切的幕后推手 sysmon。站内 Go 专题(/posts/golang/)已经讲过抢占的机制与版本历史,本节不复述,只写增量——两组可复现实验的量化数字,以及 sysmon/retake/preemptone 的源码定位。

本节要回答:抢占到底有多重要,sysmon 在其中扮演什么角色? 结论用一个实验就能说清:关闭异步抢占后,一个死循环 goroutine 会把 GC 的 STW 从 0.3 毫秒拖到 1.8 秒——三个数量级的差距。sysmon 则是唯一在后台周期性执行抢占与 P 夺回的协程,它的时间片常量 forcePreemptNS 就是 10ms。

2.3.1 实验:两组证据量化抢占

实验 A:异步抢占与 GC 的 STW。 构造一个「不含任何函数调用」的长循环——纯算术,没有协作式检查点:

//go:noinline
func longLoop(iters int) uint64 {
	var x uint64 = 1
	for i := 0; i < iters; i++ {
		x = x*6364136223846793005 + 1442695040888963407
		x ^= x >> 33
	}
	return x
}

func main() {
	runtime.GOMAXPROCS(2)
	const iters = 900_000_000 // 校准到约 1 秒的无调用工作
	go longLoop(iters)
	time.Sleep(50 * time.Millisecond) // 让死循环占住一个 P
	start := time.Now()
	runtime.GC()                      // STW 必须等这个 P 停下来
	fmt.Printf("runtime.GC() wall time = %v\n", time.Since(start))
	os.Exit(0)
}

分别用默认配置和 GODEBUG=asyncpreemptoff=1 各跑三次:

for i in 1 2 3; do GODEBUG=asyncpreemptoff=0 ./preemptgc; done
for i in 1 2 3; do GODEBUG=asyncpreemptoff=1 ./preemptgc; done
runtime.GC() wall time = 482.125µs
runtime.GC() wall time = 419.416µs
runtime.GC() wall time = 300.083µs
runtime.GC() wall time = 1.81662225s
runtime.GC() wall time = 1.728799292s
runtime.GC() wall time = 1.691802417s

差距触目惊心:开启异步抢占,GC 的 STW 是 0.300.48 毫秒;关闭后是 1.691.82 秒,涨了约 4000 倍。原因是:longLoop 内部没有任何函数调用,没有协作式抢占检查点;没有异步抢占(信号)的话,runtime 只能干等它把 9 亿次循环跑完。这就是 Go 1.14 引入异步抢占的直接动机——让 STW 不再被「无调用长循环」绑架。

实验 B:sysmon 从阻塞系统调用夺回 P。 把 GOMAXPROCS 设为 1,一个 goroutine 阻塞在真正的系统调用上(从空管道读),另一个 goroutine 做 CPU 工作:

runtime.GOMAXPROCS(1)
r, w, _ := os.Pipe()
var iters atomic.Uint64
go func() { // A:阻塞在 syscall.Read
	buf := make([]byte, 1)
	syscall.Read(int(r.Fd()), buf)
	done <- true
}()
go func() { // B:CPU 工作,必须能推进
	for i := 0; i < 40_000_000; i++ {
		iters.Add(1)
	}
}()
time.Sleep(200 * time.Millisecond)
w.Write([]byte("x")) // 解除 A 的阻塞
./sysmon
GOMAXPROCS=1, B iterations while A in syscall = 40000000

只有 1 个 P,却被 A 的系统调用占着——如果没人把 P 夺回来,B 应该一个迭代都跑不了。但实测 B 跑满了 4000 万次,说明 sysmon 把 P 从阻塞的 M 手里夺了回来,交给了 B。用 runtime/trace 看事件计数可以佐证:

go tool trace -d=footprint trace.out
GoSyscallBegin       53     0.84%   13     5.33%
GoBlock              34     0.54%   8      3.28%
ProcStart            23     0.37%   4      1.64%
GoSyscallEndBlocked  7      0.11%   2      0.82%
ProcStop             4      0.06%   2      0.82%

GOMAXPROCS=1 却出现了 ProcStart 计数 4、GoSyscallEndBlocked 计数 2:P 在 M 之间被移交过多次,这正是 retake 把 P 从系统调用中夺回、再 handoffp 交给其他 M 的痕迹。

复现基线:Go 1.27.0 darwin/arm64;Apple M1 Pro,10 逻辑核,32 GiB 内存;实验 A GOMAXPROCS=2,循环 9 亿次(约 1 秒),各跑 3 次;实验 B GOMAXPROCS=1,管道读阻塞约 200ms,-count 不适用(单次运行,数字稳定),trace 事件为单次采样的计数。

2.3.2 源码:sysmon 与两种抢占

sysmon 的职责。 src/runtime/proc.go:6537 的 sysmon 是一个特殊的、不绑定 P 的后台线程。它循环做五件事:网络轮询(netpoll)、按秒更新 GOMAXPROCS(sysmonUpdateGOMAXPROCS)、唤醒 scavenger、retake、以及强制 GC。核心片段:

// src/runtime/proc.go:6648(片段)
if retake(now) != 0 {
	idle = 0
}
// check if we need to force a GC
if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
	...
}

sysmon 的休眠策略值得注意:从 20 微秒起步,空闲越久翻倍,最多睡到 10ms。所以它平时几乎不占 CPU,但一旦有活儿(如 P 被系统调用占住)会很快醒来处理。

把这五件事在源码里对应出来,可以一次 grep 定位:

grep -n "netpoll(0)\|sysmonUpdateGOMAXPROCS\|scavenger.sysmonWake\|retake(now)\|gcTrigger{kind: gcTriggerTime" proc.go
6622:			list, delta := netpoll(0) // non-blocking - returns list of goroutines
6639:			sysmonUpdateGOMAXPROCS()
6642:		if scavenger.sysmonWake.Load() != 0 {
6648:		if retake(now) != 0 {
6654:		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {

五个职责依次是:网络轮询(把就绪的 I/O goroutine 唤醒)、更新 GOMAXPROCS(见 3.1)、唤醒 scavenger(归还内存给 OS)、retake(抢占与夺 P)、强制 GC(默认约 2 分钟一次)。sysmon 是这些「周期性维护」唯一的执行者。

两种抢占的入口都是 retake。 proc.go:6681 的 retake 遍历所有 _Prunning 的 P,检查它的 schedtick 是否长期不变:

// src/runtime/proc.go:6681(片段)
const forcePreemptNS = 10 * 1000 * 1000 // 10ms
...
schedt := int64(pp.schedtick)
if int64(pd.schedtick) != schedt {
	pd.schedtick = uint32(schedt)
	pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
	preemptone(pp)
	// If pp is in a syscall, preemptone doesn't work.
	sysretake = true
}

读法:如果同一个 P 的 schedtick 连续 10ms(forcePreemptNS)没变,说明它上面的 goroutine 一直没让出,就调用 preemptone 抢占它。这里的 10ms 就是 Go 的「时间片」。

preemptone 同时做两件事。 proc.go:6917:

// src/runtime/proc.go:6917
func preemptone(pp *p) bool {
	mp := pp.m.ptr()
	if mp == nil || mp == getg().m {
		return false
	}
	gp := mp.curg
	if gp == nil || gp == mp.g0 {
		return false
	}
	if readgstatus(gp)&^_Gscan == _Gsyscall {
		return false // 系统调用中的 G 不抢占,改为夺 P
	}
	gp.preempt = true
	gp.stackguard0 = stackPreempt // 协作式:折叠进栈溢出检查
	if preemptMSupported && debug.asyncpreemptoff == 0 {
		pp.preempt = true
		preemptM(mp) // 异步:给 M 发信号
	}
	return true
}

两件事:① 把 gp.stackguard0 设成 stackPreempt——每个函数入口的栈溢出检查会「顺带」发现抢占请求,这是协作式路径;② 若未关闭异步抢占,调用 preemptM 发信号——这是异步路径。注意 _Gsyscall 的 G 直接返回 false:系统调用中的 G 没法在 Go 代码里响应抢占,只能夺 P。

异步抢占的落地。 preemptM(src/runtime/signal_unix.go:369)给目标 M 发 sigPreempt 信号:

// src/runtime/signal_unix.go:369(片段)
func preemptM(mp *m) {
	if mp.signalPending.CompareAndSwap(0, 1) {
		// 避免对同一 M 重复发信号造成 live-lock(darwin 上曾出现)
		signalM(mp, sigPreempt)
	}
}

信号处理器会跳到 asyncPreempt(汇编,preempt.go:305 声明),再进入 asyncPreempt2(preempt.go:317),它 mcall 到系统栈、保存扩展寄存器、最终调用 gopreempt_m(proc.go:4376)把 G 放回队列:

// src/runtime/preempt.go:317(片段)
func asyncPreempt2() {
	mcall(func(gp *g) {
		gp.asyncSafePoint = true
		xRegSave(gp) // 把扩展寄存器状态从 P 挪到 G
		if gp.preemptStop {
			preemptPark(gp)
		} else {
			gopreempt_m(gp)
		}
	})
}

能否异步抢占有个前提:当前 PC 必须落在 isAsyncSafePoint(preempt.go:390)认可的「安全点」上——即那些有精确指针信息的普通 Go 代码位置。汇编函数(尤其是会写栈指针的 SPWRITE 函数)不在安全点内,这就是为什么 preemptPark 里还要二次校验 FuncFlagSPWrite。换句话说,异步抢占不是「随时能停」,而是「在编译器标记的安全点上才能停」。

2.3.3 决策:抢占相关的判断

观测/现象含义决策
GC STW 偶发到毫秒级以上有 goroutine 长期不响应抢占检查是否有无调用长循环;确认未设 asyncpreemptoff=1
GOMAXPROCS=1 下 CPU 任务仍推进sysmon 夺回了 P正常,无需干预
某 goroutine 卡在 syscall 很久_Gsyscall 的 G 不会被抢占给阻塞调用加超时/context
延迟毛刺每 10ms 一次forcePreemptNS 时间片到期检查是否有长临界区反复触发抢占
scheddetail 里 _Gsyscall 的 G 增多大量并发阻塞系统调用可能是文件 I/O 阻塞线程,考虑异步化

三条结论:

  1. 异步抢占是现代 Go 的底线保障。 它保证了「任何 goroutine 都不能无限期霸占 CPU」,这是 GC 延迟可控、调度公平的前提。GODEBUG=asyncpreemptoff=1 只应在排查「疑似异步抢占相关 bug」时临时使用——它会显著拉长 STW。
  2. 协作式 + 异步是双保险。 普通函数调用点用协作式(零成本,靠 stackguard0);没有调用点的长循环用异步(有信号成本,但兜底)。绝大多数代码走的是零成本的协作式路径。
  3. sysmon 是运行时的「值班医生」。 它不占 P、平时几乎不耗 CPU,但承担了抢占、夺 P、强制 GC、网络轮询这些「没人主动做就不做」的活。理解 retake 的 10ms 阈值,就理解了 Go 调度的「时间片」从哪来。

阅读导航:上一节:2.2 调度循环与 work stealing 实测 · 下一节:3.1 GOMAXPROCS 与容器感知实测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练