如果应用程序内存使用量正在稳步增长,这可能是由于配置错误导致的内存增长,或者由于软件错误导致内存泄漏。对于某些应用程序,因为垃圾内存回收工作困难,消耗了 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() 跟踪显示导致内存扩展的是什么处理(代码路径),稍后介绍的 page fault 跟踪可以显示消耗该内存的内容。

  1. mmap() syscall

mmap() 系统调用可由应用程序显式用于加载数据文件或创建工作段,尤其是在初始化和应用程序启动期间。在此场景中,我们感兴趣的是内存逐渐增长的应用程序,如果分配器使用mmap()而不是 brk(),则可能通过 跟踪mmap()进行调查。通常在分配较大的内存时glibc执行此功能,这些内存分配可以使用 munmap() 返回到系统。

mmap() 调用应该不频繁(如果不确定,请查看前面的 brk调用频率检查方法,并更改事件到 syscall:sys_enter_mmap),因此使用 perf 进行每个事件跟踪可能就足够了。

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

在较新的 Linux 系统 (4.8+) 上,可以使用 Linux eBPF。mmap() 可以通过其内核函数 SyS_mmap() 或 sys_mmap()。在 4.14+ 内核可以通过syscalls:sys_enter_mmap跟踪点进行跟踪。我将在这里使用SyS_mmap函数,并使用我的stackcount bcc 程序显示 eBPF 步骤:

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

与 brk()不同, mmap() 调用并不一定意味着增长,因为它们可能在使用 后不久被munmap()释放。因此,跟踪 mmap() 可能会显示许多新的映射,但大多数或全部既不是增长也不是泄漏。如果系统具有频繁的短期进程(例如,执行软件生成),则 mmap()s作为进程初始化的一部分可能会b被淹没。

与 malloc()/free() 跟踪一样,可以检查映射并关联这些地址,以便可以识别未释放的地址。我会把它留给读者。:-)

与 brk() 跟踪一样,一旦可以识别那些已增长,但没有被 munmap()释放的映射,原因有以下几种:

内存增长代码路径
映射内存泄漏代码路径
异步分配器代码路径,该路径增加了应用程序,以应对空间减小

由于 mmap() 和 munmap() 调用频率较低,因此这也是分析增长的低开销方法。如果经常调用这些,例如,超过一万次/秒,那么开销可能会变得很大。这也将是一个设计不当的分配器或应用程序的标志。

  1. Page Faults

brk() 和 mmap() 跟踪显示虚拟内存扩展。稍后的Page Faults跟踪可以跟踪写入内存时消耗物理内存,或虚拟内存初始化物理映射时导致页面错误的内存扩展活动。此活动可能发生在不同的代码路径中。

与分配器跟踪 malloc()相比,页面故障是低频活动。这意味着开销应该可以忽略不计,您甚至可以只使用 perf 的 per-event dumping选项。
在这之前可以使用perf stat检查调用频率。

# perf stat -e page-faults -I 1000 -a
#           time             counts unit events
     1.000257907                534      page-faults
     2.000581953                440      page-faults
     3.000886622                457      page-faults
     4.001184123                701      page-faults
     5.001474912                690      page-faults
     6.001793133                630      page-faults
     7.002094796                636      page-faults
     8.002401844                998      page-faults
[...]

对于具有 16 个 CPU 的系统,每秒数百页故障。使用 perf 和per-event tracing 的速率可以忽略不计。如果这是一个 CPU 系统,或者速率超过一万/秒,建议使用 eBPF 的内核摘要来降低开销。

使用 perf 和 Flamegraph:

# perf record -e page-fault -a -g -- sleep 30
# perf script > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse-perf.pl < out.stacks | ./flamegraph.pl --color=mem \
    --title="Page Fault Flame Graph" --countname="pages" > out.svg
    

在较新的 Linux 系统 (4.8+) 上,您可以使用 Linux eBPF。页面故障可以通过内核函数(例如,handle_mm_fault())或在 4.14+内核通过跟踪点 t:exceptions:page_fault_user 和 t:exceptions:page_fault_kernel 进行跟踪。这里使用跟踪点,并使用stackcount bcc 程序显示 eBPF 步骤:

# /usr/share/bcc/tools/stackcount 't:exceptions:page_fault_*' > out.stacks
[...copy out.stacks to a local system if desired...]
# ./stackcollapse.pl < out.stacks | ./flamegraph.pl --color=mem \
    --title="Page Fault Flame Graph" --countname="pages" > out.svg
    

下面是一个火焰图示例,这次来自 Java 应用程序 (SVG, PNG):

image.png

有些路径并不奇怪,比如Universe::initialize_heap到os::pretouch_memory。在这种情况下,我发现有趣的是右侧的编译器塔,它显示了由于编译Java方法而导致的内存增长,而不仅仅是数据。

上面的页面错误跟踪显示了不同的初始内存分配代码路径,或者填充物理内存的代码路径。它们是以下的其中一种情景:

内存增长代码路径
内存泄漏代码路径

同样,需要一些调查分析来区分它们。如果您正在查找泄漏,并且具有未增长的类似应用程序,则从每个页面故障火焰图中取取页面故障火焰图,然后查找额外的代码路径,是识别差异的快速方法。如果您正在开发应用程序,那么每天收集基线应该可以让您不仅识别您有额外的增长或泄漏的代码路径,而且识别它出现当天,帮助您跟踪更改。

page fault跟踪的开销可能略高于 brk() 或 mmap() 跟踪,但页面错误仍相对不常见,因此此跟踪方法的开销几乎可以忽略不计。在实践中,我发现页面故障是诊断内存增长和泄漏的一种廉价、快速且通常有效的方法。当然这不能解释一切,但值得一试。

Other Operating Systems

Summary

本文描述了调查内存增长的四种动态跟踪分析技术:

  1. Allocator function tracing
  2. brk() syscall tracing
  3. mmap() syscall tracing
  4. Page fault tracing

这些可以识别虚拟或物理内存的增长,并包括所有增长/泄漏原因。brk()、mmap()和page fault方法不能直接区分出增长还是泄漏,需要进一步分析。但是,它们的优点是开销非常低,因此适合现场生产应用程序分析。这些方法的另一个优点是,通常无需重新启动应用程序即可部署跟踪工具。

Links

See the main Flame Graphs page for other types of flame graphs and links, and the flame graph software.


Scott
39 声望40 粉丝

ORACLE数据库专家,现就职甲骨文SSC部门。