OTP 21 中的内存检测
Erlang/OTP 21 重写了内存检测模块,使其更易于使用。在这篇文章中,我将描述新功能背后的原理以及如何使用它们。
诊断工具最重要的功能之一是能够即时工作。如果需要重启,那么你试图诊断的条件可能会消失,并且你无法使用它来解决“生产”系统上的问题。
之前的实现有几个主要问题:你必须使用特定标志启动 Erlang VM,接受相当大的开销,最糟糕的是在收集所有数据时暂停 VM。
它收集的数据量也相当有问题;每个分配都有一个条目,很难分辨所有这些信息中隐藏了什么,而且由于无法判断两个分配之间的间隙是否被映射,因此在尝试解决内存碎片问题时,使用起来非常困难。
新的实现通过扫描现有数据结构来解决这些问题,以降低其开销,使其可以默认启用,并尝试以不损害系统响应能力的方式收集信息。
载体和内存碎片 #
VM 在我们称之为“载体”的大段内存中分配内存,然后在这些内存中分配块。这有很多好处:由于每个载体与其他载体完全分离,因此很容易确定何时可以将它们返回给操作系统,并且它们的可扩展性非常好,因为我们可以保证它们仅由每个线程实例修改,这使得在大多数情况下分配和释放都是无等待的。
载体有两种类型:单块载体始终包含一个大块,而多块载体可以包含几个较小的块。虽然这两种类型都依赖操作系统来最大限度地减少地址空间碎片,但后者也可能发生内部碎片,如果现有的多块载体无法满足分配要求,即使未使用的内存量超过请求,也会导致创建新的载体。
虽然你可以从 erlang:system_info({allocator, Alloc})
中收集一些关于平均载体利用率的信息,并使用 pmap
(或类似工具)来了解地址空间的碎片程度,但获取关于单个载体的信息一直很麻烦。从 OTP 21 开始,你可以请求系统提供所有载体的列表,而无需使用任何特定标志启动 VM。该列表包含有关每个载体的总大小、组合分配大小、分配计数、是否在迁移池中以及空闲块大小的直方图的信息。
我们选择使用直方图(默认情况下从 512 开始的 log2)来表示空闲块,因为它们可以很容易地一眼看出载体是否存在碎片问题;如果有很多空闲块聚集在左侧,那么可以肯定地说存在问题。
在下面的示例中,ll_alloc
载体根本没有空闲块,binary_alloc
和 eheap_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 上的演讲是一个很好的入门教程。我们关于 载体迁移 和 线程间释放 的内部文档也可能引起你的兴趣。