对于长时间运行的服务端程序,内存的使用一直是一个非常重要的监控指标,当内存的使用量一直在上升的时候,我们就需要警觉起来,因为很有可能整个系统出现了内存泄露。那么剩下的问题就比较简单了,如何动态的获知哪里有内存泄露呢?
对 Go 的程序来说,我们可以使用语言内置的 pprof 工具非常方便的对内存进行 profile,我们只需要在程序里面 import _ "net/http/pprof"
,这样启动的 HTTP server 服务器就能够被直接 profile 了。
但对 Rust,情况就没那么简单了。因为语言并没有内置这个功能,所以我们得想其他办法来解决。Rust 默认使用的是 jemalloc 这个内存分配器,jemalloc 提供了非常方便的 profile 功能。所以我们自然将目光放在了如何用 jemalloc 来 profile memory 以及如何与 Rust 整合上面了。
要打开 jemalloc 的 profile 功能,在编译的时候我们需要显示的带上 --enable-prof
选项,通常在 Linux 下面我们会安装 libunwind
库,这样 prof 默认就会使用 libunwind
了。另外,为了不跟系统的 malloc 这些函数有命名冲突,这里显示的给 jemalloc 加上了前缀,使用 --with-jemalloc-prefix="je_"
,这样我们外面就会使用 je_malloc
这种的函数名字了。
我们用官网非常简单的例子来说明内存泄露问题,如下:
void do_something(size_t i)
{
// Leak some memory.
je_malloc(i * 100);
}
上面的函数有一个典型的内存泄漏,我们调用 1000 次:
for (i = 0; i < 1000; i++) {
do_something(i);
}
剩下的就是如何来定位内存问题了。
Mem Statistics
首先我们来看看 jemalloc 自己提供的统计信息,我们可以直接使用 je_malloc_stats_print(NULL, NULL, NULL)
来将 memory 的统计输出到 stderr 上面,但这个函数输出的东西比较多,并不利于实时的查看。多数时候,我们都是使用 je_mallctl
函数,得到一些关键的统计数据,然后发送给 Prometheus 来展示,这样我们就能够在 Prometheus 里面观察到整个 jemalloc 内存变化的曲线,如果持续上升,就需要报警了。
uint64_t epoch = 1;
size_t sz = sizeof(epoch);
je_mallctl("epoch", &epoch, &sz, &epoch, sz);
size_t allocated, active, mapped;
sz = sizeof(size_t);
je_mallctl("stats.allocated", &allocated, &sz, NULL, 0);
je_mallctl("stats.active", &active, &sz, NULL, 0);
je_mallctl("stats.mapped", &mapped, &sz, NULL, 0);
printf("allocated/active/mapped: %zu/%zu/%zu\n", allocated, active, mapped);
上面我们在每次 do_something
后面得到 allocated,active 以及 mapped 这些指标,然后输出:
allocated/active/mapped: 54919648/58540032/64831488
allocated/active/mapped: 55034336/58658816/64950272
allocated/active/mapped: 55149024/58777600/65069056
allocated/active/mapped: 55263712/58896384/65187840
上面需要注意,我们需要用 epoch 来让统计的 cache 更新。
Leak Check
通过统计,我们能看到整个内存的变化曲线,但到底哪里有内存问题呢?我们可以在程序结束的时候显示的输出内存泄露。仍然使用上面的程序,我们使用 JE_MALLOC_CONF="prof_leak:true,lg_prof_sample:0,prof_final:true" ./leak
来执行,当程序退出之后,会生成一个 prof heap 的文件,我们用 jeprof 工具就可以知道内存泄露了。
jeprof leak jeprof.9001.0.f.heap
Using local file leak.
Using local file jeprof.9001.0.f.heap.
Welcome to jeprof! For help, type 'help'.
(jeprof) top
Total: 52.1 MB
52.1 100.0% 100.0% 52.1 100.0% je_prof_backtrace
0.0 0.0% 100.0% 52.1 100.0% __libc_start_main
0.0 0.0% 100.0% 52.1 100.0% _start
0.0 0.0% 100.0% 52.1 100.0% do_something
0.0 0.0% 100.0% 52.1 100.0% imalloc (inline)
0.0 0.0% 100.0% 52.1 100.0% imalloc_body (inline)
0.0 0.0% 100.0% 52.1 100.0% je_malloc
0.0 0.0% 100.0% 52.1 100.0% je_prof_alloc_prep (inline)
0.0 0.0% 100.0% 52.1 100.0% main
Heap Profiling
使用上面的方式,我们只能在程序结束的时候输出内存泄露,实际并不适用于长时间运行的程序,幸运的时候,我们可以通过 jemalloc 的一些参数以及 mallctl
函数来显示的对内存进行 profile。在运行程序之前,我们需要设置 export JE_MALLOC_CONF="prof:true,prof_prefix:jeprof.out"
,它用来告诉 jemalloc 显示的打开 prof,同时自动的生成 profile 文件名。
在代码里面,我们可以使用 mallctl("prof.dump", NULL, NULL, NULL, 0);
来对当前执行的程序生成一个 mem dump,然后过一段时间之后,用相同的方法再次生成一个,在用 jeprof 工具对比两次的 dump,就大概能知道是否有内存问题了。
具体到上面的例子,我们在程序的开始和结束都使用 mallctl
dump 一次 memory,然后对两次生成的 profile 文件进行对比:
jeprof --base=jeprof.out.19792.0.m0.heap profile jeprof.out.19792.1.m1.heap
Using local file profile.
Using local file jeprof.out.19792.1.m1.heap.
Welcome to jeprof! For help, type 'help'.
(jeprof) top
Total: 53.1 MB
53.1 100.0% 100.0% 53.1 100.0% je_prof_backtrace
0.0 0.0% 100.0% 53.1 100.0% __libc_start_main
0.0 0.0% 100.0% 53.1 100.0% _start
0.0 0.0% 100.0% 53.1 100.0% do_something
0.0 0.0% 100.0% 53.1 100.0% imalloc (inline)
0.0 0.0% 100.0% 53.1 100.0% imalloc_body (inline)
0.0 0.0% 100.0% 53.1 100.0% je_malloc
0.0 0.0% 100.0% 53.1 100.0% je_prof_alloc_prep (inline)
0.0 0.0% 100.0% 53.1 100.0% main
Rust Customized Allocator
上面说完了在 C 里面使用 jemalloc 来看内存问题,那么对于 Rust 语言来说,我们怎么处理呢?Rust 默认使用的就是 jemalloc,但发布的版本里面 jemalloc 并没有带上 profile 的功能,所以需要重新编译 Rust,对于我们来说,因为要实时的跟进 Rust 的版本,这并不是一个好办法。
幸运的是,Rust 提供了 custom allocator 的功能,也就是能使用自定义的 allocator,这样对我们来说就简单很多了,使用一个打开了 profile 功能的 jemalloc 用作自定义的 allocator,这样就能通过 mallctl
来 profile memory 了。更幸运的是,Rust 的一个开发者已经提供了相关的 allocator,我们可以直接使用。
我们可以构造一个非常简单的 case,使用 mem::forget
:
fn do_something()
{
let mut bad_vec = Vec::new();
for _ in 0..1024 {
bad_vec.push('0');
}
mem::forget(bad_vec);
}
在这个函数前后,我们都使用 mallctl
,如下:
let epoch_name = "prof.dump";
let epoch_c_name = CString::new(epoch_name).unwrap();
mallctl(epoch_c_name.as_ptr(), null_mut(), null_mut(), null_mut(), 0);
执行之后,就会生成两个 profile 文件,使用 jeprof 之后,得到:
(jeprof) top
Total: 0.5 MB
0.5 100.0% 100.0% 0.5 100.0% jemallocator::__rust_reallocate
0.0 0.0% 100.0% 0.5 100.0% __libc_start_main
0.0 0.0% 100.0% 0.5 100.0% _start
0.0 0.0% 100.0% 0.5 100.0% alloc::heap::reallocate::h1264a9399460da6c
0.0 0.0% 100.0% 0.5 100.0% alloc::raw_vec::{{impl}}::double
0.0 0.0% 100.0% 0.5 100.0% collections::vec::{{impl}}::push
0.0 0.0% 100.0% 0.5 100.0% core::ops::FnOnce::call_once (inline)
0.0 0.0% 100.0% 0.5 100.0% main
0.0 0.0% 100.0% 0.5 100.0% my_allocator::do_something::h4ffe20b1f68a3f80
0.0 0.0% 100.0% 0.5 100.0% my_allocator::main::hffe46171bdd5ea12
小结
内存问题一直是长时间运行程序需要处理的一个棘手问题,虽然 Rust 相比 C 以及 CPP,在内存处理上面有了很大的改善,但我们仍然可能会有引用泄露等问题出现,这些问题很难通过直接浏览代码,看 log 和 metrics 来看出来的,而 profile 恰恰能很好的解决,所以这也是我们一直想在 TiKV 上面加入 profile memory 的原因。
需要注意,加入 profile 之后,会影响系统的性能,所以通常,我们都会采用 sample 的方式或者动态的打开或者关闭 profile 功能。譬如,假设我们要 profile memory,就使用 mallctl
将 prof.active
打开,一段时间,在使用 mallctl
dump 出 memory,然后在关闭 profile。
另外,除了 jemalloc,其实 tcmalloc 也照样能支持 profile memory,只是因为 Rust 默认使用的是 jemalloc,我们最终我们还是决定基于 jemalloc 来使用。