Go抢占依赖三类安全点事件:函数调用、系统调用返回、GC栈扫描及异步信号;无调用的//go:nosplit循环、纯算术指令流、CGO/汇编长时计算均会失效,且需GC触发或信号支持才可能中断。
函数调用点检查 preempt 标志位会失效的场景
go 1.2 起就在绝大多数函数入口插入了抢占检查逻辑,但它的前提是“有函数调用”。如果一段代码被编译器优化成纯寄存器运算、且没有调用任何函数(包括内联失败的 fmt.println、time.now 等),那这个检查就完全跳过。
典型例子是带 //go:nosplit 的死循环:
//go:nosplit
func busyLoop() {
var x int
for { x++ }
}
这种函数既不扩容栈,也不触发调用检查,preempt 字段即使被设为 true 也永远不会读取。它会持续霸占 P,直到被其他机制打断。
- 编译器不会在
//go:nosplit函数中插入抢占检查 - 纯算术循环(无内存访问、无函数调用、无分支)可能被优化成单条指令流,逃逸所有安全点
- 该机制对 CGO 或手写汇编中的长时计算同样无效
系统调用返回时的抢占实际发生在哪一刻
当 Goroutine 进入系统调用(比如 read、select、nanosleep),它会脱离 P,P 可被其他 M 复用。但抢占不是在调用发起时发生,而是在它从内核态返回用户态的瞬间——由 exitsyscall 流程接管并强制调度。
关键细节:
- sysmon 线程每 20ms 左右轮询一次,若发现某个 P 处于
_Psyscall状态且持续 ≥forcePreemptNS(硬编码为 20ms),就调用preemptone - 此时 Goroutine 还在内核里,
preemptone只是标记,真正抢占动作延后到exitsyscall中完成 - 这意味着:一个阻塞 5s 的
read不会每 20ms 切走一次,而是等它返回时才做一次重调度
SIGURG 异步抢占的触发前提和盲区
Go 1.14+ 默认启用的异步抢占靠 SIGURG 信号中断线程执行,但它不是“定时中断”,而是按需触发:仅当 sysmon 检测到某 P 运行超时(≥10ms)且尚未被标记抢占时,才向其绑定的 M 发送信号。
它能打断纯计算循环,但仍有明确限制:
- 仅支持 x86-64 和 arm64;Windows 不支持;可通过
GODEBUG=asyncpreemptoff=1关闭 - 信号处理函数
sigtramp会先检查是否处于 GC 栈扫描、写屏障、或某些禁用信号的临界区,不安全则跳过 - 即便开了异步抢占,如果代码处在 runtime 内部长时间运行路径(如
mallocgc中的 mark 阶段),仍可能无法及时响应
GC 扫描栈时的隐式抢占机会
GC 的栈扫描阶段本身就是一个天然的抢占窗口。当 STW 结束、进入并发标记时,runtime 会遍历所有 G 的栈,并在扫描过程中主动触发 gopreempt ——这相当于借 GC 的“合法停顿”顺手做一次调度。
这个机制常被忽略,但它能兜住一些协作式与异步抢占都漏掉的 case:
- 在
//go:nosplit函数中长时间运行的 G,只要 GC 启动并扫描到它的栈,就大概率被中断 - 它不依赖函数调用或信号,而是靠 GC 对栈帧的逐帧访问触发安全点
- 但前提是 GC 必须发生;如果程序内存压力极小、GC 长期不触发,这个兜底也失效
最不容易被注意的是:**抢占不是靠“时间片计数器”实时滴答,而是依赖三类离散的安全点事件**。哪怕你写了个 100ms 的空循环,只要它没调用、没系统调用、没触发 GC、也没被信号命中,它就真的能跑满整个时间片——这不是 bug,是设计使然。











