Go Runtime 引导启动过程详解

本文翻译自 Understanding the Go Runtime: The Bootstrap,版权归原作者所有。


当你编写 Go 代码时,幕后发生了很多事情。Goroutine 轻量级,channel 开箱即用,内存由系统管理,你无需关心线程池。所有这些都由 Go runtime 驱动——这是一套复杂的基础设施,被编译到每个 Go 二进制文件中。

这是系列文章的第一篇,我们将深入探索 Go runtime。我们将了解 scheduler 如何将 goroutine 多路复用到 OS 线程上,memory allocator 如何实现无锁的快速路径分配,garbage collector 如何并发运行同时将 stop-the-world 暂停时间降至最低,以及 system monitor 如何保持一切正常运行。每个主题都会有单独的深入文章。

但在所有这些机制开始工作之前,必须先进行设置。这就是 bootstrap——在 OS 启动你的二进制文件和你的 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.002s

Go 二进制文件几乎大了 100 倍,运行时间大约 长两倍。而我们 什么都没做。发生了什么?

答案是 Go 在你的 main 函数运行之前做了 很多 事情。那额外的 1.5MB 二进制文件包含了整个 runtime:内存分配器、垃圾回收器、调度器、系统监控器,以及支持 goroutine、channel 和 map 所需的所有机制。在你获得控制权之前,Go 必须设置好所有这些。

让我们逐步走过整个 bootstrap 过程——从 OS 启动你的二进制文件到 func main() 最终运行之间发生的一切。

这是整体概览——runtime 在你的代码运行之前采取的每一步:

Go bootstrap big picture

我们将按顺序逐一介绍这些步骤。在深入之前记住这张图——它有助于了解我们在过程中的位置。

那么让我们从最开始说起——当你执行一个 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。实际的入口点是 runtime 内部的一个汇编函数(在 src/runtime/rt0_linux_amd64.s 中)。Go 支持的每种架构都有等效的入口点:_rt0_arm64_linux_rt0_386_linux 等等。它们所做的就是从栈中获取命令行参数,然后跳转到 rt0_go(在 src/runtime/asm_amd64.s 中),真正的 bootstrap 从这里开始。这是一个大的汇编函数,在任何 Go 代码运行之前奠定基础。它大致按以下顺序执行:

首先,它创建 Go 从一开始就需要的两个东西:g0m0。可以这样理解——Go 在 goroutine 上运行你的代码,而 goroutine 在 OS 线程上运行。所以在任何其他事情发生之前,runtime 至少需要一个 goroutine 和一个线程。这就是 g0 和 m0:第一个 goroutine 和第一个线程。不过 g0 有点特殊——它不会运行你的代码。它保留给 runtime 自己的内部维护工作,比如调度其他 goroutine。

然后它设置 Thread-Local Storage (TLS)。TLS 是 OS 级别的机制,为每个线程提供自己的私有存储区域——不同的线程可以读取相同的 TLS 槽位并获得不同的值。Go 使用它来存储当前在每个线程上运行的 goroutine 的指针,这样 runtime 就可以快速且无锁地回答"我现在是哪个 goroutine?“这个问题。这非常关键,以至于 runtime 会立即通过写入一个特殊值并读回来测试它——如果 TLS 不工作,程序会立即中止。

它还会检查运行在什么 CPU 上——是什么厂商,支持哪些特性。Go 二进制文件可以编译为利用更新的 CPU 指令以获得更好的性能,所以 runtime 会验证 CPU 是否确实具有这些特性。如果没有,二进制文件会打印错误并退出,而不是之后因为非法指令而崩溃。

如果二进制文件是用 CGO 支持构建的,在继续之前还有一个额外的步骤来初始化 C runtime。

完成了所有汇编级别的基础工作——TLS 正常工作、CPU 特性已知、g0 和 m0 链接好——rt0_go 通过四个函数调用过渡到 Go 代码:check() 验证编译器假设是否正确,args() 保存命令行参数,osinit() 检测 CPU 数量(这成为默认的 GOMAXPROCS),最后是 schedinit()——真正的工作在这里发生。

调度器初始化 (schedinit)

现在到了最重要的部分。schedinit()(在 src/runtime/proc.go 中)是设置所有关键 runtime 子系统的主要初始化函数。让我们按顺序看看它做了什么。

Stop the World

schedinit() 做的第一件事是将世界标记为 已停止(stopped)。“Stop the world"是你在 Go runtime 讨论中会经常听到的术语——它的意思是暂停所有 goroutine,以便 runtime 可以安全地执行需要没有其他东西同时运行的工作。在这种情况下,还没有 goroutine 存在,所以世界按定义已经停止了。但 runtime 会显式标记它,因为几个子系统的行为取决于 goroutine 是否可能并发运行。

可以把它想象成在餐厅开业前的准备工作:你摆放桌子、准备厨房、储备食材——所有这些都发生在第一位顾客进门之前。那么需要设置什么呢?

栈池初始化

goroutine 需要栈来运行。Go goroutine 从微小的 2KB 栈开始,可以动态增长,runtime 保持按大小组织的预分配栈段 ,以便创建新 goroutine 时速度很快。stackinit() 设置这些池。

当 goroutine 完成且其栈被释放时,它会回到池中供重用,而不是返回给 OS。这对性能至关重要——goroutine 创建需要廉价,每次 go 语句都向 OS 请求内存会慢得多。

但栈只是 goroutine 使用的一种内存。它们还需要堆内存——用于任何逃逸栈的东西,比如 slice、map 或通过指针返回的值。

内存分配器初始化

这就是 mallocinit() 负责的内容。它设置 Go 的内存分配器,核心思想很直观:不是每次你的代码执行 make([]byte, 100) 时都向 OS 请求内存,Go 会预先获取大块的内存,然后从这些块中分发小块。快得多。

分配器按 size classes 组织内存——有 68 个,从 8 字节到 32KB。当你分配一个 50 字节的对象时,Go 不会给你正好 50 字节。它会向上取整到最近的 size class(本例中是 64 字节),然后从预分配的 64 字节槽位块中给你一个槽位。这使事情保持简单并避免碎片化。对于大于 32KB 的任何东西,分配器完全跳过 size classes,直接从堆分配。

真正巧妙的部分稍后出现,当创建 P 时。每个 P 获得自己的 local memory cache,所以大多数分配根本不需要任何锁——goroutine 只是从自己的 P 的缓存中获取内存。只有当缓存耗尽时,它才需要去共享的中心列表补充。这是 Go 即使有许多 goroutine 并发运行也能如此快速分配内存的重要原因。

有了两大块到位——栈和堆内存——runtime 继续处理一些较小但重要的细节。

CPU Flags 和 Hash 初始化

cpuinit() 对 CPU 能力进行更详细的检查,超出汇编代码已经检测的内容——确定确切可用哪些指令集扩展。

然后 alginit() 选择 Go map 将使用的 hash 函数。如果 CPU 支持硬件 AES 指令,Go 使用它们进行 hash——速度快得多。否则,它会回退到软件实现。这个选择影响你程序中的每个 map 操作,所以值得早期正确设置。

现在 runtime 知道了硬件能做什么,是时候设置运行在其上的软件基础设施了。

Modules、Types 和主线程

这里是 runtime 构建使 Go 类型系统工作的内部表的地方。modulesinit() 构建所有编译包的表——每个包含类型信息、函数元数据和 GC bitmap。typelinksinit()itabsinit() 设置使 Go 接口工作的接口分发表。mcommoninit() 完成设置 m0(我们的主线程)并将其注册在所有线程的全局列表中。

有了内部管道到位,runtime 终于可以开始向外看——看你的程序从外部世界接收的输入。

Args、Environment 和 Security

goargs() 将原始 C 风格的 argv 转换为将成为 os.Args 的 Go string slice。goenvs() 对环境变量做同样的事情。然后 secure() 执行安全检查,checkfds() 确保 stdin、stdout 和 stderr 实际打开——防止因关闭标准文件描述符而产生的一类安全问题。

其中一个环境变量值得特别关注。

Debug Environment

GODEBUG 变量控制各种 runtime 行为。一些有用的:

  • GODEBUG=inittrace=1 — 打印每个包 init 函数的计时
  • GODEBUG=schedtrace=1000 — 每秒打印调度器状态
  • GODEBUG=gctrace=1 — 打印 GC 事件

这些在这里被解析和应用,以便对剩余的 bootstrap 生效。

此时,所有支持组件都已到位。runtime 现在转向剩下的两个大子系统。

垃圾回收器初始化

gcinit() 准备 Go 的垃圾回收器——自动释放你的程序不再需要的内存的系统。Go 使用 concurrent mark-and-sweep GC,这意味着它在你的程序继续运行时完成大部分工作,而不是停止一切。

在初始化期间,runtime 设置 GC 稍后将需要的机制:pacer,决定何时触发收集(默认情况下,当堆大小翻倍时);sweeper,在收集后回收未使用的内存;以及每个 P 的 work queues,GC worker 将使用它们来跟踪哪些对象仍然存活。

但这里有一个重要的细节:GC 被初始化但 尚未启用。它实际上不会开始运行,直到 runtime.main() 中稍后调用 gcenable()。为什么?因为启用 GC 涉及生成 goroutine(用于后台 sweep 和内存 scavenging)和创建 channel——在调度器和 runtime 其余部分完全设置好之前,这些都不起作用。更重要的是,在类型元数据和指针映射准备好之前触发 GC 周期可能导致收集器扫描不完整的数据结构。

有了 GC 结构准备好,还有最后一块拼图。

Processor (P) 初始化

runtime 需要创建 P(Processor)结构。把 P 想象成一个工作站:goroutine 需要坐在一个上面才能完成任何工作,OS 线程是操作它的工作者。每个 P 都有自己的等待运行的 goroutine 队列、自己的内存缓存(所以分配快速且不需要锁),以及自己的计时器和 GC worker 状态。

P 的数量由 GOMAXPROCS 决定,默认值等于之前检测到的 CPU 核心数。所以在 8 核机器上,你得到 8 个 P——意味着在任何给定时刻最多可以有 8 个 goroutine 真正并行运行。

Start the World

有了这些,schedinit() 调用 worldStarted()。“世界"现在被认为已启动——goroutine 并发运行的所有基础设施都已到位。餐厅开始营业了。

有了调度器、分配器、GC 和所有支持基础设施到位,runtime 终于准备好创建它的第一个真正的 goroutine。

创建 Main Goroutine

把到目前为止的一切都想象成组装一辆汽车——我们已经组装了引擎(scheduler)、燃料系统(memory allocator)和排气系统(GC)。现在是时候转动钥匙了。

回到 rt0_go,在 schedinit() 返回后,runtime 创建它的第一个 goroutine。但注意——这个 goroutine 不运行你的 main.main。它运行 runtime.main,这是 runtime 自己的 main 函数。你的代码稍后才会运行。

这个 goroutine 获得一个 2KB 的初始栈(来自我们之前设置的栈池),并被放在第一个 P 的 run queue 中,准备就绪。然后 runtime 在 m0 上启动调度循环——这就是转动钥匙。调度器立即拾取 goroutine 并开始执行 runtime.main()

引擎正在运行。我们几乎到达你的代码了——但还差一点。

runtime.main: 最后一公里

我们终于进入了作为真正的 goroutine 运行的 Go 代码。但在你的代码运行之前仍有工作要做。这是 runtime.main()(在 src/runtime/proc.go 中):

Max Stack Size 和 System Monitor

首先,runtime.main() 设置任何 goroutine 的栈可以增长的最大限制——64 位系统上是 1GB。如果 goroutine 超过这个限制(通常来自无限递归),程序会以栈溢出 panic。

然后它启动 system monitor(sysmon)——一个充当 runtime 看门狗的专用后台线程。它独立于调度器运行,监视各种事情:如果一个 goroutine 占用 P 太久,sysmon 强制它让出。如果一个 OS 线程卡在系统调用中,sysmon 拿走它的 P 并给另一个线程,这样其他 goroutine 可以继续运行。如果 GC 已经有一段时间没运行,它也会 nudging GC,检查准备好的网络 I/O,并将未使用的内存返回给 OS。

主 goroutine 此时也被 锁定到主 OS 线程。这对于与某些 C 库和 GUI 框架的兼容性是必需的,这些框架期望特定操作总是在"main"线程上发生。

有了看门狗运行和主线程安全,runtime 现在可以开始运行你的代码了——嗯,差不多。还有一些事情剩下。

Runtime init() 函数

首先,runtime 运行它自己的内部 init() 函数——属于 runtime 包及其依赖的函数。这些完成设置 schedinit() 期间尚未完全准备好的内部数据结构。

有了 runtime 自己的初始化完成,调度器完全运行,类型元数据到位,channel 工作。这意味着现在终于安全地打开 GC 了。

启用垃圾回收器

记得我们说过 GC 被初始化但未启用吗?这就是它最终被打开的地方。gcenable() 生成后台 sweeper 和 scavenger goroutine,从这一点开始,GC 在后台运行,每当堆增长足够大时收集未使用的内存。

为什么现在启用而不是稍后?因为下一步——运行你的包 init 函数——可以分配大量内存。GC 需要在那时处于活动状态。

运行 Package init() 函数

现在到了你可能熟悉的东西:init() 函数。runtime 遍历你程序中的每个包,并按 依赖顺序 运行它们的 init() 函数——如果你的包导入 fmt,那么 fmt(以及 fmt 依赖的所有东西)会在你的包之前初始化。

这也是包级变量被初始化的时候。所以如果你在文件顶部有类似 var db = connectToDB() 的东西,它现在运行,在 bootstrap 期间——而不是当 main() 开始时。

现在,终于,runtime 和你的代码之间没有什么了。

终于:你的 main()

经过所有这些——汇编入口点、TLS、CPU 检测、内存分配器、调度器、GC、系统监控器、init 函数——runtime 终于调用你的 main 函数。

但当它返回时会发生什么?

main() 返回后

当你的 main 返回时,runtime 不会立即退出。它给一个短暂的宽限期,让任何正在处理 panic 的 goroutine 完成它们的 deferred 清理。但任何其他仍在运行的 goroutine 呢?它们只是被杀死——没有警告,没有清理。如果你需要它们完成,你必须显式同步(例如,使用 sync.WaitGroup)。

总结

所以,回到我们开始的问题:为什么 Go 在"什么都不做"时"慢”?现在你知道了——它根本不是什么都不做。到你的 main 函数运行时,runtime 已经从头构建了一个完整的执行环境:第一个 goroutine 和线程、线程本地存储、栈池、内存分配器、map 的 hash 函数、垃圾回收器、每个 CPU 核心一个 P 的调度器、系统监控器线程,以及所有你的包 init 函数。

这是很多机制。但这也是为什么编写 Go 时感觉如此轻松——goroutine 廉价是因为栈池和分配器已经在那里,内存管理不可见是因为 GC 已经在运行,并发就是有效是因为调度器在你的第一行代码之前就已经设置好了。

我们在本文中高层次地涵盖了 bootstrap,触及了许多部分而没有深入任何一部分。在系列的后续文章中,我们将改变这一点。我们将仔细查看 Scheduler 以及它如何在线程间调度 goroutine,Memory Allocator 以及为什么大多数分配不需要锁,以及 Garbage Collector 以及它如何清理内存同时将 stop-the-world 暂停时间保持尽可能短。我们稍后见!

comments powered by Disqus