【翻译】深入理解 Go 运行时:引导启动 (Bootstrap)
本文章为译文,原作者 Jesús Espino。在这里查看他关于 Go Runtime 的系列文章:Understanding the Go Runtime

当你编写 Go 代码时,背后发生了很多事情。goroutine 很轻量,channel 开箱即用,内存由运行时管理,你从来不需要考虑线程池。所有这一切都由 Go 运行时(Go runtime) 驱动——它是一个复杂的基础设施,会被编译进每一个 Go 二进制文件中。
这是本系列的第一篇文章,我们将从内部探究 Go 运行时。我们会研究 调度器(scheduler) 如何将 goroutine 多路复用到操作系统线程上,内存分配器(memory allocator) 如何实现无锁的快速路径分配,垃圾收集器(garbage collector) 如何在减少停顿时间的同时并发运行,以及 系统监控器(system monitor) 如何确保一切平稳运行。每个主题都将有各自深入的专门文章。
但在这些机制开始工作之前,它们必须被初始化。这就是 引导启动(bootstrap)——在操作系统启动你的二进制文件与你的 func main() 获得控制权之间运行的过程。这就是我们今天要探索的内容。
让我们从一个问题开始:Go 在“什么都不做”时有多快?
下面是一个 C 程序,它什么都不做:
int main() {
return 0;
}这是对应的 Go 程序:
package main
func main() {
}让我们编译两者并进行比较:
$ gcc -o nothing_c nothing.c
$ go build -o nothing_go nothing.go
$ ls -lh nothing_c nothing_go
-rwxrwxr-x 1 user user 16K Feb 7 12:05 nothing_c
-rwxrwxr-x 1 user user 1.5M Feb 7 12:05 nothing_go
$ time ./nothing_c
real 0m0.001s
$ time ./nothing_go
real 0m0.002sGo 的二进制文件几乎 大了 100 倍,运行时间大约 长了一倍。而且我们做的还 什么都不做。这是怎么回事?
答案是:在你的 main 函数运行之前,Go 已经做了 大量 工作。多出来的 1.5 MB 二进制文件包含了完整的运行时:内存分配器、垃圾收集器、调度器、系统监控器,以及支持 goroutine、channel 和 map 所需的所有机制。在你获得控制权之前,Go 必须把这些全部设置好。
让我们一步步走完整个引导启动过程——从操作系统启动你的二进制文件,到你的 func main() 最终运行,这期间发生的所有事情。
下面是大局观——运行时在你代码运行之前所经历的每一个步骤:

我们将按顺序逐一讲解这些步骤。当你深入细节时,请记住这张图——它能帮你了解我们在过程中所处的阶段。
那么,让我们从最一开始开始——当你执行一个 Go 二进制文件时,实际运行的是什么?
入口点:不是你的 main()
第一个意外:你的 main 函数并不是入口点。我们可以证明这一点。让我们用 readelf 来找出我们那个“什么都不做”的二进制文件的实际入口点:
$ readelf -h nothing_go | grep "Entry point"
Entry point address: 0x467280这是一个原始地址。哪个函数在那个地址上?go tool nm 可以将地址映射到符号名:
$ go tool nm nothing_go | grep 467280
467280 T _rt0_amd64_linux它就在这里——_rt0_amd64_linux,而不是 main.main。真正的入口点是一个位于运行时内部的汇编函数(位于 src/runtime/rt0_linux_amd64.s)。Go 支持的每一种架构都有对应的入口点:_rt0_arm64_linux、_rt0_386_linux 等等。它们所做的一切就是从栈上获取命令行参数,然后跳转到 rt0_go(位于 src/runtime/asm_amd64.s),这才是真正的引导启动开始的地方。它是一个很大的汇编函数,在任何 Go 代码运行之前铺平道路。下面大致按顺序列出它做的事情:
首先,它创建了 Go 从一开始就需要的东西:g0 和 m0。可以这样理解:Go 在 goroutine 上运行你的代码,而 goroutine 运行在操作系统线程上。因此,在其它任何事情发生之前,运行时至少需要各有一个。这就是 g0 和 m0:第一个 goroutine 和第一个线程。不过 g0 有点特殊——它不会运行你的代码。它被保留用于运行时自己的内务处理,比如调度其它 goroutine。
然后它设置了 线程局部存储(Thread-Local Storage, TLS)。TLS 是一个操作系统级别的机制,为每个线程提供其私有的存储区域——不同线程读取同一个 TLS 槽位会得到不同的值。Go 用它来存储一个指向当前在该线程上运行的 goroutine 的指针,这样运行时就可以快速且无锁地回答“我现在是哪个 goroutine?”这个问题。这一点非常关键,以至于运行时立即通过写入一个魔术值并读回它来进行测试——如果 TLS 不起作用,程序会当场中止。
它还会检查运行在哪种 CPU 上——是什么厂商,支持哪些特性。Go 二进制文件可以编译为利用更新的 CPU 指令以获得更好的性能,因此运行时需要验证 CPU 确实拥有这些特性。如果没有,二进制文件会打印错误并退出,而不是在后续由于非法指令而崩溃。
如果二进制文件是在启用 CGO 支持的情况下构建的,那么还有一个额外的步骤,即在进行后续操作之前先初始化 C 运行时。
在完成了所有汇编级别的基础工作——TLS 可用、CPU 特性已知、g0 和 m0 已关联——rt0_go 会通过四个函数调用过渡到 Go 代码:check() 验证编译器假设是否正确,args() 保存命令行参数,osinit() 检测 CPU 数量(该值将成为默认的 GOMAXPROCS),最后是 schedinit()——真正的工作在这里开始。
调度器初始化 (schedinit)
现在重头戏来了。schedinit()(位于 src/runtime/proc.go)是主要的初始化函数,它设置了所有关键的运行时子系统。让我们按顺序看看它做了什么。
停止世界(Stop the World)
schedinit() 做的第一件事就是将世界标记为 已停止。“停止世界(Stop the world)”是你在 Go 运行时讨论中经常听到的一个术语——它意味着暂停所有 goroutine,以便运行时可以安全地执行需要同时没有其它东西运行的工作。在这个场景下,还没有任何 goroutine 存在,所以世界按定义已经是停止状态。但运行时会明确标记它,因为有几个子系统的行为取决于是否可能有 goroutine 并发运行。
这就像在餐厅开业前进行布置:你安排桌子、准备厨房、备好食材——所有这些都在第一位顾客进入之前完成。那么需要设置什么呢?
栈池初始化
goroutine 需要栈来运行。Go 的 goroutine 从极小的 2KB 栈开始,可以动态增长,并且运行时会按大小维护预先分配的栈段 池(pool),以便快速创建新的 goroutine。stackinit() 设置了这些池。
当一个 goroutine 结束,它的栈被释放时,它会回到池中以便复用,而不是归还给操作系统。这对性能至关重要——goroutine 创建需要廉价,如果每次 go 语句都向操作系统申请内存,那将太慢了。
但是栈只是 goroutine 使用的一种内存。它们还需要堆内存——用于任何逃逸到栈外的对象,比如切片、map 或通过指针返回的值。
内存分配器初始化
这就是 mallocinit() 负责的事情。它设置了 Go 的内存分配器,其核心思想非常直观:与其每次你的代码执行 make([]byte, 100) 时都向操作系统请求内存,不如 Go 预先获取大块内存,然后从中分配小块。这样快得多。
分配器按 尺寸类(size class) 组织内存——共有 68 个,从 8 字节到 32KB。当你分配一个 50 字节的对象时,Go 并不会给你正好 50 字节。它会向上取整到最近的尺寸类(在这里是 64 字节),然后从一个预先划分好的 64 字节槽位块中给你一个槽位。这保持了简单性并避免了碎片化。对于大于 32KB 的对象,分配器完全跳过尺寸类,直接从堆中分配。
真正巧妙的部分在后面,当 P 被创建时才会体现出来。每个 P 都有自己的 本地内存缓存,所以大多数分配根本不需要任何锁——一个 goroutine 只需从自己的 P 的缓存中获取内存。只有当该缓存用尽时,它才需要从共享的中心列表中进行补充。这是 Go 即使在大量 goroutine 并发运行时仍能快速分配内存的一个重要原因。
有了两个最大的组件——栈和堆内存——运行时接着处理几个较小但重要的细节。
CPU 标志与哈希初始化
cpuinit() 会比汇编代码已经检测到的内容更详细地检查 CPU 的能力——精确确定哪些指令集扩展可用。
接着 alginit() 会选择 Go map 将使用的哈希函数。如果 CPU 支持硬件 AES 指令,Go 会使用它们进行哈希——这会显著加快速度。否则,它会回退到软件实现。这个选择会影响你程序中每一个 map 操作,所以尽早正确地设置是值得的。
现在,运行时已经知道了硬件的能力,是时候设置建立在它之上的软件基础设施了。
模块、类型与主线程
这是运行时构建内部表的地方,这些表支撑着 Go 的类型系统。modulesinit() 构建所有已编译包的表格——每个包都包含类型信息、函数元数据和 GC 位图。typelinksinit() 和 itabsinit() 设置接口分发表,让 Go 的接口能够工作。mcommoninit() 完成对 m0(我们的主线程)的设置,并将其注册到全局的线程列表中。
内部管道就绪后,运行时终于可以开始向外看——关注你的程序从外部世界接收到的输入。
参数、环境与安全
goargs() 将原始的 C 风格的 argv 转换为将成为 os.Args 的 Go 字符串切片。goenvs() 对环境变量做同样的事情。然后 secure() 执行安全检查,checkfds() 确保 stdin、stdout 和 stderr 确实被打开——这可以防止由于关闭了标准文件描述符而导致的一类安全问题。
其中一个环境变量值得特别关注。
调试环境变量
GODEBUG 变量控制着各种运行时行为。几个有用的选项:
GODEBUG=inittrace=1—— 打印每个包的 init 函数执行时间GODEBUG=schedtrace=1000—— 每秒打印一次调度器状态GODEBUG=gctrace=1—— 打印 GC 事件
这些变量在这里被解析并应用,以便它们能在引导启动的剩余部分生效。
此时,所有辅助组件都已就位。运行时现在转向剩下的两个大子系统。
垃圾收集器初始化
gcinit() 准备 Go 的垃圾收集器——一个自动释放程序不再需要的内存的系统。Go 使用 并发标记-清除(concurrent mark-and-sweep) 的 GC,这意味着它在程序继续运行的同时完成大部分工作,而不是停止一切。
在初始化期间,运行时设置了 GC 以后需要的机制:步进器(pacer),它决定何时触发一次收集(默认是当堆大小翻倍时);清除器(sweeper),它在一次收集后回收未使用的内存;以及每个 P 的 工作队列(work queue),GC 工作线程将使用它们来跟踪哪些对象仍然存活。
但这里有一个重要的细节:GC 被初始化了,但 尚未启用。它实际上要等到稍后在 runtime.main() 中调用 gcenable() 时才会开始运行。为什么呢?因为启用 GC 涉及生成 goroutine(用于后台清除和内存回收)和创建 channel——而所有这些都要等到调度器和运行时的其它部分完全就绪后才能工作。此外,在类型元数据和指针映射就绪之前触发 GC 循环可能会导致收集器扫描不完整的数据结构。
有了 GC 结构就绪,只剩下最后一块拼图了。
处理器(P)初始化
运行时需要创建 P(Processor) 结构。你可以把 P 理解为一个工作站:一个 goroutine 需要在一个 P 上才能完成工作,而一个操作系统线程就是操作该工作站的工人。每个 P 都有自己等待运行的 goroutine 队列、自己的内存缓存(这样分配很快且不需要锁),以及自己的计时器和 GC 工作状态。
P 的数量由 GOMAXPROCS 决定,它默认为先前检测到的 CPU 核心数。因此,在一个 8 核机器上,你会得到 8 个 P——这意味着在任何时刻最多有 8 个 goroutine 能够真正并行运行。
启动世界
到此,schedinit() 调用 worldStarted()。现在世界被认为是 已启动——所有基础设施都已就绪,可以并发运行 goroutine 了。餐厅开张营业。
随着调度器、分配器、GC 以及所有支持基础设施就位,运行时终于准备好创建它的第一个真正的 goroutine。
创建主 goroutine
可以认为至此为止的所有工作都是在造一辆车——我们已经组装好了引擎(调度器)、燃油系统(内存分配器)和排气装置(GC)。现在是时候转动钥匙了。
回到 rt0_go,在 schedinit() 返回后,运行时创建了它的第一个 goroutine。但请注意——这个 goroutine 不会运行你的 main.main。它运行的是 runtime.main,即运行时自己的 main 函数。你的代码要稍后才会运行。
这个 goroutine 获得一个 2KB 的初始栈(来自我们之前设置的栈池),并被放入第一个 P 的运行队列中,准备就绪。然后运行时会启动 m0 上的调度循环——这就是钥匙转动。调度器立即拾取这个 goroutine 并开始执行 runtime.main()。
引擎开始运转。我们离你的代码已经非常近了——但还没到。
runtime.main:最后一公里
我们终于进入了作为一个普通 goroutine 运行的 Go 代码。但在你的代码运行之前,还有工作要做。以下是 runtime.main() 的内容(位于 src/runtime/proc.go):
最大栈大小与系统监控器
首先,runtime.main() 设置了任何 goroutine 的栈可以增长的最大限制——在 64 位系统上是 1GB。如果一个 goroutine 超过了这个限制(通常是由于无限递归),程序会因栈溢出而崩溃。
然后它启动了 系统监控器(system monitor)(sysmon)——一个专用的后台线程,充当运行时的看门狗。它独立于调度器运行,监视着一切:如果一个 goroutine 占用 P 的时间过长,sysmon 会强制它让出。如果一个操作系统线程被卡在系统调用中,sysmon 会把它的 P 拿走并交给另一个线程,以便其它 goroutine 可以继续运行。它还会在 GC 长时间未运行时进行提示,检查是否有网络 I/O 就绪,并将未使用的内存归还给操作系统。
此时,主 goroutine 也会 锁定到主操作系统线程。这是为了与某些 C 库和 GUI 框架兼容,这些框架期望特定操作总是在“主”线程上执行。
有了看门狗在运行,主线程也被锁定,运行时现在可以开始运行你的代码了——嗯,几乎可以。还有几件事要做。
运行时的 init() 函数
首先,运行时会执行它自己内部的 init() 函数——即属于 runtime 包及其依赖包的函数。这些函数会完成那些在 schedinit() 期间尚未完全就绪的内部数据结构的设置。
随着运行时自身的初始化完成,调度器已完全可操作,类型元数据已就位,channel 可以工作。这意味着现在终于可以安全地开启 GC 了。
启用垃圾收集器
还记得我们说过 GC 被初始化了但未启用吗?这就是它最终被开启的地方。gcenable() 会生成后台的清扫器(sweeper)和内存回收器(scavenger)goroutine,从这一刻起,GC 在后台运行,每当堆增长到足够大时就回收未使用的内存。
为什么现在才启用而不是更晚?因为下一步——运行你的包的 init 函数——可能会分配大量内存。届时 GC 必须处于活动状态。
运行包的 init() 函数
现在轮到你可能熟悉的东西了:init() 函数。运行时遍历程序中的每一个包,并按照 依赖顺序 运行它们的 init() 函数——如果你的包导入了 fmt,那么 fmt(以及 fmt 所依赖的所有包)会在你的包之前被初始化。
这也是包级变量被初始化的时候。因此,如果你在文件顶部有类似 var db = connectToDB() 这样的代码,它现在会在引导启动期间运行,而不是在 main() 开始时运行。
现在,最终,运行时和你的代码之间没有任何阻碍了。
终于:你的 main()
在经历了所有这些——汇编入口点、TLS、CPU 检测、内存分配器、调度器、GC、系统监控器、init 函数——之后,运行时终于调用了你的 main 函数。
但是当它返回时会发生什么呢?
在 main() 返回之后
当你的 main 返回时,运行时并不会立即退出。它会给出一个短暂的宽限期,让任何正在处理 panic 的 goroutine 完成它们的延迟清理。但任何其他仍在运行的 goroutine 呢?它们会被直接杀死——没有警告,没有清理。如果你需要它们完成,你必须显式地同步(例如,使用 sync.WaitGroup)。
总结
回到我们开始的问题:为什么 Go 在“什么都不做”时“慢”?现在你知道原因了——它根本不是在“什么都不做”。当你的 main 函数运行时,运行时已经从头构建了一整个执行环境:第一个 goroutine 和线程、线程局部存储、栈池、内存分配器、map 的哈希函数、垃圾收集器、每个 CPU 核心一个 P 的调度器、系统监控线程,以及你所有包的 init 函数。
这是很大的一套机制。但这也正是为什么你在编写 Go 时感觉如此轻松——goroutine 廉价,因为栈池和分配器已经就位;内存管理隐形,因为 GC 已经在运行;并发开箱即用,因为调度器在你的第一行代码之前就已设置好。
在这篇文章中,我们从较高层次介绍了引导启动,涉及了许多部分,但没有深入任何一个。在本系列后续的文章中,我们将改变这一点。我们会仔细研究 调度器 以及它如何在线程之间协调 goroutine,研究 内存分配器 以及为什么大多数分配不需要锁,研究 垃圾收集器 以及它如何在将停顿时间保持得尽可能短的同时清理内存。下次见!