Go 1.14+ 的抢占式调度与协作式调度共存,函数调用点是协作抢占唯一入口,asyncpreempt 仅在超时等条件下触发,runtime.Gosched() 是最可控的手动让出方式。

Go 1.14+ 的抢占式调度不是替代协作式调度,而是和它共存——runtime.Gosched() 依然有效,asyncpreempt 也只在特定条件下生效。你写的 goroutine 是否被及时切换,取决于它有没有“让出机会”,而不是“调度器够不够强”。
函数调用点是协作式抢占的唯一入口
Go 1.2 引入的协作式抢占依赖每次函数调用前的检查:编译器自动在绝大多数函数入口插入对 gp.preempt 的读取。一旦为 true,就会触发 gopreempt 让出 P。
- 这个机制对
for { i++ }、for {}这类纯算术空循环完全无效——没有函数调用,就没有检查点 -
//go:nosplit函数会跳过栈扩张检查,也就绕过了抢占标记检查,极易卡死调度器 - 即使开了
asyncpreempt,如果循环体里只有寄存器运算、无内存访问、无分支,CPU 可能根本不会落到安全点,信号也无法打断
SIGURG 异步抢占不是“定时中断”,而是按需触发的安全点跳转
Go 1.14+ 默认启用的异步抢占,本质是 sysmon 线程检测到某个 P 运行超时(≥10ms)后,向其绑定的 M 发送 SIGURG 信号;信号处理函数 sigtramp 在保存寄存器后,检查是否处于可安全抢占状态(比如不在 GC 栈扫描中),再修改 PC 跳转到 asyncPreempt。
- 它只在 x86-64 / arm64 上支持,Windows 下默认不工作
- 发送信号不是轮询每个 P,而是“懒触发”:仅当 sysmon 判断该 P 已超时且尚未被标记
preempt=true时才发 - 信号本身不保证立即打断——若 goroutine 正在禁用信号的临界区(如
runtime.mallocgc深度执行中),或刚被 GC 扫描过,这次抢占就会跳过
系统调用返回点是隐式重调度窗口
当 goroutine 进入系统调用(如 read、select、nanosleep),它会脱离 P,P 可被其他 M 复用。但若该系统调用耗时 ≥20ms,sysmon 的 retake() 会标记对应 P 并尝试抢占——实际切换发生在它从内核态返回用户态的瞬间,由 exitsyscall 流程接管。
- 这意味着:长阻塞系统调用(如未设 timeout 的
net.Conn.Read)仍可能拖慢调度,但不会彻底锁死整个 P - 短系统调用(如
getpid)几乎不触发 retake,也不构成调度出口 - CGO 调用不经过 Go runtime 的系统调用封装,因此不参与此机制,容易成为调度盲区
别指望调度器兜底,runtime.Gosched() 是最可控的出口
在无法引入函数调用、又不能依赖信号抢占的场景下(比如 tight loop + //go:nosplit),runtime.Gosched() 是唯一可预测、低开销、不引入额外延迟的手动让出方式。
- 它不进入网络轮询器,也不触发 GC,只是把当前 G 放入全局运行队列,立刻让出 M
- 比
time.Sleep(0)更轻量,后者会走 timer 和 netpoll,有微小但可测的延迟 - 每千次迭代或每毫秒调用一次即可,无需高频——频繁调用说明逻辑本身已偏离 Go 并发模型的设计意图
最容易被忽略的是:抢占不是“保障响应性”的银弹,而是防止调度器彻底失效的保底机制。真正决定 goroutine 行为边界的,是你代码里有没有函数调用、有没有内存访问、有没有显式让出点——调度器只响应这些信号,它不替你做设计决策。


















