这一章节只是简单教一下内存子系统相关的知识,如果想更深入学习内存子系统可以读一下 "Understanding the Linux Kernel" or "Linux System Programming"

这一章节主要是教两种动态可分配内存:堆、内存映射段

本章节把堆叫做C-heap,以此区分erlang进程里面的堆;

本章节把栈叫做C-stack;

本章节把内存映射段(memory mapped segment)叫做segment

C-heap:通过malloc来分配C-heap的内存

segment:通过mmap来分配segment的内存

11.1 内存子系统

有关erts_alloc的概述,可以看文档:erts\_alloc — erts v16.2

目前有11种不同的分配器、6种不同的分配策略和18种以上的其他不同设置,其中一些设置采用任意数值。

为了能够以任何有意义的方式使用这些设置,我们必须了解这些分配器是如何工作的,以及每个设置如何影响分配器的性能。

erts_alloc手册警告:只有绝对明白你在干嘛的时候再调整这些参数,不合适的设置会导致严重的性能衰减甚至系统崩溃。

11.2 不同类型的内存分配器

详细了解内存是如何分配的,以及不同的分配器是如何工作的,

关于系统可能遇到的问题以及如何分析和纠正行为的精彩故事,请阅读 Fred Hébert 的文章 "Troubleshooting Down the Logplex Rabbit Hole" https://blog.heroku.com/archives/2013/11/7/logplex-down-the-rabbit-hole

内存分配器:管理特定类型内存的分配和释放。

每个分配器都针对特定类型的数据,并且通常针对一种大小的数据进行专门化。

每个内存分配器都实现了分配器接口,该接口可以使用不同的算法和设置来进行实际的内存分配。

这么多不同的内存分配器的目的:是通过将相同大小的分配分组来减少碎片,并通过使频繁的分配成本降低来提高性能。

有两种特殊的、基础的或通用的内存分配器类型:sys_alloc 和 mseg_alloc,以及通过 alloc_util 框架实现的九个特定分配器。

表 1. 内存分配器列表

名称描述C-nameType-nameFlag
基础分配器malloc 接口sys_allocSYSTEMY
内存段分配器mmap 接口mseg_alloc-M
临时分配器临时分配temp_allocTEMPORARYT
堆分配器Erlang 堆数据eheap_allocEHEAPH
二进制分配器二进制数据binary_allocBINARYB
ETS 分配器ETS 数据ets_allocETSE
驱动分配器驱动数据driver_allocDRIVERR
短生命周期分配器短生命周期内存sl_allocSHORT_LIVEDS
长生命周期分配器长生命周期内存ll_allocLONG_LIVEDL
固定分配器固定大小数据fix_allocFIXED_SIZEF
标准分配器大多数其他数据std_allocSTANDARDD
字面量分配器模块常量literal_allocLITERAL(none)

11.2.1. 基础分配器:sys_alloc

不能被禁用,如果没指定用什么allocator,默认使用这个,基本上是对底层操作系统 libc 中 malloc 实现的直接映射。

所有特定分器都使用 sys_alloc 或 mseg_alloc 根据需要从操作系统分配内存。

当从操作系统分配内存时,sys_alloc 可以向请求的数量添加(填充)固定数量的千字节。这可以通过过度分配内存来减少系统调用的次数。默认填充为零。

当释放内存时,sys_alloc 会在进程中保留一些空闲内存。这个空闲内存的大小称为修剪阈值(trim threshold),默认值为 128 kb。这也减少了系统调用的次数,但代价是更高的内存占用。这意味着如果您使用默认设置运行系统,您可能会遇到 Beam 进程不会在释放内存时立即将内存返回给操作系统的情况。

sys_alloc 分配的内存 存储在 beam 进程的 C 堆中,该堆会根据需要通过 brk 系统调用增长。

11.2.2. 内存段分配器:mseg_alloc

如果底层操作系统支持 mmap,特定的内存分配器可以使用 mseg_alloc 而不是 sys_alloc 从操作系统分配内存。

通过 mseg_alloc 分配的内存区域称为段(segments)。当释放一个段时,它不会立即返回给操作系统,而是保存在段缓存中。

当分配一个新段时,如果可能的话会重用缓存的segment,即如果它的大小相同或大于请求的大小,但又不太大。absolute_max_cache_bad_fit 的值决定了被认为不太大的额外大小的千字节数。默认值为 4096 千字节。

为了不对非常小的分配重用 4096 千字节的段,还有一个 relative_max_cache_bad_fit 值,它声明如果缓存的段大于百分比就不能使用。默认值为 20%。也就是说,当请求 10 KB 的段时,可以使用 12 KB 的段。

缓存中的条目数默认为 10,但可以设置为从零到三十的任何值。

11.2.3. 内存分配器框架:alloc_util

建立在 sys_alloc 和 mseg_alloc 之上的就是这个框架 alloc_util

用于为不同类型的使用和数据实现特定的内存分配器。

该框架在 erl_alloc_util.[ch] 中实现,ERTS 使用的不同分配器在目录 "erts/emulator/beam/" 中的 erl_alloc.types 中定义。

在 SMP 系统中,通常每个调度器线程(Scheduler thread)有一个每种类型的分配器。

分配器可以使用的最小内存单元称为块(block)。当您调用分配器来分配一定量的内存时,您得到的是一个块。当您想要释放内存时,您作为参数提供给分配器的也是块。

分配器并不直接从操作系统要到block。相反,分配器从操作系统分配一个载体(carrier),可以通过 sys_alloc 或通过 mseg_alloc,后者又使用 malloc 或 mmap。如果使用 sys_alloc,载体被放置在 C 堆上;如果使用 mseg_alloc,载体被放置在段中。

多块载体(multi block carrier) :

用来放小块的,可以放多个块;大多数分配器还有一个"主多块载体",它永远不会被释放;适合频繁的、较小的分配,默认情况下,分配器通常以大约 8 MB 的块请求多块载体(可通过系统标志调整),有效地处理常见的 Erlang 内存模式。

单块载体(single block carrier):

较大的块放在这里,只能放一个块;超过单块载体阈值的分配被放置在直接从操作系统分配的单块载体中。

单块载体阈值(singleblock carrier threshold, sbct):

一个用来决定什么块是大块或是小块的边界,大于 sbct 值的分配使用单块载体,而较小的分配使用多块载体;

默认情况下,sbct 阈值设置为较大的分配,那些明显大于典型 Erlang 术语的分配,被隔离在单块载体中。较小的对象,构成大多数分配,有效地共享多块载体中的空间;

何时调整 sbct:

程序如果有不太正常的内存分配,这个sbct是可以调整的,调整它会影响运行时系统如何平衡碎片(由多块载体中的大分配引起)和开销(由于许多单块载体)之间的平衡;

增加 sbct 会将更多分配引导到多块载体中,改善内存重用并减少碎片。如果您的应用程序频繁分配中等大小的数据结构,导致碎片或频繁的操作系统级内存请求,这尤其有益。

然而,降低 sbct 会强制更多分配进入单块载体,当您的应用程序偶尔分配大内存块时,这是有意义的。单独管理这些大分配简化了它们的回收,并防止与较小分配的干扰。

如果单独调整 sbct 不能解决频繁的次要垃圾收集——通常由于大量短期分配——考虑增加进程的初始堆大小(min_heap_size)以减少分配流失。

在实践中,默认的 sbct 设置适合大多数 Erlang 应用程序。只有在性能分析指出碎片、内存开销或不寻常的分配模式的特定问题时才微调此参数。

大多数分配如果都是小块存储在多块载体里面是更好的:

多块载体因其对典型 Erlang 工作负载的效率而受到青睐——许多小的、短暂的分配。HiPE 团队的研究表明,大多数 Erlang term都很小(少于八个字),很好地适合这些多块载体。

典型的 ERTS 分配器通常维护:

• 每个分配器一个主多块载体,用于处理频繁的、小尺寸的分配。它很少释放这个载体,在内部重用释放的块以减少碎片。

• 多个单块载体,每个为大对象或二进制文件单独分配。一旦这些块被释放,单块载体会立即返回给操作系统。

块、载体和分配策略:

为了在多块载体中找到空闲的内存块,会使用分配策略。每种类型的分配器都有默认的分配策略,但您也可以使用 as 标志设置分配策略。

Erlang 运行时系统应用程序参考手册列出了以下分配策略:

• 最佳拟合(Best fit):找到满足请求块大小的最小块。(bf)

• 地址顺序最佳拟合(Address order best fit):找到满足请求块大小的最小块。如果找到多个块,选择地址最低的那个。(aobf)

• 地址顺序首次拟合(Address order first fit):找到满足请求块大小的地址最低的块。(aoff)

• 地址顺序首次拟合载体最佳拟合(Address order first fit carrier best fit):找到可以满足请求块大小的地址最低的载体,然后使用"最佳拟合"策略在该载体内找到一个块。(aoffcbf)

• 地址顺序首次拟合载体地址顺序最佳拟合(Address order first fit carrier address order best fit):找到可以满足请求块大小的地址最低的载体,然后使用"地址顺序最佳拟合"策略在该载体内找到一个块。(aoffcaobf)

• 良好拟合(Good fit):尝试找到最佳拟合,但在有限的搜索中满足于找到的最佳拟合。(gf)

• A 拟合(A fit):不搜索拟合,只检查一个空闲块以查看它是否满足请求。此策略仅用于临时分配。(af)

分配策略的选择影响空闲内存的重用效率,并直接影响碎片和性能。大多数分配器的默认策略通常是最佳拟合(bf)或地址顺序最佳拟合(aobf),它在内存利用率和分配速度之间取得平衡。

可以为每个分配器配置替代策略(使用 +M \<S\> as 系统标志)。选择不同的策略可以缓解内存碎片,但代价是在分配搜索期间更高的 CPU 开销。

11.2.4. 临时分配器:temp_alloc

您可以使用 temp_alloc 作为一个小的暂存或工作区,在函数中执行某些工作时使用。将其视为 C 栈的扩展,并以相同的方式释放它。也就是说,为了安全起见,在进行分配的函数返回之前释放由 temp_alloc 分配的内存。erl_alloc.types 中有一条注释说,您应该在模拟器开始执行 Erlang 代码之前释放 temp_alloc 块。

请注意,在与分配器相同的调度器上运行的任何 Erlang 进程在块被释放之前都不能开始执行 Erlang 代码。这意味着您不能在 BIF 或 NIF 陷阱(yield)上使用临时分配。

在默认的 R16 SMP 系统中,有 N+1 个 temp_alloc 分配器,其中 N 是调度器的数量。temp_alloc 使用"A 拟合"(af)策略。由于 temp_alloc 的分配模式基本上是栈的模式(大小大多为 0 或 1),这个策略运行良好。

11.2.5. 堆分配器:eheap_alloc

堆分配器管理进程私有数据的内存块:存储在进程堆(新生代和老生代)上的 Erlang term,以及相关结构如堆片段和 beam 寄存器。进程创建的几乎每个 Erlang term最终都在通过 eheap_alloc 获得的内存。

默认情况下,每个调度器都有一个 eheap_alloc 实例,因此在该调度器上运行的进程的内存保持大部分本地化,减少了争用。分配器的频繁任务包括:

• 进程堆:每个 Erlang 进程都有自己的堆,在其中存储数据,如元组、列表、映射、整数和任何小二进制文件(≤ 64 字节)。

• 堆片段:如果进程短暂需要更多内存但不能立即进行 GC(例如,构建大消息),VM 可能会从 eheap_alloc 分配"堆片段"。在下一次垃圾收集时,这些片段被合并或释放。

• 寄存器数组:一些运行时实现细节(例如,"beam_registers"数据结构)也使用 eheap_alloc。作为 Erlang 开发人员,您通常通过控制进程堆大小或了解进程何时生成大量短暂数据来优化 eheap_alloc 的使用。良好的实践包括如果进程频繁处理大数据,则仔细调整进程堆的大小,而不是允许许多次要 GC。

11.2.6. 二进制分配器:binary_alloc

二进制分配器用来处理二进制文件的内存。具体来说,它管理大于 64 字节的二进制文件(称为 refc 二进制文件),将它们存储在堆外并使用引用计数来跟踪它们的使用。每个进程堆只保存一个小包装器(ProcBin)指向实际的二进制文件。

这些二进制文件差异很大,从适度大小的二进制文件(数百字节)到以兆字节为单位的巨大二进制文件(如整个文件内容或来自网络的外部数据)。

分配器有几个有趣的特性:

• 最佳拟合策略:它选择最小的合适空闲块来存储新的二进制文件,这减少了碎片,可以将其视为一个细心的俄罗斯方块玩家,将二进制文件整齐地放入内存中。

• 引用计数:堆外二进制文件会一直存在,直到最后一个进程停止引用它们。如果只有一个被忘掉的进程还引用了一个巨大的二进制文件,它会一直存活,有时就很烦。

• 子二进制(切片):当您匹配二进制文件如 <<Rest/binary>> 时,较小的二进制文件(Rest)仍然引用原始的更大二进制文件,避免复制是很好的,直到您意识到您不小心让一个巨大的二进制文件存活只是为了引用一小部分。在这些情况下,调用 binary:copy/1 是比较好用。

如果内存神秘地增长,检查由于引用在进程中徘徊而挂起的大二进制文件。偶尔,您可能需要温柔地提醒垃圾收集器(通过 erlang:garbage_collect/1)或让进程休眠以更快地回收内存。

11.2.7. ETS 分配器:ets_alloc

分配器 ets_alloc 管理您心爱的 ETS(Erlang term Store) Table的内存。默认情况下,ETS 表不绑定到单个进程,这意味着即使您的进程清理垃圾,它们也会存在。请记住几个关键细节:

• 长期存在的数据:一旦您将数据放入 ETS 表中,它就会从进程堆中移出并进入自己合适的分配器。标准进程垃圾收集不会清理此空间。如果您想要内存返回,您必须显式删除数据或删除表。

• 多样化的用例:此分配器处理与 ETS 相关的所有内容——从经典哈希表到 ordered_set 结构和内部元数据。常用的或大型表很容易成为猛吃内存的地方,所以要小心。

• 短期存在的 ETS 数据:有时 ETS 可能会暂时借用其他分配器,用于快速任务如匹配或中间结果,但主要数据存在于 ets_alloc 中。

因为 ETS 表可能会增长到令人惊讶的大,偶尔使用诸如 ets:info(Tab, memory) 之类的函数检查它们的大小,或通过系统指标关注整体 ets_alloc 使用情况。

11.2.8. 驱动分配器:driver_alloc

驱动分配器处理端口、链入驱动程序和 NIF 资源的内存。简单来说,如果您超出了 Erlang 的安全网(提供的自动内存安全机制)——与 I/O 驱动程序、外部库或文件描述符交互——您最终会到这里。要记住的关键点:

• 端口和驱动数据:这里的分配包括网络套接字、打开的文件描述符(fd)和特定于您的链入驱动程序的缓冲区的结构。

• NIF 分配的数据:当 NIF 使用 enif_alloc 时,内存最终来自此分配器。VM 礼貌地等待,直到您调用 enif_free,或您的 NIF 对象优雅地退出舞台,以回收此空间。

• 外部泄漏的可能性:因为 NIF 和驱动程序绕过 Erlang 通常的内存安全规则,行为不当的驱动程序可能会无意中保留内存,造成数字等价物的漏水龙头。密切关注您的 driver_alloc 使用情况有助于捕获这些滴漏。

虽然开发人员很少直接操作 driver_alloc,但在生产环境中监控此分配器是明智的,尤其是如果您使用 NIF**。

%% 在 Erlang 的安全网内 - 自动管理
List = [1, 2, 3], % 内存自动分配
Binary = <<1, 2, 3>>. % 会被GC自动回收

%% 超出 Erlang 的安全网 - 需要手动管理
%% 使用 NIF(Native Implemented Function)
my_nif:external_function() % 调用C代码

因为使用 driver_alloc(驱动分配器)意味着:

  1. 风险更高:Erlang 的 GC 无法帮你清理这些内存
  2. 责任转移:内存管理的责任从 Erlang VM 转移到了你(程序员)
  3. 可能出错:如果 NIF/驱动程序有 bug,忘记释放内存,就会造成内存泄漏

11.2.9. 短生命周期分配器:sl_alloc

短生命周期分配器(sl_alloc)处理数据结构的内存,其生命周期超过了临时分配的一下就没了(blink-and-you-miss-it)的性质,但也不会差太多。可以将其视为存在时间足够长以打个招呼并快速喝杯咖啡然后离开的内存。典型示例包括:

• 中间缓冲区:短操作所需的小缓冲区,在调度点之间短暂徘徊——但绝对不打算过夜。

• 短暂列表:临时运行时结构——如快速系统消息缓冲区或短暂的调度元数据——几乎在您注意到它们时就消失了。

Erlang/OTP 利用 sl_alloc 进行瞬态操作,如匹配状态对象、短暂的 I/O 缓冲区和其他短暂的实体。虽然这些分配可以比真正的临时内存存活更久,但它们仍然会迅速消失。

如果您的应用程序特别健谈(chatty)——生成大量短期分配(如频繁的小驱动程序调用或简短计算),sl_alloc 可能会显著升温。使用诸如 recon_alloc:usage() 之类的工具检查使用情况可以告诉您 sl_alloc 是否被过度使用。

11.2.10. 长生命周期分配器:ll_alloc

长生命周期分配器处理打算长期存活的数据,通常与 Erlang 节点本身一样长。典型示例包括:

• 原子:一旦创建,原子将无限期地持续存在,使它们成为 ll_alloc 的永久居民。

• 加载的模块和代码:编译的模块、导出的函数和与匿名函数(funs)相关的元数据存储在这里。例如,当您通过 code:load_file(my_module) 加载模块时,其元数据会进入 ll_alloc。

• 调度器和系统结构:内部运行时结构——如调度器运行队列(run_queue)、pollset 信息(pollset)和进程注册表(proc_tab)——存在于这里,因为它们是 VM 操作的基础。

因为 ll_alloc 中的对象倾向于存在,所以此分配器通常在节点的生命周期内缓慢但持续地增长。如果您的系统频繁加载和卸载模块,您可能会看到波动。例如,重复执行热代码加载而不正确卸载旧版本可能会逐渐增加 ll_alloc 中的内存使用。

在实践中,大多数系统不会从这里回收太多内存,除非您显式卸载模块或执行节点重启。因此,偶尔浏览 recon_alloc:usage(ll_alloc) 以检测意外峰值或碎片是明智的——尤其是如果您的应用程序动态管理模块或大量长期数据。

11.2.11. 固定大小分配器:fix_alloc

固定分配器 fix_alloc 专门用于分配固定大小的对象——通常是大小永远不会改变的小 C 结构(例如,消息引用、驱动事件数据、监视器)。由于这些对象具有统一的大小,分配器可以有效地处理它们:

默认情况下,fix_alloc 使用"地址顺序最佳拟合",将释放的对象整齐地返回到相同大小块的列表中,有效地最小化碎片。可以将其想象为完美地堆叠相同的乐高积木,而不是随机地将不同大小的碎片扔进盒子里。

示例包括内部 VM 结构,如 ErlMessage、监视器引用和调度器簿记数据。

虽然开发人员通常不会直接与 fix_alloc 交互,但它在系统级别至关重要。这里的高效分配确保运行时不会因管理微小的碎片分配而陷入困境,帮助 Erlang 保持其优雅处理并发的名声。

11.2.12. 标准分配器:std_alloc

当内存不能整齐地适合 Erlang 的专用分配器桶(Erlang’s specialized allocator buckets)时,它会在 std_alloc 中找到一个家——运行时的多功能万能分配器。可以将其视为 Erlang 的内存"杂项抽屉"。

此分配器处理各种各样的分配。这可以包括对模糊短期或长期存在的短暂数据的引用、动态大小的结构、没有明确分类的 VM 子系统数据,或者只是太独特而无法整齐地分类到其他地方的内存分配。

就像其他分配器一样,您可以使用启动标志(如 +Ms 或 +Msbct)调整 std_alloc 行为。通常,不用去动他,但知道如果要改的话这里是可以调的也挺好。

在故障排除中,std_alloc 可能成为无法解释的内存峰值的主要嫌疑。诸如 erlang:system_info({allocator, std_alloc}) 或 recon_alloc 之类的工具可以快速揭示它是否囤积了比您预期更多的内存。

由于 std_alloc 收集那些无法整齐分类的内存请求,它在繁忙的节点上积累大量使用是正常的。如果您看到此分配器持续增长,这通常是一个信号,需要仔细检查应用程序行为或重新审视您对"正常"的假设。

11.2.13. 字面量分配器:literal_alloc

literal_alloc 存储编译时常量,通常称为字面量池,在加载的 Erlang 模块中。可以将其视为 VM 的"只读内存架",Erlang 在那里安全地存储常量,如在编译时定义的大静态二进制文件、元组或列表。

与典型的分配器不同,literal_alloc 是全局管理的,而不是每个调度器管理的,因为字面量在运行时不经常修改或回收。一旦加载,这些字面量会一直存在,直到它们相应的模块被显式清除或重新加载。

监控 literal_alloc 通常是平淡无奇的,但频繁的动态模块更新或重复加载包含大量字面量的模块可能值得一看。

11.11 建议

• 除非出现问题,否则从默认设置开始。

• 在调优之前监控分配器使用情况(erlang:system_info/1, recon_alloc)。

• 在受控环境中逐步测试更改。

• 没有基准测试和性能分析,避免激进的调优。

上述大多数内存分配器标志高度依赖于实现。它们的行为、可用性和默认值可能会在没有事先通知的情况下更改或完全删除。此外,运行时(erts_alloc)可能会根据内部启发式或系统约束忽略或调整提供的设置。始终使用实际系统指标和测试来验证设置。

11.12 进程内存

正如我们在第3章中看到的,进程实际上只是一些内存区域,在本章中我们将更仔细地研究栈、堆和邮箱是如何管理的。

栈和堆的默认大小是233个字(words)。可以在启动Erlang时通过+h标志全局更改此默认大小。您还可以在使用spawn_opt启动进程时通过设置min_heap_size来设置最小堆大小。

正如我们在第4章中看到的,Erlang term是带标签的,当它们存储在堆上时,它们要么是cons单元,要么是装箱对象(boxed objects)。

11.12.1 Term共享

堆上的对象在一个进程的上下文context中通过引用传递。如果您使用元组作为参数调用一个函数,那么只有对该元组的带标签引用会传递给被调用的函数。当您构建新term时,您也只会使用对子术语的引用。

这很好,因为它成本低且使用的空间很少。但是,如果您将元组发送到另一个进程或执行任何其他类型的IO,或任何导致所谓深拷贝的操作,那么数据结构将被展开。因此,如果我们将元组T发送到另一个进程P2(P2 ! T),那么T2的堆将获得一个元组,其中第一个元素指向字符串的一个副本,第二个元素指向另一个副本,使用的空间翻倍。您可以在下面的消息传递部分中看到这个结果。

如果您有嵌套的共享元组,这种深拷贝时的复制将随着嵌套级别呈指数增长。您可以通过展开高度共享的术语快速使Erlang节点崩溃,参见share.erl。

11.12.2 消息传递

当进程P1向另一个(本地)进程P2发送消息M时,进程P1首先计算M的扁平大小(flat size)。然后它通过在本地调度器上下文中对heap_frag执行heap_alloc来分配该大小的新消息缓冲区。

然后P1开始向P2发送消息M。erl_message.c中的代码首先计算M的扁平大小(在我们的示例中为23个字)。然后(在SMP系统中)如果它可以对P2加锁并且P2的堆上有足够的空间,它将把消息复制到P2的堆上。

如果P2正在运行(或退出)或堆上没有足够的空间,则分配一个新的堆片段(大小为 ErlHeapFragment的大小 - Eterm的大小 + 23*Eterm的大小),初始化后将如下所示:

erl_heap_fragment:
ErlHeapFragment* next; NULL
ErlOffHeap off_heap:
erl_off_heap_header* first; NULL
Uint64 overhead; 0
unsigned alloc_size; 23
unsigned used_size; 23
Eterm mem[1]; ?
... 22 free words

然后消息被复制到堆片段的mem部分,并更新first指针。

在任何一种情况下,都会分配一个新的邮箱(ErlMessage),对接收者加锁(ERTS_PROC_LOCK_MSGQ),并将堆上或新堆片段中的消息链接到邮箱中。

然后邮箱被链接到接收者的输入消息队列(msg_inq)中,并释放锁。

11.12.3 二进制
内部有四种二进制类型,三种是:堆二进制、子二进制(Sub Binaries)、匹配上下文,这些是存储在本地堆上的,由垃圾回收器和消息传递处理对待其他对象一样处理,并且根据需求进行复制。
引用计数(reference counting)
第四种就是:特别存储在进程堆外部的大二进制或引用二进制(large binaries或refc binaries),这些是被引用计数的(用来管理大型二进制数据的生命周期)。
存储方式:refc binary的有效载荷(payload)存储在进程堆之外,由binary allocator分配的内存中。进程堆上只存储一个小的引用结构,称为ProcBin。
引用计数机制:
每当通过ProcBin引用一个refc binary时,该binary的引用计数就会增加1
所有进程堆上的ProcBin对象都链接在一个链表中
垃圾回收后,会遍历这个链表,对于每个已死亡的ProcBin,将binary的引用计数减1
当引用计数降到0时,该binary就会被释放
优势:这使得发送大型二进制数据到其他进程时非常高效,因为不需要复制整个binary,只需要复制小的ProcBin引用。
潜在问题:由于引用只有在垃圾回收时才会被检测为"死亡",大型binary可能会在所有引用实际死亡后仍然存活很长时间,直到所有见过该binary的进程都进行了垃圾回收。
不幸的是,作为开发者,并不是很容易看出哪些进程在垃圾回收意义上"见过"一个二进制。
例子:
在这段代码中,有一个不需要进行垃圾回收的循环示例(完整示例见lb列表):
loop(Workers, N) ->
receive
WorkItem ->
Worker = lists:nth(N+1, Workers),
Worker ! WorkItem,
loop(Workers, (N+1) rem length(Workers))
end.
这个服务器会不断抓取对二进制的引用而永远不释放它们,最终会耗尽所有系统内存。
当意识到这个问题后,修复起来很容易。可以在循环的每次迭代中执行garbage_collect,或者每隔五秒左右执行一次,方法是在receive中添加一个after子句:
after 5000 → garbage_collect(), loop(Workers, N)
子二进制和模式匹配(Sub Binaries and Matching)
当你匹配出二进制的一部分时,你会得到一个子二进制。这个子二进制是一个小结构,只包含指向真实二进制的指针。这会增加原来的大二进制的引用计数,但只使用很少的额外空间。
如果匹配操作要创建被匹配部分的新副本,那将会消耗空间和时间。因此,在大多数情况下,只对二进制进行模式匹配并获得一个子二进制来处理,正是你想要的。
但存在一些退化情况。例如,想象你将一个巨大的文件(比如一本书)加载到内存中,然后匹配出一小部分(比如一个章节)来处理。问题是,在你处理完这个章节之前,整本书的其余部分仍然保留在内存中。如果你对许多书都这样做,也许你想获取文件系统中每本书的引言,那么你会将每本书的全部内容保留在内存中,而不仅仅是引言章节。这可能导致巨大的内存使用。
解决方案是,当你知道只想要大型二进制的一小部分,并且希望这个小部分保留一段时间时,使用binary:copy/1。这个函数只用于其副作用,即实际将子二进制从真实二进制中复制出来,移除对较大二进制的引用,从而有望让它被垃圾回收。
在Erlang文档中有关于二进制构造和匹配如何完成的相当详尽的解释:http://www.erlang.org/doc/efficiency_guide/binaryhandling.html
11.13 其他有趣的内存区域
11.13.1 原子表(The atom table)
Erlang中的原子(Atoms)在内部实际上是用整数来表示的。
所有原子都存储在一个称为原子表的全局结构中。原子表是一个固定大小的结构,这意味着运行中的Erlang系统中可以存在的原子数量有上限(默认为1,048,576个原子)。虽然这听起来是个很大的数字,但粗心的使用(特别是从外部数据动态创建原子)可能导致原子耗尽,进而导致整个BEAM虚拟机崩溃。
原子表中的每个条目都包含关于原子的元数据,包括:
字符串表示(原子本身的文本)
运行时系统使用的唯一内部标识符(internal identifier)
附加信息,如引用计数和在模块或函数中的使用详情
Erlang通过三种关键的内存分配器类型来维护原子:
atom_text:包含原子的字符串表示,存储原子的实际文本
atom_tab:存储原子表本身——一个用于快速查找的哈希表结构
atom_entry:为每个原子的元数据分配内存(内部表示、使用计数等)
重要特性:原子永远不会被垃圾回收。 一旦创建了原子,它会一直存在直到虚拟机关闭。这个设计决策简化了实现(并提高了查找性能),但也带来了风险:不受控制的原子创建(通常通过list_to_atom/1等动态原子生成)可能导致原子表耗尽。一旦达到原子限制,尝试创建新原子会导致运行时错误,可能导致节点崩溃。
常见问题场景:
直接将用户提供的数据转换为原子
解析大量不可信的外部输入
在循环或递归函数中重复生成原子
避免问题的方法:
验证输入:在转换为原子之前,始终验证或白名单(whitelist)用户输入
优先使用其他类型:尽可能使用现有原子,或者如果需要动态标识符,优先使用二进制或字符串
监控使用情况:定期使用Observer工具或内置函数监控原子表使用情况:
erlang:system_info(atom_count) - 当前加载的唯一原子数量
erlang:memory(atom) - 原子使用的总字节数(包括开销)
erlang:memory(atom_used) - 仅实际原子字符串使用的字节数
如果达到原子限制,有两个实用解决方案:
增加原子表大小(这通常是短期权宜之计,不应替代良好的原子使用习惯):
erl +t <new_max_atoms>
重新设计应用程序以避免无限制创建原子——通常使用二进制、字符串或整数标识符代替
安全转换方法:
使用list_to_existing_atom/1函数可以安全地将字符串转换为原子,而不会有原子耗尽的风险。此函数仅在原子已存在时才会成功。
11.13.2. 代码区(Code)
另一个重要的内存区域是代码区,编译后的Erlang模块会加载到这里。Erlang模块一旦编译,就会加载到Erlang运行时系统的这个代码内存区域中,该区域在所有进程之间共享。代码区通常是静态(static)和持久的(persistent),因为模块会一直保持加载状态,除非显式卸载或替换(通过热代码加载)。
版本管理:
当使用l(Module)或code:load_file(Module)等函数加载或重新加载模块时,旧代码不会立即删除,而是作为"旧"版本保留,直到没有进程引用它。Erlang同时维护每个模块的两个版本。这允许安全升级而不会中断运行中的进程。
常量池:
在Erlang代码中定义的常量(如数字、原子和二进制)存储在模块代码段内的常量池中。这些常量在单个模块内的内存使用效率很高,因为它们只存储一次。但是,当常量在模块外部使用时(如消息传递或插入ETS表),它们会被复制到接收进程的堆上,可能会显著增加总体内存使用量。
监控代码内存:
监控和管理代码内存使用至关重要,特别是在频繁执行热代码升级的长期运行系统中。可以使用以下内置函数检查:
code:all_loaded/0 - 查看已加载的模块
erlang:memory(code) - 监控已加载模块及其常量使用的总内存

补充:

ERTS 核心底层优化:MBC 内存池与跨核迁移机制 (Carrier Migration)
在多核(SMP)环境下,Erlang 为了避免多个调度器频繁向操作系统申请内存产生锁竞争,并解决各核之间内存负载不均的问题,设计了一套复杂的Carrier 迁移机制。

  1. 核心概念:MBC (Multi-Block Carrier)
    •Carrier (载体):ERTS 向 OS 申请的一整块大内存(通常为 2MB)。
    •Block (块):代码中实际申请的小内存单元(如 32 字节的元组)。
    •MBC:专门存放小对象的载体,内部会切分成多个 Block。每个调度器都有自己私有的 MBC 链表。
  2. 核心痛点:多核负载不均 (Imbalance)
    •现象:调度器 A 忙于处理业务,分配了大量 MBC 导致内存占用高;而调度器 B 此时很闲,其持有的 MBC 大量空闲却不释放。
    •后果:系统整体内存碎片率升高,且调度器 A 必须不断执行昂贵的系统调用(mmap)去申请新内存。
  3. 终极方案:mbcs_pool(Multi-Block Carriers Pool)
    为了实现内存的“二次利用”,ERTS 引入了MBC 迁移池逻辑:
    •抛弃 (Abandonment):
    •当一个调度器在 GC 后发现某个 MBC 的利用率低于阈值(即大部分 Block 已释放),它不会将其归还 OS。
    •该调度器将该 MBC 标记为 "Abandoned",并将其存入全局的mbcs_pool。
    夺取 (In-migration / Adoption):
    •当另一个调度器(内存紧张)需要新的 MBC 时,优先不去向 OS 申请。
    •它会先扫描mbcs_pool,如果发现别人抛弃的 MBC,直接“过户”到自己名下,重用其中的空闲 Block。
    无锁化设计:
    •为了防止这个全局池子变成新的锁瓶颈,mbcs_pool采用了基于原子操作的无锁链表(Lock-free List)或细粒度锁实现。
  4. 机制触发逻辑
    1.分配请求:进程需要申请内存。
    2.局部查找:在当前调度器的私有 MBC 链表中查找空闲空间。
    3.全局捞取:若私有空间不足,检查mbcs_pool是否有现成的“弃件”。
    4.系统申请:若池子也空,才调用mmap向 OS 申请新 Carrier。
    5.空闲检测:当释放 Block 导致整个 MBC 利用率极低时,根据策略决定:保留/抛弃到池子/归还给 OS。
  5. 开发者视角
    •性能提升:极大地减少了多核服务器在超高频内存申请时的系统调用次数。
    •自省工具:通过erlang:system_info({allocator, binary_alloc})可以看到mbc_pool的具体统计(如abandoned_carriers数量),用于分析是否存在内存碎片或分配不均的问题。