系统长时间运行之后,可用内存越来越少,甚至导致了某些服务失败,这就是典型的内存泄漏问题。这类问题通常难以预测,也很难通过静态代码梳理的方式定位。Heap Profiling 就是帮助我们解决此类问题的。
什么是 Heap Profiling
运行时的内存泄漏问题在很多场景下都相当难以排查,因为这类问题通常难以预测,也很难通过静态代码梳理的方式定位。
Heap Profiling 是如何工作的
作为对比,我们先简单了解下 CPU Profiling 是如何工作的。
Go CPU Profile
Go Heap Profile
与 CPU Profiling 不同的是,Heap Profiling 的数据采集工作并非简单通过定时器开展,而是需要侵入到内存分配路径内,这样才能拿到内存分配的数量。所以 Heap Profiler 通常的做法是直接将自己集成在内存分配器内,当应用程序进行内存分配时拿到当前的 stack trace,最终将所有样本聚合在一起,这样我们便能知道每个函数直接或间接地内存分配数量了。Heap Profiling in Go
大部分读者应该对 Go 会更加熟悉一些,因此我们以 Go 为起点和基底来进行调研。
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 调用。Go Heap Top
Name 列表示相应的函数名
Flat 列表示该函数自身分配了多少内存
Flat% 列表示 Flat 相对总分配大小的占比
Cum 列表示该函数,及其调用的所有子函数一共分配了多少内存
Cum% 列表示 Cum 相对总分配大小的占比
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。
注意,当我们将 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
由于很多情况下内存分配是有规律的,如果按照固定的粒度进行采样,最终得到的结果可能会存在很大的误差,可能刚好每次采样都赶上了某个特定类型的内存分配。这就是这里选择随机化的原因。
位于 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 时的栈展开)。
Go FramePointer Backtrace(图片来自 go-profiler-notes)
注:图中提到 Go 的所有参数均通过栈传递,这一结论现在已经过时了,Go 从 1.17 版本开始支持寄存器传参。gopclntab
与特定于 Go 的 gopclntab 不同,DWARF 是一种标准化的调试格式,Go 编译器同样为其生成的 binary 添加了 DWARF (v4) 信息,所以一些非 Go 生态的外部工具可以依赖它对 Go 程序进行调试。值得一提的是,DWARF 所包含的信息是 gopclntab 的超集。另外,我们注意到 memRecord 有多组 memRecordCycle 统计数据:
type memRecord struct { active memRecordCycle future [3]memRecordCycle }
在累加时是通过 mProf.cycle 全局变量作为下标取模来访问某组特定的 memRecordCycle。mProf.cycle 每经过一轮 GC 就会递增,这样就记录了三轮 GC 间的分配情况。只有当一轮 GC 结束后,才会将上一轮 GC 到这一轮 GC 之间的内存分配、释放情况并入最终展示的统计数据中。这个设计是为了避免在 GC 执行前拿到 Heap Profile,给我们看到大量无用的临时内存。
Heap Profiling with gperftools
gperftools(Google Performance Tools)是一套工具包,包括 Heap Profiler、Heap Checker、CPU Profiler 等工具。之所以在 Go 之后紧接着介绍它,是因为它与 Go 有很深的渊源。
Usage
Google 内部一直在使用 gperftools 的 Heap Profiler 分析 C++ 程序的堆内存分配,它可以做到:当然,我们也可以依赖 Linux 的动态链接机制来在运行阶段进行替换:
$ env LD_PRELOAD="/usr/lib/libtcmalloc.so" <binary>
当使用 LD_PRELOAD 指定了 libtcmalloc.so 后,我们程序中所默认链接的 malloc() 就被覆盖了,Linux 的动态链接器保证了优先执行 LD_PRELOAD 所指定的版本。
使用 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); } 执行流程如下:
调用 GetCallerStackTrace() 获取调用栈。
以调用栈作为 hashmap 的 key 调用 GetBucket() 获取相应的 Bucket。
累加 Bucket 中的统计数据。
在 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 相关的字段即可。
以如下代码为例:
// 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 文件。
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 的内存热点。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 的性能开销,具体测量方法因场景而异。
所有测试均单独运行在下述物理机环境:
Go
在 Go 中我们的测量方式是使用 TiDB + unistore 部署单节点,针对 runtime.MemProfileRate 参数进行调整然后分别用 sysbench 进行测量。相关软件版本及压测参数数据:
得到的结果数据: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 文件。
最终得到如下数据:
tcmalloc 与 jemalloc 的表现相差无几,OPS 相较默认内存分配器下降了 4% 左右,P99 延迟线上升了 10% 左右。
bytehound
我们没有将 bytehound 与 tcmalloc/jemalloc 放在一起的原因是在 TiKV 上实际使用 bytehound 时会在启动阶段遇到死锁问题。我们选择一个简单的 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,数据如下:
从结果来看 TPS 损失了 50% 以上。
What can BPF bring
虽然 BPF 性能开销很低,但基于 BPF 很大程度上只能拿到系统层面的指标,通常意义上的 Heap Profiling 需要在内存分配链路上进行统计,但内存分配是趋于分层的。