Go 1.14 版本开始确实引入了非协作式抢占(non-cooperative preemption),但需要明确一点:具备抢占能力并不等于每次都能成功抢占。在纯计算循环、CGO 调用、系统调用阻塞以及汇编函数绕过等场景下,调用 runtime.preemptM 很可能会静默失败,导致 Goroutine 长时间霸占 P 而不被调度。
抢占触发条件:并非超时立即中断,而是必须落在安全点上
Go 的抢占机制并非在任意指令位置都能打断,必须等待 Goroutine 主动进入所谓的“安全点”(safepoint)。常见的安全点包括:
- 函数调用返回前(例如
call指令之后、ret之前的运行时插桩处) - 栈增长检查点(
morestack_noctxt) - GC 扫描过程中对 Goroutine 栈的遍历
- channel 操作、
select、time.Sleep等运行时介入点
这意味着,一个没有函数调用、不涉及栈操作、也不触发任何运行时 hook 的死循环,比如 for { i++ },在 Go 1.20 及更早版本中几乎无法被抢占。直到 Go 1.21 引入 asyncPreempt 插桩(默认开启),才在更多指令边界插入了异步抢占检查,显著改善了这种情况。
sysmon 如何发现并发起抢占
sysmon 是一个后台 M(监控线程),大约每 20ms 轮询所有 P,对比 p.schedtick(调度计数)与 p.sysmontick(上次 sysmon 检查的时间戳)。如果发现某个 G 在同一个 P 上连续运行超过约 10ms(实际阈值基于 forcegcperiod = 2ms × 5,默认 10ms),就会向对应 M 发送 SIGURG 信号。
关键限制在于:
- 信号只发送给处于用户态且未屏蔽
SIGURG的 M;如果 M 正在执行系统调用(例如read、epoll_wait),信号会延迟到返回用户态后才处理 - 如果该 G 当前状态是
_Gsyscall或_Gwaiting(比如刚进入系统调用、或在 channel 上阻塞),preemptM直接返回false,根本不会发送信号 sysmon不负责切换 G,它只负责将 G 的状态标记为_gpreempted,之后由调度器在下次pickgoroutine时将其放回本地或全局队列
为什么 runtime.preemptM 有时没有反应
调用 runtime.preemptM 后没有效果,常见原因并非 API 使用错误,而是目标 M 根本不满足响应条件:
- 目标 M 正在执行 CGO 调用(Go 1.22 之前默认禁用 CGO 中的抢占插桩,且 M 可能脱离 Go 调度器管理)
- 目标 G 处于
_Gsyscall或_Gwaiting状态,抢占逻辑直接跳过 - 目标 M 屏蔽了
SIGURG(例如被 C 代码修改了 sigmask) - 目标 G 正在执行标记为
//go:nosplit+//go:noinline的汇编函数(比如memclrNoHeapPointers),跳过了所有抢占检查点
验证方式很简单:启用 GODEBUG=schedtrace=1000 观察调度 trace,查看对应 G 是否出现 preempted 状态;或者使用 pprof 查看 goroutine 栈是否卡在纯计算路径上。
纯计算循环的“逃逸”问题与 Go 1.21+ 改进
Go 1.14 到 1.20 对纯计算循环的抢占能力非常弱,根本原因在于依赖函数调用作为唯一的安全点。Go 1.21 引入了更激进的 asyncPreempt:编译器在更多位置(比如循环头部、比较指令之后)插入 CALL runtime.asyncPreempt,显著提升了抢占覆盖率。
但代价也很明显:
- 增加二进制体积(每个函数多出几条指令)
- 轻微性能开销(每次插桩都是一次函数调用)
- 部分极端低层函数(如 GC 相关汇编)仍被豁免
可以通过 GODEBUG=asyncpreemptoff=1 关闭该特性,但通常不建议在生产环境中这样做——它正是解决“CPU 密集型 Goroutine 卡死调度器”的核心机制。
真正棘手的从来不是“怎么让抢占发生”,而是“怎么让抢占发生在你预期的地方”:安全点不可控、状态不可达、信号被屏蔽、CGO/C 代码绕过……这些才是线上调度异常的真实根源。
