|
|
这两年在项目里几次动手写 C++ 自定义分配器 allocator,踩了不少坑,也逐渐理清了“什么时候该自己写,怎么写才不拖后腿”的边界。简单说,标准容器的默认分配器 new/delete 在大多数业务里完全够用,自定义分配器的价值主要出现在两类场景:一是频繁的小对象分配导致的碎片与系统调用开销;二是对生命周期、局部性和可观测性有强诉求的系统,比如游戏引擎、撮合撮单、网络框架的会话池。
先说动机。性能上,频繁从全局堆拿内存会引入锁竞争与缓存不友好,尤其是多线程环境。写一个基于 slab 或 free-list 的池化分配器,把同尺寸对象放进同一页,能显著提升缓存命中,减少碎片。另一个不太被提起但同样重要的点是可观测性:自定义分配器可以挂钩埋点,统计每类容器的分配量、峰值、复用率,为后续调优提供依据。调试体验也会更好,比如对越界写做红区保护,或在释放时填充毒值。
实现层面,现代 C++ 已经不再强求旧的 std::allocator 接口,建议首选标准化的 PMR(polymorphic_allocator + memory_resource)。它把“容器”和“分配策略”解耦,容器以类型擦除方式向下转发到 memory_resource::do_allocate/do_deallocate,切换策略无需模板参数扩散。实践里我通常这么分层:底层是一个 Small Object Optimized 的资源(如按 16/32/64/… 字节分级的固定块池),上面包一层统计/调试装饰器,再向外暴露一个 monotonic_buffer_resource 或自定义 resource 给 polymorphic_allocator,容器侧只管拿 pmr::vector/pmr::unordered_map。
需要注意的细节不少。第一,齐整和对齐,do_allocate 至少要满足传入的 alignment,很多人偷懒直接用 operator new(size) 会在 AVX/PMU 重载场景踩雷。第二,异常安全,分配失败要么抛出 bad_alloc,要么严格按照标准契约返回可用指针,千万别“返回空指针让上层判断”。第三,线程安全,资源是否共享要在类型语义上说清楚:要么在资源内部加锁/无锁结构保证并发安全,要么明确“每线程一个实例”,典型做法是 thread_local 资源。第四,生命周期,容器不会帮你延长资源寿命,pmr::vector 里存着的是指向 resource 的裸指针,确保 resource 活得比容器久,这是 PMR 常见坑。第五,互操作性,混用不同资源为同一容器分配的元素会导致释放路径错误,务必让 allocate/deallocate 成对落在同一 resource。
另外,别忽视工具链已有轮子。像 jemalloc、tcmalloc 在多线程小块分配上表现非常稳健,若你的问题主要是全局堆锁与碎片,直接换全局分配器往往立竿见影。自定义分配器更适合“域专用”的优化:已知上限、批量分配、集中释放(arena/monotonic 模式),或严格尺寸分级的小对象池。对于这类场景,arena 的 O(1) 释放(整块回收)和极低元数据开销,性价比极高。反之,如果你的对象尺寸高度不均、生命周期复杂交错,自定义分配器可能比系统分配器更糟,还会增加维护成本。
调试与验证建议也给一版清单:用 sanitizer 检查越界/重入;在 do_allocate/do_deallocate 写入租约号与魔数,配合断言检测二次释放;暴露统计接口,按桶打印当前/峰值/失败次数;在性能测试里分开测“冷启动碎片”“热路径缓存命中”“并发扩展性”,别只盯总 QPS。对照实现可以看看 libc++ 的 pmr 资源与 EASTL 的 allocator 设计思想,Google 的 tcmalloc 源码对尺寸分级和无锁 fast path 值得学习。标准库接口文档可从 cppreference 查起(例如 https://en.cppreference.com/w/cpp/memory/polymorphic_allocator 和 https://en.cppreference.com/w/cpp/memory/memory_resource)。
最后给个经验结论:能用 PMR 就别写老式 allocator 模板;能用现成全局分配器就别造轮子;确定要写时,先把“对象尺寸分布、并发模型、生命周期形态”这三件事量化,再落笔实现。好的分配器是“符合问题形状”的工程构件,而不是一剂万能的性能药。 |
|