Go 1.14+ 的抢占是信号驱动的异步干预,非定时中断;通过 SIGURG 在安全点触发 asyncPreempt,但纯计算循环、nosplit 函数、CGO 调用等场景可能逃逸抢占,需配合 runtime.Gosched 主动让出。
Go 1.14+ 的抢占不是“定时中断”,而是信号驱动的异步干预
go runtime 不像操作系统那样靠硬件时钟中断强制切走 goroutine,它在用户态用 sigurg 信号模拟“中断”——但这个信号只在满足条件时才发,且必须落在安全点上才能生效。
- sysmon 线程每 ~20ms 检查一次所有 P:若某个 P 处于
_Prunning状态且连续运行 ≥10ms,且尚未被标记抢占,才会向其绑定的 M 发送SIGURG - 信号 handler(
sigtramp)会先检查当前是否处于 GC 栈扫描、写屏障、或某些禁用信号的临界区;不安全就跳过,不会强行打断 - 即使信号送达,也只修改 PC 指向
asyncPreempt,真正让出 P 是在下一个安全点(如函数返回、分支跳转、内存访问后)才执行 - Windows 平台不支持该机制;可通过
GODEBUG=asyncpreemptoff=1全局关闭
纯计算循环为什么还可能卡死?安全点不是无处不在
很多人以为开了异步抢占就能打断任何 for { i++ },实际并非如此——编译器优化 + 运行时限制会让某些代码路径彻底逃逸所有安全点。
-
//go:nosplit函数内不会插入抢占检查,也不会触发栈扩张检测,preempt字段即使为 true 也永远不会被读取 - 纯寄存器运算、无内存访问、无函数调用、无分支的循环(如
for x := 0; ; x++),可能被优化成单条inc指令反复执行,中间没有可插桩位置 - CGO 调用期间 M 脱离 Go 调度器管理,
SIGURGhandler 不起作用;Go 1.22 前默认禁用 CGO 中的抢占插桩 - 手写汇编函数(如
memclrNoHeapPointers)若标记noinline和go:nosplit,同样跳过所有检查
系统调用返回才是真正的“强制重调度时机”
goroutine 进入系统调用(如 read、epoll_wait)时会脱离 P,但这不是抢占的起点;抢占动作实际发生在它从内核返回用户态的一瞬间。
- sysmon 检测到某 P 长期处于
_Psyscall(≥20ms),会调用preemptone标记对应 G,但此时 G 还在内核里,什么都不会发生 - 真正调度发生在
exitsyscall流程中:runtime 接管返回路径,检查 preempt 标志,若为 true 则直接调用gopreempt_m,把 G 放回本地或全局队列 - 这意味着一个阻塞 5s 的
read不会“被切走多次”,而是在它终于返回时被一次性调度出去——所以高延迟 syscall 仍可能导致短时饥饿 - 如果 syscall 长期 hang 住(如网络卡死),sysmon 会回收其绑定的 P 给其他 M 使用,但原 G 仍卡在内核,无法被唤醒或清理
runtime.Gosched 是唯一可控的协作式让出点
当你明确知道某段逻辑会长时间占用 CPU(比如解析大文件、做密集哈希),又无法依赖自动抢占时,runtime.Gosched 是最直接、最可靠的让出方式。
- 它不挂起当前 goroutine,只是调用
mcall(gosched_m)切换到g0栈,把当前 G 置为_Grunnable并放入全局队列 - 和抢占不同,它不依赖 sysmon、不发信号、不看安全点,只要执行到这一行,就立刻让权
- 适合放在长循环体内,例如每处理 1000 条记录后调用一次:
if i%1000 == 0 { runtime.Gosched() } - 注意不要滥用:频繁调用会增加调度开销;也不要在持有锁或处于临界区时调用,否则可能破坏一致性
真正容易被忽略的是:**抢占不是“保证响应”,而是“尽力而为”的协同机制**。它依赖编译器插桩、CPU 架构支持、信号传递可靠性、以及 runtime 内部状态的一致性。一旦其中任一环节失效(比如在 mallocgc 的 mark 阶段、或某个禁用信号的汇编块里),goroutine 就可能持续霸占 P——这时候你得靠 runtime.Gosched 或重构逻辑来兜底。











