关于数据库:内存泄漏的定位与排查Heap-Profiling-原理解析

11次阅读

共计 20018 个字符,预计需要花费 51 分钟才能阅读完成。


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 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 的场景。抉择“采样”并非后果上更优,而仅仅是一种斗争。

当然,咱们也能够自行批改 MemProfileRate 变量,将其设置为 1 会导致每次 mallocgc() 必然进行 stack trace 记录,设置为 0 则会齐全敞开 Heap Profiling,用户能够依据理论场景来衡量性能与精确度。

留神,当咱们将 MemProfileRate 设置为一个通常的采样粒度时,这个值并不是齐全准确的,而是每次都在以 MemProfileRate 为 平均值的指数分布中随机取一个值

// nextSample returns the next sampling point for heap profiling. The goal is
// to sample allocations on average every MemProfileRate bytes, but with a
// completely random distribution over the allocation timeline; this
// corresponds to a Poisson process with parameter MemProfileRate. In Poisson
// processes, the distance between two samples follows the exponential
// distribution (exp(MemProfileRate)), so the best return value is a random
// number taken from an exponential distribution whose mean is MemProfileRate.
func nextSample() uintptr

因为很多状况下内存调配是有法则的,如果依照固定的粒度进行采样,最终失去的后果可能会存在很大的误差,可能刚好每次采样都赶上了某个特定类型的内存调配。这就是这里抉择随机化的起因。

不只是 Heap Profiling,基于 sampling 的各类 profiler 总会有一些误差存在(例:SafePoint Bias),在审阅基于 sampling 的 profiling 后果时,须要时刻揭示本人不要漠视误差存在的可能性。

位于 src/runtime/mprof.go 的 mProf_Malloc() 函数负责具体的采样工作:

// Called by malloc to record a profiled block.
func mProf_Malloc(p unsafe.Pointer, size uintptr) {var stk [maxStack]uintptr
 nstk := callers(4, stk[:])
 lock(&proflock)
 b := stkbucket(memProfile, size, stk[:nstk], true)
 c := mProf.cycle
 mp := b.mp()
 mpc := &mp.future[(c+2)%uint32(len(mp.future))]
 mpc.allocs++
 mpc.alloc_bytes += size
 unlock(&proflock)

 // Setprofilebucket locks a bunch of other mutexes, so we call it outside of proflock.
 // This reduces potential contention and chances of deadlocks.
 // Since the object must be alive during call to mProf_Malloc,
 // it's fine to do this non-atomically.
 systemstack(func() {setprofilebucket(p, b)
 })
}

func callers(skip int, pcbuf []uintptr) int {sp := getcallersp()
 pc := getcallerpc()
 gp := getg()
 var n int
 systemstack(func() {n = gentraceback(pc, sp, 0, gp, skip, &pcbuf[0], len(pcbuf), nil, nil, 0)
 })
 return n
}

通过调用 callers() 以及进一步的 gentraceback() 来获取以后调用栈保留在 stk 数组中(即 PC 地址的数组),这一技术被称为调用栈回溯,在很多场景均有利用(譬如程序 panic 时的栈开展)。

注:术语 PC 指 Program Counter,特定于 x86-64 平台时为 RIP 寄存器;FP 指 Frame Pointer,特定于 x86-64 时为 RBP 寄存器;SP 指 Stack Pointer,特定于 x86-64 时为 RSP 寄存器。

一种原始的调用栈回溯实现形式是在函数调用约定(Calling Convention)上保障产生函数调用时 RBP 寄存器(on x86-64)保留的肯定是栈基址,而不再作为通用寄存器应用,因为 call 指令会首先将 RIP(返回地址)入栈,咱们只有保障接下来入栈的第一条数据是以后的 RBP,那么所有函数的栈基址就以 RBP 为头,串成了一条地址链表。咱们只需为每个 RBP 地址向下偏移一个单元,便能拿到 RIP 的数组了。

Go FramePointer Backtrace(图片来自 go-profiler-notes)

注:图中提到 Go 的所有参数均通过栈传递,这一论断当初曾经过期了,Go 从 1.17 版本开始反对寄存器传参。

因为 x86-64 将 RBP 归为了通用寄存器,诸如 GCC 等编译器默认不再应用 RBP 保留栈基址,除非应用特定的选项将其关上。然而 Go 编译器却 保留了这个个性,因而在 Go 中通过 RBP 进行栈回溯是可行的。

但 Go 并没有采纳这个简略的计划,起因是在某些非凡场景下该计划会带来一些问题,譬如如果某个函数被 inline 掉了,那么通过 RBP 回溯失去的调用栈就是缺失的。另外这个计划须要在惯例函数调用间插入额定的指令,且须要额定占用一个通用寄存器,存在肯定的性能开销,即便咱们不须要栈回溯。

每个 Go 的二进制文件都蕴含一个名为 gopclntab 的 section,这是 Go Program Counter Line Table 的缩写,它保护了 PC 到 SP 及其返回地址的映射。这样咱们就无需依赖 FP,便能 间接通过查表的形式实现 PC 链表的串联 。同时 gopclntab 中 保护了 PC 及其所处函数是否已被内联优化的信息,所以咱们在栈回溯过程中便不会失落内联函数帧。此外 gopclntab 还保护了符号表,保留 PC 对应的代码信息(函数名,行数等),所以咱们最终能力看到人类可读的 panic 后果或者 profiling 后果,而不是一大坨地址信息。

gopclntab

与特定于 Go 的 gopclntab 不同,DWARF 是一种标准化的调试格局,Go 编译器同样为其生成的 binary 增加了 DWARF (v4) 信息,所以一些非 Go 生态的内部工具能够依赖它对 Go 程序进行调试。值得一提的是,DWARF 所蕴含的信息是 gopclntab 的超集。

回到 Heap Profiling 来,当咱们通过栈回溯技术(前边代码中的 gentraceback() 函数)拿到 PC 数组后,并不需要焦急间接将其符号化,符号化的开销是相当可观的,咱们齐全能够先通过指针地址栈进行聚合。所谓的聚合就是在 hashmap 中对雷同的样本进行累加,雷同的样本指的是两个数组内容完全一致的样本。

通过 stkbucket() 函数以 stk 为 key 获取相应的 bucket,而后将其中统计相干的字段进行累加。

另外,咱们留神到 memRecord 有多组 memRecordCycle 统计数据:

type memRecord struct {
 active memRecordCycle
 future [3]memRecordCycle
}

在累加时是通过 mProf.cycle 全局变量作为下标取模来拜访某组特定的 memRecordCycle。mProf.cycle 每通过一轮 GC 就会递增,这样就记录了三轮 GC 间的分配情况。只有当一轮 GC 完结后,才会将上一轮 GC 到这一轮 GC 之间的内存调配、开释状况并入最终展现的统计数据中。这个设计是为了防止在 GC 执行前拿到 Heap Profile,给咱们看到大量无用的长期内存。

并且,在一轮 GC 周期的不同时刻咱们也可能会看到不稳固的堆内存状态。

最终调用 setprofilebucket() 将 bucket 记录到此次调配地址相干的 mspan 上,用于后续 GC 时调用 mProf_Free() 来记录相应的开释状况。

就这样,Go runtime 中始终保护着这份 bucket 汇合,当咱们须要进行 Heap Profiling 时(譬如调用 pprof.WriteHeapProfile() 时),就会拜访这份 bucket 汇合,转换为 pprof 输入所须要的格局。

这也是 Heap Profiling 与 CPU Profiling 的一个区别:CPU Profiling 只在进行 profiling 的工夫窗口内对应用程序存在肯定采样开销,而 Heap Profiling 的采样是无时无刻不在产生的,执行一次 profiling 仅仅是 dump 一下迄今为止的数据快照

接下来咱们将进入 C/C++/Rust 的世界,侥幸的是,因为大部分 Heap Profiler 的实现原理是相似的,前文所述的很多常识在后文对应的上。最典型的,Go Heap Profiling 其实就是从 Google tcmalloc 移植而来的,它们具备类似的实现形式。

Heap Profiling with gperftools

gperftools(Google Performance Tools)是一套工具包,包含 Heap Profiler、Heap Checker、CPU Profiler 等工具。之所以在 Go 之后紧接着介绍它,是因为它与 Go 有很深的渊源。

前文提到的 Go runtime 所移植的 Google tcmalloc 从外部分化出了两个社区版本:一个是 tcmalloc,即纯正的 malloc 实现,不蕴含其它附加性能;另一个就是 gperftools,是带 Heap Profiling 能力的 malloc 实现,以及配套的其它工具集。

其中 pprof 也是大家最为熟知的工具之一。pprof 晚期是一个 perl 脚本,起初演化成了 Go 编写的弱小工具 pprof,当初曾经被集成到了 Go 骨干,平时咱们应用的 go tool pprof 命令外部就是间接应用的 pprof 包。

注:gperftools 的次要作者是 Sanjay Ghemawat,与 Jeff Dean 结对编程的牛人。

Usage

Google 外部始终在应用 gperftools 的 Heap Profiler 剖析 C++ 程序的堆内存调配,它能够做到:

  • Figuring out what is in the program heap at any given time
  • Locating memory leaks
  • Finding places that do a lot of allocation

作为 Go pprof 的先人,看起来和 Go 提供的 Heap Profiling 能力是雷同的。

Go 是间接在 runtime 中的内存调配函数硬编入了采集代码,与此相似,gperftools 则是在它提供的 libtcmalloc 的 malloc 实现中植入了采集代码。用户须要在我的项目编译链接阶段执行 -ltcmalloc 链接该库,以替换 libc 默认的 malloc 实现。

当然,咱们也能够依赖 Linux 的动静链接机制来在运行阶段进行替换:

$ env LD_PRELOAD="/usr/lib/libtcmalloc.so" <binary>

当应用 LD_PRELOAD 指定了 libtcmalloc.so 后,咱们程序中所默认链接的 malloc() 就被笼罩了,Linux 的动静链接器保障了优先执行 LD_PRELOAD 所指定的版本。

在运行链接了 libtcmalloc 的可执行文件之前,如果咱们将环境变量 HEAPPROFILE 设置为一个文件名,那么当程序执行时,Heap Profile 数据就会被写入该文件。

在默认状况下,每当咱们的程序调配了 1g 内存时,或每当程序的内存应用高水位线减少了 100mb 时,都会进行一次 Heap Profile 的 dump。这些参数能够通过环境变量来批改。

应用 gperftools 自带的 pprof 脚本能够剖析 dump 进去的 profile 文件,用法与 Go 基本相同。

$ pprof --gv gfs_master /tmp/profile.0100.heap

gperftools gv

$ pprof --text gfs_master /tmp/profile.0100.heap
   255.6  24.7%  24.7%    255.6  24.7% GFS_MasterChunk::AddServer
   184.6  17.8%  42.5%    298.8  28.8% GFS_MasterChunkTable::Create
   176.2  17.0%  59.5%    729.9  70.5% GFS_MasterChunkTable::UpdateState
   169.8  16.4%  75.9%    169.8  16.4% PendingClone::PendingClone
    76.3   7.4%  83.3%     76.3   7.4% __default_alloc_template::_S_chunk_alloc
    49.5   4.8%  88.0%     49.5   4.8% hashtable::resize
   ...

同样的,从左到右顺次是 Flat(mb),Flat%,Sum%,Cum(mb),Cum%,Name。

Implementation details

相似的,tcmalloc 在 malloc() 和 operator new 中减少了一些采样逻辑,当依据条件触发采样 hook 时,会执行以下函数:

// Record an allocation in the profile.
static void RecordAlloc(const void* ptr, size_t bytes, int skip_count) {
  // Take the stack trace outside the critical section.
void* stack[HeapProfileTable::kMaxStackDepth];
  int depth = HeapProfileTable::GetCallerStackTrace(skip_count + 1, stack);
  SpinLockHolder l(&heap_lock);
  if (is_on) {heap_profile->RecordAlloc(ptr, bytes, depth, stack);
    MaybeDumpProfileLocked();}
}

void HeapProfileTable::RecordAlloc(
    const void* ptr, size_t bytes, int stack_depth,
    const void* const call_stack[]) {Bucket* b = GetBucket(stack_depth, call_stack);
  b->allocs++;
  b->alloc_size += bytes;
  total_.allocs++;
  total_.alloc_size += bytes;

  AllocValue v;
  v.set_bucket(b);  // also did set_live(false); set_ignore(false)
  v.bytes = bytes;
  address_map_->Insert(ptr, v);
}

执行流程如下:

  1. 调用 GetCallerStackTrace() 获取调用栈。
  2. 以调用栈作为 hashmap 的 key 调用 GetBucket() 获取相应的 Bucket。
  3. 累加 Bucket 中的统计数据。

因为没有了 GC 的存在,采样流程相比 Go 简略了许多。从变量命名上来看,Go runtime 中的 profiling 代码确实是从这里移植过来的。

sampler.h 中详细描述了 gperftools 的采样规定,总的来说也和 Go 统一,即:512k average sample step。

在 free() 或 operator delete 中同样须要减少一些逻辑来记录内存开释状况,这比领有 GC 的 Go 同样要简略不少:

// Record a deallocation in the profile.
static void RecordFree(const void* ptr) {SpinLockHolder l(&heap_lock);
  if (is_on) {heap_profile->RecordFree(ptr);
    MaybeDumpProfileLocked();}
}

void HeapProfileTable::RecordFree(const void* ptr) {
  AllocValue v;
  if (address_map_->FindAndRemove(ptr, &v)) {Bucket* b = v.bucket();
    b->frees++;
    b->free_size += v.bytes;
    total_.frees++;
    total_.free_size += v.bytes;
  }
}

找到相应的 Bucket,累加 free 相干的字段即可。

古代 C/C++/Rust 程序获取调用栈的过程通常是依赖 libunwind 库进行的,libunwind 进行栈回溯的原理上与 Go 相似,都没有抉择 Frame Pointer 回溯模式,都是依赖程序中的某个特定 section 所记录的 unwind table。不同的是,Go 所依赖的是本人生态内创立的名为 gopclntab 的特定 section,而 C/C++/Rust 程序依赖的是 .debug_frame section 或 .eh_frame section。

其中 .debug_frame 为 DWARF 规范定义,Go 编译器也会写入这个信息,但本人不必,只留给第三方工具应用。GCC 只有开启 -g 参数时才会向 .debug_frame 写入调试信息。

而 .eh_frame 则更为古代一些,在 Linux Standard Base 中定义。原理是让编译器在汇编代码的相应地位插入一些伪指令(CFI Directives,Call Frame Information),来帮助汇编器生成最终蕴含 unwind table 的 .eh_frame section。

以如下代码为例:

// demo.c

int add(int a, int b) {return a + b;}

咱们应用 cc -S demo.c 来生成汇编代码(gcc/clang 均可),留神这里并没有应用 -g 参数。

  .section __TEXT,__text,regular,pure_instructions
 .build_version macos, 11, 0 sdk_version 11, 3
 .globl _add                            ## -- Begin function add
 .p2align 4, 0x90
_add:                                   ## @add
 .cfi_startproc
## %bb.0:
 pushq %rbp
 .cfi_def_cfa_offset 16
 .cfi_offset %rbp, -16
 movq %rsp, %rbp
 .cfi_def_cfa_register %rbp
 movl %edi, -4(%rbp)
 movl %esi, -8(%rbp)
 movl -4(%rbp), %eax
 addl -8(%rbp), %eax
 popq %rbp
 retq
 .cfi_endproc
                                        ## -- End function
.subsections_via_symbols

从生成的汇编代码中能够看到许多以 .cfi_ 为前缀的伪指令,它们便是 CFI Directives。

Heap Profiling with jemalloc

接下来咱们关注 jemalloc,这是因为 TiKV 默认应用 jemalloc 作为内存分配器,是否在 jemalloc 上顺利地进行 Heap Profiling 是值得咱们关注的要点。

Usage

jemalloc 自带了 Heap Profiling 能力,但默认是不开启的,须要在编译时指定 –enable-prof 参数。

./autogen.sh
./configure --prefix=/usr/local/jemalloc-5.1.0 --enable-prof
make
make install

与 tcmalloc 雷同,咱们能够抉择通过 -ljemalloc 将 jemalloc 链接到程序,或通过 LD_PRELOAD 用 jemalloc 笼罩 libc 的 malloc() 实现。

咱们以 Rust 程序为例展现如何通过 jemalloc 进行 Heap Profiling。

fn main() {let mut data = vec![];
    loop {func1(&mut data);
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

fn func1(data: &mut Vec<Box<[u8; 1024*1024]>>) {data.push(Box::new([0u8; 1024*1024])); // alloc 1mb
    func2(data);
}

fn func2(data: &mut Vec<Box<[u8; 1024*1024]>>) {data.push(Box::new([0u8; 1024*1024])); // alloc 1mb
}

与 Go 一节中提供的 demo 相似,咱们同样在 Rust 中每秒调配 2mb 堆内存,func1 和 func2 各调配 1mb,由 func1 调用 func2。

间接应用 rustc 不带任何参数编译该文件,而后执行如下命令启动程序:

$ export MALLOC_CONF="prof:true,lg_prof_interval:25"
$ export LD_PRELOAD=/usr/lib/libjemalloc.so
$ ./demo

MALLOC_CONF 用于指定 jemalloc 的相干参数,其中 prof:true 示意开启 profiler,log_prof_interval:25 示意每调配 2^25 字节(32mb)堆内存便 dump 一份 profile 文件。

注:更多 MALLOC_CONF 选项能够参考文档。

期待一段时间后,即可看到有一些 profile 文件产生。

jemalloc 提供了一个和 tcmalloc 的 pprof 相似的工具,叫 jeprof,事实上它就是由 pprof perl 脚本 fork 而来的,咱们能够应用 jeprof 来审阅 profile 文件。

$ jeprof ./demo jeprof.7262.0.i0.heap

同样能够生成与 Go/gperftools 雷同的 graph:

$ jeprof --gv ./demo jeprof.7262.0.i0.heap

jeprof svg

Implementation details

与 tcmalloc 相似,jemalloc 在 malloc() 中减少了采样逻辑:

JEMALLOC_ALWAYS_INLINE int
imalloc_body(static_opts_t *sopts, dynamic_opts_t *dopts, tsd_t *tsd) {
 // ...
 // If profiling is on, get our profiling context.
 if (config_prof && opt_prof) {bool prof_active = prof_active_get_unlocked();
  bool sample_event = te_prof_sample_event_lookahead(tsd, usize);
  prof_tctx_t *tctx = prof_alloc_prep(tsd, prof_active,
      sample_event);

  emap_alloc_ctx_t alloc_ctx;
  if (likely((uintptr_t)tctx == (uintptr_t)1U)) {alloc_ctx.slab = (usize <= SC_SMALL_MAXCLASS);
   allocation = imalloc_no_sample(sopts, dopts, tsd, usize, usize, ind);
  } else if ((uintptr_t)tctx > (uintptr_t)1U) {
   allocation = imalloc_sample(sopts, dopts, tsd, usize, ind);
   alloc_ctx.slab = false;
  } else {allocation = NULL;}

  if (unlikely(allocation == NULL)) {prof_alloc_rollback(tsd, tctx);
   goto label_oom;
  }
  prof_malloc(tsd, allocation, size, usize, &alloc_ctx, tctx);
 } else {assert(!opt_prof);
  allocation = imalloc_no_sample(sopts, dopts, tsd, size, usize,
      ind);
  if (unlikely(allocation == NULL)) {goto label_oom;}
 }
 // ...
}

在 prof_malloc() 中调用 prof_malloc_sample_object() 对 hashmap 中相应的调用栈记录进行累加:

void
prof_malloc_sample_object(tsd_t *tsd, const void *ptr, size_t size,
    size_t usize, prof_tctx_t *tctx) {
 // ...
 malloc_mutex_lock(tsd_tsdn(tsd), tctx->tdata->lock);
 size_t shifted_unbiased_cnt = prof_shifted_unbiased_cnt[szind];
 size_t unbiased_bytes = prof_unbiased_sz[szind];
 tctx->cnts.curobjs++;
 tctx->cnts.curobjs_shifted_unbiased += shifted_unbiased_cnt;
 tctx->cnts.curbytes += usize;
 tctx->cnts.curbytes_unbiased += unbiased_bytes;
 // ...
}

jemalloc 在 free() 中注入的逻辑也与 tcmalloc 相似,同时 jemalloc 也依赖 libunwind 进行栈回溯,这里均不再赘述。

Heap Profiling with bytehound

Bytehound 是一款 Linux 平台的 Memory Profiler,用 Rust 编写。特点是提供的前端性能比拟丰盛,咱们关注的重点在于它是如何实现的,以及是否在 TiKV 中应用,所以只简略介绍下根本用法。

Usage

咱们能够在 Releases 页面下载 bytehound 的二进制动静库,只有 Linux 平台的反对。

而后,像 tcmalloc 或 jemalloc 一样,通过 LD_PRELOAD 挂载它本人的实现。这里咱们假如运行的是 Heap Profiling with jemalloc 一节雷同的带有内存透露的 Rust 程序:

$ LD_PRELOAD=./libbytehound.so ./demo

接下来在程序的工作目录会产生一个 memory-profiling_*.dat 文件,这便是 bytehound 的 Heap Profiling 产物。留神,与其它 Heap Profiler 不同的是,这个文件是继续更新的,而非每隔特定的工夫就生成一个新的文件。

接下来执行如下命令开启一个 web 端口用于实时剖析上述文件:

$ ./bytehound server memory-profiling_*.dat

Bytehound GUI

最直观的办法是点击右上角的 Flamegraph 查看火焰图:

Bytehound Flamegraph

从图中能够轻易看出 demo::func1 与 demo::func2 的内存热点。

Bytehound 提供了丰盛的 GUI 性能,这是它的一大亮点,大家能够参考文档自行摸索。

Implementation details

Bytehound 同样是替换掉了用户默认的 malloc 实现,但 bytehound 自身并没有实现内存分配器,而是基于 jemalloc 做了包装。

// 入口
#[cfg_attr(not(test), no_mangle)]
pub unsafe extern "C" fn malloc(size: size_t) -> *mut c_void {allocate( size, AllocationKind::Malloc)
}

#[inline(always)]
unsafe fn allocate(requested_size: usize, kind: AllocationKind) -> *mut c_void {
    // ...
    // 调用 jemalloc 进行内存调配
    let pointer = match kind {
        AllocationKind::Malloc => {if opt::get().zero_memory {calloc_real( effective_size as size_t, 1)
            } else {malloc_real( effective_size as size_t)
            }
        },
        // ...
    };
    // ...
    // 栈回溯
    let backtrace = unwind::grab(&mut thread);
    // ...
    // 记录样本
    on_allocation(id, allocation, backtrace, thread);
    pointer
}

// xxx_real 链接到 jemalloc 实现
#[cfg(feature = "jemalloc")]
extern "C" {#[link_name = "_rjem_mp_malloc"]
    fn malloc_real(size: size_t) -> *mut c_void;
    // ...
}

看起来在每次 malloc 时都会固定进行栈回溯和记录,没有采样逻辑。而在 on_allocation hook 中,调配记录被发送到了 channel,由对立的 processor 线程进行异步解决。

pub fn on_allocation(
    id: InternalAllocationId,
    allocation: InternalAllocation,
    backtrace: Backtrace,
    thread: StrongThreadHandle
) {
    // ...
    crate::event::send_event_throttled( move || {
        InternalEvent::Alloc {
            id,
            timestamp,
            allocation,
            backtrace,
        }
    });
}

#[inline(always)]
pub(crate) fn send_event_throttled< F: FnOnce() -> InternalEvent >( callback: F) {EVENT_CHANNEL.chunked_send_with( 64, callback);
}

而 EVENT_CHANNEL 的实现是简略的 Mutex<Vec<T>>:

pub struct Channel< T > {
    queue: Mutex< Vec< T > >,
    condvar: Condvar
}

Performance overhead

本节咱们来探寻一下前文所述的各个 Heap Profiler 的性能开销,具体测量方法因场景而异。

所有测试均独自运行在下述物理机环境:

主机 Intel NUC11PAHi7
CPU Intel Core i7-1165G7 2.8GHz~4.7GHz 4 核 8 线程
内存 Kingston 64G DDR4 3200MHz
硬盘 Samsung 980PRO 1T SSD PCIe4.
操作系统 Arch Linux Kernel-5.14.1

Go

在 Go 中咱们的测量形式是应用 TiDB + unistore 部署单节点,针对 runtime.MemProfileRate 参数进行调整而后别离用 sysbench 进行测量。

相干软件版本及压测参数数据:

Go Version 1.17.1
TiDB Version v5.3.0-alpha-1156-g7f36a07de
Commit Hash 7f36a07de9682b37d46240b16a2107f5c84941ba
| Sysbench                                           

| Version | 1.0.20 |
| Tables | 8 |
| TableSize | 100000 |
| Threads | 128 |
| Operation | oltp_read_only |

失去的后果数据:

MemProfileRate 论断
0: 不记录 Transactions: 1505224 (2508.52 per sec.)<br/>Queries: 24083584 (40136.30 per sec.)<br/>Latency (AVG): 51.02<br/>Latency (P95): 73.13
512k: 采样记录 Transactions: 1498855 (2497.89 per sec.)<br/>Queries: 23981680 (39966.27 per sec.)<br/>Latency (AVG): 51.24<br/>Latency (P95): 74.46
1: 全量记录 Transactions: 75178 (125.18 per sec.)<br/>Queries: 1202848 (2002.82 per sec.)<br/>Latency (AVG): 1022.04<br/>Latency (P95): 2405.65

相较“不记录”来说,无论是 TPS/QPS,还是 P95 延迟线,512k 采样记录的性能损耗根本都在 1% 以内。而“全量记录”带来的性能开销合乎“会很高”的预期,但却高的出其不意:TPS/QPS 缩水了 20 倍,P95 提早减少了 30 倍

因为 Heap Profiling 是一项通用性能,咱们无奈精确的给出所有场景下的通用性能损耗,只有在特定我的项目下的测量论断才有价值。TiDB 是一个绝对偏计算密集型的利用,内存调配频率可能不迭一些内存密集型利用,因而该论断(及后续所有论断)仅可用做参考,读者可自行测量本身利用场景下的开销。

tcmalloc/jemalloc

咱们基于 TiKV 来测量 tcmalloc/jemalloc,办法是在机器上部署一个 PD 过程和一个 TiKV 过程,采纳 go-ycsb 进行压测,要害参数如下:

threadcount=200
recordcount=100000
operationcount=1000000
fieldcount=20

在启动 TiKV 前别离应用 LD_PRELOAD 注入不同的 malloc hook。其中 tcmalloc 应用默认配置,即相似 Go 的 512k 采样;jemalloc 应用默认采样策略,且每调配 1G 堆内存就 dump 一份 profile 文件。

最终失去如下数据:

default OPS: 119037.2 Avg(us): 4186 99th(us): 14000
tcmalloc OPS: 113708.8 Avg(us): 4382 99th(us): 16000
jemalloc OPS: 114639.9 Avg(us): 4346 99th(us): 15000

tcmalloc 与 jemalloc 的体现相差无几,OPS 相较默认内存分配器降落了 4% 左右,P99 延迟线回升了 10% 左右。

在前边咱们曾经理解到 tcmalloc 的实现和 Go heap pprof 的实现基本相同,但这里测量进去的数据却不太统一,揣测起因是 TiKV 与 TiDB 的内存调配特色存在差别,这也印证了前文所讲的:“咱们无奈精确的给出所有场景下的通用性能损耗,只有在特定我的项目下的测量论断才有价值”。

bytehound

咱们没有将 bytehound 与 tcmalloc/jemalloc 放在一起的起因是在 TiKV 上理论应用 bytehound 时会在启动阶段遇到死锁问题。

因为咱们揣测 bytehound 的性能开销会十分高,实践上是无奈利用在 TiKV 生产环境的,所以咱们只需印证这个论断即可。

注:揣测性能开销高的起因是在 bytehound 代码中没有找到采样逻辑,每次采集到的数据通过 channel 发送给后盾线程解决,而 channel 也只是简略应用 Mutex + Vec 封装了下。

咱们抉择一个简略的 mini-redis 我的项目来测量 bytehound 性能开销,因为指标仅仅是确认是否可能满足 TiKV 生产环境应用的要求,而不是精确测量数据,所以咱们只简略统计并比照其 TPS 即可,具体 driver 代码片段如下:

var count int32

for n := 0; n < 128; n++ {go func() {
  for {key := uuid.New()
   err := client.Set(key, key, 0).Err()
   if err != nil {panic(err)
   }
   err = client.Get(key).Err()
   if err != nil {panic(err)
   }
   atomic.AddInt32(&count, 1)
  }
 }()}

开启 128 goroutine 对 server 进行读写操作,一次读 / 写被认为是一次残缺的 operation,其中仅仅对次数进行统计,没有测量提早等指标,最终应用总次数除以执行工夫,失去开启 bytehound 前后的不同 TPS,数据如下:

默认 Count: 11784571 Time: 60s TPS: 196409
开启 bytehound Count: 5660952 Time: 60s TPS: 94349

从后果来看 TPS 损失了 50% 以上

What can BPF bring

尽管 BPF 性能开销很低,但基于 BPF 很大水平上只能拿到零碎层面的指标,通常意义上的 Heap Profiling 须要在内存调配链路上进行统计,但内存调配是趋于分层的。

举个例子,如果咱们在本人的程序里提前 malloc 了一大块堆内存作为内存池,本人设计了调配算法,接下来所有业务逻辑所需的堆内存全都从内存池里自行调配,那么现有的 Heap Profiler 就没法用了。因为它只通知你你在启动阶段申请了一大段内存,其它时候的内存申请数量为 0。在这种场景下咱们就须要侵入到本人设计的内存调配代码中,在入口处做 Heap Profiler 该做的事件。

BPF 的问题与此相似,咱们能够挂个钩子在 brk/sbrk 上,当用户态真正须要向内核申请扩容堆内存时,对以后的 stack trace 进行记录。然而内存分配器是简单的黑盒,最常触发 brk/sbrk 的用户栈不肯定就是导致内存透露的用户栈。这须要做一些试验来验证,如果后果真的有肯定价值,那么将 BPF 作为低成本的兜底计划长期运行也未尝不可(须要额定思考 BPF 的权限问题)。

至于 uprobe,只是无侵入的代码植入,对于 Heap Profiling 自身还是要在 allocator 里走雷同的逻辑,进而带来雷同的开销,而咱们对代码的侵入性并不敏感。

https://github.com/parca-dev/parca 实现了基于 BPF 的 Continuous Profiling,但真正利用了 BPF 的模块其实只有 CPU Profiler。在 bcc-tools 中曾经提供了一个 Python 工具用来做 CPU Profiling(https://github.com/iovisor/bcc/blob/master/tools/profile.py),外围原理是雷同的。对于 Heap Profiling 临时没有太大的借鉴意义。

正文完
 0