3. 调度循环详解:核心函数调用链
Go 运行时调度器(scheduler)的核心是一个永不停止的循环,它驱动着所有 goroutine 的调度与执行。本章深入剖析调度循环的四个关键函数:schedule()、findRunnable()、execute() 和 goexit(),揭示它们如何协同工作,构成完整的调度契约。
3.1 调度循环总览
每个工作线程(M)在启动后都会进入调度循环。循环的入口是 schedule(),它调用 findRunnable() 获取可运行的 goroutine,然后通过 execute() 执行该 goroutine。当 goroutine 退出或阻塞时,控制权回到 schedule(),形成闭环。
核心契约: 调度循环保证每个 M 在任意时刻要么在执行 goroutine,要么在寻找可运行的 goroutine,永远不会空闲等待(除非所有 P 都空闲)。
3.2 schedule() — 调度循环入口
schedule() 是每个 M 进入调度循环的起点。它负责调用 findRunnable() 获取下一个可执行的 goroutine,然后调用 execute() 执行它。如果 findRunnable() 返回 nil,表示所有 P 都空闲,M 会进入休眠。
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 检查 GC 标记 | 如果当前 P 需要执行 GC 标记工作,优先处理 |
| 2 | 调用 findRunnable() | 从本地/全局队列、网络轮询器、其他 P 获取 goroutine |
| 3 | 检查 gp 是否为 nil | 如果为 nil,M 进入休眠等待唤醒 |
| 4 | 调用 execute(gp) | 将 goroutine 绑定到当前 M 并执行 |
关键观察:
schedule() 本身不包含复杂的查找逻辑,它将查找职责委托给 findRunnable(),体现了单一职责原则。
3.3 findRunnable() — 多级查找策略
findRunnable() 是调度循环中最复杂的函数,它实现了多级查找策略,确保 goroutine 能够被高效地分配到空闲的 M 上。查找顺序如下:
- 本地运行队列(LRQ): 从当前 P 的本地队列中获取 goroutine,复杂度 O(1)。
- 全局运行队列(GRQ): 从全局队列中获取,每 61 次调度检查一次,防止全局队列饥饿。
- 网络轮询器(netpoll): 检查是否有因 I/O 事件就绪的 goroutine。
- 任务窃取(work stealing): 从其他 P 的本地队列中窃取一半 goroutine。
- 空闲等待: 如果所有 P 都空闲,M 进入休眠,等待唤醒信号。
| 优先级 | 查找目标 | 复杂度 | 触发条件 |
|---|---|---|---|
| 1 | 本地运行队列 | O(1) | 每次调度 |
| 2 | 全局运行队列 | O(1) | 每 61 次调度 |
| 3 | 网络轮询器 | O(n) | 本地/全局队列为空 |
| 4 | 任务窃取 | O(n) | 以上均无结果 |
| 5 | 空闲等待 | — | 所有 P 空闲 |
性能陷阱: 任务窃取虽然能平衡负载,但频繁窃取会导致缓存亲和性下降。Go 调度器通过随机选择目标 P 和窃取一半 goroutine 来平衡负载与缓存效率。
3.4 execute() — 上下文切换
execute() 负责将选中的 goroutine 绑定到当前 M 上,并执行上下文切换。它执行以下关键操作:
- 绑定 goroutine: 将 goroutine 的
m字段指向当前 M,将 M 的curg字段指向该 goroutine。 - 更新状态: 将 goroutine 状态从
_Grunnable切换为_Grunning。 - 调用 gogo(): 通过汇编函数
gogo()恢复 goroutine 的栈和寄存器上下文,开始执行用户代码。
// 简化后的 execute() 逻辑
func execute(gp *g) {
// 1. 绑定 goroutine 到当前 M
gp.m = getg().m
getg().m.curg = gp
// 2. 更新状态
casgstatus(gp, _Grunnable, _Grunning)
// 3. 执行上下文切换
gogo(&gp.sched)
}
关键点:
gogo() 是汇编实现的函数,它直接操作栈指针和程序计数器,完成从调度器栈到 goroutine 栈的切换。一旦 gogo() 返回,控制权就完全交给了 goroutine 的用户代码。
3.5 goexit() — 优雅退出与清理
当 goroutine 执行完毕(从 go func() 返回)或调用 runtime.Goexit() 时,控制权会进入 goexit() 函数。它负责:
- 执行 defer 函数: 确保所有注册的 defer 函数按 LIFO 顺序执行。
- 清理资源: 释放 goroutine 占用的栈空间和其他资源。
- 更新状态: 将 goroutine 状态切换为
_Gdead。 - 返回调度循环: 调用
schedule()重新进入调度循环,寻找下一个可执行的 goroutine。
| 阶段 | 操作 | 说明 |
|---|---|---|
| defer 执行 | 调用 gopanic()/gorecover() | 处理 panic 和 recover,确保 defer 链完整执行 |
| 栈清理 | 调用 stackfree() | 释放 goroutine 的栈内存 |
| 状态切换 | casgstatus(gp, _Grunning, _Gdead) | 标记 goroutine 为死亡状态 |
| 循环返回 | 调用 schedule() | 重新进入调度循环 |
设计意图:
goexit() 的设计确保了 goroutine 的退出不会导致 M 空闲。M 在清理完当前 goroutine 后立即返回调度循环,继续执行其他 goroutine,实现了 M 的最大利用率。
3.6 完整调用链总结
调度循环的完整调用链可以概括为:
schedule() → findRunnable() → execute() → gogo() → [用户代码] → goexit() → schedule()
这个循环在 Go 运行时中永不停止。每个 M 都在这个循环中运行,直到程序退出。理解这个调用链是掌握 Go 调度器行为的关键。
| 函数 | 职责 | 调用者 | 被调用者 |
|---|---|---|---|
| schedule() | 调度循环入口,协调查找与执行 | goexit()、mstart() | findRunnable()、execute() |
| findRunnable() | 多级查找可运行 goroutine | schedule() | runqget()、globrunqget()、netpoll()、runqsteal() |
| execute() | 绑定 goroutine 并切换上下文 | schedule() | gogo() |
| goexit() | 清理 goroutine 并返回调度循环 | 用户代码返回 | schedule() |
契约总结: 调度循环的核心契约是“永不空闲”。通过
findRunnable() 的多级查找策略和 goexit() 的自动回环机制,Go 调度器保证了每个 M 都能持续执行 goroutine,实现了高效的并发调度。