C++ 自定义内存池与分配器:从 Arena 到 std::pmr 与无锁分配

系统讲解自定义内存分配:Arena 线性分配、Object Pool 空闲链表、std::pmr 多态分配器、无锁线程本地分配,以及与 jemalloc/tcmalloc 的对比与选型,帮助读者在正确场景中消灭 malloc 瓶颈。

一、为什么需要自定义分配器

1. malloc 到底慢在哪

通用堆分配器(glibc malloc)必须应对任意大小、任意次序的分配请求,这要求它在内部维护复杂的空闲块元数据。一次 malloc 的开销分布在:

  • 锁竞争:多线程并发分配时,堆的全局锁(或 arena 锁)成为串行点;
  • 元数据成本:每个分配块头部/尾部都附带 size、标志位等,小对象上元数据占比惊人;
  • 缓存局部性:通用分配器不保证连续分配的对象在内存中相邻,遍历链表式对象时缓存命中率低;
  • 碎片化:长期运行后,空闲块散落各处,无法满足大块连续分配。

2. 什么时候值得自研

自研分配器只在一个前提下有意义:你清楚对象的生命周期模式。典型受益场景:

  • 游戏引擎每帧创建/销毁的粒子、组件(生命周期短且统一);
  • 网络服务中每请求分配的临时缓冲(尺寸相近);
  • 高频交易中延迟敏感的小对象(malloc 的锁与系统调用不可接受)。

反过来,若分配模式随机、生命周期交错,通用分配器(或直接使用 jemalloc/tcmalloc)往往更合适。先测量,再优化是这里的第一原则。

3. 分配器接口的层次

C++ 中"分配器"有三个层次,从低到高:

  1. operator new / operator delete 重载:进程级或类级;
  2. 标准 Allocator 概念:std::allocator<T> 风格的模板接口,供 STL 容器使用;
  3. std::pmr::memory_resource:运行期多态的分配资源抽象,可动态注入。

二、Arena 分配器

1. 基本思想:一次性大块,顺序推进

Arena(也称为 bump allocator / region allocator)是最简单的池化策略:一次向系统申请一大块内存,分配时仅移动游标(bump),不释放单个对象,整个 arena 一次性归还。它把 O(1) 分配成本压缩到极致,代价是无法单独释放中间对象。

#include <cstddef>
#include <cstdlib>
#include <cassert>

class Arena {
    char* start_;
    char* end_;
    char* cur_;

public:
    explicit Arena(size_t size)
        : start_(static_cast<char*>(::operator new(size))),
          end_(start_ + size), cur_(start_) {}

    ~Arena() { ::operator delete(start_); }

    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;

    void* allocate(size_t n) {
        // 对齐到 alignof(std::max_align_t)
        n = (n + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1);
        assert(cur_ + n <= end_);
        void* p = cur_;
        cur_ += n;
        return p;
    }

    void reset() { cur_ = start_; }   // 整体复用
};

2. 典型使用场景

Arena 最适合"一批对象同时出生、同时死亡"的场景。典型例子:

  • 每帧构建一次的场景图(游戏);
  • 每次 RPC 请求的中间对象(服务端);
  • 编译器的一次编译过程(AST 结点)。
// 与构造/析构配合:Arena 只管内存,对象生命周期由使用者掌握
template<typename T>
T* construct_in_arena(Arena& arena, auto&&... args) {
    void* p = arena.allocate(sizeof(T));
    return ::new (p) T(std::forward<decltype(args)>(args)...);
}

3. 变体:Stack Allocator 与双缓冲

Arena 的进阶形态是栈式分配器(配合 RAII 标记回滚)与双缓冲(前后帧交替复用两块 arena)。栈式分配器允许按 LIFO 顺序释放:

class StackArena {
    char* mem_; char* top_;
public:
    struct Mark { char* pos; };
    Mark mark() { return {top_}; }
    void release(Mark m) { top_ = m.pos; }
};

三、Object Pool:定长对象的空闲链表

1. 核心思想

当所有对象大小一致(如 Node、Connection、Particle),可以用空闲链表把空闲对象串起来:分配从链表头取一个,释放把对象压回链表头,O(1) 且无碎片。

2. 侵入式 vs 非侵入式

  • 非侵入式:链表指针存储在独立的元数据块中,对象本身保持标准布局;
  • 侵入式:空闲指针借存在对象内存内(对象空闲时其字段无意义),省去元数据,是绝大多数定长池的做法。
#include <cstddef>
#include <cassert>

template<typename T>
class ObjectPool {
    struct Block { Block* next; };   // 空闲节点复用对象内存
    Block* free_head_ = nullptr;
    T* storage_;
    size_t capacity_;

public:
    explicit ObjectPool(size_t capacity)
        : capacity_(capacity) {
        storage_ = static_cast<T*>(::operator new(sizeof(T) * capacity));
        // 把所有槽位串成空闲链表
        char* base = reinterpret_cast<char*>(storage_);
        for (size_t i = 0; i < capacity; ++i) {
            Block* b = reinterpret_cast<Block*>(base + i * sizeof(T));
            b->next = free_head_;
            free_head_ = b;
        }
    }

    ~ObjectPool() { ::operator delete(storage_); }

    template<typename... Args>
    T* construct(Args&&... args) {
        assert(free_head_);
        void* p = free_head_;
        free_head_ = free_head_->next;
        return ::new (p) T(std::forward<Args>(args)...);
    }

    void destroy(T* obj) {
        obj->~T();
        Block* b = reinterpret_cast<Block*>(obj);
        b->next = free_head_;
        free_head_ = b;
    }
};

3. 安全性考量

定长池最大的坑是悬垂指针复用:槽位被释放后立刻重新分配,仍持有旧指针的代码会踩到新对象。缓解手段:

  • 延迟回收(等所有用户离开后统一归还,类似 hazard pointer 思想);
  • 在空闲链表里写入哨兵值,触发 debug 期检测。

四、std::pmr 与多态分配器

1. memory_resource 协议

std::pmr(polymorphic memory resources,C++17)把分配器从编译期模板参数变成运行期多态接口:

#include <memory_resource>
#include <vector>

std::pmr::monotonic_buffer_resource arena(1024 * 1024);

// 容器把分配请求转发给 resource
std::pmr::vector<int> vec(&arena);
for (int i = 0; i < 100000; ++i) vec.push_back(i);   // 全部在 arena 中分配

memory_resource 的抽象接口只有两个纯虚函数 do_allocate / do_deallocate,加上 allocate / deallocate / is_equal 的公共包装。关键特性:对象释放时不必知道它来自哪个 resource 的具体类型,多态分派自动回到正确的分配路径。

2. 标准提供的 resource

resource行为适用
std::pmr::new_delete_resource()走 operator new/delete默认兜底
std::pmr::monotonic_buffer_resource线性 bump,不单独释放短生命周期批量对象
std::pmr::synchronized_pool_resource线程安全定长池,多线程安全多线程默认选择
std::pmr::unsynchronized_pool_resource同上但非线程安全单线程性能

3. 自定义 resource 接入 STL

通过继承 memory_resource,可以把任意分配策略接入所有 pmr:: 容器:

#include <memory_resource>
#include <cstddef>

class MyResource : public std::pmr::memory_resource {
    Arena arena_;
public:
    explicit MyResource(size_t cap) : arena_(cap) {}

protected:
    void* do_allocate(size_t bytes, size_t align) override {
        return arena_.allocate(bytes);
    }
    void do_deallocate(void*, size_t, size_t) override {
        // arena 不单独释放
    }
    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }
};

4. pmr 的代价

多态分派每次分配都有一次虚函数调用;且 pmr:: 容器与标准容器类型不兼容(std::pmr::vector<T> 是 std::vector<T, polymorphic_allocator<T>> 的别名),跨边界传递时需注意类型匹配。对性能极致敏感的内层,仍倾向手写模板分配器。

五、无锁分配器

1. 线程本地缓存(TLS Cache)

无锁分配器的核心思路是避免共享:每个线程维护自己的缓存,分配/释放只在本地进行,绝不触碰全局堆。这消除了锁竞争,还顺带改善了缓存局部性(每线程的数据天然集中)。

#include <vector>
#include <thread>

class ThreadLocalArena {
    // 每线程一个 arena 实例
public:
    static Arena& local() {
        static thread_local Arena arena(64 * 1024);
        return arena;
    }
};

void worker() {
    Arena& a = ThreadLocalArena::local();   // 各线程独立
    void* p = a.allocate(32);
    // ...
}

2. 原子空闲链表

当必须在线程间共享(如多生产者单消费者),可用原子指针实现的无锁空闲链表:

#include <atomic>

struct Node { Node* next; };

class LockFreeFreeList {
    std::atomic<Node*> head_{nullptr};
public:
    void push(Node* node) {
        node->next = head_.load(std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(node->next, node,
               std::memory_order_release, std::memory_order_relaxed)) {
            // CAS 失败说明 head 变了,node->next 已被更新为最新 head
        }
    }
    Node* pop() {
        Node* h = head_.load(std::memory_order_acquire);
        while (h && !head_.compare_exchange_weak(h, h->next,
               std::memory_order_acquire, std::memory_order_relaxed)) {
        }
        return h;
    }
};

这是典型的无锁栈(Treiber 栈),push 用 release 发布节点,pop 用 acquire 获取——内存序语义可参考 https://plumephp.com/cpp-atomic-memory-order/。注意:无锁 free list 面临 ABA 问题与节点回收安全的双重挑战,若对象本身需要释放内存而非入池,还需 hazard pointer 辅助。工程上建议先评估 std::pmr::synchronized_pool_resource,多数场景它已够用。

3. 无锁分配的性能特征

无锁分配在低竞争下与 TLS 缓存相当,在高竞争(大量线程同时 pop/push)下优于互斥锁版,但劣于每线程本地缓存。真正的杀手组合是本地缓存为主 + 全局无锁池兜底,这正是 jemalloc/tcmalloc 的设计哲学。

六、与 jemalloc / tcmalloc 的对比

1. 两大现成分配器

  • jemalloc:Facebook/Redis 等使用。核心是 per-thread cache + 多层空闲分级(size classes),通过 arena 划分减少碎片,元数据开销低,多线程扩展性极强;
  • tcmalloc:Google 出品。ThreadCache 每线程缓存,超阈值后归还中心化堆,page heap 管理大块。对 C++ 大量小对象场景友好,与 Google 生态(gperftools)集成佳。

两者都支持通过 LD_PRELOAD 或 operator new 重载全局替换默认分配器,无需改动业务代码。

2. 对比表格

维度手写 Arena手写 Object Poolstd::pmrjemalloc/tcmalloc
适用对象同生命周期批量对象定长高频对象容器分配策略注入通用替换
分配复杂度O(1) bumpO(1) 链表O(1)~O(n) 视资源通常 O(1)
释放粒度整体单个单个单个
线程安全需自管需自管选同步/异步内置
调优成本低低中零(黑盒)
可控性高高中低
适用阶段热路径特化热路径特化框架层全局面替换

3. 选型决策

  • 全局性瓶颈 → 先试 LD_PRELOAD=libjemalloc.so,一行命令看效果;
  • 某个类/容器高频分配 → 用 Object Pool 或 pmr::unsynchronized_pool_resource;
  • 一批对象同时存活 → Arena / monotonic buffer,配合 reset() 复用;
  • 延迟极度敏感且分配模式已知 → 无锁本地缓存 + 原子链表兜底。

不要一开始就手写全局分配器。生产实践的正确路径是:先用 profiler 找到分配热点,再针对该热点引入最小范围的池化。

七、生产实践要点

1. 对齐与大小

分配器必须保证返回指针满足 alignof(T)。手写池建议按 alignof(std::max_align_t)(通常 16 字节)对齐;若对象含 SIMD 类型(__m256),需按 32/64 字节对齐,否则 aligned_alloc 或 posix_memalign 起步。

2. RAII 封装与所有权

内存池对象必须通过 RAII 管理(如 std::unique_ptr<T, PoolDeleter>),确保异常路径下对象归还池中。C++17 的 PMR 容器已自动处理,手写池需自行约定释放回调。

3. 与缓存局部性联动

对象池的槽位连续排布天然有利于顺序遍历。若场景是"频繁遍历整批对象"(粒子系统、ECS 组件),池化不仅省分配,还提升缓存命中率——这属于 https://plumephp.com/cpp-performance-optimization/ 中 Cache 友好编程的核心手法。与之配合的还有结构体字段重排与 SIMD 批处理。

4. 调试与统计

生产内存池应内置统计(分配次数、峰值占用、碎片率),并支持在 Debug 构建下填充哨兵值(如 0xCD)以暴露越界访问。切勿在 Release 热路径上保留无谓的断言。

八、总结

内存池的本质是用"预先规划的生命周期"换取"分配代价的可预期性"。从 Arena 的极简 bump,到 Object Pool 的定长链表,再到 std::pmr 的接口抽象与无锁分配的内存序博弈,每一层都在解决同一个问题:让内存分配不再成为性能的随机抖动源。

需要铭记的工程判断是:通用分配器(jemalloc/tcmalloc)负责兜底,自定义池负责特化热点。先测量定位热点,再以最小侵入的方式引入池化;先利用 std::pmr 与标准组件,手写仅保留在真正无法被现成工具满足的路径上。这才是内存管理从"能用"走向"高效"的正确顺序。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ 嵌入式与游戏引擎集成:宿主嵌入、绑定生成与性能内存约束
  2. C++ 跨平台构建矩阵:CMake Presets、包管理器与 CI 矩阵、ABI 兼容
  3. C++ 编译期反射与序列化:模板元编程驱动的结构与高性能二进制协议