性能优化 ---- Memory Leak

如果应用程序内存使用量正在稳步增长,这可能是由于配置错误导致的内存增长,或者由于软件错误导致内存泄漏。对于某些应用程序,因为垃圾内存回收工作困难,消耗了 CPU,性能可能会开始降低。如果应用程序使用的内存变得太大,性能可能会因为分页(swapping)而下降,或者应用程序可能会被系统杀死(Linux's OOM killer)。

针对上面的情况,就需要从应用程序或系统工具检查应用程序配置和内存使用情况。内存泄漏原因的调查虽然很困难,但有许多工具可以提供帮助。有些人在根据应用程序中使用 malloc()来调查内存使用情况(如 Valgrind和 memcheck),它还可以模拟 CPU,以便可以检查所有内存访问。这可能会导致应用程序运行速度慢 20-30 倍或更多。另一个更快的工具是使用libtcmalloc的堆分析功能,但它仍然会使应用程序慢5倍以上。其他工具采取核心转储,然后后处理,以研究内存使用情况,如 gdb。这些通常在使用核心转储时暂停应用程序,或要求终止应用程序,以便调用 free() 函数。虽然核心转储技术为诊断内存泄漏提供了宝贵的细节,但两者都不能轻松的发现内存泄漏的原因。

在此文中,我将总结我用于分析已运行的应用程序上的内存增长和泄漏的四种跟踪方法。这些方法可以利用堆栈跟踪检查内存使用的代码路径,并可视化为火焰图。我将演示有关 Linux 的分析,然后总结其他操作系统。

以下图中显示了以下四种方法,如绿色文本中的事件:

image.png

所有方法都会有些缺点,我会在下文中详细解释。

目录:

  Prerequisites
  Linux
  1. Allocator
  2. brk()
  3. mmap()
  4. Page Faults
  Other OSes
  Summary
  

Prerequisites

以下所有方法都需要堆栈跟踪才能对跟踪者可用,所以需要先修复这些跟踪器。许多应用程序都是使用 -fomit-frame-pointer的 gcc 选项编译的,这打破了基于帧指针的堆栈走行。VM 运行时(如 Java)可自由编译方法,在没有额外帮助的情况下可能无法找到其符号信息,从而导致堆栈跟踪仅十六进制。还有其他的陷阱(gotchas)。请参阅我以前关于修复Stack TracesJIT Symbols For perf。

Linux: perf, eBPF

以下是通用方法。我将使用Linux作为目标示例,然后总结其他操作系统。

Linux 上有许多用于内存分析的跟踪器。我将在这里使用 perf 和 bcc/eBPF,这是标准的 Linux 跟踪器。perf 和 eBPF 都是 Linux 内核源的一部分。perf 适用于较旧的 Linux 系统,而 eBPF 至少需要 Linux 4.8 来执行堆栈跟踪。eBPF 可以更轻松地执行内核摘要,使其更高效并降低开销。

  1. Allocator Tracing: malloc(), free(), ...

这是跟踪内存分配器函数、malloc()、free()等的方法。想象一下,你可以针对一个进程运行Valgrind memcheck "-p PID",并收集内存泄漏统计信息60秒左右。这虽然不能取得一个完整的图片, 但也有希望捕捉到足以令人震惊的泄漏。如果没能抓到有效的信息,就需要持续足够长的时间。

这些分配器函数对虚拟内存(而不是物理(驻留)内存)进行操作,而物理(驻留)内存通常是泄漏检测的目标。幸运的是,它们通常具有很强的相关性,用于识别问题代码。

有时使用分配器跟踪,开销很高,因此这更像是一种调试方法,而不是生产探查器。这是因为分配器功能(如 malloc() 和 free()可能非常频繁(每天数百万次),并且添加少量的开销可能会增加。但是,解决问题是值得的。它可以比 Valgrind 的 memcheck 或 tcmalloc 的堆剖面器的开销小。如果你想自己试试这个,我会先看看使用eBPF的内核摘要可以解决多少,这在Linux4.9及更高版本上效果最好。

1.1. Perl Example

下面是使用 eBPF 进行内核内摘要的分配器跟踪示例。利用 bcc 的stackcount工具, 使用Perl 程序简单地对 libc malloc()进行统计, 给定进程的调用, 。它使用 uprobes 探测 malloc() 函数以实现用户级动态跟踪。

# /usr/share/bcc/tools/stackcount -p 19183 -U c:malloc > out.stacks
^C
# more out.stacks
[...]

  __GI___libc_malloc
  Perl_sv_grow
  Perl_sv_setpvn
  Perl_newSVpvn_flags
  Perl_pp_split
  Perl_runops_standard
  S_run_body
  perl_run
  main
  __libc_start_main
  [unknown]
    23380

  __GI___libc_malloc
  Perl_sv_grow
  Perl_sv_setpvn
  Perl_newSVpvn_flags
  Perl_pp_split
  Perl_runops_standard
  S_run_body
  perl_run
  main
  __libc_start_main
  [unknown]
    65922
    

上面的输出是堆栈跟踪及其发生次数。例如,最后一个堆栈跟踪导致调用 malloc() 65922 次。这是内核上下文中计算的频率,并且仅在程序结束时输出结果。这样,我们避免了将每个malloc() 的数据传递到的用户空间的开销。我也使用 -U 选项仅跟踪用户级堆栈,因为我正在检测用户级函数:libc's malloc()。

然后,使用我的 FlameGraph 软件将该输出转换为火焰图:

$ ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \
    --title="malloc() Flame Graph" --countname="calls" > out.svg
    

现在把out.svg在浏览器中打开。显示了下面的火焰图。将鼠标悬停在元素上查看详细信息并单击以缩放(如果 SVG在浏览器中不起作用,请尝试 PNG):

image.png

这表明大部分内存分配通过了Perl_pp_split()路径。

如果想自己尝试此方法,请记住跟踪所有内存分配函数:malloc(), realloc(), calloc()等。还可以检测分配的内存大小,而不是样本计数(函数调用回数),以便火焰图显示分配的字节而不是调用计数。Sasha Goldshtein 已经编写出一种基于 eBPF 的高级工具,用于检测这些功能,该工具可跟踪尚未在间隔内释放的长期幸存分配,以及用于识别内存泄漏。它是 bcc 中的 memleak: 请参阅示例文件example file

1.2. MySQL Example

这个例子是处理基准负载的 MySQL 数据库服务器。我将开始使用如前面所述的堆栈计数火焰图(使用stackcount -D 30 指定持续时间为 30 秒)。生成的火焰图为 (SVG, PNG):

image.png

通过上面的火焰图,我们可以看到大多数的 malloc()呼叫是在 st_select_lex::optimize() -> JOIN::optimize()被调用的。但这不是分配大部分字节的地方。

下面是 malloc() 字节火焰图,其中宽度显示分配的总字节数(SVG, PNG):

image.png

通过上面的火焰图,我们可以看到大多数字节在 JOIN::exec()中分配,而不是 JOIN::optimize()。这些火焰图的跟踪数据大致同时捕获,因此这里的区别是,某些调用大于其他调用,而不仅仅是在跟踪之间更改工作负载。

我开发了一个单独的工具,mallocstacks,这类似于堆栈计数,但将size_t参数作为指标,而不是只计算堆栈。火焰图生成步骤是system-wide跟踪malloc():

# ./mallocstacks.py -f 30 > out.stacks
[...copy out.stacks to your local system if desired...]
# git clone https://github.com/brendangregg/FlameGraph
# cd FlameGraph
# ./flamegraph.pl --color=mem --title="malloc() bytes Flame Graph" --countname=bytes < out.stacks > out.svg

对于这个和早期的 malloc() 计数火焰图, 我添加了一个额外的步骤, 只包括 mysqld 和 sysbench 堆栈 (sysbench是 MySQL 负载生成工具).我在这里使用的实际命令是:

[...]
# egrep 'mysqld|sysbench' out.stacks | ./flamegraph.pl ... > out.svg

由于 mallocstacks.py 的输出(早期使用stackcollapse.pl)是per-stack跟踪的单行,因此很容易使用 grep/sed/awk 等工具来操作火焰图生成之前的数据。

我的mallocstacks工具只是一个概念验证,它只跟踪 malloc()。我会继续开发这些工具,但开销是一个问题。

1.3. Warning

警告:从 Linux 4.15 开始,通过 Linux uprobes 进行分配器跟踪的开销很高(在以后的内核中可能会有所改进)。此外,尽管使用堆栈跟踪的内核内频率计数, Perl 程序运行速度慢 4 倍(从 0.53 秒到 2.14 秒)。但至少它比 libtcmalloc 的堆分析要快,因为对于同一程序,它运行速度要慢 6 倍。这是最糟糕的情况,因为它包括程序初始化,这使得malloc()变得很重。导致MySQL 服务器在跟踪 malloc() 时吞吐量损失 33%(CPU 处于饱和状态,因此跟踪器没有headroom)。这在生产中不可接受。

由于这个开销问题,我尝试使用以下各节中描述的其他内存分析技术(brk(), mmap(), page faults)。

1.4. Other Examples

另一个例子是,Yichun Zhang(agentzh)使用Linux的SystemTap开发的leaks.stp,它使用内核内总结来提高效率。他从这里创建了火焰图,example here,这看起来很棒。此后,我向火焰图添加了一个新的调色板(--color=mem) ,以便我们可以区分 CPU 火焰图(热颜色)和内存图(绿色)。

但是 malloc 跟踪的开销如此之高,因此我更喜欢间接方法,如以下有关 brk(), mmap(), and page faults等章节所述。这是一个权衡:对于泄漏检测,它们不像直接跟踪分配器函数那样有效,但它们确实会产生更少的开销。

  1. brk() syscall

许多应用程序使用brk()来调查内存持续增长。此系统调用(brk())可以设置程序断点在堆段(也称为进程数据段)的末尾。brk()不是由应用程序直接调用,而是提供 malloc()/free()接口的用户级分配器。此类分配器通常不会将内存返回操作系统,而是将将释放的内存保留为将来分配的缓存。因此,brk() 通常只用于增长(而不是收缩)。我们也是假设这样来简化跟踪。

brk() 通常不频繁(例如 <1000/秒),这意味着使用 perf 进行每个事件跟踪可能就足够了。下面此方法测量使用 perf 的 brk() 的速率(在这种情况下使用内核计数):

# perf stat -e syscalls:sys_enter_brk -I 1000 -a
#           time             counts unit events
     1.000283341                  0      syscalls:sys_enter_brk
     2.000616435                  0      syscalls:sys_enter_brk
     3.000923926                  0      syscalls:sys_enter_brk
     4.001251251                  0      syscalls:sys_enter_brk
     5.001593364                  3      syscalls:sys_enter_brk
     6.001923318                  0      syscalls:sys_enter_brk
     7.002222241                  0      syscalls:sys_enter_brk
     8.002540272                  0      syscalls:sys_enter_brk
[...]

这是一个生产服务器,通常只有零 brk()s/秒。这就需要测量较长的时间(minutes) ,以捕获足够的样本绘制火焰图。

如果 brk()s 的速率也很低,则只能在采样模式下使用 perf,在采样模式下,可以per-event dumps。以下是使用 perf 和 FlameGraph 生成 brk 仪器和火焰图的步骤:

# perf record -e syscalls:sys_enter_brk -a -g -- sleep 120
# perf script > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse-perf.pl < out.stacks | ./flamegraph.pl --color=mem \
    --title="Heap Expansion Flame Graph" --countname="calls" > out.svg
    

上面包括一个"sleep 120"虚拟命令。由于 brk 的不常见,您可能需要 120 秒甚至更长时间来捕获足够的配置文件。

在较新的 Linux 系统 (4.8+) 上,您可以使用 Linux eBPF。brk() 可以通过其内核函数SyS_brk() or sys_brk()调用。或 4.14+ 内核通过syscalls:sys_enter_brk跟踪点进行跟踪。我将在这里使用函数,并再次使用我的堆栈计数 bcc 程序显示 eBPF 步骤:

# /usr/share/bcc/tools/stackcount SyS_brk > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \
    --title="Heap Expansion Flame Graph" --countname="calls" > out.svg
    

下面是堆栈计数的一些示例输出:

$ cat out.stacks
[...]

  sys_brk
  entry_SYSCALL_64_fastpath
  brk
  Perl_do_readline
  Perl_pp_readline
  Perl_runops_standard
  S_run_body
  perl_run
  main
  __libc_start_main
  [unknown]
    3

  sys_brk
  entry_SYSCALL_64_fastpath
  brk
    19
    

上面的输出包括多个堆栈跟踪及其导致 brk()的发生计数。我截断输出以仅显示最后两个堆栈和计数,尽管完整输出不会太长,因为 brk() 通常不频繁,并且没有这么多不同的堆栈:因为只有当分配器具有溢出其当前堆栈大小的请求时,它才发生。这也意味着开销非常低,应该可以忽略不计。将其与 malloc()/free() 插位器进行比较,其中减速速度可以为 4 倍及更高。

现在一个示例 brk 火焰图 (SVG, PNG):

image.png

brk()跟踪可以告诉我们导致堆扩展的代码路径。这可以是:

*  内存增长代码路径
*  内存泄漏代码路径
*  一个无辜的应用程序代码路径,碰巧溢出了当前堆的大小
*  异步分配器代码路径,该路径增加了应用程序,以应对空间减小

它需要一些侦探来区分他们。如果您特别在搜索泄漏,有时你会很幸运,这将是一个不寻常的代码路径,很容易在bug数据库中找到作为已知的泄漏。

虽然 brk() 跟踪显示导致扩展的是什么,但稍后介绍的页面错误跟踪显示随后消耗该内存的内容。

你可能感兴趣的:(运维,linux)