Go 1.14+ 抢占式调度需满足安全点、信号可达、M处于用户态等条件才能生效;纯计算循环、CGO调用、系统调用阻塞等场景下runtime.preemptM常静默失败,G仍会长期霸占P。
Go 1.14 起确实支持非协作式抢占,但「能抢占」不等于「总能抢到」——纯计算循环、CGO 调用、系统调用阻塞、汇编函数绕过等场景下,runtime.preemptM 很可能静默失败,G 仍会长期霸占 P。
Go 的抢占不是在任意指令处打断,必须等到 Goroutine 主动进入「安全点」(safepoint)。常见安全点包括:
call 指令后、ret 前的 runtime 插桩)morestack_noctxt)select、time.Sleep 等运行时介入点这意味着一个没有函数调用、不碰栈、不触发任何 runtime hook 的死循环:for { i++ },在 Go 1.20 及之前几乎无法被抢占;直到 Go 1.21 引入 asyncPreempt 插桩(默认开启),才在更多指令边界插入异步抢占检查。
sysmon 是一个后台 M,每约 20ms 轮询所有 P,比对 p.schedtick(调度计数)和 p.sysmontick(上次 sysmon 检查时间戳)。若发现某 G 在同一 P 上连续运行超过 ~10ms(实际阈值为 forcegcperiod = 2ms × 5,默认 10ms),则向对应 M 发送 SIGURG 信号。
立即学习“go语言免费学习笔记(深入)”;
关键限制:
SIGURG 的 M;若 M 正在执行系统调用(如 read、epoll_wait),信号会延迟到返回用户态才处理_Gsyscall 或 _Gwaiting(比如刚进 syscall、或在 channel 上阻塞),preemptM 直接返回 false,不发信号sysmon 不负责切换,只设 G 状态为 _gpreempted,之后由调度器下次 pickgoroutine 时将其放回本地或全局队列runtime.preemptM 有时没反应调用 runtime.preemptM 后无效果,常见原因不是 API 用错,而是目标 M 不满足响应条件:
_Gsyscall 或 _Gwaiting 状态,抢占逻辑直接跳过SIGURG(例如被 C 代码修改 sigmask)//go:nosplit + //go:noinline 的汇编函数(如 memclrNoHeapPointers),跳过所有抢占检查验证方式:启用 GODEBUG=schedtrace=1000 观察调度 trace,看对应 G 是否出现 preempted 状态;或用 pprof 查看 goroutine stack 是否卡在纯计算路径上。
Go 1.14–1.20 对纯计算循环的抢占能力极弱,本质是依赖函数调用作为唯一安全点。Go 1.21 引入更激进的 asyncPreempt:编译器在更多位置(如循环头部、比较指令后)插入 CALL runtime.asyncPreempt,显著提升抢占覆盖率。
但仍有代价:
可通过 GODEBUG=asyncpreemptoff=1 关闭,但不建议在生产环境关闭——它正是解决「CPU 密集型 Goroutine 卡死调度器」的核心机制。
真正难处理的从来不是「怎么让抢占发生」,而是「怎么让抢占发生在你预期的地方」:安全点不可控、状态不可达、信号被屏蔽、CGO/C 代码绕过……这些才是线上调度异常的真实根源。