转载 | Go Scheduler
推荐看原文 https://nghiant3223.github.io/2025/04/15/go-scheduler.html ,因为图片是直接用的截图。
本文主要关注在 ARM 架构上的 Linux 操作系统使用 Go 1.24 编程语言。可能未涵盖其他操作系统或架构的特定平台细节。
内容基于其他资料和我个人对 Go 语言的理解,因此可能不完全准确。欢迎在评论区指出错误或提出建议 😋。
介绍
⚠️ 本文假设你已经对 Go 并发(goroutines、channels 等)有基本的了解。如果你是这些概念的新手,建议先复习相关内容再继续阅读。
Go 或 Golang 于 2009 年推出,作为一种构建并发应用的编程语言,受到了稳步增长的欢迎。它设计简洁、高效且易用,特别关注并发编程。
Go 的并发模型构建在 goroutines 的概念上,goroutines 是由 Go 运行时在用户空间管理的轻量级用户线程。Go 提供了有用的同步原语,如 channels,帮助开发者轻松编写并发代码。它还采用了非平凡技术来提高 I/O 受限程序的效率。
理解 Go 调度器对 Go 程序员编写高效的并发程序至关重要。它还帮助我们更好地排查性能问题或优化 Go 程序性能。本文将探讨 Go 调度器如何随着时间演进,以及我们编写的 Go 代码在底层是如何运行的。
编译与 Go 运行时
本文涵盖大量源码解析,因此最好先对 Go 代码的编译和执行有基本了解。Go 程序构建时包含三个阶段:
- 编译(Compilation):Go 源文件(
*.go
)被编译成汇编文件(*.s
)。 - 汇编(Assembling):汇编文件(
*.s
)被组装成目标文件(*.o
)。 - 链接(Linking):目标文件(
*.o
)被链接在一起,生成单个可执行二进制文件。
要理解 Go 调度器,首先必须了解 Go 运行时。Go 运行时是编程语言的核心,提供了调度、内存管理和数据结构等基本功能。它本质上是一组使 Go 程序能够运行的函数和数据结构的集合。Go 运行时的实现可以在 runtime 包中找到。Go 运行时由 Go 语言和汇编代码结合编写,汇编代码主要用于处理寄存器等底层操作。
编译时,Go 编译器会将一些关键字和内置函数替换为 Go 运行时的函数调用。例如,用于启动新协程的关键字 go
被替换为对 runtime.newproc
的调用,用于分配新对象的 new
函数被替换为对 runtime.newobject
的调用。
你可能会惊讶地发现,Go 运行时中的某些函数根本没有 Go 语言实现。例如,getg
函数被 Go 编译器识别,并在编译期间用低级汇编代码替换。还有些函数,如 gogo
,是平台特定的,完全用汇编实现。Go 链接器负责将这些汇编实现与它们对应的 Go 声明连接起来。
在某些情况下,一个函数在其包中看似没有实现,但实际上通过 //go:linkname
编译指令链接到了 Go 运行时中的定义。例如,常用的 time.Sleep
函数就链接到了 runtime.timeSleep
的实际实现。
原始调度器
⚠️ Go 调度器不是一个独立的对象,而是一组促进调度的函数集合。此外,它并不在专用线程上运行;相反,它运行在与 goroutines 所运行的相同线程上。随着你阅读本文的后续内容,这些概念会变得更加清晰。
如果你曾从事过并发编程,可能对多线程模型有所了解。它描述了用户空间线程(如 Kotlin 和 Lua 中的协程,或 Go 中的 goroutines)如何被复用到单个或多个内核线程上。通常,有三种模型:多对一(N:1)、一对一(1:1)、多对多(M:N)。
Go 采用多对多(M:N)线程模型,该模型允许多个 goroutine 复用到多个内核线程上。这种方法牺牲了一些复杂性,以利用多核系统的优势,使 Go 程序在系统调用方面更高效,解决了 N:1 和 1:1 模型的问题。由于内核不知道什么是 goroutine,只将线程作为用户空间应用的并发单元,因此由内核线程负责运行调度逻辑、执行 goroutine 代码,并代表 goroutine 进行系统调用。
在早期,特别是 1.1 版本之前,Go 以一种简单的方式实现了 M:N 多线程模型。那时只有两个实体:goroutine(G)和内核线程(M,或称 machines)。使用一个全局运行队列存储所有可运行的 goroutine,并通过锁保护以防止竞态条件。调度器运行在每个内核线程(M)上,负责从全局运行队列中选择 goroutine 并执行它。
如今,Go 以其高性能的并发模型闻名,但早期的 Go 并非如此。Go 主要贡献者之一 Dmitry Vyukov 在其著名的Scalable Go Scheduler Design中指出了该实现的多个问题:“一般来说,调度器可能会阻碍用户使用对性能至关重要的习惯性细粒度并发。”让我详细解释他的意思。
首先,全局运行队列成为性能瓶颈。当创建一个 goroutine 时,线程必须获取锁才能将其放入全局运行队列。同样,当线程想从全局运行队列中取出一个 goroutine 时,也必须获取锁。锁不是免费的,它会带来锁竞争开销。锁竞争导致性能下降,尤其在高并发场景下更为明显。
其次,线程经常将其关联的 goroutine 切换给另一个线程,这导致了较差的局部性和过多的上下文切换开销。子 goroutine 通常需要与其父 goroutine 通信,因此让子 goroutine 在与其父 goroutine 相同的线程上运行更加高效。
第三,Go 采用了 Thread-caching Malloc,每个线程(M)都有一个线程本地缓存 mcach,用于分配或保存空闲内存。虽然 mcach 仅在执行 Go 代码的线程(M)中使用,但它甚至会附着在阻塞系统调用的线程上,而此时 mcach 根本不被使用。一个 mcach 可能占用多达 2MB 的内存,且直到线程(M)销毁前都不会被释放。由于运行 Go 代码的线程(M)与所有线程(M)数量的比例可能高达 1:100(即太多线程阻塞在系统调用),这可能导致资源过度消耗和数据局部性差。
调度器改进
既然你已经了解了早期 Go 调度器存在的问题,我们接下来看看一些改进方案,了解 Go 团队如何解决这些问题,从而拥有了如今高性能的调度器。
方案一:引入本地运行队列
每个线程(M)配备了一个本地运行队列,用于存储可运行的 goroutine。当线程(M)上的一个正在运行的 goroutine(G)通过 go 关键字创建一个新的 goroutine(G1)时,G1 会被加入到线程(M)的本地运行队列中。如果本地队列已满,G1 将被放置到全局运行队列中。在选择要执行的 goroutine 时,线程(M)会先检查自己的本地运行队列,再去访问全局运行队列。因此,这个方案解决了上一节中提到的第一个和第二个问题。
然而,这个方案无法解决第三个问题。当许多线程(M)阻塞在系统调用中时,它们的 mcach 仍然挂着,导致 Go 调度器自身的内存使用量很高,更不用说我们这些 Go 程序员编写的程序所占用的内存了。
此外,这还引入了另一个性能问题。为了避免被阻塞的线程(如上图中的 M1)中的 goroutine 处于饥饿状态,调度器应该允许其他线程从它那里“窃取”goroutine。然而,当大量线程阻塞时,扫描所有线程以找到非空的运行队列变得代价很高。
方案二:引入逻辑处理器
这个方案在Scalable Go Scheduler Design中有所描述,引入了逻辑处理器(P)的概念。逻辑(logical)意味着 P 假装执行 goroutine 代码,但实际上执行的是与 P 关联的线程(M)。线程的本地运行队列和 mcach 现在归 P 所有。
该方案有效地解决了上一节中未解决的问题。由于 mcach 现在挂在 P 上,而不是 M 上,当 G 进行系统调用时,M 会从 P 分离,因此大量 M 进入系统调用时,内存消耗保持较低。同时,由于 P 的数量有限,窃取机制也变得高效。
随着逻辑处理器的引入,多线程模型仍然是 M:N。但在 Go 中,这一模型被特指为 GMP 模型,因为它包含三种实体:goroutine、线程和处理器。
GMP 模型
Goroutine:g
当 go 关键字后跟一个函数调用时,会创建一个新的 g 实例,称为 G。G 是表示一个 goroutine 的对象,包含其执行状态、栈以及指向关联函数的程序计数器等元数据。执行一个 goroutine 就是运行 G 所引用的函数。
当一个 goroutine 执行完毕时,它不会被销毁;相反,它变为 dead 状态,并被放入当前处理器 P 的空闲列表中。如果 P 的空闲列表已满,dead 状态的 goroutine 会被移动到全局空闲列表中。当创建新的 goroutine 时,调度器会先尝试复用空闲列表中的 goroutine,而不是重新分配新的。这种回收机制使 goroutine 的创建成本显著低于新线程的创建成本。
下面的图表描述了 GMP 模型中 goroutine 的状态机。为简化起见,部分状态和状态转换被省略。触发状态转换的动作将在后续内容中进行描述。
Thread: m
所有 Go 代码——无论是用户代码、调度器代码还是垃圾收集器代码——都运行在由操作系统内核管理的线程上。为了让 Go 调度器在 GMP 模型中更好地管理线程,引入了表示线程的 m 结构体,m 的一个实例称为 M。
M 维护对当前 goroutine G 的引用;如果 M 正在执行 Go 代码,则维护对当前处理器 P 的引用;如果 M 正在执行系统调用,则维护对前一个处理器 P 的引用;如果 M 即将被创建,则维护对下一个处理器 P 的引用。
每个 M 还持有对一个特殊 goroutine g0 的引用,g0 运行在系统栈上——即由内核为线程提供的栈。与系统栈不同,普通 goroutine 的栈是动态大小的,会根据需要增长和缩小。然而,增长或缩小栈的操作本身必须在一个有效的栈上执行,为此使用了系统栈。当调度器——运行于某个 M 上——需要执行栈管理时,它会从 goroutine 的栈切换到系统栈。除了栈的增长和缩小,像垃圾回收和暂停一个 goroutine(parking a goroutine)等操作也需要在系统栈上执行。每当线程执行此类操作时,都会切换到系统栈,并在 g0 的上下文中执行操作。
与 goroutine 不同,线程在创建后会立即运行调度器代码,因此 M 的初始状态是 running。当 M 被创建或唤醒时,调度器保证总有一个空闲的处理器 P,使得该 P 可以与 M 关联以运行 Go 代码。如果 M 正在执行系统调用,它将与 P 分离(将在“处理系统调用”部分描述),而 P 可能被另一个线程 M1 获取以继续其工作。如果 M 无法从其本地运行队列、全局运行队列或 netpoll(将在“netpoll 工作原理”部分描述)中找到可运行的 goroutine,它会持续循环尝试从其他处理器 P 和全局运行队列中窃取 goroutine。注意,并非所有 M 都会进入 spinning 状态,只有当处于 spinning 状态的线程数量少于繁忙处理器数量的一半时,M 才会进入 spinning 状态。当 M 无事可做时,它不会被销毁,而是进入睡眠状态,等待被另一个处理器 P1 获取(在“寻找可运行的 goroutine”部分描述)。
下面的图表描述了 GMP 模型中线程的状态机。为了简化起见,部分状态和状态转换被省略。spinning 是 idle 的一个子状态,线程在该状态下消耗 CPU 周期专门执行 Go 运行时代码以窃取 goroutine。触发状态转换的动作将在后续内容中描述。
Processor: p
p 结构体在概念上代表用于执行 goroutine 的物理处理器。p 的实例被称为 P,它们在程序的引导阶段创建。尽管创建的线程数量可能很大(在 Go 1.24 中达到 10000),但处理器的数量通常较少,由 GOMAXPROCS 决定。无论状态如何,处理器的数量固定为 GOMAXPROCS。
为了减少全局运行队列上的锁竞争,Go 运行时中的每个处理器 P 都维护一个本地运行队列。本地运行队列不仅仅是一个简单的队列,它由两个部分组成:runnext,包含一个单独的优先 goroutine,以及 runq,这是一个 goroutine 队列。这两个部分都是 P 可执行 goroutine 的来源,但 runnext 存在的主要目的是性能优化。Go 调度器允许 P 从其他处理器 P1 的本地运行队列中窃取 goroutine。只有当前三次试图从 P1 的 runq 窃取 goroutine 失败时,P 才会查询 P1 的 runnext。因此,当 P 想要执行一个 goroutine 时,优先从自己的 runnext 查找可运行 goroutine,可以减少锁竞争。
P 的 runq 组成部分是基于数组的、固定大小的循环队列。由于基于数组且大小固定为 256 个槽,它能提供更好的缓存局部性并减少内存分配开销。固定大小对 P 的本地运行队列是安全的,因为全局运行队列还作为备份。循环队列则允许高效地添加和删除 goroutine,无需移动元素。
每个 P 实例还维护对一些内存管理数据结构的引用,如 mcache 和 pageCache。mcache 在线程缓存分配模型中充当前端角色,被 P 用于分配微型和小型对象。另一方面,pageCache 使内存分配器能够在不获取堆锁的情况下获取内存页,从而在高并发情况下提升性能。
为了使 Go 程序能很好地配合睡眠、超时或间隔等功能,P 还管理由最小堆数据结构实现的定时器,其中最近的定时器位于堆顶。当寻找可运行的 goroutine 时,P 也会检查是否有定时器已过期。如果有,P 会将带有定时器的相应 goroutine 添加到其本地运行队列,为该 goroutine 提供运行机会。
下面的图表描述了 GMP 模型中处理器的状态机。为简化起见,部分状态和状态转换被省略。触发状态转换的动作将在后续内容中描述。
在 Go 程序的早期执行阶段,存在处于 idle 状态的 GOMAXPROCS 个处理器 P。当线程 M 获取一个处理器以运行用户 Go 代码时,P 状态转换为 running。如果当前的 goroutine G 发起了系统调用,P 会与 M 分离并进入 syscall 状态。在系统调用期间,如果 P 被 sysmon 抢占(见非合作式抢占),它会先转换为 idle 状态,然后被交给另一个线程(M1)并进入 running 状态。否则,一旦系统调用完成,P 会重新附加到之前的 M 并恢复 running 状态(见系统调用处理)。
当发生“停止世界”(stop-the-world)垃圾回收时,P 状态转换为 gcStop,且在“停止世界”结束后返回到之前的状态。如果运行时减少了 GOMAXPROCS,冗余的处理器会进入 dead 状态,若后来 GOMAXPROCS 增加,dead 状态的处理器会被重用。
程序启动
为了启用 Go 的调度器,必须在程序启动阶段进行初始化。这个初始化通过汇编中的 runtime·rt0_go
函数来处理。在这个阶段,会创建线程 M0
(代表主线程)和协程 G0
(M0 的系统栈协程)。同时,主线程的线程局部存储(TLS)也被设置好,并且 G0 的地址存储在该 TLS 中,以便后续通过 getg
函数获取。
然后,启动过程调用汇编函数 runtime·schedinit
,其 Go 语言实现在 runtime.schedinit 中可以找到。该函数执行各种初始化操作,特别是调用 procresize
,它设置最多 GOMAXPROCS 个逻辑处理器 P 处于闲置状态。随后,主线程 M0 关联第一个逻辑处理器,将其状态从闲置(idle)转变为运行中(running),以执行协程。
之后,创建主协程来执行 runtime.main
函数,该函数作为 Go 运行时的入口点。在 runtime.main 函数内部,会创建一个专用线程来启动 sysmon,这个内容将在“非合作抢占”章节中介绍。需要注意的是,runtime.main 不同于我们自己编写的 main 函数,后者在运行时表现为 main_main
。
主线程随后调用 mstart
来开始在 M0 上执行,启动调度循环(schedule loop)以调度和执行主协程。在 runtime.main 中,经过额外的初始化步骤,控制权最终被交给用户自定义的 main_main 函数,程序开始执行用户的 Go 代码。
值得注意的是,主线程 M0 不仅负责运行主协程,还负责执行其他协程。每当主协程被阻塞——例如等待系统调用或在通道上等待时——主线程会寻找其他可运行的协程并执行它们。
总结起来,程序启动时,有一个协程 G 在执行 main 函数;两个线程——一个是主线程 M0,另一个被创建来启动 sysmon;一个处理器 P0 处于运行状态(running),剩余的 GOMAXPROCS-1 个处理器处于闲置状态(idle)。主线程 M0 最初关联处理器 P0,用于运行主协程 G。
下面的图示说明了程序启动时的状态。假设 GOMAXPROCS 设置为 2,且 main 函数刚刚开始执行。处理器 P0 正在执行主协程,因此处于运行状态。处理器 P1 未执行任何协程,处于闲置状态。主线程 M0 关联处理器 P0 来执行主协程,同时另一个线程 M1 被创建来执行 sysmon。
创建协程
Go 提供了一个简单的 API 来启动一个并发执行单元:go func() { ... }()。在底层,Go 运行时做了许多复杂的工作来实现这一点。go 关键字其实是 Go 运行时中 newproc
函数的语法糖,该函数负责调度一个新的协程。这个函数主要完成三件事:初始化协程,将其放入调用协程所运行的处理器 P 的运行队列中,并唤醒另一个处理器 P1。
初始化协程
当调用 newproc 时,只有在没有空闲协程可用的情况下,它才会创建一个新的协程 G。协程在执行结束返回后会进入空闲状态。新创建的协程 G 会被初始化一个 2KB 的栈空间,这个大小由 Go 运行时中的常量 stackMin
定义。此外,goexit
——负责清理逻辑和调度逻辑——会被压入 G 的调用栈,确保在 G 返回时执行。初始化完成后,G 会从 dead 状态转换为 runnable 状态,表示它已准备好被调度执行。
将协程放入队列
如前所述,每个处理器 P 都有一个运行队列(run queue),由两部分组成:runnext 和 runq。当创建新的协程时,它会被放入 runnext。如果 runnext 已经包含一个协程 G1,调度器会尝试将 G1 移动到 runq(前提是 runq 没有满),然后将新协程 G 放入 runnext。如果 runq 已满,则会将 G1 连同 runq 中一半的协程一起移动到全局运行队列,以减轻处理器 P 的负载。
为什么新协程被放入 runnext
局部性原理 (Locality of Reference),新创建的G通常更"热" 刚创建的goroutine通常会:
- 访问相同的内存区域
- 使用相同的CPU缓存
- 与创建者有数据依赖
优先调度新G的好处
- CPU缓存命中率更高 → 性能更好
- 数据局部性更好 → 减少内存访问延迟
唤醒处理器
当创建新协程,并且目标是最大化程序的并发性时,当前运行该协程的线程会尝试通过 futex 系统调用唤醒另一个处理器 P。为此,它首先检查是否有空闲的处理器。如果有空闲的处理器 P,则会创建一个新线程或唤醒已有线程进入调度循环(schedule loop),在该循环中线程会寻找可运行的协程进行执行。创建或重用线程的逻辑详见“启动线程”部分。
如前所述,GOMAXPROCS——活动处理器 P 的数量——决定了能并发运行的协程数量。如果所有处理器都忙碌且新协程不断生成,则不会唤醒已有线程或创建新线程。
整体流程总结
下图展示了协程创建的过程。为简化起见,假设 GOMAXPROCS 设置为 2,处理器 P1 尚未进入调度循环(schedule loop),且 main 函数仅不断生成新的协程。由于协程不会执行系统调用(详见“处理系统调用”一节),因此只创建了一个额外的线程 M2 用于关联处理器 P1。
调度循环
Go 运行时中的 schedule
函数负责寻找并执行一个可运行的协程。它会在各种场景下被调用:当创建新线程时、调用 Gosched
时、协程被暂停或抢占时,或者协程完成系统调用并返回后。
选择一个可运行协程的过程比较复杂,后续的“寻找可运行协程”一节会详细介绍。一旦选定协程,它会从 runnable
状态转换为 running
状态,表示它已准备好运行。此时,内核线程会调用 gogo
函数开始执行协程。
那么为什么叫做“循环”(loop)呢?正如“初始化协程”一节所述,当一个协程完成时,会调用 goexit
函数。此函数最终导致调用 goexit0
,负责清理终止中的协程,并重新进入 schedule
函数,从而使调度循环得以继续。
下图展示了 Go 运行时中的调度循环流程,其中粉色块代表用户 Go 代码中的阻塞事件,黄色块代表 Go 运行时代码中的阻塞事件。虽然以下内容看起来显而易见,但请注意调度循环是由线程执行的,这也是它在线程初始化之后(蓝色块)发生的原因。
但如果主线程卡在调度循环中,进程如何退出呢?只需看看 Go 运行时中的 main
函数,它是由主协程执行的。当 main_main
(即 Go 程序员编写的 main
函数的别名)返回后,会调用 exit
系统调用来终止进程。这就是进程能够退出的方式,也是主协程不会等待由 go
关键字生成的协程的原因。
寻找可运行的协程
线程 M 负责寻找合适的可运行协程,以尽量减少协程饥饿的情况。该逻辑在 findRunnable
函数中实现,由调度循环调用。
线程 M 按以下顺序查找可运行的协程,发现一个后停止查找:
- 检查 trace reader 协程的可用性(用于非合作抢占一节)。
- 检查垃圾回收工作协程的可用性(见垃圾回收器一节)。
- 1/61 的概率检查全局运行队列。
- 如果 M 正在旋转,检查关联处理器 P 的本地运行队列。
- 再次检查全局运行队列。
- 检查 netpoll 中 I/O 就绪协程(见 netpoll 工作原理一节)。
- 从其他处理器 P1 的本地运行队列窃取协程。
- 再次检查垃圾回收工作协程的可用性。
- 如果 M 正在旋转,再次检查全局运行队列。
步骤 1、2 和 8 仅供 Go 运行时内部使用。步骤 1 中,trace reader 用于追踪程序的执行,稍后在“协程抢占”一节会讲到它的具体作用。步骤 2 和 8 允许垃圾回收器与普通协程并发运行。虽然这些步骤不会带来“用户可见”的进展,但它们对 Go 运行时的正常运作至关重要。
步骤 3、5 和 9 不仅仅是抓取一个协程,而是尝试批量抓取以提升效率。批量大小计算公式为 (global_queue_size/number_of_processors)+1
,但受到几个因素限制:不会超过指定的最大参数,也不会超过 P 的本地队列容量的一半。确定数量后,会弹出一个协程直接返回(立即运行),其余放入 P 的本地运行队列。该批处理方法有助于处理器间的负载均衡,并减少全局队列锁上的争用,因为处理器不必频繁访问全局队列。
步骤 4 略复杂,因为处理器 P 的本地运行队列包含两部分:runnext
和 runq
。如果 runnext
非空,它会返回 runnext
中的协程;否则检查 runq
,找到可运行的协程并出队。步骤 6 会在 “netpoll 工作原理” 一节详细描述。
步骤 7 是过程最复杂的部分。它会尝试最多四次从另一处理器 P1 窃取任务。前三次尝试只从 P1 的 runq
窃取协程。如果成功,P1 的 runq
中一半的协程会转移到当前处理器 P 的 runq
。在最后一次尝试时,会优先从 P1 的 runnext
槽(若有)窃取,若失败则退回到 P1 的 runq
。
注意,findRunnable
不仅查找可运行的协程,还会唤醒在步骤 1 之前进入休眠的协程。协程一旦被唤醒,会被放入执行它的处理器 P 的本地运行队列,等待被线程 M 选中并执行。
如果在步骤 9 之后依然没有找到可运行的协程,线程 M 会在 netpoll 上等待直到最近的 timer
到期——比如当协程从睡眠中唤醒时(因为在 Go 中睡眠会内部创建一个 timer)。为什么 netpoll 会与定时器有关?这是因为 Go 的定时器系统严重依赖于 netpoll,如该代码注释所示。netpoll 返回后,M 会重新进入调度循环(schedule loop),再次搜索可运行的协程。
findRunnable 的前两个行为允许 Go 调度器唤醒休眠中的协程,使程序得以继续执行。这也解释了为什么每个协程(包括 main 协程)在休眠后都有机会运行。我们将在另一篇文章中看看下面这个 Go 程序是如何工作的。
package main
import "time"
func main()
如果处理器 P 没有定时器,其对应的线程 M 会进入空闲状态。P 会被放入空闲列表,M 通过调用 stopm
函数进入睡眠状态,直到另一个线程 M1 唤醒它,通常是在创建新协程时,如“唤醒处理器”一节所述。唤醒后,M 会重新进入调度循环,查找并执行可运行的协程。
协程抢占
抢占是指暂时中断协程执行,以允许其他协程运行,防止协程饥饿。在 Go 中有两种抢占类型:
- 非合作抢占:强制运行时间过长的协程停止。
- 合作抢占:协程主动让出其处理器 P。
下面我们来看看这两种抢占在 Go 中是如何工作的。
非合作抢占
我们通过一个例子来理解非合作抢占的工作原理。在这个程序中,有两个协程计算斐波那契数列,这是一个包含大量 CPU 运算的紧密循环。为了确保同一时间只有一个协程运行,我们在运行程序时通过设置 GOMAXPROCS
为 1 来限制最大逻辑处理器数量:GOMAXPROCS=1 go run main.go
。
package main
import (
"runtime"
"time"
)
func fibonacci(n int) int
func main()
由于只有一个处理器 P,可能会出现多种情况。第一种,两个协程都不运行,因为 main 函数已经控制了 P。第二种,一协程运行,另一协程被饿死(无法执行)。第三种,两个协程竟然同时运行——几乎像魔法一样。
幸运的是,Go 提供了工具帮助我们理解调度发生了什么。runtime/trace
包包含一个强大的工具,用于理解和排查 Go 程序。使用它时,我们需要在 main 方法中添加一些代码,将跟踪信息导出到文件。
func main()
程序运行结束后,我们使用命令 go tool trace trace.out
来可视化跟踪信息。我准备好了 trace.out
文件,如果你想尝试,可以点击这里。在下图中,横轴表示在特定时间点哪个协程在处理器 P 上运行。正如预期的那样,只有一个名为 “Proc 0” 的逻辑处理器 P,这是由于设置了 GOMAXPROCS=1
。
通过放大时间线开始部分(按 ‘W’ 键),你可以看到进程从 main.main(main 包中的 main 函数)开始运行,该函数运行在主协程 G1 上。几微秒后,仍在 Proc 0 上,协程 G10 被调度执行 fibonacci 函数,接管了处理器并抢占了 G1。
通过缩小视图(按 ‘S’ 键)并略微向右滚动,可以观察到协程 G10 后来被另外一个协程 G9 替代,G9 是下一个运行 fibonacci 函数的实例。这个协程同样运行在 Proc 0 上。请注意图中的 runtime.asyncPreempt:47
,我稍后会对此进行解释。
从演示中可以得出结论,Go 能够抢占 CPU 密集型的协程。但为什么这是可能的呢?如果一个协程持续占用 CPU,怎么可能被抢占呢?这是一个难题,在 Go 的问题追踪器上有过长时间的讨论。这个问题直到 Go 1.14 版本才得到解决,当时引入了异步抢占。
在 Go 运行时,有一个守护线程 M(没有绑定处理器 P),称为 sysmon(即系统监控器)。当 sysmon 发现一个协程持续使用处理器 P 超过 10 毫秒(这是 Go 运行时中的常量 forcePreemptNS
),它会通过执行 tgkill
系统调用发送信号,强制抢占正在运行的协程。是的,你没看错。根据 Linux 手册页,tgkill
用于向线程发送信号,而不是杀死线程。这个信号是 SIGURG
,选择该信号的原因在这里有说明。
收到 SIGURG
后,程序的执行会转移到信号处理器,这个处理器是在线程初始化时通过调用 initsig
函数注册的。请注意,信号处理器可以与协程代码或调度器代码并发运行,如下图所示。程序从主程序切换到信号处理器的执行,是由内核触发的。
- https://stackoverflow.com/questions/6949025/how-are-asynchronous-signal-handlers-executed-on-linux/
- https://unix.stackexchange.com/questions/733013/how-is-a-signal-delivered-in-linux
在信号处理器中,程序计数器被设置到 asyncPreempt
函数,使协程能够被挂起,为抢占腾出空间。在 asyncPreempt
函数的汇编实现中,它会保存协程的寄存器状态,并调用第 47 行的 asyncPreempt2
函数。这就是为什么在可视化中会出现 runtime.asyncPreempt:47
的原因。在 asyncPreempt2
中,线程 M 的协程 g0 会进入 gopreempt_m
,将协程 G 与线程 M 解除关联,并将协程 G 放入全局运行队列。随后线程继续执行调度循环(schedule loop),寻找另一个可运行的协程并执行它。
抢占信号由 sysmon 触发,但实际的抢占直到线程接收到抢占信号后才发生,这种抢占是异步的。这也解释了为什么协程实际上可以运行超过 10 毫秒的时间限制,比如示例中的协程 G9。
早期 Go 中的协作式抢占
在 Go 的早期阶段,Go 运行时自身无法抢占像上述示例中那样包含紧密循环的协程。作为 Go 程序员,我们必须让协程通过在循环体中调用 runtime.Gosched()
来主动放弃处理器 P,实现协作式调度。Stackoverflow 上曾有一个问题描述了 runtime.Gosched()
的用法和行为。
从程序员的角度来看,这种方式既繁琐又容易出错,实际上也存在一定的性能问题。因此,Go 团队决定实现一种巧妙的方法,由运行时本身来抢占协程。这个内容将在下一节中讨论。
自 Go 1.14 起的协作式抢占
你是否想过为什么我没有在每次迭代中使用 fmt.Printf
并检查终端,以确定两个协程是否都有运行的机会?这是因为如果那样做的话,抢占就变成了协作式抢占,而不再是非协作式抢占了。
反汇编程序
为了更好地理解这一点,我们先编译程序并分析其汇编代码。由于 Go 编译器会应用各种优化,这会让调试变得更加困难,因此我们需要在构建程序时禁用这些优化。可以通过下面的命令实现:
为了更方便调试,我使用 Delve —— 一个功能强大的 Go 调试器,来反汇编 fibonacci
函数:
进入调试器后,我运行以下命令查看 fibonacci
函数的汇编代码:
你可以在这里找到原始程序的汇编代码。由于我是在本地机器(darwin/arm64)上构建程序,你机器上生成的汇编代码可能会有所不同。
以上都准备好了,让我们看看 fibonacci
函数的汇编代码,以了解它的具体执行内容。
main.go:11 0x1023e8890 900b40f9 MOVD 16(R28), R16
main.go:11 0x1023e8894 f1c300d1 SUB $48, RSP, R17
main.go:11 0x1023e8898 3f0210eb CMP R16, R17
main.go:11 0x1023e889c 090c0054 BLS 96(PC)
...
main.go:17 0x1023e8910 6078fd97 CALL runtime.convT64(SB)
...
main.go:17 0x1023e895c 4d78fd97 CALL runtime.convT64(SB)
...
main.go:20 0x1023e8a18 c0035fd6 RET
main.go:11 0x1023e8a1c e00700f9 MOVD R0, 8(RSP)
main.go:11 0x1023e8a20 e3031eaa MOVD R30, R3
main.go:11 0x1023e8a24 dbe7fe97 CALL runtime.morestack_noctxt(SB)
main.go:11 0x1023e8a28 e00740f9 MOVD 8(RSP), R0
main.go:11 0x1023e8a2c 99ffff17 JMP main.fibonacci(SB)
MOVD 16(R28), R16
指令从寄存器 R28
的偏移量 16 处加载一个值,R28
保存了协程数据结构 g
,并将该值存储到寄存器 R16
中。加载的值是 stackguard0
字段,它作为当前协程的栈保护。那什么是栈保护呢?你可能知道协程的栈是可动态增长的,但 Go 运行时如何确定何时需要增长呢?栈保护是放置在栈末尾的一个特殊值。当栈指针达到这个值时,Go 运行时检测到栈快满了,需要增长——这正是接下来的三条指令所做的。
SUB $48, RSP, R17
指令从寄存器 RPS
取出协程栈指针,加载到寄存器 R17
,并从中减去 48。
CMP R16, R17
将栈保护和栈指针进行比较,
BLS 96(PC)
会在栈指针小于等于(注意,这里是“less or equal”,而不是“大于等于”)栈保护时跳转到程序中 96 条指令以后的地址。为什么用“≤”而不用“≥”呢?因为栈向下增长,所以栈指针总是比栈保护要大。
你是否想过为什么这些指令在 Go 代码中看不到,但在汇编代码中却出现了?这是因为编译时,Go 编译器会自动将这些指令插入到函数的序言(prologue)中。这个机制适用于所有函数,比如 fmt.Println
,而不仅仅是我们的 fibonacci
。
执行到 96 条指令后,会遇到 MOVD R0, 8(RSP)
指令,接着执行
CALL runtime.morestack_noctxt(SB)
。
runtime.morestack_noctxt
函数最终会调用 newstack
来扩展栈,并可选地进入 gopreempt_m
,触发前面提到的非协作式抢占。协作式抢占的关键点在于进入 gopreempt_m
的条件,即 stackguard0 == stackPreempt
,这意味着只要协程想要扩展它的栈,且其 stackguard0
之前被设置成了 stackPreempt
,它就会被抢占。
stackPreempt
可以由 sysmon
设置,条件是某个协程已运行超过 10 毫秒。之后,如果该协程调用函数,或者被线程的信号处理器以非协作方式抢占,协程就会被协作式抢占,以先发生者为准。它也可以在协程进入或退出系统调用时,或者在垃圾回收的追踪阶段被设置。有关详细内容,请参见 sysmon preemption、syscall entry/exit、garbage collector tracing。
追踪可视化
好了,让我们重新运行程序——确保已经设置了 GOMAXPROCS=1
,然后检查追踪结果。
你可以清楚地看到,协程在仅仅几十微秒后就会放弃逻辑处理器——这与非协作抢占不同,后者可能会持续超过 10 毫秒。值得注意的是,协程 G9 的堆栈跟踪在循环体内的 fmt.Printf
处结束,说明了函数序言中的栈保护检查。这个可视化图准确地展示了协作抢占的过程,即协程主动让出处理器。
系统调用处理
系统调用是内核提供的服务,用户空间应用通过 API 访问这些服务。这些服务包括基本操作,例如读取文件、建立连接或分配内存。在 Go 语言中,你很少需要直接与系统调用交互,因为标准库提供了更高级的抽象来简化这些任务。
然而,理解系统调用的工作原理对于深入了解 Go 运行时、标准库内部实现以及性能优化至关重要。Go 运行时采用了 M:N 线程模型,并通过逻辑处理器 P 进一步优化,使得其处理系统调用的方法尤为有趣。
系统调用分类
在 Go 运行时,针对内核系统调用有两个封装函数:RawSyscall
和 Syscall
。我们编写的 Go 代码会使用这些函数来调用系统调用。每个函数都接受一个系统调用编号及其参数,并返回结果值和错误码。
Syscall
通常用于那些执行时间不可预测的操作,例如从文件读取或写入 HTTP 响应。由于这些操作的持续时间是非确定性的,Go 运行时需要对其进行管理,以确保资源的高效使用。该函数协调协程 G
、线程 M
和处理器 P
,使得 Go 运行时能够在阻塞系统调用过程中保持性能和响应性。
不过,并非所有系统调用都是不可预测的。例如,获取进程 ID 或当前时间通常快速且稳定。对于此类操作,使用 RawSyscall
。由于这类调用不会涉及调度,协程 G
、线程 M
和处理器 P
之间的关联在原始系统调用执行时保持不变。
在内部,Syscall
会调用 RawSyscall
来执行实际的系统调用,但会在其外层包装附加的调度逻辑,这部分内容将在下一节详细介绍。
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
Syscall
中的调度
调度逻辑分别由 runtime_entersyscall
函数和 runtime_exitsyscall
函数实现,分别在实际系统调用之前和之后执行。底层这两个函数实际上对应的是 runtime.entersyscall
和 runtime.exitsyscall
,它们的关联关系在编译时就已建立。
在进行实际系统调用之前,Go 运行时会记录发起系统调用的协程不再使用 CPU。协程 G
从 running
状态转变为 syscall
状态,且其栈指针、程序计数器和帧指针都会被保存以备后续恢复。线程 M
和处理器 P
的关联关系暂时解除,P
也转变为 syscall
状态。该逻辑由 runtime.reentersyscall
实现,该函数在 runtime.entersyscall
中被调用。
有趣的是,sysmon
(前文“非协作抢占”部分提及)不仅会监控运行协程代码的处理器(即处于 running
状态的 P
),还会监控执行系统调用的处理器(即处于 syscall
状态的 P
)。如果一个 P
在 syscall
状态下超过 10 毫秒,sysmon
不会非协作抢占运行中的协程,而是发生“处理器交接”(processor handoff)。这意味着协程 G
与线程 M
的关联保持不变,并且会将另一线程 M1
关联到该 P
,从而允许可运行的协程在 M1
线程上执行。显然,由于 P
正在执行代码,其状态改变为 running
,不再是之前的 syscall
。
需要注意的是,在系统调用仍在进行过程中,无论 sysmon
是否抢占了 P
,协程 G
与线程 M
的关联始终保持存在。为什么?因为 Go 程序(包括 Go 运行时和我们编写的 Go 代码)只是用户空间进程,唯一的执行单位是线程,而线程负责运行 Go 运行时代码、执行 Go 代码并发起系统调用。如果线程 M
代表某个协程 G
发起系统调用,即使线程 M
被 sysmon
抢占了,也会继续阻塞等待系统调用完成,然后才调用 runtime.exitsyscall
函数。
另一个重要点是:每当处理器 P
处于 syscall
状态时,**它不能被另一个线程 M
占用以执行代码,直到 sysmon
抢占它或系统调用完成为止。**因此,如果多个系统调用同时发生,程序(不包括系统调用部分)将不会有任何进展。这也是为什么 Dgraph 数据库将 GOMAXPROCS
硬编码设置为128,以“允许更多的磁盘 I/O 调用被调度”。
如 runtime.exitsyscall
中所述,系统调用完成后调度器有两条路径可走:快速路径和慢速路径。后者只有在前者不可行时才会执行。
快速路径发生在有可用处理器 P
用于执行刚完成系统调用的协程 G
时。这个 P
可以是之前执行过协程 G
的同一个(如果它仍处于 syscall
状态,即没有被 sysmon
抢占),也可以是任何当前处于空闲状态的处理器 P1
,以先找到者为准。需要注意的是,系统调用完成时,之前的处理器 P
可能已不再处于 syscall
状态,因为它已被 sysmon
抢占。在快速路径退出之前,协程 G
会从 syscall
状态切换为 running
状态。
在慢速路径中,调度器会再次尝试获取任何空闲的处理器 P
。如果找到了,协程 G
会被调度在该处理器 P
上运行。否则,协程 G
会被加入全局运行队列,同时关联的线程 M
会被 stopm
函数停止,等待被唤醒以继续执行调度循环。
网络 I/O 和文件 I/O
这项调查显示,75% 的 Go 使用场景是网络服务,45% 是静态网站。这并非巧合,Go 设计之初就注重高效的 I/O 操作,以解决臭名昭著的问题——C10K。为了了解 Go 是如何解决该问题的,我们来看看 Go 在底层如何处理 I/O 操作。
HTTP 服务器底层原理
在 Go 中,启动一个 HTTP 服务器非常简单明了。例如:
package main
import "net/http"
func main()
像 http.ListenAndServe()
和 http.HandleFunc()
这样的函数看起来可能非常简单,但在底层,它们抽象了许多底层网络的复杂性。Go 依赖许多基础的 socket 操作(如下图所示)来管理网络通信。
具体来说,http.ListenAndServe()
利用 socket()
、bind()
、listen()
、accept()
这些系统调用来创建 TCP 套接字,这些套接字本质上是文件描述符。它将 TCP 监听套接字绑定到指定的地址和端口,监听传入连接,并创建一个新的已连接套接字来处理客户端请求。整个过程无需你编写套接字处理代码。同样地,http.HandleFunc()
用于注册你的处理函数,抽象了底层细节,比如使用 read()
系统调用读取数据,使用 write()
系统调用向网络套接字写入数据。
然而,对于 HTTP 服务器来说,高效地处理成千上万的并发请求并没有那么简单。Go 采用了多种技术来实现这一点。让我们仔细看看 Linux 中一些重要的 I/O 模型,以及 Go 如何利用它们。
阻塞 I/O、非阻塞 I/O 及 I/O 多路复用
I/O 操作可以是阻塞的,也可以是非阻塞的。当线程发出阻塞系统调用时,其执行将被挂起,直到系统调用完成并返回所请求的数据。相反,非阻塞 I/O 不会挂起线程;如果数据可用,则直接返回所请求的数据;如果数据尚不可用,则返回错误(例如 EAGAIN
或 EWOULDBLOCK
)。阻塞 I/O 实现较为简单,但效率较低,因为它需要为 N 个连接生成 N 个线程。相比之下,非阻塞 I/O 复杂一些,但如果实现得当,能够显著提升资源利用率。下面的图示将直观对比这两种模型。
另一种值得一提的 I/O 模型是 I/O 多路复用,其中使用 select
或 poll
系统调用等待一组文件描述符中的某一个变为可进行 I/O 操作。在该模型中,应用程序会阻塞在这些系统调用之一上,而不是阻塞在具体的 I/O 系统调用(如上图所示的 recvfrom
)上。当 select
返回表明套接字可读时,应用程序调用 recvfrom
将请求的数据复制到用户空间的应用缓冲区。
Go 的 I/O 模型
Go 结合了非阻塞 I/O 和 I/O 多路复用来高效地处理 I/O 操作。由于 select
和 poll
存在性能瓶颈——如本文博客所述——Go 避免使用它们,转而采用更具可扩展性的替代方案:Linux 上的 epoll
,Darwin 上的 kqueue
,以及 Windows 上的 IOCP。Go 引入了 netpoll,一个抽象这些替代方案的函数,用以提供跨不同操作系统的统一 I/O 多路复用接口。
netpoll 的工作原理
使用 netpoll 需要四个步骤:在内核空间创建一个 epoll
实例,使用该 epoll
实例注册文件描述符,epoll
轮询这些文件描述符的 I/O 状态,最后从 epoll
实例中注销文件描述符。下面我们来看 Go 是如何实现这些步骤的。
创建 epoll 实例及注册 Goroutine
当 TCP 监听器接受一个连接时,会调用带有 SOCK_NONBLOCK
标志的 accept4
系统调用,将套接字的文件描述符设置为非阻塞模式。随后,系统会创建若干描述符以整合 Go 运行时的 netpoll。
-
创建一个
net.netFD
实例来封装套接字的文件描述符。这个结构体为基于底层内核文件描述符执行网络操作提供了更高层的抽象。当初始化net.netFD
后,会调用epoll_create
系统调用创建一个epoll
实例。这个epoll
实例在poll_runtime_pollServerInit
函数中初始化,且该函数被封装在sync.Once
中,以确保它只执行一次。由于sync.Once
的保证,在一个 Go 进程中只存在单个epoll
实例,并且该实例在进程整个生命周期内被使用。 -
在
poll_runtime_pollOpen
函数中,Go 运行时会分配一个runtime.pollDesc
实例,包含调度信息和对参与 I/O 操作的 goroutine 的引用。然后将套接字的文件描述符以兴趣列表的形式使用epoll_ctl
系统调用注册到epoll
,调用参数为EPOLL_CTL_ADD
。由于epoll
监视的是文件描述符而非 goroutine,epoll_ctl
还会将文件描述符与runtime.pollDesc
实例关联,从而让 Go 调度器在 I/O 就绪时能识别并恢复相应的 goroutine。 -
创建一个
poll.FD
实例以支持带轮询的读写操作。它通过poll.pollDesc
持有对runtime.pollDesc
的间接引用,poll.pollDesc
只是一个简单的包装器。
⚠️ Go 在使用单个 epoll
实例时存在问题,具体情况可见这个公开问题讨论。围绕 Go 是否应该使用单个或多个 epoll
实例,甚至是否采用其他 I/O 多路复用模型如 io_uring
,社区中仍有讨论。
基于该模型在网络 I/O 方面的成功,Go 也利用了 epoll
来处理文件 I/O。一旦文件被打开,会调用 syscall.SetNonblock
函数将文件描述符设置为非阻塞模式。随后,poll.FD
、poll.pollDesc
和 runtime.pollDesc
实例被初始化,用以将文件描述符注册到 epoll
的兴趣列表中,从而实现文件 I/O 的多路复用。
下图展示了这些描述符之间的关系。同时,net.netFD
、os.File
、poll.FD
和 poll.pollDesc
是用 Go 代码(具体来说是在 Go 标准库中)实现的,而 runtime.pollDesc
则存在于 Go 运行时本身。
轮询文件描述符
当一个 goroutine 从套接字或文件读取时,最终会调用 poll.FD
的 Read
方法。在该方法中,goroutine 会调用 read
系统调用从文件描述符获取可用数据。如果 I/O 数据尚不可用,即返回 EAGAIN
错误,Go 运行时会调用 poll_runtime_pollWait
方法将该 goroutine 挂起。写入套接字或文件时的行为类似,主要区别是 Read
被替换为 Write
,read
系统调用被替换为 write
。当 goroutine 处于 waiting
状态时,netpoll 的职责是在线程文件描述符准备好进行 I/O 时,将该 goroutine 呈现给 Go 运行时,这样它才能被恢复执行。
在 Go 运行时中,netpoll 其实只是同名的一个函数。在 netpoll 函数中,调用 epoll_wait
系统调用监视最多 128 个文件描述符,在指定时间内等待事件发生。该系统调用会返回先前注册(如前面章节所述)的、对应每个准备就绪的文件描述符的 runtime.pollDesc
实例。最后,netpoll 从 runtime.pollDesc
中提取 goroutine 的引用,并将它们交给 Go 运行时。
那么,netpoll 函数究竟何时被调用?当线程寻找可运行的 goroutine 来执行时触发,如调度循环(schedule loop)所述。根据 findRunnable
函数,只有当当前 P
的本地运行队列和全局运行队列都没有可运行的 goroutine 时,Go 运行时才会调用 netpoll。这意味着,即使文件描述符已经准备好进行 I/O,goroutine 也不一定会立即被唤醒。
如前所述,netpoll 可以阻塞指定时间,这由 delay
参数决定。如果 delay
为正,则阻塞指定的纳秒数;如果 delay
为负,则阻塞直到有 I/O 事件准备好;如果 delay
为零,则立即返回当前已就绪的所有 I/O 事件。在 findRunnable
函数中,delay
参数传递为 0,表示如果有一个 goroutine 正等待 I/O,调度器就可以安排另一个 goroutine 在同一个内核线程上运行。
注销文件描述符
如上所述,epoll
实例最多监视 128 个文件描述符。因此,当文件描述符不再需要时,注销它们非常重要,否则可能导致某些 goroutine 饥饿。当文件或网络连接不再使用时,应通过调用其 Close
方法关闭它。
在底层,会调用 poll.FD
的 destroy
方法。该方法最终调用 Go 运行时的 poll_runtime_pollClose
函数,通过 epoll_ctl
系统调用和 EPOLL_CTL_DEL
操作,将文件描述符从 epoll
的兴趣列表中注销。
整体流程
下图展示了 netpoll 在 Go 运行时中处理文件 I/O 的完整过程。网络 I/O 的流程类似,只是在此基础上增加了用于接受连接并关闭连接的 TCP 监听器。为了简化起见,诸如 sysmon
和其他空闲处理器 P
等组件未包含在图中。
垃圾回收器
你可能知道 Go 包含了一个垃圾回收器(GC)来自动回收未使用对象的内存。然而,如“程序启动”部分所述,当程序启动时,初始并没有可用的线程来运行 GC。那么,GC 实际上是在哪里运行的呢?
在回答这个问题之前,我们先快速了解一下垃圾回收的工作原理。Go 使用的是追踪式垃圾回收器,它通过遍历从一组根引用出发的已分配对象图来识别存活和死亡对象。从根对象可达的视为存活;不可达的则视为死亡,可以回收。
Go 的 GC 实现了支持弱引用的三色标记算法。这种设计允许垃圾回收器与程序并发运行,显著减少“停止世界”(STW)暂停时间,并提升整体性能。
一次 Go 垃圾回收周期可以分为四个阶段:
- 第一次 STW:暂停整个进程,使所有处理器
P
都能进入安全点。 - 标记阶段:GC goroutine 短暂占用处理器
P
来标记可达对象。 - 第二次 STW:再次暂停进程,允许 GC 完成标记阶段。
- 清扫阶段:恢复进程运行,在后台回收不可达对象的内存。
需要注意的是,在第 2 步,垃圾回收工作 goroutine 会与普通 goroutine 在同一处理器 P
上并发运行。findRunnable
函数(见“查找可运行的 Goroutine”部分)不仅寻找普通 goroutine,也会寻找 GC goroutine(步骤 1 和 2)。
常用函数
获取 Goroutine:getg
在 Go 运行时,有一个常用函数用于获取当前内核线程中正在运行的 goroutine,即 getg()
。查看源码你会发现该函数没有具体实现。这是因为在编译时,编译器会将对该函数的调用重写为从线程本地存储(thread-local storage,TLS)或寄存器中获取当前 goroutine 的指令。
那么,当前 goroutine 何时会存储到线程本地存储中以供后续检索呢?这发生在 goroutine 的上下文切换过程中,具体在 gogo
函数中执行,该函数由 execute
调用。当信号处理程序被触发时,也会在 sigtrampgo
函数中进行类似操作。
Goroutine 挂起:gopark
这是 Go 运行时中一个常用的过程,用于将当前 goroutine 转换为 waiting
状态,并调度另一个 goroutine 运行。下面的代码片段突出展示了它的一些关键点。
func gopark(unlockf func(*g, unsafe.Pointer) bool, ...)
在 releasem
函数中,会将该 goroutine 的 stackguard0
设置为 stackPreempt
,以触发最终的协作式抢占。控制权随后转移给同一线程下当前运行该 goroutine 的 go
系统 goroutine,进而调用 park_m
函数。
在 park_m
中,goroutine 的状态被设为 waiting
,并且该 goroutine 与线程 M
的关联被解除。此外,gopark
接收一个 unlockf
回调函数,该函数会在 park_m
中执行。如果 unlockf
返回 false
,则挂起的 goroutine 会立即重新变为可运行状态,并通过调用 execute
在同一个线程 M
上重新调度。否则,线程 M
会进入调度循环(schedule loop),选择一个 goroutine 并执行它。
启动线程:startm
这个函数负责调度一个线程 M
来运行指定的处理器 P
。下图展示了该函数的流程,其中 M1
线程是 M2
线程的父线程。
如果 P
为 nil
,函数会尝试从全局空闲处理器列表中获取一个空闲处理器。如果没有空闲处理器可用,函数会直接返回——这表明最大数量的活跃处理器已被使用,且无法创建或激活额外的线程 M
。如果找到空闲处理器(或者参数中已传入 P
),函数将创建一个新线程 M1
(如果没有空闲线程)或者唤醒一个已有的空闲线程,使其运行处理器 P
。
唤醒后,现有线程 M
会继续进入调度循环(schedule loop)。如果创建了新线程,则通过 clone
系统调用完成,入口函数为 mstart
。随后,mstart
会转入调度循环,寻找可运行的 goroutine 并执行。
停止线程:stopm
该函数将线程 M
添加到空闲列表,并使其进入休眠状态。stopm
直到线程 M
被另一个线程唤醒才会返回,通常是在创建新 goroutine 时被唤醒,如“唤醒处理器”部分所述。这个过程通过 futex
系统调用实现,使线程 M
在等待时不会占用 CPU 时间。
处理器移交:handoffp
handoffp
负责将处理器 P
的所有权从在系统调用中阻塞的线程 M
转移到另一个线程 M1
。处理器 P
会与 M1
关联,以便通过调用 startm
在某些条件下推进运行:如果全局运行队列非空、本地运行队列非空、存在追踪工作或垃圾回收任务,或者当前没有线程处理网络轮询。如果以上条件均不满足,处理器 P
会被放回处理器空闲列表。
Go 运行时 API
Go 运行时提供了多个 API,用于与调度器和 goroutine 交互。同时,这些 API 也允许 Go 程序员根据应用的具体需求调优调度器以及诸如垃圾回收器等组件。
GOMAXPROCS
该函数用于设置 Go 运行时中的处理器数量 P
,从而控制 Go 程序的并行度。GOMAXPROCS
的默认值是 runtime.NumCPU
函数的返回值,该函数查询操作系统分配给 Go 进程的 CPU 数量。
GOMAXPROCS
的默认值在某些情况下可能存在问题,特别是在容器化环境中,有关详情可以参考这篇优秀的文章。目前有一个持续的提案,计划让 GOMAXPROCS
尊重 CPU cgroup 限额,以提升其在此类环境中的表现。在未来的 Go 版本中,GOMAXPROCS
可能会变得不再必要,就像官方文档中所述:“随着调度器的改进,这个调用将会被废弃。”
某些 I/O 受限的程序可能会从比默认值更高的处理器数量 P
中受益。例如,Dgraph 数据库将 GOMAXPROCS
硬编码为 128,以便调度更多的 I/O 操作。
Goexit
该函数用于优雅地终止当前 goroutine。所有延迟调用(defer)会在 goroutine 终止前执行。程序会继续执行其他 goroutine。如果所有其他 goroutine 都退出,程序则会崩溃。Goexit
更适合用于测试场景,而非实际应用,比如在测试用例需要提前中止(例如前提条件未满足)但仍希望执行延迟清理时使用。
结论
Go 调度器是一个强大且高效的系统,通过 goroutine 实现轻量级并发。在这篇博客中,我们探讨了它的发展历程——从最初的模型到 GMP 架构——以及关键组件,如 goroutine 的创建、抢占、系统调用处理和网络轮询集成。
- https://nghiant3223.github.io/2025/04/15/go-scheduler.html
- kelche.co. Go Scheduling.
- unskilled.blog. Preemption in Go.
- Ian Lance Taylor. What is system stack?
- [6], [7] Michael Kerrisk. The Linux Programming Interface.
- [8], [9], [10] W. Richard Stevens. Unix Network Programming.
- zhuanlan.zhihu.com. Golang program startup process analysis.
- Madhav Jivrajani. GopherCon 2021: Queues, Fairness, and The Go Scheduler.
- [1], [2], [3] Abraham Silberschatz, Peter B. Galvin, Greg Gagne. Operating System Concepts.