NEE's Blog

深入理解 Go 运行时调度器

March 13, 2026

本文翻译自 The Scheduler,原载于 Hacker News。


在上一篇文章中,我们探讨了 Go 的内存分配器如何管理堆内存——从操作系统获取大型内存区域,将其划分为 spans 和 size classes,并使用三级层次结构(mcache、mcentral、mheap)使大多数分配操作无需加锁。一个关键细节是:每个 P(processor)都有自己的内存缓存。但我们从未真正解释过什么是 P,运行时如何决定哪个 goroutine 在哪个线程上运行。这就是调度器的工作,也是我们今天要探索的主题。

调度器是运行时中回答一个看似简单问题的组件:下一个运行哪个 goroutine? 你的程序中可能有数百、数千甚至数百万个 goroutine,但你只有少量的 CPU 核心。调度器的工作是将所有这些 goroutine 多路复用到少量的操作系统线程上,让每个核心都保持忙碌,同时确保没有 goroutine 被饿死。

如果你使用过 goroutine 和 channel,你已经在不知不觉中受益于调度器。每个 go 语句、每个 channel 发送和接收、每个 time.Sleep——它们都与调度器交互。让我们来看看它是如何工作的。

GMP 模型

调度器围绕三个概念构建,通常称为 GMP 模型G(goroutine)、M(machine/操作系统线程)和 P(processor)。让我们逐一看看。

G — Goroutine

G 是一个 goroutine——Go 运行时对一段并发工作的表示。每次你写 go f(),运行时就会创建(或复用)一个 G 来跟踪该函数的执行。

G 结构体有很多字段,但理解其工作原理最重要的是:一个小的(起始只有 2KB),一些保存的寄存器(栈指针、程序计数器等)以便调度器可以暂停和恢复它,一个跟踪 goroutine 正在做什么的状态字段(运行中、等待中、就绪),以及指向当前运行它的 M 的指针。完整结构体在 src/runtime/runtime2.go 中还有更多——用于 panic 和 defer 处理、GC 辅助跟踪、性能分析标签、定时器等字段。

与操作系统线程相比,线程通常以 1-8MB 的栈开始并携带大量内核状态。goroutine 要轻量得多——这就是为什么你可以在单个程序中拥有数百万个 goroutine。而操作系统线程?几千个就会开始感受到压力。

M — Machine(操作系统线程)

M(定义在 src/runtime/runtime2.go)是一个操作系统线程——实际执行代码的东西。调度器的工作是将 goroutine 放到 M 上运行。

每个 M 有两个值得了解的 goroutine 指针。第一个是 curg——当前在这个线程上运行的用户 goroutine。第二个是 g0——每个 M 都有自己的 g0。g0 是一个特殊的 goroutine,专为运行时的自身管理保留——调度决策、栈管理、垃圾回收簿记。它的栈比普通 goroutine 大得多:通常 16KB,根据操作系统和是否启用竞态检测,可能是 32KB 或 48KB。与普通 goroutine 不同,g0 栈不会增长——它在分配时固定,所以必须足够大以处理运行时需要做的任何事情。当调度器需要做决策(下一个运行哪个 goroutine,如何处理阻塞操作)时,它会从你的 goroutine 切换到这个 M 的 g0 来完成工作。

M 还有一个指向当前附加的 P 的指针。这很重要:没有 P,M 无法运行 Go 代码。它只是一个空闲的操作系统线程,什么也不做。为什么 M 需要 P?

P — Processor

这是设计的巧妙之处。P(定义在 src/runtime/runtime2.go)不是 CPU 核心,也不是线程——它是一个调度上下文。把它想象成一个工作站:它有 goroutine 高效运行所需的一切,M 必须先坐下来才能做任何实际工作。

为什么不让 M 直接运行 goroutine?问题在于系统调用。当 M 进入内核时,整个操作系统线程都会阻塞——如果所有调度资源都附加在 M 上,它们也会被卡住。运行队列、内存缓存,一切都会冻结直到系统调用返回。通过将所有这些放在单独的 P 上,运行时可以将 P 从阻塞的 M 上分离,交给一个空闲的 M。即使线程被卡住,工作仍在继续。

每个 P 都有自己的本地运行队列——最多 256 个就绪运行的 goroutine 列表。它还有一个 runnext 槽位,就像下一个要执行的 goroutine 的快速通道。有一个 gFree 列表,已完成的 goroutine 被保留在那里以便复用而不是从头分配。它甚至携带自己的 mcache——我们在内存分配器文章中看到的每 P 内存缓存。因为每个 P 都有自己的一份所有这些东西,使用它的线程不需要争抢共享锁——这是一个额外的好处。

P 的数量由 GOMAXPROCS 控制,默认为 CPU 核心数。所以在 8 核机器上,你有 8 个 P,意味着任何时刻最多 8 个 goroutine 可以真正并行运行。但你可以有比 P 多得多的 M——有些可能在系统调用中阻塞,而其他正在积极运行 goroutine。关键是任何时候只有 GOMAXPROCS 个 M 可以运行 Go 代码。

这种解耦是调度器设计的核心,随着我们深入文章,你会明白为什么它如此重要。

全局调度状态(schedt)

schedt 结构体(定义在 src/runtime/runtime2.go)是全局调度器状态。它只有一个实例——一个名为 sched 的全局变量——它保存了不属于任何特定 P 或 M 的所有内容。把它想象成 P 和 M 在需要协调时检查的共享公告板。

里面有什么?首先是全局运行队列runq)——一个不在任何 P 本地队列中的 goroutine 链表。这些是从已满的本地队列溢出的 goroutine,或者从系统调用返回但找不到 P 的 goroutine。还有一个全局空闲列表gFree)——等待被复用的已死 goroutine——当 P 的本地空闲列表用完时,从这里补充;当 P 有太多已死 goroutine 时,会把一些倒回这里。同样的两级模式我们在内存分配器中看到过:本地缓存用于快速路径,共享池作为备份。

然后是空闲列表。当 P 没有 M 运行它时,它进入 pidle 列表。当 M 没有工作也没有 P 时,它进入 midle 列表并休眠。调度器还跟踪当前自旋(寻找工作)的 M 数量 nmspinning——我们稍后会解释自旋是什么意思——以及 GC 是否正在请求 stop-the-world 暂停 gcwaiting。所有这些共享状态都由 sched.lock 保护——但锁的设计是短暂持有,因为热路径(从本地队列选择 goroutine)根本不触及 schedt

除了 schedt,运行时还保存了每个创建过的 G、M 和 P 的主列表——全局变量 allgsallmallp。这些不用于调度决策。它们的存在是为了运行时在需要做全局操作时能找到所有内容,比如在垃圾回收期间扫描所有 goroutine 栈,或在 sysmon 中检查卡住的系统调用。

Go 调度器示意图

Goroutine 的生命周期

让我们跟随一个 goroutine 从出生到死亡——有时还会重生。状态定义在 src/runtime/runtime2.go 中。

诞生:创建和初始步骤

当你写 go f() 时,编译器将其转换为对 newproc() 的调用(在 src/runtime/proc.go),运行时需要一个 G 结构体来表示这个新 goroutine。但它不一定从头分配——首先,它检查当前 P 的已死 goroutine 本地空闲列表。如果有可用的,它会被复用,包括栈。如果本地列表为空,它尝试从 schedt 中的全局空闲列表获取一批。只有当两者都为空时,运行时才分配一个带有新 2KB 栈的新 G。这种复用就是 goroutine 创建如此廉价的原因——大多数时候,只是从列表中取出一个 G 并重新初始化几个字段。

如果 G 是从空闲列表回收的,它已经处于 _Gdead 状态——那是 goroutine 完成时去的地方。如果是新分配的,它从 _Gidle 开始(一个空白结构体,从未使用过)并立即转换到 _Gdead。无论哪种方式,G 在设置开始前都处于 _Gdead。等等——已经死了?是的,但只是技术上的。_Gdead 意味着”不被调度器使用”——这是正在设置或已完成并等待复用的 goroutine 的状态。运行时将其用作配置 G 内部结构时的安全”停放”状态。

在初始化期间,运行时准备 goroutine 以便它准备好运行。它将栈指针设置到栈顶,将程序计数器指向你的函数以便它知道从哪里开始执行,并放置一个指向 goexit 的返回地址——goroutine 清理处理器。这样,当你的函数完成并返回时,执行自然落入 goexit,无需任何特殊的”完成了吗?”检查。

设置完成后,G 移动到 _Grunnable 并进入当前 P 的 runnext 槽位,取代之前在那里的任何东西。这意味着新 goroutine 很快就会运行——就在当前 goroutine 让出之后。

现在 goroutine 活着了——坐在运行队列上,准备执行,只等 M 来取走它。

运行中

当调度器从队列中取出这个 G 时,它转换到 _Grunning。这是活跃状态——goroutine 正在 M 上执行你的代码,有一个 P。这是它度过有生产力的时间的地方。

但 goroutine 很少直接运行到完成。在某些时候,有什么会中断流程,接下来发生什么取决于 goroutine 为什么停止。这就是故事分支的地方。

阻塞和解除阻塞

也许 goroutine 尝试从空 channel 接收,或者获取锁定的 mutex,或者 sleep。一个可能让你惊讶的细节:没有外部的”调度器线程”飞进来停放 goroutine。goroutine 自己停放自己。

假设你的 goroutine 在空 channel 上执行 <-ch。channel 实现看到没有东西可以接收,所以它调用 gopark() 来停放 goroutine 直到值到达。goroutine 切换到 g0 栈,将自己的状态改为 _Gwaiting,并将自己添加到 channel 的等待队列。之后,从调度器的角度来看它就消失了——不在任何运行队列上,只是坐在 channel 的内部等待列表中。M 不会休眠。它调用 schedule() 并取出下一个 goroutine。从 M 的角度看,一个 goroutine 被停放,另一个开始运行——M 一直保持忙碌。

gopark() 还记录 goroutine 为什么阻塞——channel 接收、mutex 锁、sleep、select 等等。这就是当你查看 goroutine 转储或性能分析数据时显示的内容,所以你可以确切地知道每个 goroutine 在等待什么。

现在看另一面:当 goroutine 等待的事情终于发生时会发生什么?假设另一个 goroutine 在那个 channel 上发送一个值。发送者找到我们在 channel 等待队列上的 goroutine,直接将值复制给它,并调用 goready()。这将 goroutine 的状态改回 _Grunnable 并将其放入发送者的 runnext 槽位——意味着它很快就会运行,就在发送者让出之后。这种 runnext 放置在生产者和消费者 goroutine 之间创造了紧密的来回。G1 发送,G2 接收并立即运行,G2 发回,G1 接收并立即运行——几乎像协程互相交接,调度开销最小。

系统调用

在 channel 和 mutex 上阻塞是一回事——goroutine 被停放,但 M 和 P 保持空闲。系统调用是另一回事,因为它们阻塞整个操作系统线程。

当 goroutine 进行系统调用时——读取文件、接受网络连接、任何进入内核的操作——整个操作系统线程阻塞。在进入内核之前,goroutine 调用 entersyscall(),它保存上下文并将状态改为 _Gsyscall。但这里有一个重要细节:M 不让出它的 P。它保留着。为什么?因为大多数系统调用很快——几微秒——goroutine 会回来并在同一个 P 上继续运行,就像什么都没发生一样。没有锁,没有协调,没有开销。

但一旦 goroutine 处于 _Gsyscall,它就有失去 P 的危险。如果系统调用时间太长,sysmon 可以过来并夺取 P——将其从阻塞的 M 分离并交给另一个线程,这样其运行队列中的 goroutine 继续运行。这就是 G-M-P 解耦真正发挥作用的地方:线程卡在内核中,但工作继续前进。

当系统调用完成时,goroutine 检查它是否仍有 P。如果有——很好,继续。如果 sysmon 拿走了它,goroutine 尝试获取任何空闲的 P。如果根本没有空闲的 P,它将自己放入全局运行队列等待被取走。我们将在后续文章中更详细地介绍 sysmon。

栈增长

还有一件事可能在 goroutine 运行时发生:它可能耗尽栈空间。Go goroutine 以微小的 2KB 栈开始,与操作系统线程不同,它们没有预先获得固定大小的栈。相反,编译器在大多数函数的开头插入一个小检查,称为栈增长序言。这个检查将当前栈指针与栈限制比较——如果没有足够的空间进行下一个函数调用,运行时就介入。

当这种情况发生时,运行时分配一个新的更大的栈(通常是两倍大小),复制旧栈内容,调整所有引用栈地址的指针,并释放旧栈。然后 goroutine 在新的更大的栈上继续运行,就像什么都没发生一样。这就是 Go 能够运行数百万个 goroutine 的原因——它们从小开始,只在真正需要空间时才增长。

抢占

goroutine 也可能被非自愿地停止。到目前为止我们看到的一切——在 channel 上阻塞、进行系统调用、完成——都涉及 goroutine 配合。但如果 goroutine 永不让出?一个没有函数调用、channel 操作或内存分配的紧密计算循环永远不会给调度器机会在该 P 上运行其他任何东西。

Go 有两个答案。第一个是协作式抢占:编译器在大多数函数的开头插入一个小检查,测试 goroutine 是否被要求让出。当运行时想要抢占 goroutine 时,它翻转一个标志,下一个函数调用触发检查并将控制权交还给调度器。这很便宜——它复用已经存在的栈增长检查——但它只在函数调用时有效。

第二个是异步抢占:对于卡在没有函数调用的紧密循环中的 goroutine,运行时直接向线程发送操作系统信号(Unix 上是 SIGURG)。信号处理器中断 goroutine,保存其上下文,并让出给调度器。这是重锤——即使协作式抢占无法工作时它也有效。

在这两种情况下,被抢占的 goroutine 直接转换到 _Grunnable 并回到运行队列——它很快就会获得另一个运行机会。还有特殊的 _Gpreempted 状态,但那是为 GC 或调试器需要通过 suspendG 完全挂起 goroutine 时保留的。无论哪种情况,都是 sysmon 检测运行时间过长的 goroutine(超过 10ms)并触发抢占。

死亡和回收

最后,goroutine 的函数返回。还记得创建期间 PC 被设置为指向 goexit 吗?所以返回落入 goexit0(),goroutine 处理自己的死亡。它将自己的状态改为 _Gdead,清理其字段,放弃 M 关联,并将自己放入 P 的空闲列表。然后它调用 schedule() 为这个 M 找下一个 goroutine。

G 没有被释放或垃圾回收。它坐在空闲列表上,栈和所有,等待被复用。这是一个关键优化——分配和设置新 G 比重新初始化一个已死的要昂贵得多。这就是故事回到起点的地方:一个新的 go 语句可能会从空闲列表中取出同一个 G,重新初始化它,并让它再次经历整个旅程。

自助服务模式

贯穿所有这些阶段有一个模式:goroutine 总是做自己状态转换工作的那个。没有中央调度器线程在拉动字符串——goroutine 自己停放自己,将自己添加到等待队列,自己清理,并调用调度器选择下一个 G。调度器实际上只是一组 goroutine 在自己上调用的函数,使用 M 的 g0 栈做簿记。

大多数 goroutine 在 _Grunnable_Grunning_Gwaiting 之间弹跳——就绪、运行、等待、就绪、运行、等待——直到它们最终完成并返回 _Gdead

调度循环

现在是调度器的核心:schedule() 函数(在 src/runtime/proc.go)。这是一个在每个 M 上运行的循环,在 g0 栈上,它的工作很简单:找到一个可运行的 goroutine 并执行它。当 goroutine 停止运行(它阻塞、完成或被抢占),控制返回到 schedule(),循环重新开始。

Go 调度器循环

goroutine 运行直到它将控制权交还给调度器——要么自愿(通过在 channel 上阻塞、调用 runtime.Gosched() 等)要么非自愿(通过抢占)。然后我们回到 schedule(),寻找下一个 goroutine。

schedule() 函数本身很简单。它检查一些特殊情况(这个 M 是否锁定到特定 goroutine?),然后调用 findRunnable() 获取下一个 goroutine。一旦有了,它调用 execute() 运行它。

有趣的部分是 findRunnable()——那是所有决策发生的地方。让我们分解它究竟如何搜索工作。

寻找工作:搜索顺序

findRunnable()(在 src/runtime/proc.go)是回答”我接下来应该运行什么?”的函数。它按特定顺序搜索多个来源,一直寻找直到找到东西——如果真的无事可做,它会停放 M 休眠直到工作出现,然后恢复搜索。

1. GC 和追踪工作

在寻找用户 goroutine 之前,调度器检查是否有运行时工作要做。如果 GC 活跃并需要标记 worker,那优先。如果执行追踪已启用且其读取器 goroutine 就绪,那也优先。运行时自己的需求优先。

2. 全局队列公平性检查

61 次调度调用,调度器在查看本地队列之前从全局运行队列获取单个 goroutine。为什么是 61?这是一个质数,有助于避免检查总是与同一 goroutine 对齐的同步模式。重点是防止饿死:如果 goroutine 不断被添加到本地队列,全局队列中的那些可能永远等待而没有这个检查。

3. 本地运行队列

这是快速路径,也是大多数 goroutine 的来源。调度器首先检查 runnext 槽位——一个保存最可能接下来运行的单个 goroutine 的优先位置。如果 runnext 已设置,goroutine 获得它并继承当前时间片,意味着它不重置调度 tick。这是生产者-消费者模式的优化:如果 G1 在 channel 上发送并唤醒 G2,G2 进入 runnext 并立即运行,几乎像直接交接。

如果 runnext 为空,调度器从环形缓冲区取出——一个最多 256 个 goroutine 的无锁循环队列。只有拥有的 M 写入这个队列(单生产者),所以常见情况不需要锁。

4. 全局运行队列(再次)

如果本地队列为空,检查全局队列。这次,调度器不是只取一个 goroutine,而是获取一批。这分摊了获取全局锁(sched.lock)的成本。一次锁获取,多个 goroutine。

5. 网络轮询

在诉诸窃取之前,调度器检查 netpoller 看是否有网络 I/O 就绪。如果任何 goroutine 在等待网络操作且这些操作现在完成,那些 goroutine 变得可运行。

6. 工作窃取

如果以上都为空,就该窃取了。调度器查看其他 P 的本地队列并取走它们的一半 goroutine。这是保持所有核心忙碌的机制,即使工作分布不均匀。

7. 最后手段:停放

如果真的到处都没有事可做——没有本地工作、没有全局工作、没有网络 I/O、没有东西可窃取——M 释放其 P,将其放入空闲 P 列表,并停放自己休眠。稍后新工作出现时它会被唤醒。

自旋线程

这里有一个微妙的平衡。当线程用完工作——它的本地队列为空,没有什么可窃取——它应该立即休眠吗?如果这样做,而新工作在一微秒后到达,就没有人醒来去取它。另一个线程必须从睡眠中唤醒,这需要时间。另一方面,如果太多空闲线程保持清醒燃烧 CPU 周期寻找不存在的工作,那是纯粹的浪费。

Go 的答案是自旋线程。当 M 用完工作时,它不会立即停放。相反,它进入自旋状态——主动检查队列并尝试窃取——短暂时间后才会放弃并休眠。运行时将自旋线程数量限制为最多忙碌 P 数量的一半——所以在有 6 个忙碌 P 的 8 核机器上,最多 3 个线程可以同时自旋。足够响应迅速,又不会太多浪费 CPU。

另一面是当新工作出现时——比如创建了新 goroutine 或 channel 解除阻塞。运行时更加保守:只有在自旋线程时才唤醒睡眠线程。如果已经有一个自旋线程在那里,它会捡起新工作。目标很简单:总是有人准备好抓取新工作,但不要太多人。

上下文切换

让我们简要谈谈 goroutine 上下文切换期间发生什么,因为这是让整个系统快速的原因。

当调度器从一个 goroutine 切换到另一个时,它需要保存当前 goroutine 在哪里并恢复下一个 goroutine 在哪里停下。好消息是 goroutine 的状态小得惊人。mcall() 汇编函数只保存 3 个值——栈指针、程序计数器和基址指针——到一个微小的 gobuf 结构体。就这些。为什么这么少?因为 goroutine 切换发生在函数调用边界,在这些点编译器已经按照正常调用约定将任何重要的寄存器溢出到栈。切换只需要保存足够的信息来再次找到栈。

gogo() 做相反的事:它恢复那些保存的值并直接跳入 goroutine。mcall()gogo() 一起是每次自愿 goroutine 切换背后的机制。对于异步抢占(goroutine 被信号中断),必须保存完整的寄存器集——但那是例外,不是常见路径。

它很快。goroutine 上下文切换大约需要 50-100 纳秒——大约 200 个 CPU 周期。与操作系统线程上下文切换相比,后者涉及保存完整寄存器集和切换内核栈——成本是 1-2 微秒,慢 10 到 40 倍。这是 goroutine 比线程扩展得好得多的重要原因。

总结

Go 调度器使用 GMP 模型将 goroutine 多路复用到操作系统线程:G(goroutine)是工作,M(操作系统线程)提供执行,P(processor)携带调度上下文——本地运行队列、内存缓存,以及高效运行 goroutine 所需的一切。全局 schedt 结构体用共享状态将一切联系起来,如全局运行队列、空闲列表和自旋线程计数。

我们跟随 goroutine 走过了整个生命周期——从创建(尽可能复用已死的 G),通过运行、阻塞(goroutine 自己停放)、系统调用(P 分离以便其他 goroutine 继续运行)、栈增长和抢占(协作和异步)。最后,goroutine 自己清理并回到空闲列表等待复用。

schedule()findRunnable() 中的调度循环驱动一切——检查本地队列、每 61 个 tick 检查全局队列以保证公平性、netpoller,以及在放弃之前从其他 P 窃取。自旋线程通过短暂保持清醒来捕获新工作来保持系统响应,而 goroutine 之间的上下文切换由于涉及的状态量小,只需大约 50-100 纳秒。

如果你想自己探索实现,主要调度器代码在 src/runtime/proc.go,数据结构在 src/runtime/runtime2.go,汇编例程在 src/runtime/asm_*.s


个人感想

这篇文章是理解 Go 并发模型底层实现的绝佳资源。几个关键点值得强调:

  1. G-M-P 解耦的智慧:将调度上下文(P)从操作系统线程(M)分离是 Go 调度器设计的精髓。这种设计使得即使线程阻塞在系统调用中,调度资源仍可用于其他线程,实现了真正的高效并发。

  2. 自助式调度:goroutine 自己管理自己的状态转换这个设计非常巧妙——没有中央调度器线程的开销,每个 goroutine 都是调度决策的积极参与者。

  3. 上下文切换的效率:50-100 纳秒 vs 1-2 微秒的差异解释了为什么 Go 可以轻松处理数百万并发——这不是魔法,是精心设计的工程。

  4. 公平性与效率的平衡:61 这个质数的选择、runnext 槽位、工作窃取机制——每一个细节都在平衡效率与公平,防止饿死的同时最大化吞吐量。

理解这些底层机制,能帮助我们在实际开发中做出更好的设计决策,比如理解何时应该使用 buffered channel、何时需要显式让出控制权,以及为什么某些并发模式比其他更高效。

comments powered by Disqus