bigywater.tech
Go · 59

系统调用阻塞时的 P 移交:sysmon 在什么时候介入

一个 goroutine 阻塞在系统调用里,它占着的 P 怎么办?如果 P 也跟着停了,其它 goroutine 就饿死了。这篇用只有一个 P 的最苛刻设置量出被饿的时间,再用不通知运行时的原始系统调用做对照,并读源码里 sysmon 拿走 P 的规则。

≈ 65 µs
GOMAXPROCS=1 时,即使系统调用阻塞 100 ms,另一个 goroutine 也只等这么久就拿到 P
RawSyscall
不通知运行时的系统调用不移交 P:另一个 goroutine 要等到它返回
20 µs
sysmon 睡眠间隔的起点,retake 要求 P 在系统调用里至少停留一个周期

阻塞的系统调用会让出 P

实测 · 本机执行输出
  系统调用阻塞    100 µs:另一个 goroutine 开始运行的延迟 最小 42 µs / 中位 45 µs / 最大 62 µs;系统调用本身耗时中位 160 µs
  系统调用阻塞   1000 µs:另一个 goroutine 开始运行的延迟 最小 10 µs / 中位 13 µs / 最大 156 µs;系统调用本身耗时中位 1511 µs
  系统调用阻塞  10000 µs:另一个 goroutine 开始运行的延迟 最小 10 µs / 中位 41 µs / 最大 97 µs;系统调用本身耗时中位 13750 µs
  系统调用阻塞 100000 µs:另一个 goroutine 开始运行的延迟 最小 6 µs / 中位 65 µs / 最大 223 µs;系统调用本身耗时中位 108548 µs

syscall.Syscall6 进入内核前先告诉运行时“我要进系统调用了”(entersyscall);运行时的监控线程 sysmon 发现这个 P 在系统调用里超过一个周期且有别的 goroutine 在等,就把 P 拿走交给别的线程。实验里这个交接只需几十微秒,所以只有一个 P 时,另一个 goroutine 也不会饿死。

不通知运行时就没有交接

实测 · 本机执行输出
  系统调用阻塞    100 µs:另一个 goroutine 开始运行的延迟 最小 106 µs / 中位 145 µs / 最大 171 µs;系统调用本身耗时中位 144 µs
  系统调用阻塞   1000 µs:另一个 goroutine 开始运行的延迟 最小 289 µs / 中位 1512 µs / 最大 1515 µs;系统调用本身耗时中位 1511 µs
  系统调用阻塞  10000 µs:另一个 goroutine 开始运行的延迟 最小 26 µs / 中位 14989 µs / 最大 15026 µs;系统调用本身耗时中位 14987 µs
  系统调用阻塞 100000 µs:另一个 goroutine 开始运行的延迟 最小 22798 µs / 中位 29988 µs / 最大 30102 µs;系统调用本身耗时中位 29989 µs

RawSyscall 不调用 entersyscall,运行时认为这个 goroutine 一直在运行用户代码,P 不会被拿走,另一个 goroutine 只能等它返回——延迟与系统调用时长几乎一样。这就是为什么只有确定几乎立刻返回的系统调用才适合用 raw 版本。

有空闲 P 时更快

实测 · 本机执行输出
  系统调用阻塞    100 µs:另一个 goroutine 开始运行的延迟 最小 3 µs / 中位 4 µs / 最大 48 µs;系统调用本身耗时中位 155 µs
  系统调用阻塞   1000 µs:另一个 goroutine 开始运行的延迟 最小 3 µs / 中位 6 µs / 最大 9 µs;系统调用本身耗时中位 1506 µs
  系统调用阻塞  10000 µs:另一个 goroutine 开始运行的延迟 最小 3 µs / 中位 10 µs / 最大 63 µs;系统调用本身耗时中位 15013 µs
  系统调用阻塞 100000 µs:另一个 goroutine 开始运行的延迟 最小 7 µs / 中位 36 µs / 最大 158 µs;系统调用本身耗时中位 108533 µs

GOMAXPROCS=4 时有空闲的 P,等待的 goroutine 几乎立刻被唤醒去跑(4–36 µs),不必等 sysmon 从阻塞的线程手里拿走 P。这个差异我是从现象推的,没有读唤醒路径的代码。

sysmon 的节奏

实测 · 本机执行输出
  proc.go:6548-6558
    	for {
    		if idle == 0 { // start with 20us sleep...
    			delay = 20
    		} else if idle > 50 { // start doubling the sleep after 1ms...
    			delay *= 2
    		}
    		if delay > 10*1000 { // up to 10ms
    			delay = 10 * 1000
    		}
    		usleep(delay)
  proc.go:6743-6765
    		// Retake the P if it's there for more than 1 sysmon tick (at least 20us).
    		if syst := int64(pp.syscalltick); !sysretake && int64(pd.syscalltick) != syst {
    			pd.syscalltick = uint32(syst)
    			pd.syscallwhen = now
    			thread.resume()
    			goto done
    		}
    
    		// On the one hand we don't want to retake Ps if there is no other work to do,
    		// but on the other hand we want to retake them eventually
    		// because they can prevent the sysmon thread from deep sleep.
    		if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
    			thread.resume()
    			goto done
    		}
    
    		// Take the P. Note: because we have the scan bit, the goroutine
    		// is at worst stuck spinning in exitsyscall.
    		thread.takeP()
    		thread.resume()
    		n++
    
    		// Handoff the P for some other thread to run it.

源码里 sysmon 在忙的时候每 20 µs 看一次,空闲久了逐渐拉长到 10 ms;retake 的规则是 P 在系统调用里停留超过一个周期才可能被拿走,并且如果没有别的活可干,最长可以拖到 10 ms 才拿。这解释了为什么实验里等待延迟是几十微秒,而不是 10 ms——因为有一个等着运行的 goroutine。

分步演示

系统调用与 P 的移交
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。系统调用用 select(0, nil, nil, nil, &timeval) 在 macOS 上阻塞(超时精度本身比请求的粗,如 1000 µs 的请求耗时约 1511 µs);每个设置 20 次取最小/中位/最大;“另一个 goroutine”只是记录自己开始运行的时间;读过的源码:runtime/proc.go 的 sysmon 开头与 retake 的一段
    • 没有验证:Linux 上的延迟、systemcall 时间极短时(< 20 µs)的行为、handoffp 的完整流程、RawSyscall 被抢占信号打断的细节(100 ms 那一行提前返回的原因)、cgo 调用与 Syscall 在移交上的异同、sysmon 睡眠间隔随负载变化的实测
    京ICP备2026067502号-1京公网安备11010502063487号