Understanding the Go Scheduler
深入理解 Go Scheduler
Go Scheduler
- Introduction
- Compilation and Go Runtime
- Primitive Scheduler
- Scheduler Enhancement
- GMP Model
- Program Bootstrap
- Creating a Goroutine
- Schedule Loop
- Finding a Runnable Goroutine
- Goroutine Preemption
- Handling System Calls
- Network I/O and File I/O
- How netpoll Works
- Garbage Collector
- Common Functions
- Runtime APIs
免责声明
这篇博文主要关注在 ARM 架构的 Linux 上的 Go 1.24 编程语言。它可能不涵盖其他操作系统或架构的平台特定细节。 内容基于其他来源和我自己对 Go 的理解,因此可能不完全准确。欢迎在评论区纠正我或提出建议 😄。
引言
⚠️ 这篇文章假设您已经对 Go 并发(goroutines、channels 等)有基本的了解。如果您是这些概念的新手,请在继续之前先复习一下它们。
Go 于 2009 年推出,作为一种用于构建并发应用程序的编程语言,其受欢迎程度稳步增长。它被设计为简单、高效和易于使用,重点是并发编程。
Go 的并发模型建立在 goroutines 的概念之上,goroutines 是由 Go runtime 在用户空间中管理的轻量级用户线程。 Go 提供了有用的同步原语,例如 channels,以帮助开发人员轻松编写并发代码。它还使用非凡的技术来提高 I/O 密集型程序的效率。
对于 Go 程序员来说,理解 Go scheduler 对于编写高效的并发程序至关重要。它还可以帮助我们更好地解决性能问题或调整 Go 程序的性能。在这篇文章中,我们将探讨 Go scheduler 如何随着时间的推移而演变,以及我们编写的 Go 代码在底层是如何发生的。
编译和 Go Runtime
这篇文章涵盖了很多源代码的演练,所以最好先对 Go 代码是如何编译和执行的有一个基本的了解。当一个 Go 程序被构建时,有三个阶段:
- 编译 : Go 源代码文件 (
*.go
) 被编译成汇编文件 (*.s
)。 - 汇编 : 汇编文件 (
*.s
) 然后被汇编成目标文件 (*.o
)。 - 链接 : 目标文件 (
*.o
) 被链接在一起,生成一个单一的可执行二进制文件。
flowchart LR
start((Start)) ==> |*.go files|compiler[Compiler]
compiler ==> |*.s files|assembler[Assembler]
assembler ==> |*.o files|linker[Linker]
linker ==> |Executable binary file|_end(((End)))
Go 代码如何转换为可执行二进制文件
要理解 Go scheduler,你必须首先理解 Go runtime。Go runtime 是编程语言的核心,提供诸如调度、内存管理和数据结构等基本功能。它不过是一组使 Go 程序能够运行的函数和数据结构集合。Go runtime 的实现可以在 runtime 包中找到。Go runtime 是用 Go 和汇编代码混合编写的,其中汇编代码主要用于诸如处理寄存器等底层操作。

Go runtime 的角色
在编译时,Go 编译器会将一些关键字和内置函数替换为 Go runtime 的函数调用。例如,go
关键字——用于派生一个新的 goroutine——会被替换为对 runtime.newproc
的调用,或者 new
函数——用于分配一个新的对象——会被替换为对 runtime.newobject
的调用。
你可能会惊讶地发现,Go runtime 中的一些函数根本没有 Go 实现。例如,像 getg
这样的函数会被 Go 编译器识别,并在编译期间替换为底层汇编代码。其他函数,例如 gogo
,是平台特定的,并且完全用汇编实现。Go 链接器的职责是将这些汇编实现与其 Go 声明连接起来。
在某些情况下,一个函数似乎在其包中没有实现,但实际上是使用 //go:linkname
编译器指令链接到 Go runtime 中的一个定义。例如,常用的 time.Sleep
函数链接到其在 runtime.timeSleep
的实际实现。
原始的 Scheduler
⚠️ Go scheduler 不是一个独立的对象,而是一组促进调度的函数集合。此外,它不是在专门的线程上运行;相反,它运行在 goroutines 运行的同一个线程上。当您阅读完帖子的其余部分时,这些概念将变得更加清晰。 如果您曾经从事并发编程,您可能熟悉多线程模型。它指定了用户空间线程(Kotlin 中的 coroutines,Lua 中的线程或 Go 中的 goroutines)如何复用到单个或多个内核线程上。通常,有三种模型:多对一 (N:1)、一对一 (1:1) 和多对多 (M:N)。
|
|
---|---|---
多对一多线程模型1 | 一对一多线程模型2 | 多对多线程模型3
Go 选择多对多 (M:N) 线程模型,它允许多个 goroutines 复用到多个内核线程上。这种方法牺牲了复杂性来利用多核系统,并使 Go 程序在系统调用方面高效,从而解决了 N:1 和 1:1 模型的问题。由于内核不知道 goroutine 是什么,并且只提供线程作为用户空间应用程序的并发单元,因此是内核线程运行调度逻辑,执行 goroutine 代码,并代表 goroutines 进行系统调用。 在早期,特别是在 1.1 版本之前,Go 以一种天真的方式实现了 M:N 多线程模型。只有两个实体:goroutines (G
) 和内核线程 (M
,或 machines)。使用一个单一的全局运行队列来存储所有可运行的 goroutines,并用锁来保护,以防止竞争条件。scheduler——在每个线程M
上运行——负责从全局运行队列中选择一个 goroutine 并执行它。
Go 的原始的 scheduler 现在,Go 以其高性能的并发模型而闻名。不幸的是,早期 Go 并非如此。Dmitry Vyukov——Go 的一位关键贡献者——在他的著名文章 Scalable Go Scheduler Design 中指出了这种实现的多个问题:“总的来说,scheduler 可能会阻止用户使用惯用的细粒度并发,在这种情况下性能至关重要。” 让我更详细地解释一下他的意思。
首先,全局运行队列是性能的瓶颈。当创建一个 goroutine 时,线程必须获得一个锁才能将其放入全局运行队列中。类似地,当线程想要从全局运行队列中取出一个 goroutine 时,它们也必须获得锁。您可能知道锁定不是免费的,它确实会因为锁竞争而产生开销。锁竞争会导致性能下降,尤其是在高并发场景中。
其次,线程经常将其关联的 goroutine 切换到另一个线程。这会导致较差的局部性以及过多的上下文切换开销。子 goroutine 通常想要与其父 goroutine 进行通信。因此,使子 goroutine 在与其父 goroutine 相同的线程上运行会更有效率。
第三,由于 Go 一直在使用 Thread-caching Malloc,每个线程 M
都有一个线程本地缓存 mcache
,以便它可以用于分配或保存空闲内存。虽然 mcache
仅由执行 Go 代码的 M
使用,但它甚至附加到阻塞在系统调用中的 M
,这些 M
根本不使用 mcache
。一个 mcache
最多可以占用 2MB 的内存,并且在线程 M
被销毁之前不会释放。由于运行 Go 代码的 M
与所有 M
之间的比例可能高达 1:100(太多的线程阻塞在系统调用中),这可能会导致过多的资源消耗和较差的数据局部性。
Scheduler 增强
现在您已经了解了早期 Go scheduler 的问题,让我们检查一些增强建议,看看 Go 团队是如何解决这些问题的,以便我们今天拥有一个高性能的 scheduler。
提议 1:引入本地运行队列
每个线程 M
都配备了一个本地运行队列来存储可运行的 goroutines。当线程 M
上运行的 goroutine G
使用 go
关键字派生一个新的 goroutine G1
时,G1
会被添加到 M
的本地运行队列中。如果本地队列已满,则 G1
会被放置在全局运行队列中。当选择一个 goroutine 来执行时,M
首先检查其本地运行队列,然后再查询全局运行队列。因此,该提议解决了上一节中描述的第一个和第二个问题。

用于 scheduler 增强的提议 1
但是,它无法解决第三个问题。当许多线程 M
阻塞在系统调用中时,它们的 mcache
保持附加状态,从而导致 Go scheduler 本身的高内存使用率,更不用说我们——Go 程序员——编写的程序的内存使用率了。
它也引入了另一个性能问题。为了避免使阻塞的 M
的本地运行队列中的 goroutines 像上图中的 M1
一样处于饥饿状态,scheduler 应该允许其他线程从其_窃取_ goroutine。然而,由于大量的线程阻塞,扫描所有线程以找到一个非空的运行队列变得非常昂贵。
提议 2:引入逻辑处理器
该提议在 Scalable Go Scheduler Design 中进行了描述,其中引入了 逻辑 处理器 P
的概念。逻辑 的意思是,P
假装执行 goroutine 代码,但实际上,是与 P
关联的线程 M
实际执行执行。线程的本地运行队列和 mcache
现在归 P
所有。
该提议有效地解决了上一节中未解决的问题。由于 mcache
现在附加到 P
而不是 M
,并且当 G
进行系统调用时,M
与 P
分离,因此当有大量 M
进入系统调用时,内存消耗保持在较低水平。此外,由于 P
的数量有限,窃取 机制非常高效。

用于 scheduler 增强的提议 2 随着逻辑处理器的引入,多线程模型仍然是 M:N。但是在 Go 中,它专门被称为 GMP 模型,因为有三种实体:goroutine、线程和处理器。
GMP 模型
Goroutine: g
当 go
关键字后跟一个函数调用时,会创建一个新的 g
实例,称为 G
。G
是一个表示 goroutine 的对象,包含诸如其执行状态、栈以及指向关联函数的程序计数器等元数据。执行一个 goroutine 仅仅意味着运行 G
引用的函数。
当一个 goroutine 完成执行时,它不会被销毁;相反,它会变成 dead 状态,并被放置到当前处理器 P
的空闲列表中。如果 P
的空闲列表已满,则 dead goroutine 会被移动到全局空闲列表中。当创建一个新的 goroutine 时,scheduler 会首先尝试从空闲列表中重用一个,然后再从头开始分配一个新的。这种回收机制使 goroutine 的创建比创建一个新的线程便宜得多。
下图和表格描述了 GMP 模型中 goroutines 的状态机。为了简单起见,省略了一些状态和转换。触发状态转换的操作将在帖子中描述。
状态 | 描述 ---|--- Idle | 刚被创建,尚未初始化 Runnable | 当前在运行队列中,即将执行代码 Running | 不在运行队列中,正在执行代码 Syscall | 正在执行系统调用,不执行代码 Waiting | 不执行代码,不在运行队列中,例如,等待 channel Dead | 当前在空闲列表中,刚刚退出,或者正在初始化
GMP 模型中 goroutines 的状态机
Thread: m
所有的 Go 代码——无论是用户代码、scheduler 还是垃圾回收器——都在由操作系统内核管理的线程上运行。为了使 Go scheduler 在 GMP 模型中很好地工作,引入了表示线程的 m
结构体,并且 m
的一个实例被称为 M
。
M
维护对当前 goroutine G
的引用,如果 M
正在执行 Go 代码,则维护对当前处理器 P
的引用,如果 M
正在执行系统调用,则维护对上一个处理器 P
的引用,如果 M
即将被创建,则维护对下一个处理器 P
的引用。
每个 M
还保存对一个特殊 goroutine 的引用,称为 g0
,它在系统栈上运行——内核提供给线程的栈。与系统栈不同,常规 goroutine 的栈大小是动态调整的;它会根据需要增长和收缩。然而,增长或收缩栈的操作本身必须在有效的栈上运行。为此,使用了系统栈。当 scheduler——在 M
上运行——需要执行栈管理时,它会从 goroutine 的栈切换到系统栈。除了栈的增长和收缩,诸如垃圾回收和 parking 一个 goroutine 等操作也需要在系统栈上执行。每当一个线程执行这样的操作时,它会切换到系统栈,并在 g0
的上下文中执行该操作。
与 goroutine 不同,线程在 M
被创建后立即运行 scheduler 代码,因此 M
的初始状态是 running。当 M
被创建或唤醒时,scheduler 保证始终有一个 idle 处理器 P
,以便它可以与 M
关联以运行 Go 代码。如果 M
正在执行系统调用,它将与 P
分离(将在 处理系统调用 部分中描述),并且 P
可能会被另一个线程 M1
获取以继续其工作。如果 M
无法从其本地运行队列、全局运行队列或 netpoll
中找到可运行的 goroutine(将在 netpoll 如何工作 部分中描述),它会继续旋转以从其他处理器 P
和全局运行队列再次窃取 goroutines。请注意,并非所有的 M
都会进入旋转状态,只有当旋转线程的数量小于繁忙处理器数量的一半时才会这样做。当 M
没有什么可做时,它不会被销毁,而是进入睡眠状态,并等待稍后被另一个处理器 P1
获取(在 寻找一个可运行的 Goroutine 中描述)。
下图和表格描述了 GMP 模型中线程的状态机。为了简单起见,省略了一些状态和转换。Spinning 是 idle 的一个子状态,其中线程消耗 CPU 周期来专门执行窃取 goroutine 的 Go runtime 代码。触发状态转换的操作将在帖子中描述。
状态 | 描述 ---|--- Running | 正在执行 Go runtime 代码或用户 Go 代码 Syscall | 当前正在执行(阻塞在)系统调用 Spinning | 正在从其他处理器窃取 goroutine Sleep | 睡眠,不消耗 CPU 周期
GMP 模型中线程的状态机
Processor: p
p
结构体在概念上表示一个用于执行 goroutines 的物理处理器。p
的实例被称为 P
,它们在程序的引导阶段创建。虽然创建的线程数量可能很大(在 Go 1.24 中为 10000),但是处理器数量通常很小,并且由 GOMAXPROCS
决定。无论其状态如何,都恰好有 GOMAXPROCS
个处理器。
为了最小化全局运行队列上的锁竞争,Go runtime 中的每个处理器 P
维护一个本地运行队列。本地运行队列不仅仅是一个队列,而是由两个组件组成:runnext
,它保存一个单一的优先级 goroutine,以及 runq
,它是一个 goroutines 队列。这两个组件都作为 P
的可运行 goroutines 来源,但是 runnext
专门作为性能优化而存在。Go scheduler 允许 P
从其他处理器 P1
的本地运行队列中窃取 goroutines。只有在前三次尝试从其 runq
窃取失败后,才会咨询 P1
的 runnext
。因此,当 P
想要执行一个 goroutine 时,如果它首先从其 runnext
查找一个可运行的 goroutine,则锁竞争会减少。
P
的 runq
组件是一个基于数组的、固定大小的循环队列。通过基于数组的和固定大小的 256 个槽,它允许更好的缓存局部性,并减少了内存分配开销。固定大小对于 P
的本地运行队列是安全的,因为我们也有全局运行队列作为备份。通过循环,它可以有效地添加和删除 goroutines,而不需要移动元素。
每个 P
实例还维护对一些内存管理数据结构的引用,例如 mcache
和 pageCache
。mcache
作为 Thread-Caching Malloc 模型中的前端,并被 P
用于分配微型和小对象。另一方面,pageCache
使内存分配器能够在不获取 堆锁 的情况下获取内存页,从而提高了高并发下的性能。
为了使 Go 程序能够很好地与 sleeps、timeouts 或 intervals 一起工作,P
还管理由 min-heap 数据结构实现的 timers,其中最近的 timer 位于堆的顶部。当寻找一个可运行的 goroutine 时,P
也会检查是否有任何 timer 已经过期。如果是这样,P
会将其对应的带有 timer 的 goroutine 添加到其本地运行队列中,从而使该 goroutine 有机会运行。
下图和表格描述了 GMP 模型中处理器的状态机。为了简单起见,省略了一些状态和转换。触发状态转换的操作将在帖子中描述。
状态 | 描述
---|---
Idle | 不执行 Go runtime 代码或用户 Go 代码
Running | 与一个正在执行用户 Go 代码的 M
关联
[Syscall](https://nghiant3223.github.io/2025/04/15/<https:/github.com/golang/go/blob/go1.24.0