本文章为译文,原作者 Jesús Espino。在这里查看他关于 Go Runtime 的系列文章:Understanding the Go Runtime

首图

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

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

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

让我们从最基本的构建块开始——整个调度器所围绕的三个核心结构。

GMP 模型

调度器建立在三个概念之上,通常称为 GMP 模型G(goroutine), M(machine/OS 线程),以及 P(处理器)。我们在启动引导那篇文章中曾简单提及,现在让我们来仔细看看它们。

让我们逐一了解。

G — Goroutine

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

G 实际上携带了什么?该结构体有很多字段,但我认为对于理解其工作原理最有用的几个是:一个很小的 (起始仅 2KB),一些 保存的寄存器(栈指针 SP、程序计数器 PC 等),以便调度器可以暂停它并在之后恢复它,一个 状态 字段,用于跟踪 goroutine 正在做什么(运行中、等待中、可运行),以及一个指向当前正在运行它的 M 的指针。在 src/runtime/runtime2.go 中的完整结构体还有更多内容——用于 panic 和 defer 处理的字段、GC 辅助统计、性能分析标签、计时器等。

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

所以 goroutine 就是工作任务。但必须有人实际执行这些工作——CPU 并不知道 goroutine 是什么。它只知道如何运行线程。

M — 机器 (OS 线程)

一个 M(定义在 src/runtime/runtime2.go)就是一个 OS 线程——真正执行代码的东西。调度器的工作就是将 goroutine 放到 M 上,让它们得以运行。

每个 M 有两个值得了解的 goroutine 指针。第一个是 curg ——当前在此线程上运行的用户 goroutine。那就是你的代码。第二个是 g0 ——每个 M 都有自己的 g0。g0 是一个特殊的 goroutine,保留给运行时自身的内部维护工作——调度决策、栈管理、垃圾回收记录等。它拥有比常规 goroutine 大得多的栈:通常是 16KB,但根据操作系统和竞态检测器是否启用,也可能是 32KB 或 48KB。与常规 goroutine 不同,g0 的栈 不会增长 ——它在分配时就是固定的,所以必须一开始就足够大,以处理运行时可能需要做的任何事情。当调度器需要做出决策(接下来运行哪个 goroutine,如何处理阻塞操作)时,它会从你的 goroutine 切换到该 M 的 g0 上来完成这项工作。可以把 g0 想象成 M 的“管理模式”——它运行调度逻辑,然后将控制权交还给一个用户 goroutine。

M 还有一个指向它当前所依附的 P 的指针。这很重要:没有 P,M 就无法运行 Go 代码。它只是一个闲置的 OS 线程,无所事事地待在那里。为什么 M 需要 P 呢?

P — 处理器

这是设计中巧妙的部分。一个 P(定义在 src/runtime/runtime2.go)既不是 CPU 核心也不是线程——它是一个 调度上下文。可以把它想象成一个工作站:它拥有 goroutine 高效运行所需的一切,而 M 必须“坐”到其中一个工作站前才能开始真正的工作。

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

因此,每个 P 携带自己的 本地运行队列 (local run queue) ——一个最多包含 256 个可运行 goroutine 的列表。它还有一个 runnext 槽位,就像一个“快速通道”,用于存放下一个要执行的 goroutine。有一个 gFree 列表 用于存放已完成的 goroutine,以便回收重用,而不是从头分配。它甚至携带自己的 mcache ——我们在 内存分配器 文章中见过的每个 P 的内存缓存。由于每个 P 都有所有这些资源的副本,使用它们的线程不需要一直争夺共享锁——这是一个很好的额外好处。

P 的数量由 GOMAXPROCS 控制,其默认值为 CPU 核心数。所以在 8 核机器上,你有 8 个 P,意味着在任何时刻,最多只有 8 个 goroutine 可以真正地并行运行。但你可以拥有远多于 P 数量的 M——有些可能阻塞在系统调用中,而另一些则在积极地运行 goroutine。关键在于,在任何给定时间,只有 GOMAXPROCS 个 M 能够运行 Go 代码。

这种解耦是调度器设计的核心,在我们阅读本文其余部分时,会看到它为何如此重要。

所以我们有了 G、M 和 P——但总得有人来追踪它们全部。那就是 schedt 结构体。

调度器状态 (schedt)

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

那里有什么?首先是 全局运行队列 (global run queue)runq)——一个由不在任何 P 本地队列中的 goroutine 组成的链表。这些 goroutine 要么是从已满的本地队列溢出出来的,要么是从系统调用返回后找不到 P 的。还有一个 全局空闲列表 (global free list)gFree)存放着等待被回收的死去的 goroutine——当某个 P 的本地空闲列表用尽时,它会从这里批量获取;当某个 P 有太多死去的 goroutine 时,它会把一些归还到这里。这与我们在内存分配器中看到的模式相同:本地缓存用于快速路径,共享池作为后备。

然后是 空闲列表。当一个 P 没有 M 在运行它时,它会进入 pidle 列表。当一个 M 既没有工作也没有 P 时,它会进入 midle 列表并休眠。调度器还跟踪当前有多少 M 正在 自旋 (spinning)(寻找工作)并将计数存放在 nmspinning 中——我们将在 文章后面 解释自旋的含义——以及 GC 是否请求了 stop-the-world 暂停 的状态标记在 gcwaiting 中。所有这些共享状态都由 sched.lock 保护——但该锁设计为持有时间非常短,因为热路径(从本地队列获取 goroutine)根本不会触及 schedt

除了 schedt 之外,运行时还维护着 所有 G、M 和 P 的主列表 ——全局变量 allgsallm,和 allp。它们不用于调度决策。它们的存在是为了当运行时需要做一些全局性的事情时,能够找到 所有东西,比如在垃圾回收期间扫描所有 goroutine 栈,或者在 sysmon 中检查卡住的系统调用。

这是完整的示意图:

Go 调度器示意图

现在舞台已经搭好,是时候看看演员们的表演了。让我们跟随一个 goroutine 的生命周期,看看它在整个“战场”上是如何移动的。

Goroutine 的一生

让我们追踪一个 goroutine 从诞生到死亡——有时甚至是从死亡到重生的全过程。这些状态定义在 src/runtime/runtime2.go 中,但我们不打算罗列它们,而是通过故事来了解。

诞生:创建与第一步

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

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

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

一旦设置完成,G 就进入 _Grunnable 状态,并被放入当前 P 的 runnext 槽位,替换掉之前在那里的任何东西。这意味着新的 goroutine 很快就会运行——就在当前 goroutine 让出 CPU 之后。

现在 goroutine 诞生了——它坐在运行队列上,准备执行,只等一个 M 来取走它。

运行中

当调度器从队列中选中这个 G 时,它会转换到 _Grunning 状态。这是活动状态——goroutine 正在一个拥有 P 的 M 上执行你的代码。这是它度过生产性时光的地方。

但 goroutine 很少能一口气运行到底。在某个时刻,总会有事情打断它的执行流,接下来发生的事情取决于 为什么 goroutine 停止了。故事在这里开始分支。

阻塞与解除阻塞

也许 goroutine 试图从一个空的 channel 接收数据,或者试图获取一个已被锁住的互斥锁,或者调用了 sleep。这里有一个可能会让你惊讶的细节:并没有一个外部的“调度器线程”突然介入并将 goroutine 暂停。 Goroutine 是自己暂停自己的。

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

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

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

系统调用

在 channel 和互斥锁上阻塞是一回事——goroutine 暂停了,但 M 和 P 保持自由。系统调用则是另一回事,因为它们会阻塞整个 OS 线程。

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

但是,一旦 goroutine 进入 _Gsyscall 状态,它就面临着 失去 其 P 的风险。如果系统调用耗时过长, sysmon 就会介入, 收回 P ——将它从被阻塞的 M 上分离,交给另一个线程,以便其运行队列中的 goroutine 能继续执行。这就是 G-M-P 解耦真正发挥作用的地方:线程卡在内核里,但工作可以继续推进。

当系统调用完成时,goroutine 会检查它是否还持有自己的 P。如果还在——太好了,继续运行。如果被 sysmon 拿走了,goroutine 会尝试获取任意一个空闲的 P。如果没有空闲的 P,它就将自己放到 全局运行队列 中,等待被某个 M 取走。我们将在后续文章中更详细地介绍 sysmon。

到目前为止,我们看到了 goroutine 自愿阻塞的情况——在 channel、互斥锁和系统调用上。但幕后还有更微妙的事情发生在每次 goroutine 调用函数时。

栈增长

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

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

这里提到栈检查,是因为正如我们将在下一节中看到的,调度器利用它来实现协作式抢占 (cooperative preemption)。

抢占

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

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

第二种是 异步抢占:对于那些卡在 tight loop 中没有函数调用的 goroutine,运行时会直接向该线程发送一个 OS 信号(在 Unix 上是 SIGURG)。信号处理程序中断 goroutine,保存其上下文,并让出 CPU 给调度器。这是一把重型武器——即使在协作式抢占无法生效时也能工作。

在这两种情况下,被抢占的 goroutine 会直接转换到 _Grunnable 状态并放回运行队列——它很快会再次获得运行机会。还有一个特殊的 _Gpreempted 状态,但那是保留给当 GC 或调试器需要通过 suspendG 完全挂起一个 goroutine 时使用的。在这两种抢占场景中,都是由 sysmon 检测到运行时间过长(超过 10ms)的 goroutine 并触发抢占。我们将在系统监控文章中探讨细节。

死亡与回收

最后,goroutine 的函数执行完毕返回了。还记得吗,在创建时 PC 被设置为指向 goexit?所以返回时会落到 goexit0(),goroutine 处理自己的“后事”。它将自己的状态改为 _Gdead,清理其字段,解除与 M 的关联,并将自己放到 P 的空闲列表上。然后它调用 schedule() 为该 M 寻找下一个 goroutine。

这个 G 不会被释放或垃圾回收。它待在空闲列表上,带着它的栈和一切,等待被回收重用。这是一个关键的优化——分配和设置一个新的 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 (mark worker),那会优先处理。如果执行追踪 (execution tracing) 已启用且其 reader goroutine 已就绪,那也会优先处理。运行时自身的需求排在第一位。

2. 全局队列公平性检查

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

3. 本地运行队列

这是快速路径,大多数 goroutine 都来自这里。调度器首先检查 runnext 槽位——一个优先级位置,存放着最有可能运行的下一个 goroutine。如果 runnext 有值,该 goroutine 会被取走并 继承当前的时间片,意味着不会重置调度滴答计数。这是对生产者-消费者模式的优化:如果 G1 在 channel 上发送并唤醒 G2,G2 会进入 runnext 并立即运行,几乎就像直接交接一样。

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

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

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

5. 网络轮询

在求助于窃取之前,调度器检查 网络轮询器 (netpoller),看是否有网络 I/O 已经就绪。如果有任何 goroutine 曾因等待网络操作而阻塞,并且这些操作现在已完成,这些 goroutine 就会变成可运行状态。我们将在未来的文章中讨论网络轮询器的工作原理。

6. 工作窃取

如果以上所有来源都一无所获,那就该偷了。调度器会查看 其他 P 的本地队列,并拿走它们一半的 goroutine。这种机制确保即使工作分布不均,所有核心也能保持忙碌。

7. 最后的手段:停放

如果确实没有任何事情可做——没有本地工作,没有全局工作,没有网络 I/O,没什么可偷的——M 就会释放它的 P,将其放到空闲 P 列表上,然后自己停放 (park) 并进入休眠。当有新工作出现时,它会被唤醒。

但这个“停放”的决定并不像看起来那么简单。一个线程在没活干时应该立即休眠,还是应该稍等片刻,看看是否马上会有新活出现?

自旋线程

这里需要达到一个微妙的平衡。当一个线程的工作耗尽时——它的本地队列为空,也没什么可偷的——它应该立即休眠吗?如果它立即休眠,而一微秒后新工作就来了,那就没有线程醒着去处理它。必须从休眠中唤醒另一个线程,这会消耗时间。另一方面,如果有太多空闲线程保持清醒,白白消耗 CPU 周期去寻找不存在的工作,那纯粹是浪费。

Go 的答案是 自旋线程 (spinning threads)。当一个 M 工作耗尽时,它不会立即停放。相反,它会进入一个自旋状态——主动检查队列并尝试窃取——持续一小段时间,然后才放弃并进入休眠。运行时限制自旋线程的数量,最多不超过 忙碌 P 数量的一半 ——所以在 8 核机器上,如果有 6 个 P 忙碌,最多可以有 3 个线程同时自旋。这个数量足以保证响应性,又不会多到浪费 CPU。

硬币的另一面是当新工作出现时——例如一个新的 goroutine 被创建,或者一个 channel 解除阻塞。运行时会更加保守:只有当 自旋线程数为零 时,它才会唤醒一个休眠的线程。如果已经有一个自旋线程在寻找工作,它就会接手这个新工作。目标很简单:始终有人准备好接手新工作,但又不会人太多。

所有这些机制——阻塞、解除阻塞、系统调用、抢占——都涉及到从一个 goroutine 切换到另一个。让我们看看这个切换的实际成本是多少。

上下文切换

我们简短地谈谈在 goroutine 上下文切换期间发生了什么,因为这是整个系统快速运行的关键。

当调度器从一个 goroutine 切换到另一个时,它需要保存当前 goroutine 的运行位置,并恢复下一个 goroutine 上次离开时的位置。好消息是,一个 goroutine 的状态非常小。 mcall() 这个汇编函数只保存 3 个值 ——栈指针、程序计数器和基址指针——存入一个微小的 gobuf 结构体中。就这些。为什么这么少?因为 goroutine 切换发生在函数调用边界,而在这些点上,编译器已经按照常规调用约定将所有重要的寄存器溢出到栈上了。切换只需要保存足以再次找到栈的信息。

gogo() 执行相反的操作:恢复那些保存的值,并直接跳转到 goroutine 中。一起, mcall()gogo() 构成了每次自愿 goroutine 切换背后的机制。对于异步抢占(goroutine 被信号中断在执行的中间位置),则需要保存完整的寄存器集合——但那是例外情况,不是常见路径。

而且它很快。一次 goroutine 上下文切换大约需要 50–100 纳秒 ——大约 200 个 CPU 周期。相比之下,OS 线程上下文切换需要保存完整的寄存器集合并切换内核栈——这需要 1–2 微秒,慢 10 到 40 倍。这是为什么 goroutine 比线程扩展性好得多的一个重要原因。

让我们总结一下我们学到的内容。

总结

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

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

schedule()findRunnable() 中的调度循环驱动着一切——检查本地队列、每 61 次滴答检查一次全局队列以保证公平性、检查网络轮询器、在放弃前从其他 P 窃取工作。 自旋线程 通过短暂保持清醒以捕捉新工作,使系统保持响应性,而 上下文切换 在 goroutine 之间仅需约 50–100 纳秒,这得益于所需保存的状态非常少。

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

在下一篇文章中,我们将探讨 垃圾回收器 ——它如何追踪哪些对象仍然存活,并回收其余部分,同时让你的程序继续运行。