Go 1.14+ 默认启用基于信号的抢占式调度,但仅在安全点(如函数入口、循环回边等)响应SIGURG信号触发调度,避免破坏寄存器与栈一致性;纯计算循环若无安全点会饿死M,GC STW时则强制修改PC跳转抢占。
go 1.14+ 默认启用基于信号的抢占式调度,但不是完全“随时中断”,而是依赖协作点 + 抢占信号触发调度器介入。
goroutine 为什么不能被任意指令打断
Go 的 goroutine 运行在用户态线程(M)上,不经过内核调度;若强行在任意机器指令处中断,会破坏寄存器状态、栈帧一致性,甚至导致 defer、recover 或栈分裂逻辑错乱。因此 Go 选择“安全点”(safepoint)机制:只在编译器插入的特定检查点响应抢占。
- 这些检查点包括:函数入口、循环回边(loop back-edge)、函数调用前、垃圾回收扫描暂停点
- Go 编译器(
cmd/compile)会在生成 SSA 后自动插入runtime·morestack_noctxt类似桩调用(实际是轻量检查),供调度器判断是否需抢占 - 没有循环或函数调用的纯计算 goroutine(如
for { i++ })仍可能饿死——它不会主动走到 safepoint
系统信号(SIGURG)如何触发抢占
Go 运行时在启动时对每个 M 安装 SIGURG 信号处理器(Linux/macOS 下复用该信号,Windows 用 Async Procedure Call)。当调度器判定某 goroutine 超时(默认 10ms),就向其所在 M 发送该信号。
- 信号 handler 不做调度决策,只设置
G.preempt = true和G.stackguard0 = stackPreempt - 下一次该 G 执行到任何 safepoint 时,会比对
stackguard0是否等于stackPreempt;若是,则主动调用gosched_m让出 M - 注意:
SIGURG是非实时信号,不保证立即投递;若 M 正在执行系统调用(如read),信号会被延迟到返回用户态后才处理
GC STW 期间的强制抢占更激进
垃圾回收进入标记终止(mark termination)阶段前,需要所有 G 停在安全状态。此时运行时会绕过常规信号路径,直接通过 g.signal = _Gpreempted + 修改 g.sched.pc 强制跳转到 gosched_m。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 这种修改 PC 的方式只用于 STW 场景,属于“最后手段”,不适用于常规调度
- 被强制抢占的 G 栈必须可扫描(即未处于栈分裂中间态),否则 GC 会 panic 并 crash(错误信息类似
"runtime: unexpected return pc for runtime.xxx called from 0x...") - 可通过
GODEBUG=schedtrace=1000观察 STW 中的抢占行为,会看到大量preempted状态
如何验证当前 goroutine 是否被抢占过
没有公开 API 直接暴露抢占历史,但可通过运行时调试手段间接确认:
立即学习“go语言免费学习笔记(深入)”;
- 启用
GODEBUG=schedtrace=1000,scheddetail=1,观察输出中某 G 的状态是否频繁出现runnable→running→runnable,且间隔接近 10ms - 在关键循环中插入
runtime.Gosched()或runtime.DoWork()(Go 1.21+),可显式让出,避免因无 safepoint 导致调度器无法介入 - 使用
pprof的goroutineprofile 查看阻塞型调用占比;若大量 goroutine 长时间处于running但无系统调用,可能是 safepoint 稀疏所致
真正难处理的是那些既无函数调用、又无循环、还密集操作寄存器的场景(比如手写汇编或某些数学 kernel),它们天然避开所有 safepoint——这时只能靠开发者主动插入 runtime.Gosched(),或者接受它暂时垄断 M。

















