1. 背景:Erlang 多核内存分配的问题
Erlang/OTP 虚拟机(ERTS)的每个调度器(Scheduler)线程都绑定独立的分配器实例(Allocator Instance),以消除全局内存分配锁。每个分配器通过 mmap 向操作系统申请大块连续内存,称为载体(Carrier),并在内部将载体切割为小块(Block)供 Erlang 进程使用。
这种设计在并发性能上具有优势,但在内存使用峰值过后会产生一个问题:某分配器可能持有大量利用率极低的多块载体(仅少数块被占用),而其他分配器却因缺乏可用块而不得不申请新的载体。其结果是系统总体内存需求已经下降,载体数量与物理内存占用(RSS)却不降反增。
2. 载体迁移机制的引入
为解决上述问题,ERTS 引入了载体迁移(Carrier Migration)机制,允许低利用率的多块载体在不同分配器实例之间流动复用。
2.1 所有权(Owner)与经营权(Employer)分离
载体迁移的核心是“所有权与经营权分离”。每个载体包含两个角色:
+----------------------------------------------------+
| Carrier(一块 mmap 内存) |
| |
| Owner(所有者):创建该载体的分配器实例 |
| - 永久不变 |
| - 唯一有权执行 munmap 归还内存 |
| |
| Employer(雇主):当前管理内部块的分配器实例 |
| - 可随迁移改变 |
| - 负责日常分配(allocate)与释放(deallocate) |
+----------------------------------------------------+- Owner:创建载体的分配器实例,生命周期内不变,负责最终内存释放。这保证了释放操作无需跨线程同步,且在 NUMA 系统上载体物理归属节点不会混淆。
- Employer:实际使用载体的分配器实例,可变更。当雇主发现载体利用率低于阈值,可将其“放弃”至全局池;其他急需内存的分配器可从池中“获取”并成为新雇主。
2.2 载体池的无锁实现
供迁移使用的全局载体池(Carrier Pool)是一个无锁环形双向链表。池中包含一个哨兵节点(Sentinel)作为边界标记。池为空时,哨兵的 next 与 prev 均指向自身。
Head(原子指针,可动态变化)
|
v
+-----------+ +-----------+ +-----------+
| Sentinel | <-> | MBC A | <-> | MBC B | <-> ...(环形)
| (哨兵) | | (真实载体)| | (真实载体)|
+-----------+ +-----------+ +-----------+每个池中载体都包含 next / prev 指针及状态标记(IN_POOL、BUSY、HOMECOMING)。为降低高并发下的竞争,池操作采用两项策略:
- 跳过第一个节点:插入或获取时,总是从 Head 指针所指节点的下一节点开始操作,避免所有线程集中修改第一个节点,消除缓存行弹跳热点。
- 方向分离:插入操作沿
next指针顺时针方向遍历,倾向于在靠近哨兵的尾部插入;获取操作沿prev指针逆时针方向遍历,从远离哨兵的另一端开始查找。
Head
|
v
+---------+
+----| MBC A |----+
| +---------+ |
| prev next |
v v
获取方向(逆时针) 插入方向(顺时针)
| |
v v
+---------+ +---------+
| MBC C | | MBC B |
+---------+ +---------+
\ /
+-------+--------+
v
+-----------+
| Sentinel | (插入倾向尾部)
+-----------+线程安全通过原子操作保证,指针修改遵循“先 next 后 prev”的顺序以避免死锁。被移出池的载体还需要经过线程进度机制的延迟回收,确保所有可能引用它的线程完成后,才允许其重新入池或释放。
2.3 线程进度(Thread Progress):安全回收的“时钟锁”
无锁链表中的载体被从池中移除后,只是完成了逻辑删除,不能立刻执行 munmap。原因是其他线程可能在先前的遍历中已经读取到该载体的指针;若此时释放内存,后续解引用将导致野指针访问。
ERTS 的线程进度(Thread Progress)机制可将此类问题理解为一种基于 epoch / generation 的宽限期判断:参与遍历的线程在关键区间报告自身进度;当系统确认所有相关线程都已越过某个进度点时,在该点之前可能持有的旧指针便不再有效,可以安全回收对应载体。
T = 100
|
|-- [线程 B] 开始遍历池子,记录本地进度 = 100,并取得 Carrier X 的指针
|
|-- [线程 A] Fetch 取得 Carrier X(逻辑删除)
| └── 为 X 记录回收标签:tag = 100
| └── 此时不能立即销毁或重新发布 X
|
|-- [线程 B] 结束遍历,不再持有 X 的指针,并推进本地进度
|
|-- [系统] 确认所有可能处于 T = 100 的线程均已离开该区间
|
|-- [线程 A / Owner] 当前进度已越过 tag
| └── 可以安全地重新入池,或执行 munmap
v这里的核心不是让每一次指针访问都修改共享计数,而是通过进度快照批量确认“旧读者已经离场”。这与引用计数的取舍如下:
| 方案 | 优点 | 代价 |
|---|---|---|
| 引用计数 | 直观,能即时得知对象是否仍被引用 | 每次取得或释放引用都需要原子更新,易造成缓存行争用 |
| 线程进度 / Epoch | 读路径通常只需进入、离开受保护区间,适合批量延迟回收 | 回收会延后,必须等待宽限期结束 |
ERTS 选择线程进度机制,正是为了避免高并发读路径上的频繁原子增减操作;它是 Carrier 跨线程迁移后能够安全回收的最后一道保障。
3. 旧算法的 Bad Cluster 问题
在 OTP 17.4 之前,所有获取操作均从哨兵节点开始顺序扫描。
旧算法:所有线程从 Sentinel 开始搜索
Head(固定指向 Sentinel)
|
v
+------------------------------------------------------+
| Sentinel <-- 坏载体 1(碎片多) |
| <-- 坏载体 2(碎片多) |
| <-- 坏载体 3(碎片多) |
| <-- ... 达到搜索限制 |
+------------------------------------------------------+
|
v 搜索配额耗尽,分配失败 -> 强制 mmap
+-----------+
| 好载体 | 被埋在坏集群后面,无法取到
+-----------+当大量碎片化严重、空闲块极小的“低质载体”因利用率低被不断放入池中时,它们逐渐聚集在哨兵附近,形成坏集群(Bad Cluster)。由于每次搜索都必须先遍历这些无法满足需求的载体,当集群长度超过搜索限制时,所有搜索均失败。
调度器被迫分配新载体,这些新载体之后又可能再次成为低质载体入池,形成恶性循环,最终内存耗尽。本质上,这是一个固定入口点导致的遍历阻塞问题。
4. 新算法的改进:自属载体优先与多入口搜索
OTP 17.4 引入私有池树(pooled_tree)和多入口策略来解决坏集群问题。
- 私有池树:每个分配器维护一棵
pooled_tree,仅存储由自己创建(即 Owner)且已放弃到池中的载体。载体被放弃时,若 Employer 正是 Owner,直接加入;否则通过延迟释放队列先归还 Owner 再加入。 搜索优先级:
- 搜索自己的
pooled_tree,优先复用历史自属载体。 - 若
pooled_tree中没有满足请求的块,但存在自己的载体,则以该载体为入口进入全局链表搜索,避免从哨兵开始。 - 最后才考虑复活已标记为待释放的空载体。
- 搜索自己的
新算法:每个调度器用自己的旧载体作为入口
核 A 入口 --> [坏载体 A](A 自己扔的) ---> ...
核 B 入口 --> [坏载体 B](B 自己扔的) ---> ...
核 C 入口 --> [好载体 C](直接命中)
全局池链表:
... <-> [载体] <-> [载体] <-> [Sentinel] <-> ...
^ ^
| |
核 B 入口 核 C 入口该策略使每个分配器拥有独立的链表入口点,分散了搜索起点,有效规避了坏集群堵塞,同时减少了全局池上的竞争。
5. 为何该算法在 NUMA 架构上性能提升更为显著
以上改进解决了功能性缺陷(坏集群),但文档特别指出:we prefer carriers created by the thread itself, which is good for NUMA performance。要理解这一点,需要先了解 NUMA 架构与 UMA(SMP)架构的区别。
5.1 UMA 与 NUMA 简述
UMA(统一内存访问)架构中,所有处理器核心共享同一个内存控制器,访问任意内存位置的延迟完全相同。典型系统为单路 CPU。
+------+ +------+ +------+ +------+
| Core | | Core | | Core | | Core |
+--+---+ +--+---+ +--+---+ +--+---+
| | | |
+---------+---------+---------+----> 统一内存控制器 --> [内存]NUMA(非统一内存访问)架构中,系统划分为多个节点,每个节点包含一组核心及其本地内存。访问本地内存延迟低,访问远端内存需通过片间互连总线。
+-------------------+ 互联总线 +-------------------+
| 节点 0 | <===========> | 节点 1 |
| +---+ +---+ | | +---+ +---+ |
| | C0| | C1| ... | | | C4| | C5| ... |
| +---+ +---+ | | +---+ +---+ |
| [本地内存 0] | | [本地内存 1] |
+-------------------+ +-------------------+
访问本地快;访问远端慢 访问本地快;访问远端慢5.2 算法在 NUMA 上的高效原理
在 NUMA 感知的 Erlang VM 中,调度器线程被绑定到特定 NUMA 节点的核心上。当调度器调用 mmap 创建载体时,操作系统会优先从其所在节点分配物理内存,因此一个调度器自属的载体,物理上通常位于其本地内存。
新算法的搜索优先级带来以下效果:
- 优先复用本地内存:优先使用
pooled_tree中的自属载体,或以自属载体作为全局池入口,可提高复用本地内存的概率,使数据访问更接近低延迟、高带宽的本地节点。 - 避免远端访问的随机惩罚:若无此优先策略,调度器可能随机获取到其他节点创建的载体(远端内存),导致高并发下执行路径中混杂高延迟访问,吞吐量下降,尾延迟恶化。
UMA 与 NUMA 的收益差异:
- 在 UMA 系统上,所有内存延迟一致,复用自属载体主要提供分散入口,解决坏集群这一功能性缺陷,并无显著的本地性性能红利。
- 在 NUMA 系统上,该策略不仅解决了坏集群,更关键的是提升本地节点内存的复用率,减少跨节点远端访问带来的结构性性能损耗。
6. 完整生命周期流程
一个载体的完整生命周期可概括为:
- 创建:调度器 A(Owner)通过
mmap申请本地物理内存,成为载体 X 的所有者。 - 经营:A 作为初始 Employer,从 X 中切分小块供使用。
- 放弃:当利用率低于阈值,A 将 X 从自己的空闲块搜索树中摘除,插入全局载体池及自己的
pooled_tree(若当前 Employer 非 Owner,则通过延迟释放队列回传给 Owner)。 - 获取:调度器 B 按优先级搜索:先查自己的
pooled_tree,再以自属载体为入口搜索全局池,最后才考虑待释放载体。获取后 B 成为新 Employer。 - 延迟回收:X 从池中移除或转交时,记录线程进度标签;在相关线程全部越过该标签前,X 不得重新发布或物理释放。
- 释放:当载体完全为空,Owner(A)在确认线程进度宽限期结束后,调用
munmap归还操作系统。
7. 分配策略与性能权衡
载体迁移要求分配器使用分离式空闲块管理的策略,目前实现的有 aoff、aoffcaobf、aoffcbf。它们在支持迁移的同时会带来一定的数据结构维护开销,其中 aoffcbf 是性能损失较小的折中方案。对延迟极度敏感且无需迁移的场景,仍可使用传统的 gf 或 bf 策略,但可能牺牲内存回收能力。
8. 总结
Erlang 的载体迁移机制由所有权与经营权分离、无锁环形池、方向分离并发控制、自属载体优先的多入口搜索,以及线程进度驱动的延迟回收共同构成。前几项机制使载体能够高效流动、复用并保持 NUMA 本地性;线程进度则确保被移除的载体只有在所有潜在读者离开后才能重新发布或释放。它在解决多核环境下内存复用与回收问题的同时,也将软件层面的复用逻辑映射到硬件的本地节点上,从而在 NUMA 系统上获得更明显的性能收益。