OTP 21 中的内存检测

2018 年 5 月 2 日 · 作者:John Högberg

Erlang/OTP 21 重写了内存检测模块,使其更易于使用。在这篇文章中,我将描述新功能背后的原理以及如何使用它们。

诊断工具最重要的功能之一是能够即时工作。如果需要重启,那么你试图诊断的条件可能会消失,并且你无法使用它来解决“生产”系统上的问题。

之前的实现有几个主要问题:你必须使用特定标志启动 Erlang VM,接受相当大的开销,最糟糕的是在收集所有数据时暂停 VM。

它收集的数据量也相当有问题;每个分配都有一个条目,很难分辨所有这些信息中隐藏了什么,而且由于无法判断两个分配之间的间隙是否被映射,因此在尝试解决内存碎片问题时,使用起来非常困难。

新的实现通过扫描现有数据结构来解决这些问题,以降低其开销,使其可以默认启用,并尝试以不损害系统响应能力的方式收集信息。

载体和内存碎片 #

VM 在我们称之为“载体”的大段内存中分配内存,然后在这些内存中分配块。这有很多好处:由于每个载体与其他载体完全分离,因此很容易确定何时可以将它们返回给操作系统,并且它们的可扩展性非常好,因为我们可以保证它们仅由每个线程实例修改,这使得在大多数情况下分配和释放都是无等待的。

载体有两种类型:单块载体始终包含一个大块,而多块载体可以包含几个较小的块。虽然这两种类型都依赖操作系统来最大限度地减少地址空间碎片,但后者也可能发生内部碎片,如果现有的多块载体无法满足分配要求,即使未使用的内存量超过请求,也会导致创建新的载体。

虽然你可以从 erlang:system_info({allocator, Alloc}) 中收集一些关于平均载体利用率的信息,并使用 pmap(或类似工具)来了解地址空间的碎片程度,但获取关于单个载体的信息一直很麻烦。从 OTP 21 开始,你可以请求系统提供所有载体的列表,而无需使用任何特定标志启动 VM。该列表包含有关每个载体的总大小、组合分配大小、分配计数、是否在迁移池中以及空闲块大小的直方图的信息。

我们选择使用直方图(默认情况下从 512 开始的 log2)来表示空闲块,因为它们可以很容易地一眼看出载体是否存在碎片问题;如果有很多空闲块聚集在左侧,那么可以肯定地说存在问题。

在下面的示例中,ll_alloc 载体根本没有空闲块,binary_alloceheap_alloc 看起来很健康,有一些非常大的块,而 fix_alloc 载体有点碎片化,约 3KB 的空闲空间被分成 22 个小于 512 字节的块(尽管这对这种分配器类型来说不是问题)。

1> instrument:carriers().
{ok,{512,
     [{ll_alloc,1048576,0,1048344,71,false,{0,0,0,0,0,0,0,0,0,0,0,0,0,0}},
      {binary_alloc,1048576,0,324640,13,false,{3,0,0,1,0,0,0,2,0,0,0,0,0,0}},
      {eheap_alloc,2097152,0,1037200,45,false,{2,1,1,3,4,3,2,2,0,0,0,0,0,0}},
      {fix_alloc,32768,0,29544,82,false,{22,0,0,0,0,0,0,0,0,0,0,0,0,0}},
      {...}|...]}}

(可以使用 instrument:carriers/1 来调整直方图和要查找的分配器。)

分配和内存利用率 #

那些使用过 erlang:memory() 的人可能熟悉 system 类别是多么令人恼火的通用。可以使用 erlang:system_info({allocator, Alloc}) 获取更多信息,但它最多只能告诉你,是 (例如) driver_alloc 占用了所有内存,而不会让你知道是哪个驱动程序或 NIF。

虽然在开发过程中很容易分辨出哪个驱动程序或 NIF 导致了问题,但在与另外半打的其他驱动程序或 NIF 一起使用时,就不那么容易了。新的“分配标记”功能将帮助你找出内存的去向,代价是每个分配一个字。分配以块大小直方图(类似于载体信息)的形式呈现,按其来源和类型分组。

2> instrument:allocations()
{ok,{128,0,
     #{udp_inet =>
           #{driver_event_state => {0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0}},
       tty_sl =>
           #{io_queue => {0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             drv_internal => {0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0}},
       system =>
           #{db_segment => {0,0,0,0,0,18,0,0,1,0,0,0,0,0,0,0,0,0},
             heap => {0,0,0,0,20,4,2,2,2,3,0,1,0,0,1,0,0,0},
             thr_prgr_data => {38,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             db_term => {271,3,1,52,80,1,0,0,0,0,0,0,0,0,0,0,0,0},
             code => {0,0,0,5,3,6,11,22,19,20,10,2,1,0,0,0,0,0},
             binary => {18,0,0,0,7,0,0,1,0,0,0,0,0,0,0,0,0,0},
             atom_entry => {8681,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             message => {0,40,78,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0},
             ... }
       spawn_forker =>
           #{driver_select_data_state =>
                 {1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}},
       ram_file_drv => #{drv_binary => {0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0}},
       prim_file =>
           #{process_specific_data => {2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             nif_trap_export_entry => {0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             monitor_extended => {0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             drv_binary => {0,0,0,0,0,0,1,0,3,5,0,0,0,1,0,0,0,0},
             binary => {0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}},
       prim_buffer =>
           #{nif_internal => {0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
             binary => {0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}}}}}

上面的示例是在为所有分配器启用分配标记的情况下获取的(+Muatags true 命令行参数),以便让你更好地了解它的功能。默认情况下,它只为驱动程序/NIF 分配和二进制文件启用,因为这些是最常见的罪魁祸首,并且它们的分配通常都很大,一个字的开销只是沧海一粟。

(与载体一样,可以使用 instrument:allocations/1 来调整直方图和要查找的分配器。)

延伸阅读 #

对于那些想了解更多关于我们的内存分配器如何工作的人,Lukas Larsson 在 EUC 2014 上的演讲是一个很好的入门教程。我们关于 载体迁移线程间释放 的内部文档也可能引起你的兴趣。

实现此更改的 PR 可以在 这里找到,旧的检测模块的文档可以在 这里找到。