Go runtime调度器是用户态GMP模型,通过findRunnable()按优先级从runnext、本地队列、全局队列、work-stealing、netpoll获取G,结合协作让出与信号抢占(如SIGURG)避免饥饿,系统调用时解耦M与P以复用资源。

Go runtime 调度器不是“自动帮你跑 goroutine”那么简单——它是一套在用户态精细控制执行流的机制,核心目标是用少量 OS 线程(M)高效承载海量 goroutine(G),同时兼顾 CPU 密集与 I/O 密集场景。关键判断:你写的 go f() 不会立刻执行,而是在 findRunnable() 被选中、经 execute() 挂到 M 上后才真正运行;调度是否及时,取决于 P 的本地队列、全局队列、网络轮询器和抢占信号的协同。
goroutine 是怎么被挑出来执行的?
findRunnable() 是整个调度循环的“找活干”环节,它按固定优先级尝试获取可运行的 G:
- 先查当前 P 的
runnext(高优先级抢占预留位),命中则直接返回 - 再查 P 的本地运行队列(
runq),这是最快路径,无锁、无竞争 - 若本地空,以 1/61 概率检查全局队列(
sched.runq),防饿死 - 接着尝试从其他 P 的本地队列“偷工作”(work-stealing),这是负载均衡的关键
- 最后调用网络轮询器(
netpoll),唤醒因 I/O 就绪而挂起的 G - 若全无收获,当前 M 进入自旋(spinning)或休眠,等待新任务
注意:pp.schedtick % 61 == 0 这个魔数不是随便定的——太频繁查全局队列会增加锁竞争,太稀疏又会导致新创建的 goroutine 长时间得不到调度。
为什么 goroutine 不会卡死整个线程?
Go 通过两种机制避免单个 G 独占 M:
-
协作式让出点:在函数调用、栈增长、channel 操作等位置插入
morestack或goready,主动触发调度检查 -
抢占式中断:当 G 运行超时(默认 10ms),runtime 向其所在 M 发送
SIGURG信号,强制在下一个安全点(如函数返回前)插入gosched_m,让出 M
但注意:纯计算循环(如 for {} 且无函数调用)在 Go 1.14 之前无法被抢占,Go 1.14+ 引入基于信号的异步抢占,但仍有安全点限制——比如循环体内没调用任何函数,仍可能逃逸调度。
系统调用时 P 和 M 怎么不浪费?
传统线程模型下,一次阻塞式系统调用会让整个线程挂起。Go 的解法是解耦:
- 当 G 进入阻塞系统调用(如
read、accept),M 会与 P 解绑,P 被释放给其他空闲 M 复用 - M 单独陷入系统调用,G 被标记为
Gwaiting状态并关联到该 M - 调用返回后,M 尝试重新绑定原 P;若失败(P 已被占用),则把 G 推入全局队列或尝试唤醒一个空闲 P
这个设计意味着:1000 个 goroutine 同时做文件读取,不会创建 1000 个 OS 线程,而可能只用十几个 M 轮转处理——但前提是这些调用真能被 runtime 识别为“阻塞型”。用 syscall.Syscall 直接调系统调用,就可能绕过这套机制,导致 M 数暴涨。
什么时候你会感知到调度器的存在?
多数时候调度器是透明的,但以下场景会暴露其行为边界:
- 用
runtime.GOMAXPROCS(n)改变 P 数量时,实际生效需等待所有 M 完成当前工作并重新绑定 - 大量 goroutine 在 channel 上阻塞时,
select的随机性由 runtime 内部伪随机数决定,不是轮询 - 用
pprof查看runtime.schedule耗时突增,往往说明本地队列长期为空,频繁跨 P 偷任务或查全局队列 - GC 标记阶段会暂停所有 G 执行(STW),但 Go 1.22+ 已将 STW 控制在百微秒级,重点其实是 Mark Assist 的抢占频率
最容易被忽略的是:goroutine 的“启动延迟”不是零——从 go f() 到真正执行,中间隔着至少一次 goready 入队 + findRunnable 检出,这在高负载 P 上可能有几十微秒偏差;如果你依赖精确的启动时序(比如模拟定时器抖动),就得考虑这个非确定性。


















