Pony's Arena Allocator
摘要
Pony 语言作者在 TCP 压力测试中加入随机化(swarm testing)后暴露了旧内存分配器的三个问题:跨线程释放的大块内存永不回收、内存被切成 32 字节槽后无法用于其他尺寸、相邻空闲块永不合并(导致混合尺寸负载下分配耗时从 4000 块的 0.49 秒恶化到 64000 块九分钟无法完成)。根因是旧分配器使用全局池和按尺寸分类的空闲链表,线程不追踪自己分配的内存。新分配器受 snmalloc 启发采用区域(region)与竞技场(arena)两级设计:从操作系统申请 256 MiB(64 位)大区域,切成 8 MiB 竞技场并绑定到单个线程,用位图记录单元空闲状态,相邻空闲天然是连续区间无需合并;跨线程释放改为批量发送而非每次一个原子操作,并引入每线程缓存避免不必要的跨线程协调;空闲线程通过定时 tick(10 毫秒起、翻倍至 500 毫秒上限)将内存归还操作系统。测试数据:105 MiB 内存在线程空闲后降至 6.5 MiB(旧分配器保持 104 MiB),1 GiB 工作集峰值 1025 MiB 空闲后稳定在 27 MiB(旧分配器全程 1019 MiB)。新增 --ponymemoryprofile 标志(1 到 10,默认 3)用于在内存占用与吞吐之间权衡。该分配器已合并到主分支并成为所有支持平台的默认实现,已知代价是工作集超过线程缓存时旧分配器更快,预计月底发布。
荐读理由
按线程归属做内存回收、批量跨线程释放、空闲超时归还 OS 这套设计可直接借鉴到你的并发系统,且它用压测随机化挖出隐藏内存泄漏的做法,能纠正你'测试不失败即系统稳固'的判断
原文
Pony's Arena Allocator
Here’s how it was. We’d been running stress tests on the TCP system in ponyc every day for a really long time. Those tests hadn’t failed in forever. There’s two ways you can look at “no tests are failing.” Either your system is rock solid or your tests aren’t covering enough of the codebase. I’d long felt that the lack of stress test failures was the result of “tests aren’t good enough.”
A few months back, I did something about that. I took the TCP stress tests that did the same thing on every run and added some “swarm testing goodness” — randomizing what they were doing and how they were being run, from run to run. A lot more code was getting exercised, and out popped a bunch of bugs.
This post is about one of those “bugs.”
Under certain usage patterns, memory grew without bound. A little investigation showed that it wasn’t a bug in the TCP code itself. The test was triggering an interesting collection of edge cases in the Pony runtime’s allocator.
There were three reasons the old allocator could hold on to memory in a way that could lead to the degenerate case I was seeing with the failing stress test instance.
In Pony, a message is allocated by its sender and freed by its receiver. About half of all frees land on a different thread than the one that did the allocation. A large block freed on the wrong thread stayed reserved, never reclaimed. A workload that passed blocks between threads reserved fresh address space for every block it allocated and freed, without bound. At ten thousand blocks, it was still climbing, about 4.3 MiB of address space per block.
Once memory was carved into 32-byte slots, it held 32-byte objects forever. The allocator never returned it for a different size.
Two adjacent free blocks were never merged. The old allocator kept a sorted free list, and mixed-size workloads slowed to a crawl walking it. 0.49 seconds to allocate at 4,000 blocks. 39.6 seconds at 16,000. 64,000 blocks did not finish in nine minutes.
All three came from the same place: the old allocator used a global pool with per-size-class free lists shared by every thread. No thread tracked which memory it allocated.
The fix for all three: make each thread track the memory it allocates — what’s in use, what’s free, when a region is empty. That tracking is what the old allocator lacked. With it, the allocator can merge freed memory, return empty regions to the OS, and reuse memory across threads.
The New Allocator¶
I built a new allocator, inspired by snmalloc’s region-based design. Memory is organized in two tiers: large regions requested from the OS, split into smaller arenas. Each arena belongs to one thread. That thread is responsible for all the bookkeeping in its arenas — tracking what’s allocated, what’s free, and when memory can go back to the OS. Cross-thread frees are batched and routed to the owning thread rather than handled globally.
Regions and arenas¶
The allocator requests memory from the OS in large aligned chunks called regions — 256 MiB on 64-bit machines, 64 MiB on 32-bit. Regions are shared by every thread and never unmapped. By not unmapping regions, a thread walking the region list has a memory safety guarantee that the rest of the design depends on.
Threads take arenas from regions. An arena is 8 MiB on 64-bit, 2 MiB on 32-bit, and each is bound to a thread. The owning thread is responsible for maintaining the arena’s bookkeeping. When an arena empties, its physical pages go back to the OS, but its address space stays parked in the region for reuse.
Freeing memory starts with finding which arena it belongs to. An arena’s starting address is aligned to its own size. On 64-bit, every arena starts on an 8 MiB boundary. Mask the low bits of any pointer and you get the arena’s base address. One instruction. No memory read. Nice and fast. We like nice and fast over here in Ponyland.
Units, slabs, and the bitmap¶
An arena is divided into 16 KiB units. A slab is one or more contiguous units serving one size class. There are 16 size classes, from 32 bytes up to 1 MiB, each a power of two. For small classes, multiple objects fit into a single unit — a 32-byte class fits 512 objects per unit. Large classes span multiple units.
Free or used is one bit per unit in a bitmap. An 8 MiB arena has 512 units, which fits in 8 bitmap words on a 64-bit machine.
Two free units next to each other in memory are two zero bits next to each other in the bitmap. They’re already one contiguous span; there’s nothing to merge. Finding N free units is scanning for N consecutive zeros. No merge code. No sorted free list. No O(n) walk. The old allocator’s block boundary — where two adjacent free blocks never merged — can’t happen here because the representation doesn’t allow it. Adjacency in memory is adjacency in the bitmap, and adjacency in the bitmap is a span. Always was.
Ascending first-fit in one direction leaves long runs in the other. Without that split, single-unit allocations would fragment the large spans that multi-unit slabs need.
Arena bookkeeping — the bitmap, per-unit records — lives at the arena’s start. Nothing sits in front of an allocated object.
Cross-thread frees¶
In Pony, a message is allocated by the thread running the sender and freed by the thread running the receiver. Those are usually different threads. Cross-thread frees aren’t an edge case — they happen constantly, and the allocator has to be fast at them.
The direct approach is one atomic operation per foreign free: push the freed object onto the owning thread’s inbox. However, testing showed that under contention performance collapsed. The direct approach doesn’t work.
Instead, the freeing thread collects freed objects and sends them to the owner in batches. One atomic for 32 frees instead of 32 atomics for 32 frees. The performance difference between the batched and unbatched is huge. The owning thread processes the freed memory and can reuse it.
The thread cache¶
Batching cross-thread frees is faster than one atomic per free, but sending the batch back to the owner still costs something. Better to avoid sending it at all when you can. Each thread keeps a small cache of recently freed blocks, one per size class. When a thread frees a block that belongs to another thread, it goes into the local cache. If the thread later needs a block of that size, it reuses the cached one directly — no cross-thread coordination, no batch to send. The block only gets routed back to its owner when the cache overflows, the thread goes idle, or the thread exits.
Each size class gets a cache depth: the byte budget (256 KiB) divided by the class size or the profile’s floor count, whichever is larger, capped at 512 blocks. Large classes need the floor — without it, the byte budget divides down to zero or one entry, and every churn cycle pays a full slab reserve and release.
The idle return flushes the entire cache, so the floor costs resident memory only while a thread actively churns. Once a thread parks, its cache is empty.
Memory return¶
When a thread has been idle long enough, it starts giving memory back to the OS. The old allocator never did this. A thread that goes briefly idle and gets work again keeps its memory. But a thread that stays idle gives back everything it holds. Cache, dirty pages, all of it. A parked thread holds nothing.
The mechanism is a timed tick. An idle thread checks in every 10 milliseconds, doubling the interval each quiet visit up to a 500-millisecond cap. Once it reaches the cap, all held memory goes back to the OS.
While a thread is active, small freed slabs stay resident and get returned in batches rather than one at a time. Larger freed slabs get returned immediately.
I started this post talking about the old allocator hanging on to memory in problematic ways, so what does it look like now? Here are some numbers from testing: 105 MiB of size-class memory, thread goes idle, resident memory falls to 6.5 MiB. The old allocator held all of it, 104 MiB. At a 1 GiB working set, the arena peaks at 1,025 MiB and settles to 27 MiB after idle. The old allocator: 1,019 MiB, start to finish.
The tuning knob¶
Not every program has the same needs. I spent several days testing different tunable parameters that let you trade memory usage for performance. They’re exposed in a new flag, --ponymemoryprofile, a scale from 1 to 10. The default is 3. At 1, the allocator returns memory to the OS as aggressively as possible — lowest footprint. At 10, it holds on to memory longer to avoid the cost of returning and re-acquiring it — highest throughput.
Every setting returns everything when a thread goes idle. The tradeoff only matters while a thread is actively working.
What’s next¶
The arena allocator is merged to main and is the default on every platform Pony supports. There’s one tradeoff worth knowing: the old allocator was faster in workloads where the working set exceeds the thread cache, because it never gave memory back. That’s the price of reclaiming.
I’m letting the stress tests run for several days to see if they shake out any bugs. Expect a release by the end of the month.
这条对你有帮助吗?