go runtime调度器本身不休眠,休眠的是工作线程m;m因无g可运行而解绑p后通过notesleep等系统调用休眠,唤醒由netpoll、goready、sysmon协同触发。
go runtime 调度器本身不会“休眠”,它始终在运行;真正会休眠的是工作线程(m),而唤醒动作由 netpoll、sysmon、goready 等协同触发——关键在于区分“谁在休眠”和“谁被唤醒”。
为什么 M 会进入休眠?不是 G 在 sleep 吗
用户常混淆 time.Sleep 和底层调度行为:time.Sleep 只是把当前 G 状态设为 _Gwaiting 并挂到定时器队列,真正让出 CPU 的是它所在的 M。当 M 找不到可运行的 G(本地队列、全局队列、网络轮询都无货),就会进入自旋(spinning)或最终休眠(park)。
-
M休眠前会先解绑P,把P放入空闲列表(sched.pidle),避免阻塞逻辑处理器 - 休眠不是调用
os.Sleep,而是通过notesleep(Linux 下基于 futex)或semasleep等系统调用实现 - 若
M正在执行系统调用(如read),它也会脱离P,但此时不算“休眠”,而是_Msyscall状态
唤醒 M 的三种主要路径
唤醒不是随机发生的,每种路径对应明确的事件源和触发点:
-
网络就绪/超时:
netpoll检测到 fd 可读/可写/超时后,调用notewakeup唤醒等待该 fd 的G;若此时没有空闲M,则通过wakep()启动新M或复用空闲P -
其他 G 就绪:当
goready把一个G推入某个P的本地队列时,若该P当前无绑定M,会触发wakep()→startm()→ 启动或复用M -
sysmon 抢占检查:后台
sysmon线程每 20ms 检查一次是否需要唤醒休眠的M(例如发现有空闲P但无M绑定,或全局队列积压)
常见误判:G 阻塞 ≠ M 休眠,G 退出 ≠ 调度器唤醒
很多调试者看到 goroutine 不跑了,就以为调度器“卡住”或“没唤醒”,其实多数情况是逻辑问题而非调度失效:
- 纯计算循环(
for {})在 Go 1.14+ 默认可被抢占,但若循环体内无函数调用、无栈增长、无 channel 操作,仍可能逃逸安全点——这不是唤醒失败,而是根本没插入抢占检查点 -
runtime.Gosched()手动让出仅影响当前G,不唤醒任何M;它只是把当前G放回本地队列尾部,等下次轮到 -
time.Sleep(1 * time.Second)后G被标记为可运行,但若所有P都被长任务占满且无空闲M,它仍需排队——这不是唤醒没发生,而是调度资源不足
调试休眠/唤醒问题的关键信号
真要定位调度异常,别猜,看真实行为:
- 加
GODEBUG=schedtrace=1000:观察M是否长期处于idle或spinning状态,P的runqsize是否持续为 0 - 用
strace -e trace=futex,clone,epoll_wait看M是否真的陷入系统调用,还是卡在用户态循环 - 注意
runtime_pollWait返回值:若返回err == nil但后续无数据,很可能是超时被netpoll唤醒,而非 I/O 就绪
最易被忽略的一点:唤醒动作本身不保证立即执行——它只把 G 标记为 _Grunnable 并入队;真正运行还要等 M 调度循环再次调用 findRunnable(),而这个过程受本地队列优先级、偷窃概率(1/61)、runnext 预留等多重策略影响。











