因为M线程阻塞时失联,P作为关键调度资源不能闲置,sysmon每20ms扫描并主动将_Psyscall态P解绑为_Pidle供其他M复用;裸syscall或LockOSThread会绕过该机制导致P被长期霸占。
为什么 syscall 阻塞时 P 会被“抢走”?
因为 go 的 m 线程一旦陷入阻塞式系统调用(如 read、epoll_wait、accept),就无法继续执行任何 go 代码,也无法响应抢占信号——此时它对调度器来说是“失联”的。但 p 是调度资源,不能跟着一个挂起的 m 干等。所以 sysmon 会主动把 p 从该 m 上“收回”,交给空闲 m 继续跑其他 g。
-
sysmon每约 20ms 扫描一次所有 P,发现状态为_Psyscall且停留超 20μs(1 个 sysmon tick),就触发retake - 被 retake 的 P 会清空绑定的 M,进入
_Pidle状态,等待下一个空闲 M 来绑定 - 原 M 在系统调用返回后,会尝试重新找 P;若找不到,就把自己休眠进
sched.midle队列 - 这个机制和抢占纯计算循环不同:它不依赖信号或栈检查,而是靠状态轮询 + 主动解绑
哪些 syscall 不会触发 P 抢占?
不是所有系统调用都会让 P 进入 _Psyscall。Go 运行时只对标准库封装的 I/O(如 net.Conn.Read、os.ReadFile)做了非阻塞封装 + netpoller 集成。而直接调用 syscall.Read 或 unix.Read 会绕过这套机制,导致 M 真阻塞、P 被长期霸占。
- 标准库函数(
net/http、os.Open、io.Copy)→ 自动注册到 netpoller → 可被抢占 - 裸
syscall.Syscall/unix.Syscall→ 不进 netpoller → P 卡死,直到系统调用返回 - CGO 中的阻塞调用(如 C
fread)→ 若未配runtime.LockOSThread(),M 可能被回收,唤醒后需重新申请 P,延迟不可控
如何验证 P 是否被成功 retake?
别只看程序“没卡住”,得看运行时证据。最直接的方式是开启调度追踪:
- 加环境变量
GODEBUG=schedtrace=1000启动,观察输出中Psyscall行是否短暂出现又消失;若持续存在,说明 retake 失败 - 用
go tool trace:启动时调用runtime/trace.Start,打开 trace 页面后搜ProcStatus,看 P 状态是否在running↔syscall↔idle间切换 - 写对比测试:单 P 下起两个 goroutine,一个
syscall.Read(os.Stdin, buf),另一个time.AfterFunc(100*time.Millisecond, func(){ println("alive") });若 “alive” 能按时打印,说明 P 已被抢走并复用
retake 失败的典型场景和规避方式
retake 不是万能的。当 P 被长期独占却没被取回,往往是因为调度器“不敢动”或“动不了”。
-
runtime.LockOSThread()后做阻塞 syscall → M 和 P 强绑定,retake会跳过该 P(避免破坏线程亲和性) - CGO 调用中设置了
export CGO_ENABLED=0或禁用了抢占插桩(Go 1.22 前默认关)→ M 阻塞期间无法被中断 - 系统调用本身极快(如
getpid)→sysmon还没来得及扫描,调用已返回,retake 逻辑不触发 - 规避建议:I/O 尽量走标准库;必须用裸 syscall 时,手动加
runtime.Gosched()或拆成小块 +time.Sleep(0)让出机会
真实世界里,P 被 retake 的时机比抢占纯循环更可靠,但它极度依赖“标准库封装”这一层抽象。一旦跳出这个边界,调度器就退化成协作式,而你得自己扛起让出 CPU 的责任。











