Go 的抢占是 sysmon 每20ms调用retake检查G是否超10ms执行,仅在安全点(函数调用、栈检查等)触发异步抢占,不依赖时间片轮转;runtime.Gosched()仅手动让出M但不触发抢占,无法解决纯计算循环卡顿。

Go 10ms 抢占不是靠时间片轮转
Go 的抢占不是操作系统那种基于硬件时钟中断的硬抢占,而是由 sysmon 线程在用户态主动发起的协作式软抢占。关键点在于:它不依赖 CPU 时间片到期,而依赖 Goroutine 是否主动让出(如 channel 操作、函数调用、GC 点),以及 sysmon 定期扫描是否超时。
真实行为是:sysmon 每 20ms 左右调用一次 runtime.retake(),检查所有 P 上正在运行的 G 是否已执行超过 10ms(通过 G.m.preempted 和 G.stackguard0 配合栈溢出检测触发)。一旦判定超时,就向对应 M 发送异步抢占信号(asyncPreempt),等该 G 下一次函数调用或栈检查时被拦截并切换。
- 抢占只发生在“安全点”:函数入口、栈增长检查、GC 扫描点等,不会在任意指令中间打断
- 纯计算循环(如
for { i++ })若无函数调用或 channel 操作,可能长期不被抢占——这是常见卡顿根源 -
GOMAXPROCS不影响抢占频率,但影响有多少 P 同时被sysmon扫描
为什么 runtime.Gosched() 不等于抢占
runtime.Gosched() 是显式让出当前 M,把 G 放回 P 的本地队列尾部,等待下次调度。它不触发抢占逻辑,也不改变 G 的 preempt 状态,更不会通知 sysmon。
它的作用非常有限:仅用于避免单个 G 独占 M 过久(比如长循环中手动插入),但无法替代系统级抢占。如果 G 正在执行密集计算且没调用任何 runtime 函数,Gosched() 被调用后仍可能立刻被同一个 M 重新取出执行——因为 P 队列里只有它一个可运行 G。
立即学习“go语言免费学习笔记(深入)”;
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 它不释放 P,M 依然绑定着 P,只是放弃当前 G 的执行权
- 它不阻塞 G,G 状态从
_Grunning变为_Grunnable,仍留在原 P 队列 - 它不能解决因 syscall 或网络 I/O 导致的 M 阻塞问题,那是 M-P 解绑机制负责的
抢占失效的典型场景与验证方式
最常被忽略的是「无函数调用的死循环」:编译器会内联、消除栈检查,导致 asyncPreempt 无法插入,sysmon 的抢占信号永远收不到响应。
验证是否被抢占很简单:在循环中加 println(1) 或 runtime.KeepAlive(&i),观察输出间隔;或用 go tool trace 查看 Goroutine 在 trace 中的运行块是否超过 10ms。
- 使用
go run -gcflags="-l" main.go关闭内联,让函数调用保留在汇编中,可恢复抢占点 - 在循环里插入
runtime·nop()(需 asm 注入)或time.Sleep(0)强制调度点 - 注意
select {}是永久阻塞,不触发抢占;而select { case 因涉及 timer goroutine,会自然让出
GMP 中抢占与阻塞的分工边界
抢占(preemption)和阻塞(blocking)是两套独立机制:前者解决 CPU 占用不公平,后者解决资源等待不浪费。很多人混淆二者,以为阻塞了就自动让出 CPU——其实不然。
例如 net.Conn.Read():底层调用 read 系统调用时,M 会脱离 P 并进入内核等待,此时 P 被其他 M 接管,其他 G 继续运行;但这个过程不涉及抢占,G 状态变为 _Gwaiting,M 状态变为 _Msyscall,完全由网络轮询器(netpoller)唤醒。
- 抢占只发生在 G 处于
_Grunning状态且占用 CPU 过久时 - 阻塞发生在 G 调用 syscall 或等待 channel、timer 等时,触发 M-P 解绑或 G 状态迁移
- 两者共存但互不触发:一个 G 可能既被抢占过,又在后续发生 syscall 阻塞

















