调度循环:Go runtime 如何永不停歇地找人干活
GMP 把 goroutine、OS 线程、逻辑处理器这三层结构搭好,只是搭了个舞台。真正让这台戏能一直唱下去的,是 runtime.schedule() 为核心的调度循环。它就像一个永不下班的工头:每次线程 M 醒来,都要问自己三个问题------谁要干活?去哪儿找活?找不到了怎么办?
一、为什么需要调度循环
每个 M 一旦被创建(runtime.mstart),就会进入调度循环,直到线程退出。循环的职责可以概括为:
- 找一个可运行的 G(goroutine)。
- 切换到该 G 的用户栈执行。
- G 阻塞或完成后,再次回到循环找下一个 G。
如果没有这个循环,一个 goroutine 跑完,线程就无事可做了。正是这个循环,让少量 OS 线程能服务成千上万个 goroutine。
二、调度循环的入口
线程启动后会调用 runtime.mstart,最终进入 runtime.mstart1,再调用 runtime.schedule()。核心路径简化如下:
text
mstart
└── mstart1
└── schedule() // 找 G
└── execute() // 绑定 G 到 M 并运行
└── gogo(&g.gobuf) // 切换到 g 的栈
└── 运行用户代码
└── goexit1()
└── mcall(goexit0)
└── schedule() // 再次进入循环
goexit 在创建 goroutine 时被悄悄放到栈底,所以用户函数返回后,会自动触发 goexit,把 G 状态改为 dead,并回到调度循环。
三、schedule() 的找 G 优先级
runtime.schedule() 会按顺序从多个来源找可运行的 G:
| 来源 | 说明 |
|---|---|
| GC 后台任务 | 如果有 mark worker 需要运行,优先执行 |
| 当前 P 的本地运行队列(LRQ) | 最快,无锁访问 |
| 全局运行队列(GRQ) | 每 61 次调度会从 GRQ 拿一批,防止本地队列饥饿 |
| 网络轮询器(netpoll) | 把就绪的网络 goroutine 唤醒并加入 LRQ |
| Work Stealing | 从其他 P 偷一半任务 |
| 自旋/休眠 | 实在找不到,M 进入自旋或休眠等待 |
这个顺序体现了 Go 调度器的设计哲学:能本地不全局,能异步不阻塞,能偷则偷。
四、execute 与 gogo:真正切换到 G
找到 G 后,schedule() 调用 execute(gp, inheritTime):
- 把 G 的状态从
_Grunnable改为_Grunning。 - 把 G 绑定到当前 M。
- 调用
gogo(&gp.sched),从g0栈切换到 G 的用户栈。
g0 是每个 M 自带的系统 goroutine 栈,调度代码都在 g0 上跑。用户代码在 G 自己的栈上跑。切换时,保存当前寄存器到 g0.sched,恢复目标 G 的寄存器,跳转到 G 的程序计数器。
五、调度循环的终止
正常情况下,M 的调度循环不会终止,除非:
- M 被显式要求退出(如
runtime.stopm)。 - 程序调用
runtime.Goexit()且没有更多任务。 - 整个进程退出。
六、代码实践:观察调度循环的行为
下面这个程序用 GOMAXPROCS=1 强制只有一个 P,再用 GODEBUG=schedtrace=... 观察调度器输出,同时在代码里模拟 goroutine 切换。
go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
// 主动让出 CPU,模拟 G 被调度走
for i := 0; i < 3; i++ {
fmt.Printf("goroutine %d: step %d on %s\n", id, i, runtime.Version())
runtime.Gosched()
}
}
func main() {
// 限制为单 P,便于观察调度循环在单个队列上的轮转
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
// 主 goroutine 也主动让出,让子 goroutine 有机会上 CPU
for i := 0; i < 5; i++ {
runtime.Gosched()
time.Sleep(10 * time.Millisecond)
}
wg.Wait()
fmt.Println("all goroutines done")
}
运行结果(片段):
text
goroutine 0: step 0 on go1.26...
goroutine 1: step 0 on go1.26...
goroutine 2: step 0 on go1.26...
goroutine 0: step 1 on go1.26...
goroutine 1: step 1 on go1.26...
goroutine 2: step 1 on go1.26...
all goroutines done
可以看到,三个 goroutine 在单 P 上交替执行。runtime.Gosched() 显式触发调度,schedule() 从 LRQ 取出下一个可运行的 G。
七、再看一个调度器 trace 输出
运行时可以加环境变量查看调度器事件:
bash
GODEBUG=schedtrace=1000 go run main.go
输出类似:
text
SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0]
字段含义:
| 字段 | 含义 |
|---|---|
| gomaxprocs | P 的总数 |
| idleprocs | 空闲的 P |
| threads | M 的总数 |
| spinningthreads | 自旋的 M |
| idlethreads | 休眠的 M |
| runqueue | 全局队列长度 |
[0] |
每个 P 的本地队列长度 |
八、逐条解读
runtime.GOMAXPROCS(1)把 P 固定为 1,所有 goroutine 都在同一个 LRQ 竞争。runtime.Gosched()让当前 G 放弃 CPU,回到_Grunnable,进入 LRQ 尾部。schedule()被触发,从 LRQ 头部取出下一个 G 执行。- 单 P 时不会出现 Work Stealing,因为没有其他 P 可偷。
- 主 goroutine 的
Sleep会触发 M/P 解绑,让 netpoll/sysmon 有机会处理其他事件。 sync.WaitGroup确保主 goroutine 等待所有子 goroutine 完成,避免提前退出。
九、小结
| 概念 | 一句话总结 |
|---|---|
| 调度循环 | M 创建后永不退出地找 G、执行 G、再找的循环 |
| schedule() | 按 GC → LRQ → GRQ → netpoll → Work Stealing 顺序找可运行 G |
| execute() | 把 G 绑定到 M,调用 gogo 切换到用户栈 |
| goexit | 放在每个 G 栈底,函数返回后清理 G 并回到调度循环 |
| g0 | 每个 M 的系统栈,调度代码在上面运行 |