Pony 的区域分配器

Lobsters Hottest 工具

摘要

这篇文章描述了一种为 Pony 语言设计的新区域分配器,它解决了在压力测试中发现的内存增长和性能问题,设计灵感来源于 snmalloc。

<p><a href="https://lobste.rs/s/vnpw9j/pony_s_arena_allocator">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/17 00:00

# Pony 的 Arena 分配器 - Pony 来源:https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/ https://github.com/ponylang/ponylang-website/edit/main/docs/blog/posts/ponys-arena-allocator.md事情是这样的。长期以来,我们每天都在对 ponyc 中的 TCP 系统进行压力测试。这些测试已经很久没有失败过了。对于“没有测试失败”有两种看法:要么是你的系统坚如磐石,要么是你的测试没有覆盖到足够的代码库。我早就觉得,压力测试没有失败是因为“测试不够好”。 几个月前,我为此采取了一些行动。我对每次运行都做同样事情的 TCP 压力测试进行改进,加入了一些“群集测试的妙处”——在每次运行中随机化它们的行为和执行方式。这样一来,更多的代码被测试到了,结果就冒出了一堆 bug。 这篇文章就是关于其中一个“bug”的。 在某些使用模式下,内存会无限增长。稍作调查发现,这并不是 TCP 代码本身的 bug。测试触发了 Pony 运行时分配器中的一系列有趣的边缘情况。 旧的分配器以可能导致我遇到的失败压力测试实例那种退化情况的方式持有内存,有三个原因。 1. 在 Pony 中,消息由发送者分配,由接收者释放。大约一半的释放操作发生在执行分配的线程之外的另一个线程上。在错误线程上释放的大块内存会保持保留状态,永远不会被回收。一个在各线程间传递内存块的工作负载,对它分配和释放的每个内存块都会保留新的地址空间,没有限制。在处理一万个内存块时,内存仍在增长,大约每个块占用 4.3 MiB 的地址空间。 2. 一旦内存被分割成 32 字节的槽位,它就会永远持有 32 字节的对象。分配器永远不会为其他尺寸返回它。 3. 两个相邻的空闲块永远不会合并。旧的分配器维护一个排序的空闲列表,处理混合大小的工作负载时,遍历它会变得极其缓慢。在 4,000 个块时,分配耗时 0.49 秒。在 16,000 个块时,耗时 39.6 秒。64,000 个块在九分钟内无法完成。 这三个问题都源于同一个地方:旧的分配器使用一个全局池,其中按大小类别划分的空闲列表由所有线程共享。没有线程追踪它分配了哪些内存。 解决这三个问题的方法:让每个线程追踪它分配的内存——哪些在使用,哪些空闲,以及一个区域何时为空。这种追踪是旧分配器所缺乏的。有了它,分配器可以合并已释放的内存、将空区域归还给操作系统,并跨线程重用内存。 ## 新的分配器¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#the-new-allocator) 我构建了一个新的分配器,灵感来自 snmalloc (https://github.com/microsoft/snmalloc) 的基于区域的设计。内存被组织成两个层级:从操作系统请求的大区域,被分割成更小的 arena。每个 arena 属于一个线程。该线程负责其 arena 中的所有簿记工作——追踪哪些内存已分配,哪些空闲,以及内存何时可以归还给操作系统。跨线程释放被批量处理并路由到拥有它的线程,而不是全局处理。 ### 区域与 arena¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#regions-and-arenas) 分配器从操作系统请求大型对齐内存块,称为区域——在 64 位机器上为 256 MiB,在 32 位机器上为 64 MiB。区域由所有线程共享,并且永远不会取消映射。通过不取消映射区域,遍历区域列表的线程可以得到内存安全保证,这是设计其余部分所依赖的。 线程从区域中获取 arena。一个 arena 在 64 位系统上为 8 MiB,在 32 位系统上为 2 MiB,并且每个都绑定到一个线程。拥有它的线程负责维护 arena 的簿记。当一个 arena 清空时,其物理页面会归还给操作系统,但其地址空间会停留在区域中以供重用。 释放内存始于找到它属于哪个 arena。arena 的起始地址与其自身大小对齐。在 64 位系统上,每个 arena 都从 8 MiB 边界开始。屏蔽任何指针的低位,你就能得到 arena 的基地址。一条指令。无需内存读取。既快又好。在 Ponyland,我们就喜欢这种高效的设计。 ### 单位、slab 和位图¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#units-slabs-and-the-bitmap) 一个 arena 被划分为 16 KiB 的单位。一个 slab 是一个或多个连续的单位,服务于一个大小类别。共有 16 个大小类别,从 32 字节到 1 MiB,每个都是 2 的幂。对于小类别,多个对象可以放入一个单位——32 字节类别每个单位可容纳 512 个对象。大类别则跨越多个单位。 空闲或已用在位图中用每个单位一个比特表示。一个 8 MiB 的 arena 有 512 个单位,在 64 位机器上可以放入 8 个位图字中。 内存中相邻的两个空闲单位就是位图中相邻的两个零比特。它们已经是一个连续的跨度;无需合并。寻找 N 个空闲单位就是在扫描 N 个连续的零。不需要合并代码。没有排序的空闲列表。没有 O(n) 的遍历。旧分配器的障碍——相邻空闲块永不合并——在这里不可能发生,因为数据结构本身不允许。内存中的相邻性就是位图中的相邻性,而位图中的相邻性就是一个跨度。一直如此。 在一个方向上进行递增首次适配,会在另一个方向上留下长段可用空间。没有这种分割,单单位分配会破坏大跨度,而多单位 slab 需要这些大跨度。 Arena 的簿记——位图、每个单位的记录——位于 arena 的起始位置。没有东西位于已分配对象的前面。 ### 跨线程释放¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#cross-thread-frees) 在 Pony 中,消息由运行发送者的线程分配,由运行接收者的线程释放。这些通常是不同的线程。跨线程释放不是边缘情况——它们不断发生,分配器必须能够快速处理它们。 直接的方法是每次释放都进行一次原子操作:将释放的对象推送到拥有它的线程的收件箱。然而,测试显示在竞争激烈时性能会崩溃。直接的方法行不通。 取而代之的是,释放线程收集释放的对象,并将它们批量发送给拥有者。32 次释放只进行一次原子操作,而不是进行 32 次原子操作。批量处理和未批量处理之间的性能差异巨大。拥有线程处理释放的内存并可以重用它。 ### 线程缓存¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#the-thread-cache) 批量处理跨线程释放比每次释放都进行原子操作更快,但将批次发送回拥有者仍然有一些开销。如果可能,最好避免发送。每个线程维护一个最近释放的内存块的小型缓存,每个大小类别一个。当线程释放一个属于另一个线程的块时,它会被放入本地缓存。如果线程后来需要相同大小的块,它会直接重用缓存中的块——无需跨线程协调,无需发送批次。只有当缓存溢出、线程进入空闲状态或线程退出时,块才会被路由回其拥有者。 每个大小类别都有一个缓存深度:字节预算(256 KiB)除以类别大小或分析中的最低数量(取较大值),上限为 512 个块。大类别需要最低数量——没有它,字节预算除下来可能为零或一,那么每次使用周期都需要支付整个 slab 的保留和释放成本。 空闲返回会清空整个缓存,因此最低数量的成本仅在某线程活跃地使用内存时才会占用常驻内存。一旦线程暂停,其缓存就是空的。 ### 内存归还¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#memory-return) 当一个线程空闲足够长时间后,它会开始将内存归还给操作系统。旧分配器从未这样做过。一个短暂空闲然后又开始工作的线程会保留其内存。但一个持续空闲的线程会归还它持有的所有内存。缓存、脏页,全部归还。一个暂停的线程不持有任何内存。 机制是一个定时的 tick。一个空闲线程每隔 10 毫秒检查一次,每次静默检查间隔时间加倍,上限为 500 毫秒。一旦达到上限,所有持有的内存都会归还给操作系统。 当一个线程活跃时,释放的小 slab 会保持常驻,并以批量方式归还,而不是一次一个。较大的释放 slab 则立即归还。 我开始这篇文章时谈到旧分配器以有问题的方式持有内存,那么现在的情况如何呢?以下是一些测试数据:105 MiB 的大小类别内存,线程变为空闲后,常驻内存降至 6.5 MiB。旧分配器则持有全部 104 MiB。在一个 1 GiB 工作集的情况下,arena 峰值为 1,025 MiB,空闲后稳定在 27 MiB。旧分配器:从头到尾保持 1,019 MiB。 ### 调优旋钮¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#the-tuning-knob) 并非每个程序的需求都相同。我花了几天时间测试不同的可调参数,允许你在内存使用量和性能之间进行权衡。它们通过一个新的标志 `--ponymemoryprofile` 暴露出来,这是一个从 1 到 10 的刻度。默认值为 3。设为 1 时,分配器会尽可能积极地将内存归还给操作系统——占用空间最小。设为 10 时,它会更长时间地持有内存,以避免归还和重新获取内存的开销——吞吐量最高。 每个设置在线程空闲时都会归还所有内存。这种权衡仅在线程活跃工作时才重要。 ## 下一步计划¶ (https://www.ponylang.io/blog/2026/08/ponys-arena-allocator/#whats-next) Arena 分配器已合并到主分支,并且成为 Pony 支持的所有平台上的默认分配器。有一个权衡需要了解:在工作集超过线程缓存的工作负载中,旧分配器更快,因为它从不归还内存。这就是回收内存的代价。 我让压力测试运行几天,看看它们是否暴露出任何错误。预计月底前会有发布版本。

相似文章

Malloc() 算法对比

Hacker News Top

本文详细比较了 malloc() 算法中的 arena 架构,讨论了多线程程序中的内存分配问题,并探讨了内存分配器前端和后端设计的演变。

2026年分配器的现状 - 六个月后

Lobsters Hottest

本文更新了Rust中自定义分配器的稳定化进展,重点介绍了最近的开发,例如Allocator trait变得dyn兼容,以及解决不健全性问题的努力。

Dense Arena Interner:编译器性能的引擎

Hacker News Top

本文解释了如何通过实现 Dense Arena Interner,将字符串和结构转换为稠密整数,从而在词法分析期间承担前期哈希成本后实现 O(1) 比较,大幅提升编译器性能。

使用Rust arena关闭一个三年之久的issue

Lobsters Hottest

一位Gleam核心团队成员通过用arena分配的引用替换装箱文档,改进了语言的漂亮打印性能,减少了10%的峰值内存使用,并关闭了一个三年之久的issue。

静态分配,恒定工作

Lobsters Hottest

本文探讨了静态分配策略,以防止释放后使用、类型混淆等内存安全问题,讨论了对象池和代际索引,并介绍了来自TigerStyle的技巧,以在初始化后避免动态内存分配。