title: 内存泄漏的定位与排查:Heap Profiling 原理解析
author: [‘张业祥’]
date: 2021-11-17
summary: 本文将介绍一些常见的 Heap Profiler 的实现原理及使用方法,帮助读者更容易地理解 TiKV 中相关实现,或将这类分析手段更好地运用到自己项目中。
tags: [‘tikv 性能优化’]
文章目录
-
- title: 内存泄漏的定位与排查:Heap Profiling 原理解析 author: ['张业祥'] date: 2021-11-17 summary: 本文将介绍一些常见的 Heap Profiler 的实现原理及使用方法,帮助读者更容易地理解 TiKV 中相关实现,或将这类分析手段更好地运用到自己项目中。 tags: ['tikv 性能优化']
- 什么是 Heap Profiling
- Heap Profiling 是如何工作的
- Heap Profiling in Go
- Heap Profiling with gperftools
- Heap Profiling with jemalloc
- Heap Profiling with bytehound
- Performance overhead
- What can BPF bring
系统长时间运行之后,可用内存越来越少,甚至导致了某些服务失败,这就是典型的内存泄漏问题。这类问题通常难以预测,也很难通过静态代码梳理的方式定位。Heap Profiling 就是帮助我们解决此类问题的。
TiKV 作为分布式系统的一部分,已经初步拥有了 Heap Profiling 的能力。本文将介绍一些常见的 Heap Profiler 的实现原理及使用方法,帮助读者更容易地理解 TiKV 中相关实现,或将这类分析手段更好地运用到自己项目中。
什么是 Heap Profiling
运行时的内存泄漏问题在很多场景下都相当难以排查,因为这类问题通常难以预测,也很难通过静态代码梳理的方式定位。
Heap Profiling 就是帮助我们解决此类问题的。
Heap Profiling 通常指对应用程序的堆分配进行收集或采样,来向我们报告程序的内存使用情况,以便分析内存占用原因或定位内存泄漏根源。
Heap Profiling 是如何工作的
作为对比,我们先简单了解下 CPU Profiling 是如何工作的。
当我们准备进行 CPU Profiling 时,通常需要选定某一时间窗口,在该窗口内,CPU Profiler 会向目标程序注册一个定时执行的 hook(有多种手段,譬如 SIGPROF 信号),在这个 hook 内我们每次会获取业务线程此刻的 stack trace。
我们将 hook 的执行频率控制在特定的数值,譬如 100hz,这样就做到每 10ms 采集一个业务代码的调用栈样本。当时间窗口结束后,我们将采集到的所有样本进行聚合,最终得到每个函数被采集到的次数,相较于总样本数也就得到了每个函数的相对占比。
借助此模型我们可以发现占比较高的函数,进而定位 CPU 热点。
在数据结构上,Heap Profiling 与 CPU Profiling 十分相似,都是 stack trace + statistics 的模型。如果你使用过 Go 提供的 pprof,会发现二者的展示格式是几乎相同的:

Go CPU Profile

Go Heap Profile
与 CPU Profiling 不同的是,Heap Profiling 的数据采集工作并非简单通过定时器开展,而是需要侵入到内存分配路径内,这样才能拿到内存分配的数量。所以 Heap Profiler 通常的做法是直接将自己集成在内存分配器内,当应用程序进行内存分配时拿到当前的 stack trace,最终将所有样本聚合在一起,这样我们便能知道每个函数直接或间接地内存分配数量了。
Heap Profile 的 stack trace + statistics 数据模型与 CPU Proflie 是一致的。
接下来我们将介绍多款 Heap Profiler 的使用和实现原理。
注:诸如 GNU gprof、Valgrind 等工具的使用场景与我们的目的场景不匹配,因此本文不会展开。原因参考 gprof, Valgrind and gperftools - an evaluation of some tools for application level CPU profiling on Linux - Gernot.Klingler。
Heap Profiling in Go
大部分读者应该对 Go 会更加熟悉一些,因此我们以 Go 为起点和基底来进行调研。
注:如果一个概念我们在靠前的小节讲过了,后边的小节则不再赘述,即使它们不是同一个项目。另外出于完整性目的,每个项目都配有 usage 小节来阐述其用法,对此已经熟悉的同学可以直接跳过。
Usage
Go runtime 内置了方便的 profiler,heap 是其中一种类型。我们可以通过如下方式开启一个 debug 端口:
import _ "net/http/pprof"
go func() {
log.Print(http.ListenAndServe("0.0.0.0:9999", nil))
}()
然后在程序运行期间使用命令行拿到当前的 Heap Profiling 快照:
$ go tool pprof http://127.0.0.1:9999/debug/pprof/heap
或者也可以在应用程序代码的特定位置直接获取一次 Heap Profiling 快照:
import "runtime/pprof"
pprof.WriteHeapProfile(writer)
这里我们用一个完整的 demo 来串一下 heap pprof 的用法:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"time"
)
func main() {
go func() {
log.Fatal(http.ListenAndServe(":9999", nil))
}()
var data [][]byte
for {
data = func1(data)
time.Sleep(1 * time.Second)
}
}
func func1(data [][]byte) [][]byte {
data = func2(data)
return append(data, make([]byte, 1024*1024)) // alloc 1mb
}
func func2(data [][]byte) [][]byte {
return append(data, make([]byte, 1024*1024)) // alloc 1mb
代码持续地在 func1 和 func2 分别进行内存分配,每秒共分配 2mb 堆内存。
将程序运行一段时间后,执行如下命令拿到 profile 快照并开启一个 web 服务来进行浏览:
$ go tool pprof -http=":9998" localhost:9999/debug/pprof/heap

Go Heap Graph
从图中我们能够很直观的看出哪些函数的内存分配占大头(方框更大),同时也能很直观的看到函数的调用关系(通过连线)。譬如上图中很明显看出是 func1 和 func2 的分配占大头,且 func2 被 func1 调用。
注意,由于 Heap Profiling 也是采样的(默认每分配 512k 采样一次),所以这里展示的内存大小要小于实际分配的内存大小。同 CPU Profiling 一样,这个数值仅仅是用于计算相对占比,进而定位内存分配热点。
注:事实上,Go runtime 对采样到的结果有估算原始大小的逻辑,但这个结论并不一定准确。
此外,func1 方框中的 48.88% of 90.24% 表示 Flat% of Cum%。
什么是 Flat% 和 Cum%?我们先换一种浏览方式,在左上角的 View 栏下拉点击 Top:

Go Heap Top
-
Name 列表示相应的函数名
-
Flat 列表示该函数自身分配了多少内存
-
Flat% 列表示 Flat 相对总分配大小的占比
-
Cum 列表示该函数,及其调用的所有子函数一共分配了多少内存
-
Cum% 列表示 Cum 相对总分配大小的占比
Sum% 列表示自上而下 Flat% 的累加(可以直观的判断出从哪一行往上一共分配的多少内存)
上述两种方式可以帮助我们定位到具体的函数,Go 提供了更细粒度的代码行数级别的分配源统计,在左上角 View 栏下拉点击 Source:

Go Heap Source
在 CPU Profiling 中我们常用火焰图找宽顶来快速直观地定位热点函数。当然,由于数据模型的同质性,Heap Profiling 数据也可以通过火焰图来展现,在左上角 View 栏下拉点击 Flame Graph:

Go Heap Flamegraph
通过上述各种方式我们可以很简单地看出内存分配大头在 func1 和 func2。然而现实场景中绝不会这么简单就让我们定位到问题根源,由于我们拿到的是某一刻的快照,对于内存泄漏问题来说这并不够用,我们需要的是一个增量数据,来判断哪些内存在持续地增长。所以可以在间隔一定时间后再获取一次 Heap Profile,对两次结果做 diff。
Implementation details
本节我们重点关注 Go Heap Profiling 的实现原理。
回顾 “Heap Profiling 是如何工作的” 一节,Heap Profiler 通常的做法是直接将自己集成在内存分配器内,当应用程序进行内存分配时拿到当前的 stack trace,而 Go 正是这么做的。
Go 的内存分配入口是 src/runtime/malloc.go 中的 mallocgc() 函数,其中一段关键代码如下:
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ...
if rate := MemProfileRate; rate > 0 {
// Note cache c only valid while m acquired; see #47302
if rate != 1 && size < c.nextSample {
c.nextSample -= size
} else {
profilealloc(mp, x, size)
}
}
// ...
}
func profilealloc(mp *m, x unsafe.Pointer, size uintptr) {
c := getMCache()
if c == nil {
throw("profilealloc called without a P or outside bootstrapping")
}
c.nextSample = nextSample()
mProf_Malloc(x, size)
}
这也就意味着,每通过 mallocgc() 分配 512k 的堆内存,就调用 profilealloc() 记录一次 stack trace。
为什么需要定义一个采样粒度?每次 mallocgc() 都记录下当前的 stack trace 不是更准确吗?
完全精确地拿到所有函数的内存分配看似更加吸引人,但这样带来的性能开销是巨大的。malloc() 作为用户态库函数会被应用程序非常频繁地调用,优化内存分配性能是 allocator 的责任。如果每次 malloc() 调用都伴随一次栈回溯,带来的开销几乎是不可接受的,尤其是在服务端长期持续进行 profiling 的场景。选择 “采样” 并非结果上更优,

本文介绍内存泄漏的概念与HeapProfiler的工作原理,重点解析Go、tcmalloc、jemalloc及bytehound等工具的使用方法与实现细节。
最低0.47元/天 解锁文章
862

被折叠的 条评论
为什么被折叠?



