内存池实战:定长与变长分配、线程缓存及工程接入

发布时间:2026/10/26 2:09:29
内存池实战:定长与变长分配、线程缓存及工程接入

简介:面向C++内存管理与并发性能优化的内存池实现,适合有一定C++基础、关注频繁小块内存分配效率与多线程竞争问题的开发者学习参考。工程采用全局一级内存池与线程独立二级内存池的两级结构:一级管理大块内存,二级承担线程本地小块分配,并在释放时回收至池中复用,以减少系统调用和碎片;代码同时涉及初始化、分配释放流程、互斥同步及空闲块管理等关键环节,并留有块头开销、动态扩展、碎片整理与锁竞争等改进讨论空间。压缩包共33个文件,以17个h头文件和9个cpp源文件为主体,辅以vcxproj、sln工程文件、txt说明、bat脚本及lib库文件,整体约19KB,便于直接查看类结构与编译验证。已有1269人学习,适合借此理解内存池的分层设计、并发控制思路与可优化方向,并对照自身项目做取舍。

1. 压测曲线上的那根毛刺,多半是分配器给的

有个日志聚合服务在压测时表现得很奇怪:平均 QPS 稳住了,但 P99 延迟每隔几十秒就突然升高一次,采样工具显示最耗时的操作不在业务代码里,而是集中在malloc/free上。这类抖动很难用日志定位,因为问题发生在分配器内部,没法直接看到它做了什么,你看到的只是延迟曲线上的毛刺。内存池要解决的正是这件事——把“每次创建对象都向系统申请一次内存”改成“先成批申请、再逐个发放”,让分配延迟从不可预测变成基本恒定。它最适合这几类场景:对象创建销毁频率高、对象大小集中、对尾延迟敏感的服务端程序。如果你只是偶尔 new 一个对象,那真没必要自己实现内存池。

2. 定长内存池:一百来行代码把 malloc 从热路径上挪走

第一版实现的时候,不要急着写通用分配器。绝大多数坑都来自“想一次解决所有尺寸”,结果既没吃透小块分配,又把大块路径搞复杂。先从定长池开始,它简单到你可以逐行看懂,但已经能覆盖连接上下文、消息节点、链表节点、固定大小的帧缓冲这些真实场景里最常见的需求。

2.1 为什么不直接用标准库分配器,而要自己写一个定长池

标准库分配器其实已经有线程缓存,小对象的malloc/free并不慢,单线程下很多机器能压到几十纳秒。自己写定长池的理由不在“绝对更快”,而在这四点:

第一,路径长度可控。通用分配器要处理任意 size、选择 bin、判断是否合并、决定是否归还系统,每一步都可能因为内存状态不同而走不同分支,延迟方差大。定长池只有“取链表头”和“把块压回链表头”两步。

第二,局部性可以自己安排。所有同类型对象的块都在同一段连续内存里,CPU 预取和 cache line 命中率比散落的堆块高得多。这一点在遍历链表、树这类指针密集结构时收益非常明显。

第三,释放策略可控。通用分配器会把空闲页还给系统,下一次分配又要重新申请,这一来一回的代价在尾延迟上就是一根毛刺。定长池默认不归还,内存占用会稍微高一些,但延迟曲线比较平稳。

第四,可观测性好。你自己写的池可以随时打印“当前用了多少块、峰值多少、chunk 数量多少”,这在排查内存泄漏时是很有用的信息。

代价也很清楚:块大小向上对齐会浪费内存,一个池只服务一种尺寸,多尺寸就要多个池。所以定长池的适用范围是“对象尺寸固定且数量大”,不是“所有对象”。

2.2 空闲链表加侵入式复用:最小可运行的定长池

核心思路是用块本身的前几个字节存下一个空闲块的指针,这叫侵入式空闲链表,不额外占内存。代价是块大小不能小于一个指针。

#include <cstddef> #include <cstdint> #include <cstdlib> #include <new> #include <vector> class FixedPool { public: // block_size: 单块字节数;blocks_per_chunk: 每次向系统申请的块数 explicit FixedPool(std::size_t block_size, std::size_t blocks_per_chunk = 512) : block_size_(align_up(block_size < sizeof(void*) ? sizeof(void*) : block_size, kAlign)), blocks_per_chunk_(blocks_per_chunk), chunk_size_(block_size_ * blocks_per_chunk_ + kAlign) {} ~FixedPool() { for (void* p : chunks_) std::free(p); } FixedPool(const FixedPool&) = delete; FixedPool& operator=(const FixedPool&) = delete; void* allocate() { if (free_list_ == nullptr && !grow()) throw std::bad_alloc(); Node* n = free_list_; free_list_ = n->next; // 取链表头,LIFO 复用,缓存最热 ++used_; return n; } void deallocate(void* p) noexcept { if (p == nullptr) return; Node* n = static_cast<Node*>(p); n->next = free_list_; // 头插,O(1) 归还 free_list_ = n; --used_; } std::size_t block_size() const noexcept { return block_size_; } std::size_t used() const noexcept { return used_; } private: struct Node { Node* next; }; static constexpr std::size_t kAlign = alignof(std::max_align_t); static std::size_t align_up(std::size_t v, std::size_t a) { return (v + a - 1) & ~(a - 1); } bool grow() { void* raw = std::malloc(chunk_size_); if (raw == nullptr) return false; chunks_.push_back(raw); // 起始地址按 max_align_t 对齐,保证每块都可用于任意类型 auto base = reinterpret_cast<std::uintptr_t>(raw); auto start = reinterpret_cast<char*>((base + kAlign - 1) & ~(kAlign - 1)); for (std::size_t i = 0; i < blocks_per_chunk_; ++i) { Node* n = reinterpret_cast<Node*>(start + i * block_size_); n->next = free_list_; free_list_ = n; } return true; } std::size_t block_size_; std::size_t blocks_per_chunk_; std::size_t chunk_size_; Node* free_list_ = nullptr; std::size_t used_ = 0; std::vector<void*> chunks_; };

逻辑说明:grow()一次性向系统申请一大段内存,把它切成blocks_per_chunk_个等长块,然后按正序压入空闲链表。由于是头插,链表头实际是最后一块,之后分配会从最后一块开始往前取,整条链表的访问顺序和内存地址顺序相反,但都在同一段连续内存里,缓存表现依然好。allocate()从链表头取,deallocate()往链表头压,都是 O(1),没有循环、没有分支判断。

参数说明:block_size会被向上对齐到max_align_t(常见 16 字节),小于指针大小的请求会被抬到指针大小,否则存不下next。blocks_per_chunk控制每次申请多少块,512 是个比较稳的起点。chunk_size_多加了kAlign字节,是为了给对齐调整留出余量,代价是每个 chunk 浪费十几个字节,可以接受。

注意:这个类本身不是线程安全的。要么每个线程一个实例,要么在外层加锁,两种方案的取舍后面章节再展开。

2.3 块大小、chunk 容量与增长策略:四个必须自己定的参数

参数定错比代码写错更难查,因为程序不会崩,只会慢慢变慢或者内存慢慢涨上去。

参数常见取值影响调整建议
块大小对象大小向上对齐到 16 字节越大内部碎片越多,越小越可能放不下先统计真实对象大小分布,按 P95 定,超出的走大块路径
每 chunk 块数256 / 512 / 1024太小导致频繁向系统申请,太大导致首次分配延迟高让 chunk 大小落在 64KB~1MB 区间
增长策略固定容量 / 容量翻倍翻倍能减少申请次数,但峰值内存不可预测长连接服务用固定,突发型业务用翻倍
对齐粒度16 字节 / 64 字节64 字节对齐能避免伪共享,但浪费 4 倍空间只在确认有跨核写竞争时用 64

关于增长策略多说一句。固定容量在 chunk 用完后继续申请同样大小的新 chunk,总内存随峰值线性增长,行为可预测;翻倍策略在长期运行、偶发尖峰的场景下会留下大量用不上的内存,而且这些内存不会自动归还。我一般的做法是固定容量,配合一个“池的最大 chunk 数”上限,超过就退化成直接malloc,宁可慢一点也不要让内存无上限膨胀。

还有一个容易忽略的点:块大小最好取 16 的整数倍。如果你的块是 24 字节,连续两块会跨 cache line,遍历时可能多一次内存访问。取 32 会浪费 8 字节,但访问模式更整齐。

2.4 用基准测出真实的纳秒数,而不是凭感觉

池子写完必须测,而且要在自己机器上测。别人的数字参考价值有限,因为编译器、CPU 型号、是否开了 ASLR、标准库版本都会影响结果。

#include <chrono> #include <cstdio> // 简单基准:每轮分配一次、立刻释放一次,测单次操作平均耗时 template <typename Fn> double bench_ns_per_op(Fn&& fn, std::size_t iters) { auto t0 = std::chrono::steady_clock::now(); for (std::size_t i = 0; i < iters; ++i) fn(); auto t1 = std::chrono::steady_clock::now(); auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count(); return static_cast<double>(ns) / static_cast<double>(iters); } int main() { constexpr std::size_t kIters = 5'000'000; FixedPool pool(64); // 预热,让 CPU 频率和 cache 状态稳定下来 for (int i = 0; i < 100000; ++i) { void* p = pool.allocate(); pool.deallocate(p); } double a = bench_ns_per_op([&] { void* p = pool.allocate(); pool.deallocate(p); }, kIters); volatile void* sink = nullptr; // 防止编译器把 new/delete 优化掉 double b = bench_ns_per_op([&] { void* p = ::operator new(64); sink = p; ::operator delete(p); }, kIters); std::printf("pool : %.2f ns/op\n", a); std::printf("malloc: %.2f ns/op\n", b); return 0; }

逻辑说明:用steady_clock而不是system_clock,前者单调递增,不会因为系统时间调整而出现负值。跑之前先预热十万次,避免把首次增长 chunk 的代价混进平均值。sink是必要的,如果不把指针写到一个编译器看不见的地方,整个new/delete对可能被优化掉,测出来接近 0。

参数说明:kIters取五百万次,单次操作在几十纳秒量级时总耗时约零点几秒,足够稳定又不会等太久。想测释放顺序对缓存的影响,把“分配后立刻释放”改成“批量分配再批量释放”,数字会明显不同——这恰好说明基准测试的结论必须和真实访问模式对齐。

测出来的典型量级是:开了线程缓存的标准库分配器,单线程小对象一分配一释放大概在二三十纳秒;定长池通常能压到十纳秒以内。差距不算惊天动地,但方差小得多,而尾延迟看的恰恰是方差。多线程争用场景下差距会拉开,因为标准库分配器要处理 arena 竞争,而定长池如果每线程一个实例,根本没有竞争。

3. 变长内存池:size class 分档与 slab 切块

定长池只解决一种尺寸。真实业务里更常见的是一堆尺寸相近但不一样的对象:有的 48 字节,有的 96 字节,有的 300 字节。给每种尺寸开一个定长池当然可以,问题是尺寸种类一多,池的数量就爆炸了,每种池都要预占内存。这时候需要 size class。

3.1 size class 怎么分档:线性、等比还是混合

分档策略决定了内部碎片率和查表速度,是变长池最核心的设计选择。

  • 全部线性(每 8 字节一档):碎片最小,但档位多达几百个,查表慢,每个尺寸都要维护独立的空闲链表和 slab 列表。
  • 全部等比(每档 ×1.25):档位少,但小尺寸区域的相对浪费大,比如 33 字节的请求会被分到 48 字节档,浪费 45%。
  • 混合:小尺寸线性、大尺寸等比。这也是实际工程里最常见的做法。
块大小步进方式33 字节请求的浪费备注
16 / 32 / 48 / 6416 线性31%覆盖绝大多数小对象
80 / 96 / 112 / 12816 线性—128 以内一律 16 一档,查表可直接算
192 / 256 / 384 / 512约 1.33 倍—中等尺寸,碎片可接受
768 / 1024 / 2048 / 4096约 2 倍—再大就走大块路径

实现上不需要复杂计算,一张常量表加二分就够了:

#include <cstddef> constexpr std::size_t kClassCount = 16; constexpr std::size_t kClassSize[kClassCount] = { 16, 32, 48, 64, 80, 96, 112, 128, 192, 256, 384, 512, 768, 1024, 2048, 4096 }; // 返回请求字节数对应的档位下标;n 必须 >= 1 inline int size_to_class(std::size_t n) { if (n <= 128) return static_cast<int>((n + 15) / 16) - 1; // 前 8 档直接算 int lo = 8, hi = static_cast<int>(kClassCount) - 1; while (lo < hi) { // 后 8 档二分 int mid = (lo + hi) / 2; if (kClassSize[mid] < n) lo = mid + 1; else hi = mid; } return lo; }

逻辑说明:128 字节以内是 16 字节一档,档位下标可以直接用除法算出来,省掉一次查表。128 以上档位稀疏,二分四次就能定位,比线性扫描稳定。这个函数在每次分配都会被调用,所以它必须足够便宜——用std::lower_bound也能写对,但手写二分能少一层模板实例化,看汇编更直观。

参数说明:kClassSize的最后一个值是 4096,超过这个尺寸的请求不进池,直接走大块路径。这个阈值不是固定的,页大小是 4KB 的机器上取 4096 比较自然;如果你的业务对象普遍偏大,把它提到 8192 也行,但要注意 slab 里能切的块数会变少,管理开销占比上升。

3.2 slab 结构:一次系统申请切出 N 个块,用位图记占用

定长池用空闲链表记空闲块,变长池如果每档都维护空闲链表,节点一多内存开销就上来了。更常见的做法是 slab 加位图:一段连续内存切成 N 个等长块,用位图记录哪块被占用。

#include <cstdint> #include <cstdlib> #include <cstring> struct Slab { char* base; // slab 起始地址 std::size_t block_size; // 每块字节数 std::size_t capacity; // 块数 std::size_t used; // 已占用块数 std::uint64_t* bitmap; // 每 bit 表示一块是否占用 std::size_t bitmap_words() const { return (capacity + 63) / 64; } }; // 从 slab 中取一块,返回 nullptr 表示已满 void* slab_alloc(Slab* s) { if (s->used >= s->capacity) return nullptr; for (std::size_t w = 0; w < s->bitmap_words(); ++w) { std::uint64_t inv = ~s->bitmap[w]; if (inv == 0) continue; // 这一组 64 块全满,跳过 int bit = __builtin_ctzll(inv); // 找最低位的空闲块 std::size_t idx = w * 64 + bit; if (idx >= s->capacity) return nullptr; // 尾部补齐位不可用 s->bitmap[w] |= (std::uint64_t(1) << bit); ++s->used; return s->base + idx * s->block_size; } return nullptr; } // 归还一块,p 必须来自同一个 slab void slab_free(Slab* s, void* p) { auto offset = static_cast<char*>(p) - s->base; std::size_t idx = static_cast<std::size_t>(offset) / s->block_size; s->bitmap[idx / 64] &= ~(std::uint64_t(1) << (idx % 64)); --s->used; }

逻辑说明:slab_alloc按 64 位字扫描位图,取反后找最低置位,一次能定位到具体块。命中率高的场景下第一组 64 块里通常就有空位,循环第一次就退出。slab_free用指针减基址算偏移,再除以块大小得到下标,不需要额外存索引——这是 slab 相比链表方案在释放路径上的优势,释放更快,而且天然支持乱序释放。

参数说明:capacity一般取(slab_size - header) / block_size,slab_size 建议是页大小的整数倍,比如 64KB,这样切出来的块数在不同档位下比较均衡。位图本身也要占内存,capacity个块需要capacity / 8字节,这部分要在分配 slab 时一起算进去。__builtin_ctzll是 GCC / Clang 的内建函数,MSVC 上对应_BitScanForward64,跨平台时用一层宏包起来。

注意:slab_free里的指针减法和除法都假设p确实来自这个 slab。如果调用方传错了指针,这里会静默地把别的块的位清掉,症状是“内存莫名其妙被复用”,非常难查。上线版本里建议加一个 debug 开关,用base <= p < base + capacity * block_size做一次区间校验。

3.3 大块内存走独立通道,什么时候归还给系统

超过阈值(比如 4KB)的请求不要进 slab,直接malloc,但在指针前面加一个头记录原始大小,释放时据此判断走哪条路径。

struct alignas(16) BigHeader { std::uint64_t size; // 用户请求的字节数 std::uint64_t magic; // 固定值,用于校验指针合法性 }; void* big_alloc(std::size_t n) { void* raw = std::malloc(n + sizeof(BigHeader)); if (!raw) return nullptr; auto* h = static_cast<BigHeader*>(raw); h->size = n; h->magic = 0x424947424C4B3031ull; // "BIGBLK01" return h + 1; } void big_free(void* p) { if (!p) return; auto* h = static_cast<BigHeader*>(p) - 1; if (h->magic != 0x424947424C4B3031ull) return; // 不是大块,交回小对象路径 h->magic = 0; // 清掉,防止重复释放 std::free(h); }

逻辑说明:大块路径的关键不是快,而是能把“块从哪来”这件事说清楚。加了magic之后,释放时可以先判断这块是不是大块路径分配的,避免把指针错误地退回小对象池。清零magic是重复释放的第一道拦截,第二次进来直接返回而不是二次free。

参数说明:BigHeader用alignas(16)是为了保证用户拿到的指针仍然满足最大对齐要求,否则operator new返回的指针不能安全地用于long double之类高对齐类型。头大小 16 字节,对 4KB 以上的块来说开销可以忽略。阈值怎么定?看你的 slab 大小,一般取 slab 大小的 1/8 到 1/4,太小会让大量中大块走慢路径,太大则 slab 内部浪费严重。

3.4 线程本地缓存:把锁从每次分配降到每 N 次分配

单线程池再好,多线程一加锁就白搭。最常见的做法是每线程一份缓存,分配释放全在本地完成,本地空了才去中央池批量取一批,本地攒够一批再批量还回去。

struct ThreadCache { static constexpr std::size_t kBatch = 32; void* free_list[kClassCount] = {}; std::size_t count[kClassCount] = {}; void* alloc(int cls) { if (count[cls] == 0) refill(cls); // 本地空,批量向中央池要 if (count[cls] == 0) return nullptr; void* p = free_list[cls]; free_list[cls] = *static_cast<void**>(p); --count[cls]; return p; } void dealloc(int cls, void* p) { *static_cast<void**>(p) = free_list[cls]; free_list[cls] = p; ++count[cls]; if (count[cls] >= kBatch * 2) flush(cls); // 攒够两批再还,减少争用 } private: void refill(int cls); // 从中央池一次取 kBatch 个,挂到本地链表 void flush(int cls); // 把多余的块一次还回中央池 };

逻辑说明:refill和flush都带批量,把加锁次数摊薄到kBatch次分配一次。dealloc的阈值设成kBatch * 2而不是kBatch,是为了避免“刚还回去又要重新取”的抖动——本地保留一批余量,短时间的小波动不会触发中央池交互。

参数说明:kBatch取 32 是经验值,太小摊薄效果不明显,太大导致线程间内存分配不均,一个线程空闲时手里攥着几百个块,另一个线程却在频繁取。线程退出时记得把本地缓存整个还回去,否则内存会跟着线程一起“消失”,在长期运行、线程池频繁扩缩容的服务里,这就是 RSS 只涨不降最典型的原因。

4. 接进工程:全局 operator new 重载与容器分配器适配

池子写完只是半成品,接不进现有代码就是白写。接入方式主要有三种,按侵入性从低到高:全局operator new重载、给容器传自定义 allocator、显式用对象池创建对象。三种方式各有边界,混用不当就会翻车。

4.1 全局 operator new 重载的三个硬性约束

全局重载的好处是零侵入,老代码一行不改就吃到池化收益。代价是你必须满足标准对分配函数的所有要求,任何一条不满足都是未定义行为,而且症状往往在很久以后才出现。

#include <cstdlib> #include <new> namespace { constexpr std::uint64_t kPoolTag = 0x504F4F4C504F4F4Cull; // "POOLPOOL" // 所有分配统一加 16 字节头,保证两条路径的释放逻辑可区分 struct alignas(16) BlockHeader { std::uint64_t tag; // 等于 kPoolTag 表示来自池 std::uint64_t size; // 用户请求的原始字节数 }; } void* operator new(std::size_t n) { if (n <= kMaxSmall) { void* raw = global_pool().allocate(n + sizeof(BlockHeader)); if (raw) { auto* h = static_cast<BlockHeader*>(raw); h->tag = kPoolTag; h->size = n; return h + 1; } } void* raw = std::malloc(n + sizeof(BlockHeader)); // 大块或池耗尽时兜底 if (!raw) throw std::bad_alloc(); auto* h = static_cast<BlockHeader*>(raw); h->tag = 0; h->size = n; return h + 1; } void operator delete(void* p) noexcept { if (!p) return; auto* h = static_cast<BlockHeader*>(p) - 1; if (h->tag == kPoolTag) global_pool().deallocate(h); else std::free(h); // 必须还回 malloc,不能还回池 }

逻辑说明:头里存tag是为了让释放路径能区分来源。兜底路径也加了头,这一点很关键——如果只有池化路径带头,那么delete时读p - 16读到的是别的对象的数据,行为不可预测。统一加头之后,两条路径对称,释放逻辑只有一条。

参数说明:kMaxSmall是进池的上限,一般设成 4096 或 8192。头占 16 字节,意味着一次 8 字节的new实际要池化 24 字节,对小对象来说开销占比不小,所以小对象密集的场景更应该配 size class 而不是简单的定长池。另外,C++14 之后还有带大小的operator delete(void*, std::size_t)版本,如果你的编译选项里开了 sized deallocation,最好一并重载,否则可能出现 sized 版走系统默认、unsized 版走你的池的不对称情况。

注意:全局重载影响整个进程,包括你依赖的第三方库。有些库会在自己的模块里malloc出一块内存,然后在你这边free,如果它没有走你的operator new,头就不存在,h->tag读到的是垃圾值。

4.2 给标准容器挂自定义 allocator

容器场景更适合用专用 allocator,因为容器的分配释放是成对的、可控的,不需要靠头部标记去猜来源。

#include <cstddef> #include <new> template <typename T> struct PoolAllocator { using value_type = T; PoolAllocator() noexcept = default; template <typename U> PoolAllocator(const PoolAllocator<U>&) noexcept {} T* allocate(std::size_t n) { if (n > std::size_t(-1) / sizeof(T)) throw std::bad_alloc(); return static_cast<T*>(global_pool().allocate(n * sizeof(T))); } void deallocate(T* p, std::size_t) noexcept { global_pool().deallocate(p); } template <typename U> bool operator==(const PoolAllocator<U>&) const noexcept { return true; } template <typename U> bool operator!=(const PoolAllocator<U>&) const noexcept { return false; } }; // 用法 std::vector<int, PoolAllocator<int>> v;

逻辑说明:allocate直接把字节数交给池,池内部按 size class 选档,不加BlockHeader——因为这种分配只被容器使用,deallocate必然由同一个 allocator 调回,不需要靠头部判断来源。operator==恒返回true表示所有实例等价,这是无状态 allocator 的标准写法,容器据此可以自由地在实例之间搬移内存。

参数说明:value_type必须存在,这是 allocator 的契约;rebind在 C++17 之后可以省略,但如果你还要兼容更早的标准,加上更保险。n > max / sizeof(T)的检查不能省,容器传入的n在某些极端场景下确实会溢出。

这种方式的边界很明确:只对显式指定 allocator 的容器生效,std::string、std::map这些都要逐个改。大工程里通常只给最热的几个容器换,改全部收益递减,维护成本反而上升。

4.3 placement new:池只管借内存,构造析构还归对象自己

定长池返回的是原始内存,对象构造和析构要靠 placement new 和显式析构。

#include <new> #include <utility> template <typename T> class ObjectPool { public: template <typename... Args> T* create(Args&&... args) { void* mem = pool_.allocate(); try { return new (mem) T(std::forward<Args>(args)...); // placement new } catch (...) { pool_.deallocate(mem); // 构造失败必须还内存 throw; } } void destroy(T* p) noexcept { p->~T(); // 先析构 pool_.deallocate(p); // 再还内存,顺序不能反 } private: FixedPool pool_{sizeof(T)}; };

逻辑说明:构造函数抛异常时必须把内存还回去,否则这块内存就永久泄漏了——这是 placement new 最容易漏的一步。destroy里先析构再还内存,顺序反了会出现“对象析构时访问已被回收的内存”。

参数说明:FixedPool的块大小传sizeof(T)就够了,池内部会向上对齐。如果T里有柔性数组或需要变长尾部,得改成变长池并按实际字节数申请。

提示:对象池适合生命周期短、创建频繁、构造代价不高的类型。如果类型构造里有大量计算,池化省下的那次分配相对构造开销可以忽略,收益不明显。

5. 排查清单:内存池上线后最容易翻车的五个现场

池子跑起来只是开始,真正花钱的是排查。下面五条都是实际项目里踩过的,每条按“现象、原因、解决”写,对照着查能省不少时间。

5.1 现象:进程 RSS 只涨不落,跑一夜涨了 800MB

原因通常有三种。一是线程本地缓存没在退出时归还,线程池扩缩容后缓存跟着线程一起泄漏;二是变长池的 slab 只增不减,高峰过后空 slab 一直挂着;三是大块路径走malloc,但free之后分配器没把页还给系统。

排查顺序:先看线程数随时间的变化,如果线程数稳定但 RSS 涨,问题在 slab;如果线程数也在波动,问题在线程缓存。解决方案是给 slab 加一个“连续两次扫描全空就释放”的策略,线程退出时把本地缓存整个还回中央池,必要时在低峰期主动调用一次收缩。

5.2 现象:开了地址检查就报堆缓冲区越界,代码翻三遍没找到

原因往往不是你的代码越界,而是池给的块比请求的大。比如请求 100 字节,size class 给了 112 字节,地址检查工具看到你写了 105 字节,但按块大小算没越界,按请求大小算越界了——两份信息不一致,报错位置就会很怪。

解决方案是让池在 debug 模式下按请求大小精确分配,或者给地址检查工具提供__sanitizer_annotate_contiguous_container之类的标注,把块内的实际可用范围告诉工具。上线版本不需要这些,但调试版本必须开。

5.3 现象:换内存池后 QPS 没涨反而跌了

典型的“优化了以为热的路径”。原因可能是:池化后对象在内存里的分布变了,原来顺序分配的对象现在被链表头插打乱,遍历时缓存命中率下降;也可能是全局operator new重载把一些本来走线程缓存很快的分配拖进了带锁的中央池。

排查办法是用采样工具抓一下,看热点是不是从分配转移到了业务代码里的某个循环。我遇到过最隐蔽的一次是字符串拼接:池化后小块分配变快,字符串增长更频繁,反而导致更多的memcpy。解决思路是给热路径对象预分配足够容量,而不是靠池去加速反复扩容。

5.4 现象:多线程下偶发段错误,单线程跑一万次不复现

原因几乎总是池的线程安全假设错了。要么是同一个池实例被多线程共用但没加锁,要么是 A 线程分配、B 线程释放,而释放路径把块还进了 B 线程的本地缓存,A 线程下次取到这块时头部信息已经错乱。

解决方案是明确所有权:要么每线程独立池,跨线程释放走中央池;要么在块头里记录归属线程 ID,释放时路由到正确位置。前者简单,内存利用率略低;后者复杂,但内存可控。小团队建议先做前者,简单方案先跑起来,复杂方案等真出问题再上。

5.5 现象:程序退出时崩溃在 delete 上

退出顺序问题。全局池对象如果是静态存储期,它的析构可能早于某些仍在运行的对象析构,那些对象析构时delete一个池化块,访问的已经是销毁后的池结构。

解决方案是把池做成函数内静态对象(static FixedPool& global_pool()),保证初始化顺序,同时让池的析构函数什么都不做——进程退出时操作系统会回收全部内存,不需要你手动free,把析构留空反而最安全。这是那种“什么都别做反而最安全”的情况。

注意:这条也适用于单元测试。测试里创建池、分配、销毁池,如果还有对象活着,析构时就会崩。测试代码里务必先销毁对象再销毁池。

6. 再快一步:无锁空闲链表与批量回收的收益边界

. 6.1 无锁空闲链表的 ABA 问题用标记指针解决:把 next 指针和版本号打进一个 64 位字,高 16 位存版本、低 48 位存地址(x86-64 用户态地址实际只用 48 位,前提是不开五级页表)。每次出栈版本号加一,`compare_exchange

本文还有配套的精品资源,点击获取