在 go 的协作式调度模型下,若 goroutine 在无阻塞、无函数调用的紧密循环中反复执行原子操作(如 atomic.adduint64),将长期独占 p(processor),导致其他 goroutine 无法被调度,引发“调度饥饿”;runtime.gosched 是显式的让出点,主动将当前 goroutine 置为可运行状态并让渡 cpu 时间片,保障公平调度。
在 go 的协作式调度模型下,若 goroutine 在无阻塞、无函数调用的紧密循环中反复执行原子操作(如 atomic.adduint64),将长期独占 p(processor),导致其他 goroutine 无法被调度,引发“调度饥饿”;runtime.gosched 是显式的让出点,主动将当前 goroutine 置为可运行状态并让渡 cpu 时间片,保障公平调度。
Go 的调度器采用
M:N 协作式调度模型
(即 M 个用户态 goroutine 映射到 N 个 OS 线程),其核心原则是:
goroutine 只在特定“调度点”(scheduling points)才可能被抢占或让出 CPU
。这些调度点包括:
调用阻塞系统调用(如 os.ReadFile、net.Conn.Read)
进行 channel 操作(发送/接收且需等待)
调用 time.Sleep、sync.Mutex.Lock 等会挂起的函数
函数调用(尤其是非内联的函数)——Go 编译器会在多数函数入口插入调度检查
显式调用 runtime.Gosched()
而 atomic.AddUint64 是一个
纯用户态、零开销、内联汇编实现的原子指令
(如 XADDQ on amd64)。它不触发任何函数调用、不访问内存屏障外的共享状态、不进入运行时系统——
完全不构成调度点
。因此,当代码写成:
该 goroutine 就会陷入「无限紧密循环」,且因未到达任何调度点,Go 调度器
不会主动中断它
(Go 在早期版本中甚至完全无抢占式调度;即使现代 Go 1.14+ 引入了基于信号的异步抢占,也仅对长时间运行的
函数
生效,而单条原子指令仍远低于抢占阈值)。结果是:该 P 被彻底独占,其他就绪 goroutine(包括主 goroutine 的 time.Sleep 和 fmt.Println)无法获得执行机会,程序卡死 —— 这正是你移除 runtime.Gosched() 后程序永不退出的根本原因。
runtime.Gosched() 的作用正是填补这一空白:它
立即暂停当前 goroutine 的执行,将其放回全局运行队列(或本地队列),并触发调度器选择下一个可运行 goroutine 在当前 P 上继续执行
。它不休眠、不阻塞、不释放 P,只是“礼貌让座”。
✅ 正确写法(带调度让出):
⚠️ 注意事项:
Gosched 不是万能银弹:频繁调用会显著降低吞吐(如每轮循环都调用),应权衡性能与公平性;生产环境更推荐使用 time.Sleep(0)(语义等价且更直观)或改用带自然阻塞点的模式(如 select + time.After)。
自 Go 1.14 起,运行时已支持基于协作信号的
异步抢占
,但仅对持续运行超 10ms 的 goroutine 生效,且原子指令本身执行极快(纳秒级),仍无法被自动抢占。
sync/atomic 文档未提及 Gosched,是因为原子包只保证内存操作的线程安全性,
不负责调度语义
;调度责任属于 runtime 层,需开发者在长循环中主动协同。
总结:runtime.Gosched() 并非原子操作的“配套函数”,而是你在编写
无天然调度点的计算密集型循环
时,向 Go 调度器发出的明确信号:“我自愿让出,请调度其他人”。这是 Go 协作式哲学的直接体现——调度自由,但需自律。
for {
atomic.AddUint64(&ops, 1)
// ❌ 无调度点:goroutine 持续占用当前 P,永不让出
}for {
atomic.AddUint64(&ops, 1)
runtime.Gosched() // ✅ 显式调度点:允许其他 goroutine 运行
}