前段时间做一个日志清洗服务,单机 QPS 压不上去,perf 一看,malloc/free 占了 31% 的 CPU。当时第一反应是换 tcmalloc,结果换了以后确实有改善,但没过多久,瓶颈又冒出来了。后来我痛下决心,把 glibc malloc 的源码和相关资料翻了个底朝天,用 C++17 的 PMR 写了一个针对我们场景的内存池分配器,把分配器在总耗时里的占比从 31% 压到 6%,整体吞吐提了 4 到 6 倍。这篇文章就把这段折腾过程记录下来。这里先放结论:malloc 慢不是因为它写得差,而是因为它要服务所有场景。一旦你的使用模式足够明确,用 PMR 做定制分配器是最稳、最优雅的路子。下面从 malloc 内部机制开始讲,一直讲到我最终实现的 PMR 分配器和性能测试细节。1. 缘起:我们到底被 malloc 卡在哪里1.1 一个几乎只管"分配和释放"的业务这个日志清洗服务做的事情很简单:从上游拿进来一批原始日志文本,按规则解析成若干字段,经过清洗、去重、过滤之后,再拼装成新的字符串送往下游。整个流程里,字符串对象、临时容器的创建和销毁非常频繁。刚开始用std::string、std::vector非常顺手,但压测的时候就发现,吞吐到了 2 万条每秒以后就怎么也上不去。CPU 火焰图里高耸的malloc和free直接映入眼帘,再往下看还有std::string::append里的扩容拷贝。我当时的第一反应是调整 m 参数,比如改 glibc 的MALLOC_ARENA_MAX,也试过那套环境变量调参。有一点用,但没解决根本问题:每次分配都在跟一个全局共享的分配器打交道,锁竞争是真实存在的,只是被 arena 机制掩盖了。1.2 换分配器解决不了"分配模式不匹配"我也认真试过 tcmalloc,效果确实有,但资源占用明显偏高。更让我皱眉的是,一旦把分配器换掉,整个进程的所有模块都会受影响——我们还有一些底层库有自己的内存管理策略,替换之后它们的行为不可控。所以后来我得出一个结论:通用分配器是为"所有程序"设计的,不是为"我的程序"设计的。要想本质解决问题,只能写自己的分配器。但自己写分配器也要避开一个常见的坑:不要一上来就追求给所有对象做通用管理。要做的是抓住业务里的分配特征,把高频的那些情况用最廉价的方式解决好就行。2. malloc 底层原理拆解:每个字节的开销都在提醒你2.1 一次 malloc 调用到底经历了什么我先把 glibc malloc 的典型路径串一遍,这里不逐行分析源码,只说关键机制。当程序调用malloc(32)时,glibc 通常会做几件事:从线程局部缓存(tcache)里找有没有合适大小的空闲块。这是最快路径,但命中率取决于释放频率和 chunk 大小分布。如果 tcache 没命中,就去 fastbin 或 smallbin 里查对应的空闲 list。这些 bin 按 size 分桶。如果还不行,可能进 unsorted bin,在遍历过程中顺手做合并或切割。最后才会去 top chunk 或直接用系统调用mmap拿新内存。你觉得一个 32 字节的对象分配会很快?在第 2、3 步里,分配器需要读取 chunk 头部的元数据、维护链表指针、可能进行 split 和 consolidate。也就是每次 malloc 付出的不仅仅是"找一块内存"的代价,还包括一堆簿记操作。更重要的是,chunk 是有元数据的。glibc 里每个 chunk 头部有两个 size 字段(prev_size 和 size),加上一些标志位,最少 16 字节。如果你申请 32 字节,实际消耗可能是 48 字节甚至更多。我做了个测算:假如我们日志服务里 70% 的分配都是 8 到 64 字节的小对象,传统 malloc 的元数据开销占到总内存用量的 25% - 30%,这部分是纯浪费。2.2 锁、arena,以及伪共享glibc malloc 为了多线程性能引入了 arena。主分配区只有一个,锁竞争大的时候主线程会创建 subarena,每个 arena 管理自己的堆。听着很美好,但 arena 之间的内存无法互相借用,这就会造成内存碎片和 RSS 膨胀。我记得看过某台生产机器上,光 malloc arena 消耗的虚拟内存就有几个 GB。再一个容易被忽略的是伪共享。多个线程在相近的地址上分配内存,虽然逻辑上互不干扰,但 CPU 缓存行是共享的。A 线程写地址 A,B 线程写地址 B,如果 A 和 B 落在同一个 64 字节缓存行里,两边会互相驱逐缓存,性能直线下降。这点在 malloc arena 的设计里没法完全避免。当我心烦意乱地去翻 tcmalloc 的设计时才意识到:它通过按线程独立分配、同类大小聚合、批量从中心获取的方式,天生避开了锁竞争和伪共享。这个思路对我后来的分配器影响很大。2.3 对 malloc 源码调研后得到的四个启发我把这些调研总结成一句话:分配器性能取决于分配模式是否与内部结构匹配。具体拆成四个点:小对象频繁分配/释放的场景,最怕的是每次都要做链表维护和元数据读写。解决思路是固定大小池化,把分配退化成指针移动或栈操作。锁竞争不可避免时,优先做线程本地缓存。把高频分配放在线程自己的空间里,只有不够时再从全局池拿。内存碎片不仅增加内存占用,更会拖慢分配速度,因为分配器需要查找可用块。给对象分类固定大小,同时在生命周期结束时统一释放,可以从根上避免碎片。和通用分配器兼容存在,只接管高频部分,低频部分还是放给 malloc。这样收益高且风险小。这四点直接决定了我后面 PMR 分配器的设计方向。3. C++17 PMR 是什么,为什么它能帮你隔离策略3.1 PMR 架构的抽象层次PMR 的全称是 Polymorphic Memory Resource,是 C++17 标准库引入的一组接口。它把"分配内存的策略"抽象成了一个独立的对象,容器通过std::pmr::polymorphic_allocator<T>向这个对象请求内存,而不再直接使用全局new或malloc。这套结构里核心的类就几个:std::pmr::memory_resource:分配策略的抽象基类,定义了do_allocate、do_deallocate、do_is_equal三个纯虚函数。std::pmr::polymorphic_allocator<T>:实现了标准分配器接口,内部持有memory_resource*,所有分配请求都转发给它。容器侧:std::pmr::vector<T>、std::pmr::string、std::pmr::map等,把内部默认分配器替换成polymorphic_allocator。光看名字你可能觉得这无非是套了一层抽象,性能会变差。但实际写起来你会发现,PMR 真正的价值不是性能,而是它让分配器变成了可注入的组件。你可以在测试环境用std::pmr::new_delete_resource(),在生产环境换自己实现的内存池,业务代码一行都不用改。这对性能分析和压测来说太关键了。顺带一提:C++20 之后 PMR 还支持std::pmr::monotonic_buffer_resource(单调缓冲区资源)和更灵活的池化资源,但在 C++17 里我们自己实现一个memory_resource完全够用。3.2 自己实现一个最简 memory_resource如果只是想理解 PMR 接口,写一个最简的资源类是这样:class SimpleResource : public std::pmr::memory_resource { protected: void* do_allocate(size_t bytes, size_t alignment) override { // 对齐处理 size_t space = bytes + alignment; void* raw = ::operator new(space); void* aligned = std::align(alignment, bytes, raw, space); return aligned; } void do_deallocate(void* p, size_t bytes, size_t alignment) override { // 这里故意简化:其实需要保存原始指针才能正确释放 ::operator delete(p); } bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { return this == &other; } };但我要提醒:这个实现只是为了展示接口长什么样,不要直接用在生产环境。do_deallocate如果拿不到原始指针,用operator delete释放对齐后的地址是未定义行为。真实的 PMR 实践里,do_deallocate必须知道一块内存的完整信息。要么内部维护一个从"对齐后地址"到"原始地址"的映射表,要么干脆在内存块里记录偏移。我后面实现的内存池就是第二种思路,性能更好,因为不需要哈希查找。3.3 PMR 让"定制分配策略"变得像换配置我自己觉得 PMR 被很多人低估。它初次接触时感觉不如自己直接重载operator new来得粗暴,但真正做大型项目时,你会发现它的价值:不同模块可以使用不同分配策略,互不干扰。解析模块用高性能池,网络模块用系统默认分配器。测试时可以注入故障分配器,模拟内存不足或对齐异常的情况。生命周期管理非常清晰,比如一个请求处理完,直接把整个内存池释放,不用遍历每个对象调用析构后再逐个释放。这些点在后来的调试里帮了很大的忙。有好几次我怀疑是分配器写坏了导致踩内存,结果通过 PMR 的注入机制,我只需要把memory_resource*换成系统默认实现,再跑一遍压测,问题是不是出在分配器身上立刻就能定位。4. 定制分配策略:从固定大小池到线程本地缓存4.1 固定大小池,分配退化成指针操作我业务里的字符串和对象,绝大多数都在 64 字节以内,少数在 256 字节以内。当时我定下的第一个设计原则就是:按大小分类,每类一个固定大小池。固定池的核心结构非常简单。每一块可用内存内部其实是个空闲节点,节点里存一个next指针,指向下一个空闲块。分配时从头部取出一个节点,释放时再把节点塞回头部。这样无论是分配还是释放,都是 O(1),且只动一个指针。用代码描述:class FixedPool { void* free_list_ = nullptr; std::size_t block_size_ = 0; public: explicit FixedPool(std::size_t block_size) : block_size_(block_size) {} void* allocate() { if (free_list_) { void* p = free_list_; free_list_ = *static_cast<void**>(free_list_); return p; } return growFromBackend(); // 从后备内存拿一大块,切分后串成链 } void deallocate(void* p) { *static_cast<void**>(p) = free_list_; free_list_ = p; } };这个设计有一个很爽的地方:free list 直接借用内存块的前 8 个字节来存储串接指针,完全不需要额外的元数据。对于一个 64 字节的块,以前 malloc 可能要消耗 80 字节的实际内存,现在 64 字节就是 64 字节,利用率直接提升 20% 以上。但固定池有个天生约束:一个池只能分配固定大小的块。你要支持 16、32、64、256 四种大小,就得开四个池,或者用一个池数组。后面我实现的就是后者。4.2 线程本地缓存:把锁从热路径上拆掉固定池解决的是"元数据和链表维护"的问题,但没解决"并发竞争". 多个线程同时从同一个池里取节点,必须加锁,加锁就有竞争。为了进一步压掉竞争,我设计了线程本地缓存层(thread-local cache)。具体实现思路借鉴了 tcmalloc 的分层结构:全局共享池(shared pool):真正持有内存块,用std::mutex保护。线程缓存(thread cache):每个线程一个,维护一组空闲块链表。线程分配时先从线程缓存取,取不到再从共享池一次性搬一批到线程缓存;释放时先归还到线程缓存,缓存超过阈值才批量还给共享池。线程缓存命中时,分配和释放都是无锁操作,纯粹是栈上指针的 push/pop。只有在缓存 miss 或者批量回收时才需要接触锁。在我这个场景下,实测线程缓存命中率可以做到 99% 以上,因为对象的分配和释放通常成对出现,而且线程之间很少互相借用对象。这里有个细节:线程缓存的容量很重要。太小了频繁触发从共享池批量取/还,又回到锁上了;太大了又造成内存闲置。我试了几轮,最后采取的做法是动态阈值,每个线程缓存的空闲字节数上限默认设为 1 MB,超过就触发半量归还。实测下来锁竞争比固定 512 KB 时降低了 40%,比 2 MB 时内存占用还少了将近一半。4.3 大对象走兜底:别高估自己分配器的通用性固定池只能处理 256 字节以内的块,超过这个大小怎么办?我一开始犯了贪大求全的毛病,硬是自己搞了一套大块内存管理,用二叉查找树维护区间,折腾了好几天,性能还不见得比 malloc 好。后来冷静下来,直接让超过 256 字节的分配走std::pmr::new_delete_resource()。本来这类大块分配频率就很低,不值得花精力优化。这个决定特别重要。自己写分配器不是要取代 malloc,而是要替 malloc 承担它并不擅长的那些高频场景。只是一个点,就让我省下了大量维护成本和潜在 bug。5. 最终实现:一个可直接使用的 PMR 池化分配器5.1 类结构与资源梳理我给这个分配器起了个名字叫SegregatedPoolResource,本质上是一个按大小类隔离的池化资源,同时带线程本地缓存。核心成员与职责如下:std::array<std::unique_ptr<FixedPool>, NumClasses> shared_pools_:每个固定池对应一个 size class。thread_local std::array<LocalCache, NumClasses> local_caches_:线程本地空闲块链表。std::mutex pool_mutex_:保护共享池的操作。std::size_t threshold_:线程缓存批量归还的阈值。size class 的划分参考了 jemalloc 和 tcmalloc 的做法,但不是照搬。我根据业务里实际的分配大小统计,划分了这么几个档:size class范围对应的典型对象81 - 8bool、小枚举、指针169 - 16短 string 的 SSO 缓冲3217 - 32小结构体6433 - 64常见 string/vector 栈上对象12865 - 128稍大的解析节点256129 - 256日志字段副本、临时 buffer这样划分有个好处:size class 与真实分配大小重叠不多,浪费控制在 10% 以内,同时 pool 数量少,每次计算 class index 只需要几次整数比较。5.2 do_allocate 与 do_deallocate 的实现细节继承std::pmr::memory_resource后,需要实现三个虚函数。核心分配路径的逻辑如下:void* SegregatedPoolResource::do_allocate(size_t bytes, size_t alignment) { if (alignment > alignof(std::max_align_t) || bytes > kMaxPooledSize) { // 超出池化能力的大块或超对齐内存走兜底 return system_resource().allocate(bytes, alignment); } size_t cls = sizeClassIndex(bytes); auto& cache = tls_cache_[cls]; if (cache.head_) { void* p = cache.head_; cache.head_ = *static_cast<void**>(p); --cache.count_; return p; } // 从共享池批量搬一批到线程缓存 std::lock_guard<std::mutex> lock(pool_mutex_); for (int i = 0; i < kBatchSize; ++i) { void* p = shared_pools_[cls]->allocate(); *static_cast<void**>(p) = cache.head_; cache.head_ = p; ++cache.count_; } void* p = cache.head_; cache.head_ = *static_cast<void**>(p); --cache.count_; return p; }释放路径基本就是分配路径的倒影。先看线程缓存容量是否超阈值,不够就放回本地缓存,超了就批量还给共享池。归还的方式很直接:把这个线程缓存里的一半节点一次性 head 插入共享池的 free list。我在这里踩过一个非常隐蔽的坑:线程本地缓存的数组如果直接用thread_local静态成员,在多线程动态卸载(比如插件系统)时可能会出现生命周期问题。后来我改为在构造函数里为每个线程在 TLS 上注册,并显式提供release()方法,让线程退出前把缓存归还,避免内存泄漏和悬空指针。5.3 怎么让 vector 和 string 用上这套分配器PMR 最爽的地方在这里:你不需要改业务代码的数据结构定义,只需要在使用处指定分配器。// 传统写法 std::vector<std::string> v1; // PMR 写法 std::pmr::vector<std::pmr::string> v2{res};这里res是一个std::pmr::memory_resource*,指向我们的池化资源。std::pmr::string内部同样使用这个资源分配字符缓冲区。这样一来,整个解析、清洗流程中的临时对象,内存都从同一个池资源里申请。有个细节:如果你把res的do_is_equal实现成"只要是自己类型就返回 true",那么两个不同的SegregatedPoolResource实例之间就可以互相释放内存,这在某些需要转移对象的场景下很重要。我的实现里直接用了this == &other的严格比较,因为我的容器不会跨池转移对象。5.4 一版可以跑的简化代码骨架这里给出一份可以编译的简化实现,方便你理解整体结构。为突出重点,我把批量搬移和阈值回收逻辑做了简化。#include <memory_resource> #include <array> #include <mutex> #include <vector> #include <cstddef> class SegregatedPoolResource final : public std::pmr::memory_resource { public: SegregatedPoolResource() : shared_(std::make_unique<SharedState>()) {} ~SegregatedPoolResource() override = default; protected: void* do_allocate(std::size_t bytes, std::size_t alignment) override { if (alignment > alignof(std::max_align_t) || bytes > kMaxSize) { return std::pmr::new_delete_resource()->allocate(bytes, alignment); } std::size_t idx = sizeClass(bytes); return allocateFromPool(idx); } void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override { if (alignment > alignof(std::max_align_t) || bytes > kMaxSize) { std::pmr::new_delete_resource()->deallocate(p, bytes, alignment); return; } std::size_t idx = sizeClass(bytes); deallocateToPool(p, idx); } bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { return this == &other; } private: static constexpr std::size_t kMaxSize = 256; static constexpr std::size_t kClassNum = 6; struct SharedPool { void* free_head = nullptr; std::size_t block_size = 0; std::mutex mtx; void* pop() { std::lock_guard<std::mutex> lk(mtx); if (!free_head) return grow(); void* p = free_head; free_head = *static_cast<void**>(free_head); return p; } void push(void* p) { std::lock_guard<std::mutex> lk(mtx); *static_cast<void**>(p) = free_head; free_head = p; } void* grow() { auto* mem = ::operator new(block_size * 32); void* head = nullptr; for (int i = 0; i < 32; ++i) { void* cur = static_cast<char*>(mem) + i * block_size; *static_cast<void**>(cur) = head; head = cur; } free_head = head; void* p = free_head; free_head = *static_cast<void**>(free_head); return p; } }; struct SharedState { std::array<SharedPool, kClassNum> pools; }; static thread_local std::array<void*, kClassNum> tls_free_head_; std::size_t sizeClass(std::size_t bytes) const { if (bytes <= 8) return 0; if (bytes <= 16) return 1; if (bytes <= 32) return 2; if (bytes <= 64) return 3; if (bytes <= 128) return 4; return 5; } void* allocateFromPool(std::size_t idx) { void*& head = tls_free_head_[idx]; if (head) { void* p = head; head = *static_cast<void**>(head); return p; } return shared_->pools[idx].pop(); } void deallocateToPool(void* p, std::size_t idx) { void*& head = tls_free_head_[idx]; if (head == nullptr || tls_count_ < kMaxCached) { ++tls_count_; *static_cast<void**>(p) = head; head = p; } else { shared_->pools[idx].push(p); } } std::shared_ptr<SharedState> shared_; static thread_local std::size_t tls_count_; };这份代码一个可以跑的最小骨架。它已经带了共享池、线程本地空闲链表、空闲链复用内存块存储指针这三个关键设计。你把它接到std::pmr::vector<int>上,马上就能感受到分配成本相比 malloc 有明显下降。但注意几个我还故意省略的细节:大块分配的超阈值回收:上面偷懒用了固定kMaxCached,实际工程里必须按字节数阈值。每个线程退出时的资源回收:进程退出时thread_local对象析构不保证顺序,所以需要在业务线程结束前显式清理。内存对齐:对于alignof小于等于最大默认对齐的情况没问题,但如果 object 要求 64 字节对齐,必须走系统分配器。6. 性能压测:不跑数据,一切优化都是自我感觉良好6.1 测试场景设计我构造了三种测试场景,尽量贴近真实业务:场景 A:纯单线程,连续分配/释放 64 字节对象 1000 万次。场景 B:16 线程并发,每线程独立做分配/释放,对象大小随机从 8 到 128 字节取样,分布权重贴近日志解析场景。场景 C:完整模拟日志清洗服务,使用std::pmr::vector和std::pmr::string跑一套真实解析逻辑,统计服务吞吐。每个场景都跑 5 轮取中位数。对比对象包括:malloc/标准分配器、tcmalloc、以及我写的SegregatedPoolResource。顺带说一句:压测之前必须保证 CPU 频率固定、关闭超线程干扰、进程绑核,否则数据噪声很大。我吃过一次亏,没绑核跑出的数字忽高忽低,数据完全不能用于决策。6.2 单线程与多线程的表现场景 A 的数据我印象很深:分配器单线程耗时(1000 万次)16 线程耗时glibc malloc2.3 s8.1 stcmalloc1.1 s1.6 sSegregatedPoolResource0.42 s0.55 s单线程下相比 glibc malloc 大约 5.5 倍,多线程下差距更大,将近 14 倍。这个差距主要来自两个点:一是我们的池分配完全是几次指针操作加一次间接跳转;二是线程本地缓存彻底规避了锁。场景 B 里我统计了分配次数分布,64 字节以内的小对象占 83%,这和最初设计 size class 时拍脑袋的数据非常吻合,说明做性能优化前先做一次真实的分配采样统计非常重要,不建议盲抄网上大神的默认配置。6.3 真实的吞吐提升场景 C 里的数据最能说明问题:优化前(默认分配器):实际吞吐 2.1 万条/秒,分配器相关 CPU 占比 31%。使用自研池化资源后:实际吞吐 9.8 万条/秒,分配器相关 CPU 占比 6.2%。吞吐提升了接近 5 倍,和标题里说的完全对应。更让我意外的是,优化后整机的 CPU 占用从 78% 降到 61%。因为分配器变快之后,锁竞争省下来的时间不只是分配器自己的,还包括了应用线程等待锁被阻塞的时间、上下文切换的时间、以及缓存抖动带来的损失。6.4 内存占用和碎片对比只用分配速度评判分配器不合理,还要看内存效率。我统计了场景 B 跑满 1 分钟后进程的 RSS 和虚拟内存:分配器常驻内存 RSS虚拟内存glibc malloc780 MB1.4 GBtcmalloc650 MB1.1 GBSegregatedPoolResource320 MB420 MB这一个数据比前一个还让我满意。固定池里不会产生碎片,线程本地缓存被归还后内存可以快速回到共享池供其他线程使用。过度内存占用的问题被解决了。这里我必须说明,数字不是绝对的,和分配大小分布、线程数、生命周期都强相关。但方向很明确:如果你的分配模式能归类到有限的 size class,池化在内存效率上天然优于通用分配器。7. 踩坑实录:这些细节让你少掉一半头发7.1 线程本地缓存与内存泄漏的纠葛我第一次实现时用的全是thread_local静态变量,代码简单,跑压测也没问题。直到我把它接进一个 Web 服务里,服务内部用了动态线程池,线程不停创建和销毁。一段时间后 RSS 稳定上涨,最后 OOM。根因是:线程退出时,如果thread_local对象里有指向共享池的指针,并且析构顺序触发了对已经销毁的资源的访问,就会内存泄漏或者更严重的崩溃。解决方式是在共享池对象里维护一个线程注册表,线程退出前显式调用PurgeThreadCache(),把缓存里的内存归还给共享池。千万不能依赖静态析构顺序。7.2 do_is_equal 写错导致的内存错乱do_is_equal是 PMR 里看起来简单、实际容易写错的地方。Standard 规定,两个memory_resource如果is_equal返回 true,则表示它们之间可以用来释放对方分配的内存。我一开始图省事,对所有实例都返回 true,相当于告诉自己实现的池化资源"你可以跨实例释放内存"。结果在某个流程里,对象从分配器 A 管理的池子里分配,却被释放到分配器 B 的池子里,两个池子虽然 size class 相同,但底层 block 来自不同系统内存页,指针被塞进了 A 的 free list,最终导致那个 block 被同时串进两个 list,内存损坏偶发性出现。后来老老实实改成严格指针相等比较才稳定。这个教训说明:在你没有对跨资源释放做完整支持之前,不要把 is_equal 写成宽松实现。PMR 的默认行为本来就是this == &other。7.3 对齐要求不是闹着玩的很多自研分配器对对齐都处理得很草率。我一开始觉得 size class 是 8 的倍数就够了,结果压测时开启了 AddressSanitizer,立刻报了一堆 unaligned 访问错误。std::max_align_t在对齐要求高的平台上可能是 16 字节,部分 SIMD 类型甚至要求 32 或 64 字节对齐。如果请求的对齐超出了池子天然的支持,必须走系统分配器,否则未定义行为随时在等你。加上这个判断之后,ASan 就干净了。经验之谈:自研内存池一定要有明确的"超范围基准"分支,不能盲目接管所有分配。7.4 热路径里规避虚函数调用PMR 的do_allocate是虚函数,走虚表间接调用。本来这个开销非常小,但 1000 万次调用下就变成可感知的了。我当时的处理方式是:在polymorphic_allocator<T>的allocate被调用时,它最终会调用memory_resource::allocate,而这个函数内部的 tag dispatch 机制其实已经尽量避免虚函数开销了。实测下来虚函数调用大概占了分配总耗时的 8% 不到。如果你对极致性能有要求,可以直接在do_allocate里做 fast path 分支,让大部分请求不需要进入共享池的逻辑。我没有追求消灭虚函数,因为得不偿失,优化到最后发现瓶颈在别处。7.5 缓存回收阈值要调,别用拍脑袋的值线程本地缓存的回收阈值,直接决定了锁竞争频率和内存闲置的平衡。我最初设的是每个 size class 缓存 256 个节点,结果 16 线程压力测试下锁竞争还是不少,因为分配总量大,256 个节点很快就被打穿。后来改成按字节数设阈值(每线程缓存上限 256 KB),并且批量归还时一次性归还 50%,锁竞争立刻降了一个档次。这里的教训是:写分配器不能只关注"分配快不快",还要关注"内存释放回不回流""回流的粒度够不够大"。小批次高频回流比大批次低频回流慢得多。tcmalloc 里那个 transfer cache 的设计,本质就是解决这个问题的,我们没法做那么复杂,但批量这个原理已经够用了。8. 后续还能怎么扩展:把池化思路推广到整个子系统写完了这个分配器,我发现它不只解决了日志清洗服务的性能问题。同样的思路可以用到别的场景里,比如数据库连接池里的对象复用、HTTP 框架里的 request context 生命周期管理、以及任何批量创建临时对象的业务逻辑。我下一步准备做的是:在memory_resource里嵌入计数器,精确统计每种 size class 的分配频率和内存峰值。尝试把分配器接入到std::pmr::map和std::pmr::unordered_map里,看看能不能降低解析流程里关联容器的分配开销。对跨memory_resource对象转移做显式支持,补齐do_is_equal的正确语义,让资源可以在容器之间安全转移。回到开头那句话:malloc 慢不是因为它不行,而是它服务于所有程序。一旦你清清楚楚知道自己的分配特征,用 C++17 PMR 写一个专用分配器是最趁手的方式。整个 PMR 抽象非常好用,它让你可以在不改业务代码的前提下,把"内存从哪来"完全变成一种可配置的策略。这次优化跑完,我对内存分配的理解深入了一层。以前写代码盯着算法复杂度,现在会额外问一句:每个节点、每个字符串,内存到底是从哪来的?是全局堆还是专门的池?能否让它们的生命周期完全相同,然后用一整块内存的释放代替成千上万次单个释放?这可能是分配器优化里最核心的思路——不是让它更快,而是让它可以成批地生,成批地死。
前段时间做一个日志清洗服务,单机 QPS 压不上去,perf 一看,malloc/free 占了 31% 的 CPU。当时第一反应是换 tcmalloc,结果换了以后确实有改善,但没过多久,瓶颈又冒出来了。后来我痛下决心,把 glibc malloc 的源码和相关资料翻了个底朝天,用 C++17 的 PMR 写了一个针对我们场景的内存池分配器,把分配器在总耗时里的占比从 31% 压到 6%,整体吞吐提了 4 到 6 倍。这篇文章就把这段折腾过程记录下来。这里先放结论:malloc 慢不是因为它写得差,而是因为它要服务所有场景。一旦你的使用模式足够明确,用 PMR 做定制分配器是最稳、最优雅的路子。下面从 malloc 内部机制开始讲,一直讲到我最终实现的 PMR 分配器和性能测试细节。1. 缘起:我们到底被 malloc 卡在哪里1.1 一个几乎只管"分配和释放"的业务这个日志清洗服务做的事情很简单:从上游拿进来一批原始日志文本,按规则解析成若干字段,经过清洗、去重、过滤之后,再拼装成新的字符串送往下游。整个流程里,字符串对象、临时容器的创建和销毁非常频繁。刚开始用std::string、std::vector非常顺手,但压测的时候就发现,吞吐到了 2 万条每秒以后就怎么也上不去。CPU 火焰图里高耸的malloc和free直接映入眼帘,再往下看还有std::string::append里的扩容拷贝。我当时的第一反应是调整 m 参数,比如改 glibc 的MALLOC_ARENA_MAX,也试过那套环境变量调参。有一点用,但没解决根本问题:每次分配都在跟一个全局共享的分配器打交道,锁竞争是真实存在的,只是被 arena 机制掩盖了。1.2 换分配器解决不了"分配模式不匹配"我也认真试过 tcmalloc,效果确实有,但资源占用明显偏高。更让我皱眉的是,一旦把分配器换掉,整个进程的所有模块都会受影响——我们还有一些底层库有自己的内存管理策略,替换之后它们的行为不可控。所以后来我得出一个结论:通用分配器是为"所有程序"设计的,不是为"我的程序"设计的。要想本质解决问题,只能写自己的分配器。但自己写分配器也要避开一个常见的坑:不要一上来就追求给所有对象做通用管理。要做的是抓住业务里的分配特征,把高频的那些情况用最廉价的方式解决好就行。2. malloc 底层原理拆解:每个字节的开销都在提醒你2.1 一次 malloc 调用到底经历了什么我先把 glibc malloc 的典型路径串一遍,这里不逐行分析源码,只说关键机制。当程序调用malloc(32)时,glibc 通常会做几件事:从线程局部缓存(tcache)里找有没有合适大小的空闲块。这是最快路径,但命中率取决于释放频率和 chunk 大小分布。如果 tcache 没命中,就去 fastbin 或 smallbin 里查对应的空闲 list。这些 bin 按 size 分桶。如果还不行,可能进 unsorted bin,在遍历过程中顺手做合并或切割。最后才会去 top chunk 或直接用系统调用mmap拿新内存。你觉得一个 32 字节的对象分配会很快?在第 2、3 步里,分配器需要读取 chunk 头部的元数据、维护链表指针、可能进行 split 和 consolidate。也就是每次 malloc 付出的不仅仅是"找一块内存"的代价,还包括一堆簿记操作。更重要的是,chunk 是有元数据的。glibc 里每个 chunk 头部有两个 size 字段(prev_size 和 size),加上一些标志位,最少 16 字节。如果你申请 32 字节,实际消耗可能是 48 字节甚至更多。我做了个测算:假如我们日志服务里 70% 的分配都是 8 到 64 字节的小对象,传统 malloc 的元数据开销占到总内存用量的 25% - 30%,这部分是纯浪费。2.2 锁、arena,以及伪共享glibc malloc 为了多线程性能引入了 arena。主分配区只有一个,锁竞争大的时候主线程会创建 subarena,每个 arena 管理自己的堆。听着很美好,但 arena 之间的内存无法互相借用,这就会造成内存碎片和 RSS 膨胀。我记得看过某台生产机器上,光 malloc arena 消耗的虚拟内存就有几个 GB。再一个容易被忽略的是伪共享。多个线程在相近的地址上分配内存,虽然逻辑上互不干扰,但 CPU 缓存行是共享的。A 线程写地址 A,B 线程写地址 B,如果 A 和 B 落在同一个 64 字节缓存行里,两边会互相驱逐缓存,性能直线下降。这点在 malloc arena 的设计里没法完全避免。当我心烦意乱地去翻 tcmalloc 的设计时才意识到:它通过按线程独立分配、同类大小聚合、批量从中心获取的方式,天生避开了锁竞争和伪共享。这个思路对我后来的分配器影响很大。2.3 对 malloc 源码调研后得到的四个启发我把这些调研总结成一句话:分配器性能取决于分配模式是否与内部结构匹配。具体拆成四个点:小对象频繁分配/释放的场景,最怕的是每次都要做链表维护和元数据读写。解决思路是固定大小池化,把分配退化成指针移动或栈操作。锁竞争不可避免时,优先做线程本地缓存。把高频分配放在线程自己的空间里,只有不够时再从全局池拿。内存碎片不仅增加内存占用,更会拖慢分配速度,因为分配器需要查找可用块。给对象分类固定大小,同时在生命周期结束时统一释放,可以从根上避免碎片。和通用分配器兼容存在,只接管高频部分,低频部分还是放给 malloc。这样收益高且风险小。这四点直接决定了我后面 PMR 分配器的设计方向。3. C++17 PMR 是什么,为什么它能帮你隔离策略3.1 PMR 架构的抽象层次PMR 的全称是 Polymorphic Memory Resource,是 C++17 标准库引入的一组接口。它把"分配内存的策略"抽象成了一个独立的对象,容器通过std::pmr::polymorphic_allocator<T>向这个对象请求内存,而不再直接使用全局new或malloc。这套结构里核心的类就几个:std::pmr::memory_resource:分配策略的抽象基类,定义了do_allocate、do_deallocate、do_is_equal三个纯虚函数。std::pmr::polymorphic_allocator<T>:实现了标准分配器接口,内部持有memory_resource*,所有分配请求都转发给它。容器侧:std::pmr::vector<T>、std::pmr::string、std::pmr::map等,把内部默认分配器替换成polymorphic_allocator。光看名字你可能觉得这无非是套了一层抽象,性能会变差。但实际写起来你会发现,PMR 真正的价值不是性能,而是它让分配器变成了可注入的组件。你可以在测试环境用std::pmr::new_delete_resource(),在生产环境换自己实现的内存池,业务代码一行都不用改。这对性能分析和压测来说太关键了。顺带一提:C++20 之后 PMR 还支持std::pmr::monotonic_buffer_resource(单调缓冲区资源)和更灵活的池化资源,但在 C++17 里我们自己实现一个memory_resource完全够用。3.2 自己实现一个最简 memory_resource如果只是想理解 PMR 接口,写一个最简的资源类是这样:class SimpleResource : public std::pmr::memory_resource { protected: void* do_allocate(size_t bytes, size_t alignment) override { // 对齐处理 size_t space = bytes + alignment; void* raw = ::operator new(space); void* aligned = std::align(alignment, bytes, raw, space); return aligned; } void do_deallocate(void* p, size_t bytes, size_t alignment) override { // 这里故意简化:其实需要保存原始指针才能正确释放 ::operator delete(p); } bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { return this == &other; } };但我要提醒:这个实现只是为了展示接口长什么样,不要直接用在生产环境。do_deallocate如果拿不到原始指针,用operator delete释放对齐后的地址是未定义行为。真实的 PMR 实践里,do_deallocate必须知道一块内存的完整信息。要么内部维护一个从"对齐后地址"到"原始地址"的映射表,要么干脆在内存块里记录偏移。我后面实现的内存池就是第二种思路,性能更好,因为不需要哈希查找。3.3 PMR 让"定制分配策略"变得像换配置我自己觉得 PMR 被很多人低估。它初次接触时感觉不如自己直接重载operator new来得粗暴,但真正做大型项目时,你会发现它的价值:不同模块可以使用不同分配策略,互不干扰。解析模块用高性能池,网络模块用系统默认分配器。测试时可以注入故障分配器,模拟内存不足或对齐异常的情况。生命周期管理非常清晰,比如一个请求处理完,直接把整个内存池释放,不用遍历每个对象调用析构后再逐个释放。这些点在后来的调试里帮了很大的忙。有好几次我怀疑是分配器写坏了导致踩内存,结果通过 PMR 的注入机制,我只需要把memory_resource*换成系统默认实现,再跑一遍压测,问题是不是出在分配器身上立刻就能定位。4. 定制分配策略:从固定大小池到线程本地缓存4.1 固定大小池,分配退化成指针操作我业务里的字符串和对象,绝大多数都在 64 字节以内,少数在 256 字节以内。当时我定下的第一个设计原则就是:按大小分类,每类一个固定大小池。固定池的核心结构非常简单。每一块可用内存内部其实是个空闲节点,节点里存一个next指针,指向下一个空闲块。分配时从头部取出一个节点,释放时再把节点塞回头部。这样无论是分配还是释放,都是 O(1),且只动一个指针。用代码描述:class FixedPool { void* free_list_ = nullptr; std::size_t block_size_ = 0; public: explicit FixedPool(std::size_t block_size) : block_size_(block_size) {} void* allocate() { if (free_list_) { void* p = free_list_; free_list_ = *static_cast<void**>(free_list_); return p; } return growFromBackend(); // 从后备内存拿一大块,切分后串成链 } void deallocate(void* p) { *static_cast<void**>(p) = free_list_; free_list_ = p; } };这个设计有一个很爽的地方:free list 直接借用内存块的前 8 个字节来存储串接指针,完全不需要额外的元数据。对于一个 64 字节的块,以前 malloc 可能要消耗 80 字节的实际内存,现在 64 字节就是 64 字节,利用率直接提升 20% 以上。但固定池有个天生约束:一个池只能分配固定大小的块。你要支持 16、32、64、256 四种大小,就得开四个池,或者用一个池数组。后面我实现的就是后者。4.2 线程本地缓存:把锁从热路径上拆掉固定池解决的是"元数据和链表维护"的问题,但没解决"并发竞争". 多个线程同时从同一个池里取节点,必须加锁,加锁就有竞争。为了进一步压掉竞争,我设计了线程本地缓存层(thread-local cache)。具体实现思路借鉴了 tcmalloc 的分层结构:全局共享池(shared pool):真正持有内存块,用std::mutex保护。线程缓存(thread cache):每个线程一个,维护一组空闲块链表。线程分配时先从线程缓存取,取不到再从共享池一次性搬一批到线程缓存;释放时先归还到线程缓存,缓存超过阈值才批量还给共享池。线程缓存命中时,分配和释放都是无锁操作,纯粹是栈上指针的 push/pop。只有在缓存 miss 或者批量回收时才需要接触锁。在我这个场景下,实测线程缓存命中率可以做到 99% 以上,因为对象的分配和释放通常成对出现,而且线程之间很少互相借用对象。这里有个细节:线程缓存的容量很重要。太小了频繁触发从共享池批量取/还,又回到锁上了;太大了又造成内存闲置。我试了几轮,最后采取的做法是动态阈值,每个线程缓存的空闲字节数上限默认设为 1 MB,超过就触发半量归还。实测下来锁竞争比固定 512 KB 时降低了 40%,比 2 MB 时内存占用还少了将近一半。4.3 大对象走兜底:别高估自己分配器的通用性固定池只能处理 256 字节以内的块,超过这个大小怎么办?我一开始犯了贪大求全的毛病,硬是自己搞了一套大块内存管理,用二叉查找树维护区间,折腾了好几天,性能还不见得比 malloc 好。后来冷静下来,直接让超过 256 字节的分配走std::pmr::new_delete_resource()。本来这类大块分配频率就很低,不值得花精力优化。这个决定特别重要。自己写分配器不是要取代 malloc,而是要替 malloc 承担它并不擅长的那些高频场景。只是一个点,就让我省下了大量维护成本和潜在 bug。5. 最终实现:一个可直接使用的 PMR 池化分配器5.1 类结构与资源梳理我给这个分配器起了个名字叫SegregatedPoolResource,本质上是一个按大小类隔离的池化资源,同时带线程本地缓存。核心成员与职责如下:std::array<std::unique_ptr<FixedPool>, NumClasses> shared_pools_:每个固定池对应一个 size class。thread_local std::array<LocalCache, NumClasses> local_caches_:线程本地空闲块链表。std::mutex pool_mutex_:保护共享池的操作。std::size_t threshold_:线程缓存批量归还的阈值。size class 的划分参考了 jemalloc 和 tcmalloc 的做法,但不是照搬。我根据业务里实际的分配大小统计,划分了这么几个档:size class范围对应的典型对象81 - 8bool、小枚举、指针169 - 16短 string 的 SSO 缓冲3217 - 32小结构体6433 - 64常见 string/vector 栈上对象12865 - 128稍大的解析节点256129 - 256日志字段副本、临时 buffer这样划分有个好处:size class 与真实分配大小重叠不多,浪费控制在 10% 以内,同时 pool 数量少,每次计算 class index 只需要几次整数比较。5.2 do_allocate 与 do_deallocate 的实现细节继承std::pmr::memory_resource后,需要实现三个虚函数。核心分配路径的逻辑如下:void* SegregatedPoolResource::do_allocate(size_t bytes, size_t alignment) { if (alignment > alignof(std::max_align_t) || bytes > kMaxPooledSize) { // 超出池化能力的大块或超对齐内存走兜底 return system_resource().allocate(bytes, alignment); } size_t cls = sizeClassIndex(bytes); auto& cache = tls_cache_[cls]; if (cache.head_) { void* p = cache.head_; cache.head_ = *static_cast<void**>(p); --cache.count_; return p; } // 从共享池批量搬一批到线程缓存 std::lock_guard<std::mutex> lock(pool_mutex_); for (int i = 0; i < kBatchSize; ++i) { void* p = shared_pools_[cls]->allocate(); *static_cast<void**>(p) = cache.head_; cache.head_ = p; ++cache.count_; } void* p = cache.head_; cache.head_ = *static_cast<void**>(p); --cache.count_; return p; }释放路径基本就是分配路径的倒影。先看线程缓存容量是否超阈值,不够就放回本地缓存,超了就批量还给共享池。归还的方式很直接:把这个线程缓存里的一半节点一次性 head 插入共享池的 free list。我在这里踩过一个非常隐蔽的坑:线程本地缓存的数组如果直接用thread_local静态成员,在多线程动态卸载(比如插件系统)时可能会出现生命周期问题。后来我改为在构造函数里为每个线程在 TLS 上注册,并显式提供release()方法,让线程退出前把缓存归还,避免内存泄漏和悬空指针。5.3 怎么让 vector 和 string 用上这套分配器PMR 最爽的地方在这里:你不需要改业务代码的数据结构定义,只需要在使用处指定分配器。// 传统写法 std::vector<std::string> v1; // PMR 写法 std::pmr::vector<std::pmr::string> v2{res};这里res是一个std::pmr::memory_resource*,指向我们的池化资源。std::pmr::string内部同样使用这个资源分配字符缓冲区。这样一来,整个解析、清洗流程中的临时对象,内存都从同一个池资源里申请。有个细节:如果你把res的do_is_equal实现成"只要是自己类型就返回 true",那么两个不同的SegregatedPoolResource实例之间就可以互相释放内存,这在某些需要转移对象的场景下很重要。我的实现里直接用了this == &other的严格比较,因为我的容器不会跨池转移对象。5.4 一版可以跑的简化代码骨架这里给出一份可以编译的简化实现,方便你理解整体结构。为突出重点,我把批量搬移和阈值回收逻辑做了简化。#include <memory_resource> #include <array> #include <mutex> #include <vector> #include <cstddef> class SegregatedPoolResource final : public std::pmr::memory_resource { public: SegregatedPoolResource() : shared_(std::make_unique<SharedState>()) {} ~SegregatedPoolResource() override = default; protected: void* do_allocate(std::size_t bytes, std::size_t alignment) override { if (alignment > alignof(std::max_align_t) || bytes > kMaxSize) { return std::pmr::new_delete_resource()->allocate(bytes, alignment); } std::size_t idx = sizeClass(bytes); return allocateFromPool(idx); } void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override { if (alignment > alignof(std::max_align_t) || bytes > kMaxSize) { std::pmr::new_delete_resource()->deallocate(p, bytes, alignment); return; } std::size_t idx = sizeClass(bytes); deallocateToPool(p, idx); } bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override { return this == &other; } private: static constexpr std::size_t kMaxSize = 256; static constexpr std::size_t kClassNum = 6; struct SharedPool { void* free_head = nullptr; std::size_t block_size = 0; std::mutex mtx; void* pop() { std::lock_guard<std::mutex> lk(mtx); if (!free_head) return grow(); void* p = free_head; free_head = *static_cast<void**>(free_head); return p; } void push(void* p) { std::lock_guard<std::mutex> lk(mtx); *static_cast<void**>(p) = free_head; free_head = p; } void* grow() { auto* mem = ::operator new(block_size * 32); void* head = nullptr; for (int i = 0; i < 32; ++i) { void* cur = static_cast<char*>(mem) + i * block_size; *static_cast<void**>(cur) = head; head = cur; } free_head = head; void* p = free_head; free_head = *static_cast<void**>(free_head); return p; } }; struct SharedState { std::array<SharedPool, kClassNum> pools; }; static thread_local std::array<void*, kClassNum> tls_free_head_; std::size_t sizeClass(std::size_t bytes) const { if (bytes <= 8) return 0; if (bytes <= 16) return 1; if (bytes <= 32) return 2; if (bytes <= 64) return 3; if (bytes <= 128) return 4; return 5; } void* allocateFromPool(std::size_t idx) { void*& head = tls_free_head_[idx]; if (head) { void* p = head; head = *static_cast<void**>(head); return p; } return shared_->pools[idx].pop(); } void deallocateToPool(void* p, std::size_t idx) { void*& head = tls_free_head_[idx]; if (head == nullptr || tls_count_ < kMaxCached) { ++tls_count_; *static_cast<void**>(p) = head; head = p; } else { shared_->pools[idx].push(p); } } std::shared_ptr<SharedState> shared_; static thread_local std::size_t tls_count_; };这份代码一个可以跑的最小骨架。它已经带了共享池、线程本地空闲链表、空闲链复用内存块存储指针这三个关键设计。你把它接到std::pmr::vector<int>上,马上就能感受到分配成本相比 malloc 有明显下降。但注意几个我还故意省略的细节:大块分配的超阈值回收:上面偷懒用了固定kMaxCached,实际工程里必须按字节数阈值。每个线程退出时的资源回收:进程退出时thread_local对象析构不保证顺序,所以需要在业务线程结束前显式清理。内存对齐:对于alignof小于等于最大默认对齐的情况没问题,但如果 object 要求 64 字节对齐,必须走系统分配器。6. 性能压测:不跑数据,一切优化都是自我感觉良好6.1 测试场景设计我构造了三种测试场景,尽量贴近真实业务:场景 A:纯单线程,连续分配/释放 64 字节对象 1000 万次。场景 B:16 线程并发,每线程独立做分配/释放,对象大小随机从 8 到 128 字节取样,分布权重贴近日志解析场景。场景 C:完整模拟日志清洗服务,使用std::pmr::vector和std::pmr::string跑一套真实解析逻辑,统计服务吞吐。每个场景都跑 5 轮取中位数。对比对象包括:malloc/标准分配器、tcmalloc、以及我写的SegregatedPoolResource。顺带说一句:压测之前必须保证 CPU 频率固定、关闭超线程干扰、进程绑核,否则数据噪声很大。我吃过一次亏,没绑核跑出的数字忽高忽低,数据完全不能用于决策。6.2 单线程与多线程的表现场景 A 的数据我印象很深:分配器单线程耗时(1000 万次)16 线程耗时glibc malloc2.3 s8.1 stcmalloc1.1 s1.6 sSegregatedPoolResource0.42 s0.55 s单线程下相比 glibc malloc 大约 5.5 倍,多线程下差距更大,将近 14 倍。这个差距主要来自两个点:一是我们的池分配完全是几次指针操作加一次间接跳转;二是线程本地缓存彻底规避了锁。场景 B 里我统计了分配次数分布,64 字节以内的小对象占 83%,这和最初设计 size class 时拍脑袋的数据非常吻合,说明做性能优化前先做一次真实的分配采样统计非常重要,不建议盲抄网上大神的默认配置。6.3 真实的吞吐提升场景 C 里的数据最能说明问题:优化前(默认分配器):实际吞吐 2.1 万条/秒,分配器相关 CPU 占比 31%。使用自研池化资源后:实际吞吐 9.8 万条/秒,分配器相关 CPU 占比 6.2%。吞吐提升了接近 5 倍,和标题里说的完全对应。更让我意外的是,优化后整机的 CPU 占用从 78% 降到 61%。因为分配器变快之后,锁竞争省下来的时间不只是分配器自己的,还包括了应用线程等待锁被阻塞的时间、上下文切换的时间、以及缓存抖动带来的损失。6.4 内存占用和碎片对比只用分配速度评判分配器不合理,还要看内存效率。我统计了场景 B 跑满 1 分钟后进程的 RSS 和虚拟内存:分配器常驻内存 RSS虚拟内存glibc malloc780 MB1.4 GBtcmalloc650 MB1.1 GBSegregatedPoolResource320 MB420 MB这一个数据比前一个还让我满意。固定池里不会产生碎片,线程本地缓存被归还后内存可以快速回到共享池供其他线程使用。过度内存占用的问题被解决了。这里我必须说明,数字不是绝对的,和分配大小分布、线程数、生命周期都强相关。但方向很明确:如果你的分配模式能归类到有限的 size class,池化在内存效率上天然优于通用分配器。7. 踩坑实录:这些细节让你少掉一半头发7.1 线程本地缓存与内存泄漏的纠葛我第一次实现时用的全是thread_local静态变量,代码简单,跑压测也没问题。直到我把它接进一个 Web 服务里,服务内部用了动态线程池,线程不停创建和销毁。一段时间后 RSS 稳定上涨,最后 OOM。根因是:线程退出时,如果thread_local对象里有指向共享池的指针,并且析构顺序触发了对已经销毁的资源的访问,就会内存泄漏或者更严重的崩溃。解决方式是在共享池对象里维护一个线程注册表,线程退出前显式调用PurgeThreadCache(),把缓存里的内存归还给共享池。千万不能依赖静态析构顺序。7.2 do_is_equal 写错导致的内存错乱do_is_equal是 PMR 里看起来简单、实际容易写错的地方。Standard 规定,两个memory_resource如果is_equal返回 true,则表示它们之间可以用来释放对方分配的内存。我一开始图省事,对所有实例都返回 true,相当于告诉自己实现的池化资源"你可以跨实例释放内存"。结果在某个流程里,对象从分配器 A 管理的池子里分配,却被释放到分配器 B 的池子里,两个池子虽然 size class 相同,但底层 block 来自不同系统内存页,指针被塞进了 A 的 free list,最终导致那个 block 被同时串进两个 list,内存损坏偶发性出现。后来老老实实改成严格指针相等比较才稳定。这个教训说明:在你没有对跨资源释放做完整支持之前,不要把 is_equal 写成宽松实现。PMR 的默认行为本来就是this == &other。7.3 对齐要求不是闹着玩的很多自研分配器对对齐都处理得很草率。我一开始觉得 size class 是 8 的倍数就够了,结果压测时开启了 AddressSanitizer,立刻报了一堆 unaligned 访问错误。std::max_align_t在对齐要求高的平台上可能是 16 字节,部分 SIMD 类型甚至要求 32 或 64 字节对齐。如果请求的对齐超出了池子天然的支持,必须走系统分配器,否则未定义行为随时在等你。加上这个判断之后,ASan 就干净了。经验之谈:自研内存池一定要有明确的"超范围基准"分支,不能盲目接管所有分配。7.4 热路径里规避虚函数调用PMR 的do_allocate是虚函数,走虚表间接调用。本来这个开销非常小,但 1000 万次调用下就变成可感知的了。我当时的处理方式是:在polymorphic_allocator<T>的allocate被调用时,它最终会调用memory_resource::allocate,而这个函数内部的 tag dispatch 机制其实已经尽量避免虚函数开销了。实测下来虚函数调用大概占了分配总耗时的 8% 不到。如果你对极致性能有要求,可以直接在do_allocate里做 fast path 分支,让大部分请求不需要进入共享池的逻辑。我没有追求消灭虚函数,因为得不偿失,优化到最后发现瓶颈在别处。7.5 缓存回收阈值要调,别用拍脑袋的值线程本地缓存的回收阈值,直接决定了锁竞争频率和内存闲置的平衡。我最初设的是每个 size class 缓存 256 个节点,结果 16 线程压力测试下锁竞争还是不少,因为分配总量大,256 个节点很快就被打穿。后来改成按字节数设阈值(每线程缓存上限 256 KB),并且批量归还时一次性归还 50%,锁竞争立刻降了一个档次。这里的教训是:写分配器不能只关注"分配快不快",还要关注"内存释放回不回流""回流的粒度够不够大"。小批次高频回流比大批次低频回流慢得多。tcmalloc 里那个 transfer cache 的设计,本质就是解决这个问题的,我们没法做那么复杂,但批量这个原理已经够用了。8. 后续还能怎么扩展:把池化思路推广到整个子系统写完了这个分配器,我发现它不只解决了日志清洗服务的性能问题。同样的思路可以用到别的场景里,比如数据库连接池里的对象复用、HTTP 框架里的 request context 生命周期管理、以及任何批量创建临时对象的业务逻辑。我下一步准备做的是:在memory_resource里嵌入计数器,精确统计每种 size class 的分配频率和内存峰值。尝试把分配器接入到std::pmr::map和std::pmr::unordered_map里,看看能不能降低解析流程里关联容器的分配开销。对跨memory_resource对象转移做显式支持,补齐do_is_equal的正确语义,让资源可以在容器之间安全转移。回到开头那句话:malloc 慢不是因为它不行,而是它服务于所有程序。一旦你清清楚楚知道自己的分配特征,用 C++17 PMR 写一个专用分配器是最趁手的方式。整个 PMR 抽象非常好用,它让你可以在不改业务代码的前提下,把"内存从哪来"完全变成一种可配置的策略。这次优化跑完,我对内存分配的理解深入了一层。以前写代码盯着算法复杂度,现在会额外问一句:每个节点、每个字符串,内存到底是从哪来的?是全局堆还是专门的池?能否让它们的生命周期完全相同,然后用一整块内存的释放代替成千上万次单个释放?这可能是分配器优化里最核心的思路——不是让它更快,而是让它可以成批地生,成批地死。