深入理解Go运行时:性能分析
摘要
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。
暂无内容
查看缓存全文
缓存时间: 2026/07/14 10:15
# 性能剖析
来源:https://internals-for-interns.com/posts/go-runtime-profiling
在上一篇文章(https://internals-for-interns.com/posts/go-runtime-reflect/) 中,我们拆解了 reflect 包,发现它的魔力主要来源于*编译器留下的优秀注释*——类型描述符在构建时被冻结到只读数据中,以及一个知道如何遍历这些数据的包。整篇文章都在讲述读取那些在 `main` 启动之前就已经存在于内存中的元数据。
今天我们来换个视角。性能剖析是运行时捕捉你的程序*运动中的状态*——采样它正在做什么以及时间花在哪里,然后将这些累积成可以用 `go tool pprof` 打开的文件。每个样本的核心是一个**调用栈**,它是由我们在Stacktraces文章(https://internals-for-interns.com/posts/go-runtime-stacktraces/) 中看到的同一个栈展开器生成的。所以性能剖析本质上是实时*捕捉瞬间*,构建在构建时的*栈读取*之上。
但 Go 不仅仅只有一种性能剖析类型——它提供了**五种**:CPU、堆、阻塞、互斥锁和协程。乍一看,它们像是五个不相关的子系统,但它们都共享同一个骨架,一旦你看穿了这一点,整个概念就变成了一个以五种方式重复的核心思想。(第六种,即*协程泄漏*剖析,正在 Go 1.27 中开发——Alex Rios 有一系列优秀的文章(https://alexrios.me/blog/goroutine-leak-detection-orphans/) 深入探讨了它——但我们今天只讨论已经发布的这五种。)让我们从这里开始。
所有五种剖析最深的共同点是最终得到的东西:**文件**。无论你收集了哪种剖析,最终落到磁盘上的文件格式都是一样的——而且出奇地简单:那就是 *pprof 剖析文件*,一个 gzip 压缩的协议缓冲区(https://github.com/google/pprof/blob/main/proto/profile.proto)。它甚至不是 Go 特有的;它是 Google 的 C++ 剖析器 (gperftools) 输出的相同格式,并且 `go tool pprof` 与更广泛的 pprof 生态系统共享。所以,在我们探究每种剖析是如何*收集*的之前,让我们先看看它们被收集到什么样的*容器*中。
在最高层,它是一个 `Profile` 消息,而重要的部分只是几个重复的字段:
```
message Profile {
repeated ValueType sample_type = 1; // 样本中每个数字的含义
repeated Sample sample = 2; // 实际数据
repeated Mapping mapping = 3; // 加载的二进制文件 / 共享库
repeated Location location = 4; // 一个 PC,解析到代码中的一个位置
repeated Function function = 5; // 名称、文件、起始行号
repeated string string_table = 6; // 每个字符串,已去重
}
```
巧妙之处在于内联存储的信息非常少。一个 `Sample` 几乎没什么内容——一组值和一个 *位置 ID* 列表,叶节点在前:
```
message Sample {
repeated uint64 location_id = 1; // 调用栈,以引用的形式
repeated int64 value = 2; // 例如 [样本计数, CPU 纳秒]
repeated Label label = 3; // 附加到样本的额外键/值标签
}
```
每个这些片段都存在于 `Profile` 内部的各自表中,它们之间通过 ID 相互引用。综合来看,整体结构如下所示(简化的示意图):
剖析数据结构:一个 Profile 容器,包含样本、位置、函数和字符串表,样本通过 ID 引用位置,位置引用函数,名称在字符串表中只存储一次
图中展示了这种间接性。一个 `Sample` 从不持有函数名甚至栈帧——它持有一个 `location_id` 列表,每个栈帧一个,叶节点在前。沿着其中一个 ID 进入 `location` 表,你会到达一个 `Location`,它(通过 `Line`)指向一个 `Function`,并且还记录了它来自哪个 `Mapping`——即该地址所在的加载的二进制文件或共享库。`Function` 最终包含人类可读的信息——名称、文件、起始行号——但这些也不是字符串;它们是整数索引,指向底部共享的 `string_table`。所以这是一条查找链:样本 → 位置 → 函数 → 字符串。而且没有任何重复:相同的 `Location`、`Function` 和字符串会被触及该代码位置的所有样本引用,所以一个出现在一万个样本中的栈只存储一次其函数名。
这也是每个剖析的抽象“值”获得含义的地方:`sample_type` 声明了每个样本中的数字实际衡量什么。这是各个剖析之间形状唯一真正不同的部分——相同的容器,不同的列标签——所以当我们在本文后面介绍每种剖析类型时,我们会逐一填写。
关于剖析的*本质*就讲这么多。现在让我们看看运行时如何实际填充它。
### 数据如何到达那里
无论你正在收集哪种剖析,有一件事是恒定的:在某个环节,运行时用栈展开器**捕获一个调用栈**,最终所有数据都会以我们刚才看到的 pprof 文件的形式输出。然而,一个栈如何从一端到达另一端,在五种剖析中并非完全相同——这种差异才是真正的故事所在。
五种剖析可归纳为**三种收集模型**:
- **CPU** 以*异步*方式记录。一个信号中断一个正在运行的线程,处理程序捕获栈并将其记录到一个环形缓冲区,一个独立的后台协程在该缓冲区填满时将其排空。这是一个真正的流式管道。
- **堆、阻塞和互斥锁** 以*原地*方式记录。当事件触发时,栈被哈希到一个长期存在的按栈分组的记录表中,并且匹配的记录计数器会立即增加。后台没有流式传输和排空操作——数据只是在那里累积,而剖析文件是在你请求时才按需生成的。
- **协程** 在*执行期间根本不记录*。没有触发点,也没有缓冲区。当你请求剖析文件时,运行时立即遍历每个活跃的协程的栈,进行一次快照。
所以,真正在不同剖析之间变化的不是单个旋钮——而是整个收集模型。让我们按照它们使用的模型分组,逐一介绍这五种剖析。
## CPU 剖析:信号驱动型
这是五种剖析中最具特色的一种,因为它的触发点完全来自*程序外部*:操作系统用信号中断正在运行的线程,大约每秒 100 次,并询问“你刚才在做什么?”
当你调用 `pprof.StartCPUProfile` 时,运行时设置必要的定时器来中断正在运行的线程——这样用于测量 CPU 时间消耗的采样就能进行——并启动一个后台协程来收集结果 (`src/runtime/pprof/pprof.go:888` (https://github.com/golang/go/blob/go1.26.4/src/runtime/pprof/pprof.go#L888))。记住这个后台协程——稍后我们将看到它为何重要。
设置定时器是简单的部分——有趣的是当其中一个定时器触发时会发生什么。
### 捕获一个时钟滴答
每个定时器滴答时,程序会被**中断**:线程被从它正在做的事情中强行拉出,在指令中间,去运行处理程序,然后预期它能够精确地从被打断的地方继续执行。这使得处理程序处于一个非常尴尬的境地——它不能分配内存,也不能获取普通的锁 (`src/runtime/proc.go:5748` (https://github.com/golang/go/blob/go1.26.4/src/runtime/proc.go#L5748))。
然后处理程序用栈展开器捕获调用栈,填充样本的值和你的**标签**(你可以通过 `pprof.Do` (https://pkg.go.dev/runtime/pprof#Do) 附加的标签),并存储它。但记住,它不能分配或锁定——所以它需要一个不需要这两者的地方来存放样本。这正是它拥有的:一个为此限制而设计的数据结构,一个**无锁、预分配的环形缓冲区**(`src/runtime/profbuf.go:91` (https://github.com/golang/go/blob/go1.26.4/src/runtime/profbuf.go#L91)),可以无需分配和获取锁即可追加数据。然而,作为环形缓冲区,它是有限的:如果填满的速度快于排空的速度,就没有空间存放下一个样本——所以与其让被中断的线程等待空间,缓冲区只是丢弃样本并记录丢失的数量(丢失一个样本是可以接受的;冻结线程则不行)。这听起来像是个问题——没人希望样本被丢弃在地板上。而正是我们之前启动的那个后台协程发挥了作用。
它的工作是尽可能快地排空环形缓冲区——并不是为了立即写出什么,而正是为了让环形缓冲区很少填满,从而避免样本丢失。它每次拉出一批样本,然后将其合并到一个以调用栈为键的内存映射(`profMap`)中,相同的栈被去重并就地合并,增加匹配条目的计数。在会话期间完全不会触及输出文件。只有在调用 `StopCPUProfile` 时,当最后一批样本被排空后,构建器才会*单次*遍历该映射,并将其转换为 pprof 格式——一次性输出每个样本、位置、函数和字符串——然后关闭这个你可以用 `go tool pprof` 打开的文件。
让我们用可视化方式看一下整个过程:
CPU 剖析生命周期:信号处理程序将样本收集到无锁环形缓冲区中,后台协程将其排空到 profMap 哈希表中,相同的栈被去重并累计计数,然后在 StopCPUProfile 时一次性写入 pprof 文件
正如我们在这里看到的,大约每秒 100 次,被中断的线程将调用栈放入环形缓冲区,后台收集器将这些栈排空并聚合到 `profMap` 中,最终——当你停止剖析时——所有数据被转换并写入 pprof 文件。
我们跟随一个调用栈走完了从中断到文件的全程。但一个栈本身并不是剖析——使其成为剖析的是伴随它的数字。
### 每个样本携带的值
CPU 剖析的全部意义在于了解每个栈消耗了*多少 CPU 时间*——而这就是每个样本上的 `value[]` 插槽发挥作用的地方。一个 CPU 样本携带**两个**值:一个简单的**计数**,表示定时器捕获到该确切栈的次数,以及一个以纳秒为单位的**CPU 时间**估计值。巧妙之处在于运行时从未实际测量时间——它只计数滴答数。纳秒数只是计数乘以采样周期,由于定时器以 100 Hz 触发,每个滴答大约代表 10 毫秒。所以一个被捕获 50 次的栈大约被记为 500 毫秒,而“这个函数用了 3.2 秒”实际上是约 320 个滴答乘以每个滴答 10 毫秒的结果 (`src/runtime/pprof/proto.go:348` (https://github.com/golang/go/blob/go1.26.4/src/runtime/pprof/proto.go#L348))。
CPU 剖析的触发来自*程序外部*。下一个剖析的触发则来自程序内部深处。
## 堆剖析:分配器采样型
堆剖析与 CPU 剖析共享其骨架——栈展开器捕获一个栈,结果同样是 pprof 文件——但它属于*原地*模型,而非流式模型。一个触发点被触发,栈立即被合并到一个长期存在的表中,并且剖析文件只在你请求时才生成。所以我们将重点放在它的不同之处,主要有三点:**样本在哪里累积**、**什么触发一个样本**,以及**每个样本收集什么数据**。我们按这个顺序来,从存储开始(阻塞和互斥锁剖析也共享这一点)。
### 按栈分组的桶表
CPU 剖析需要无锁环形缓冲区,因为它是在信号处理程序内部记录的,那里既不能分配也不能获取锁。堆剖析则是在分配器内部记录——虽然仍然是一个微妙的区域,但不是信号处理程序——所以它*可以*获取锁。而能够锁定正是它能够做 CPU 处理程序做不到的事情:就地聚合。因此,它使用一个哈希表来存储以调用栈为键的记录,而不是流式缓冲区,这些记录称为**桶**(`src/runtime/mprof.go:75` (https://github.com/golang/go/blob/go1.26.4/src/runtime/mprof.go#L75))。相同的栈会合并到同一条记录中:在一百万次分配都发生在代码的同一行的情况下,不会创建一百万条记录,它们都会找到该栈对应的那个桶,并增加其计数器。
让我们看看这如何改变收集的生命周期:
堆剖析生命周期:大约每 512 KiB 采样一次分配,并在原地折叠到运行时的按栈桶表中,该表永久存在并跟踪分配和释放计数;按需时,一次性快照桶数据并写出为 pprof 文件
如图中所示,分配会直接聚合到桶表中,随着它们发生而进行——没有排空协程,也没有 Start/Stop 窗口,桶表一直在运行时中存在并持续累积。然后,每当需要剖析文件时,它会被一次性导出到 pprof 文件中。
这就是样本累积的地方。现在,什么决定何时采集一个样本?
### 触发点:分配器,采样方式
触发点就是**分配器**(https://internals-for-interns.com/posts/go-memory-allocator/) 本身:每次分配都会经过运行时代码,所以没有什么需要中断的——运行时只是注意到并在此处记录样本 (`profilealloc`,`src/runtime/malloc.go:2238` (https://github.com/golang/go/blob/go1.26.4/src/runtime/malloc.go#L2238))。
记录*每次*分配的成本太高,所以它只采样其中一部分——平均每 `MemProfileRate` 字节分配一次,默认是 512 KiB。但它不是在固定的 512 KiB 边界上采样,因为如果分配模式是规则的,可能会与采样步调一致,导致总是采样同一个位置。相反,样本之间的间隔是*随机化*的,围绕平均值波动,这样随着时间的推移,每个分配站点都有公平的机会被捕获。(`MemProfileRate = 1` 采样所有分配;`0` 表示关闭。)
我们知道何时采集堆样本。下面是它记录的内容。
### 数据:分配数以及仍存活的对象
CPU 样本携带的是滴答计数,而堆样本携带**四个**数字,分为两对:在该栈上分配了多少对象以及这些对象占用了多少字节 (`alloc_objects` / `alloc_space`),以及其中有多少*仍然存活*——尚未释放——以及它们占用的字节数 (`inuse_objects` / `inuse_space`)。桶会持续记录分配和释放的累计数,而“正在使用”就是分配数减去释放数。
最后这部分隐藏了一个微妙之处。分配是在发生时立即计数的,但**释放却不是**——一个对象只有在垃圾收集器对其进行清扫之后才被计为释放,这要晚得多。如果你只是简单地在它们到达时计数,任何快照都会不平衡:它会显示大量分配,但很少有对应的释放,仅仅是因为这些释放还没有被计数。因此,运行时故意*延迟*计数:它安排在同一个会计周期内的分配和最终释放被计算,并且只在一个周期完全结算后才公布结果——这就是“直到所有选票都计算完毕才宣布结果”的技巧。
还有另一个调整:**缩放**。由于剖析器大约每 512 KiB 才记录一次分配,每个保留的样本代表了许多未被记录的分配。因此,在向你显示数字之前,它会将每个样本按比例放大,以估计真实的总数。问题是,较大的对象更容易被捕获(它们消耗了更多的 512 KiB 预算),所以运行时对较小的、容易遗漏的分配进行大幅放大,而对几乎总是能被捕获的大对象几乎不做缩放 (`src/runtime/pprof/protomem.go:16` (https://github.com/golang/go/blob/go1.26.4/src/runtime/pprof/protomem.go#L16))。
堆剖析关注的是*内存*。接下来的两个剖析关注的则是完全不同的东西——花在等待上的时间。
## 阻塞剖析:等待型
与堆剖析类似,阻塞剖析重用我们已经见过的机制——按栈分组的桶表、原地聚合、按需生成 pprof 文件。所以我们再次只关注不同之处:此时是什么触发了样本,以及这些样本携带了什么值。第二个问题很简单:每个阻塞样本都跟踪等待发生的位置、等待了多长时间、以及出现了多少次这类等待。这给出了一对值:总等待时间和发生次数。
但第一个问题——什么触发了样本——则更为微妙。阻塞剖析不仅仅记录你在互斥锁上的等待时间;它还记录你在通道操作、和`sync.WaitGroup`的函数上的等待,实际上是通过`runtime.gopark`发生的*任何*阻塞。
相似文章
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。
amd64 微架构级别对 Go 有多大帮助?
使用 Roaring Bitmap 库对不同 amd64 微架构级别(GOAMD64)编译的 Go 程序进行性能评估,结果表明启用诸如 popcnt (v2) 或 AVX-512 等新指令集可以显著提升性能。
Go 实验详解
本文介绍了 Go 语言中实验性功能的处理方式、生命周期以及近期实验示例。
Go中select的实现
解释Go语言select语句的实现,涵盖编译器重写和运行时的selectgo函数。