内存泄漏的定位与排查:Heap Profiling 原理解析

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

title: 内存泄漏的定位与排查:Heap Profiling 原理解析
author: [‘张业祥’]
date: 2021-11-17
summary: 本文将介绍一些常见的 Heap Profiler 的实现原理及使用方法,帮助读者更容易地理解 TiKV 中相关实现,或将这类分析手段更好地运用到自己项目中。
tags: [‘tikv 性能优化’]


系统长时间运行之后,可用内存越来越少,甚至导致了某些服务失败,这就是典型的内存泄漏问题。这类问题通常难以预测,也很难通过静态代码梳理的方式定位。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 gprofValgrind 等工具的使用场景与我们的目的场景不匹配,因此本文不会展开。原因参考 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 的场景。选择 “采样” 并非结果上更优,

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值