C++ 语言实战(十一):内存池与对象池——吞吐 ×2.3 的复用之道

第 11 / 13 章
C++ 语言实战(十一):内存池与对象池——吞吐 ×2.3 的复用之道

ch03 干掉了 string 深拷贝,账面上 malloc 风暴平息了——但那只是冰山露出水面的部分。这一章二期扩容(50 路加到 80 路)触底,露出了水面下的东西:吞吐不但没跟着涨,反而跌了。顺着这个反直觉的现象往下挖,就是本章的主战场:new/delete 的底层机制、ptmalloc 的锁与碎片、placement new 与对象池。最后给出一组放之四海皆准的数字:同样的业务逻辑,只改内存分配策略,吞吐 ×2.3。

一、事故现场:通道加到 80 路,吞吐反而跌了

二期扩容的验收目标很朴素:50 路 → 80 路,总吞吐从 4.3 万帧/秒线性涨到 7 万帧/秒。实测:3.8 万帧/秒。加硬件反而变慢,这种「线性预期的背叛」通常意味着某个随线程数恶化的隐性瓶颈。

通道扩容反直觉现象

先把嫌疑对象的画像摆出来。当时每帧的处理链里,Frame 是标准做法——构造时 new,里面套着 vector 装载荷,出口线程发完就析构:

// 采集线程
auto* f = new Frame(ch, raw);          // 每帧一次分配,10 万次/秒
ring.push(f);
// 出口线程(另一个线程)
auto f = ring.pop();
send(*f);
delete f;                              // 释放发生在另一个线程

三个数字叠起来就是案情全貌:10 万次/秒的分配频率、分配与释放在不同线程、每帧的载荷长度各不相同(vector 反复分配内部缓冲)。这正好构成 glibc ptmalloc 最难受的工作模式。

二、排查过程:让分配器自己交代

第一步,perf 看时间去向。perf top 里 malloc / free / malloc_consolidate 三个符号合计吃掉 19% CPU——业务代码没改一个字节,CPU 却在给内存管理打工。第二步,strace -c 看到 mmap 调用频率异常(大块载荷反复走 mmap/munmap 系统调用,每次都是一次 TLB 击穿)。第三步,malloc_stats() 拉分配器家底:8 个 arena、free chunk 碎片 41MB、RSS 比活跃数据多 60%。

三个证据拼出完整机制:ptmalloc 为减少争用给每个线程分 arena(默认 8×核数),但 arena 只缓解「同线程分配释放」的场景——我们这里 A 线程 new、B 线程 free,chunk 要在 arena 之间迁移,锁照抢不误;线程一多,arena 切换与锁争用随核数恶化,所以通道越多吞吐越跌。而变长的 vector 载荷让 free 的 chunk 大小五花八门,碎片率水涨船高,RSS 缓慢虚胖(客户现场「跑一周内存翻倍」的另一半答案,也是它)。

结论:不是代码错,是把分配器用在了它最差的工况上。修复方向就此定下——热路径的分配必须「定长、同线程、可复用」。

三、底层原理:new 的双层结构与复用的三块基石

new 不是一步操作

new Frame(...) 其实是两件事:先 operator new 拿一块裸内存(底层调 malloc,malloc 底层走 brk/mmap 向系统要内存),再在这块内存上跑构造函数;delete 反过来,先析构再 operator delete 还内存。这两层是解耦的——可以在 A 内存上构造、在 B 时机析构、把内存还给自己的池而不是系统。C++ 把这套能力开成语法,就是对象池的全部法理依据。

placement new:在指定地址上构造

void* mem = pool.acquire();                  // 池里拿内存(不构造)
Frame* f = new (mem) Frame(ch, raw);         // placement new:在此地构造
/* ...使用... */
f->~Frame();                                 // 显式析构(必须配对!)
pool.release(f);                             // 内存还给池

new (mem) T(...) 只做构造不做分配,配对的是显式调用析构——这是极少数允许你亲手写 ~T() 的场景,配对纪律和 ch02 的 RAII 同源:构造析构谁也绕不开谁。工业守则是在池的接口里把这对操作封死(acquire 内做构造、release 内做析构),用户永远摸不到裸的 placement new。

对齐:池内存不是 char 数组那么简单

定长池的内存块要保证对齐正确:Frame 里有 double 和 atomic,对齐要求 8;如果池还想让每个 Frame 独占缓存行(呼应 ch07 的伪共享),要 64 对齐。C++17 的 std::aligned_alloc(64, size) 或按 alignof(T) 计算步长都行,偷懒用 char buf[n] 在对齐上就是碰运气——x86 上大概率「碰巧对」,ARM 上直接 SIGBUS 教做人。顺带记住 C++17 的对齐版 new(operator new(size, align_val_t)),alignas(64) 的类型 new 出来自动带对齐。

碎片的两种长相

内部碎片:分配粒度 16B,你要 17B,实际占 32B——浪费在块内。外部碎片:free 出来一堆小洞,加起来够 1MB,但没有一块连续——新的大对象谁也放不进去,只能找系统再要。ptmalloc 用 size class 分箱缓解,但「变长 + 多线程」仍然是最差工况。池的本质就是把变长世界折叠回定长世界:每帧载荷按最大长度封顶(协议里本来就有长度上限,ch05 的 kMaxPayload 现成),框内定长,碎片从根上消失。

四、正确姿势:先用标准轮子,再手写极致

第一梯队:C++17 PMR,标准库自带的池。 多数场景到这就够了:

std::pmr::unsynchronized_pool_resource pool;          // 单线程池资源
std::pmr::vector<Sample> buf{&pool};                  // 分配走池,内部缓冲可复用

载荷 vector 挂上池之后,反复分配的内部缓冲在池内复用,碎片与 mmap 风暴立刻缓解。PMR 的价值是分配策略与容器解耦——业务代码零改动,换个 resource 就换了策略。

第二梯队:定长对象池,热路径的极致版。 Frame 本体的分配/释放也收进来——侵入式 free list(空闲块自己当链表节点,零额外内存):

class FramePool {
public:
    explicit FramePool(size_t n) {          // 预分配,运行期零系统调用
        mem_ = std::aligned_alloc(64, n * sizeof(Frame));
        for (size_t i = 0; i < n; ++i)
            push_free(reinterpret_cast<Frame*>(mem_ + i * sizeof(Frame)));
    }
    Frame* construct(int ch, int32_t raw) {
        if (!free_head_) return nullptr;    // 池空:不阻塞不扩容,交上层决策
        Frame* f = free_head_;
        free_head_ = f->next;               // 从链头摘
        return new (f) Frame(ch, raw);      // placement new 就地构造
    }
    void destroy(Frame* f) {
        f->~Frame();                        // 显式析构,与 construct 配对
        push_free(f);                       // 压回链头
    }
private:
    void push_free(Frame* f) { f->next = free_head_; free_head_ = f; }
    Frame* free_head_ = nullptr;
    void* mem_;
};

三个设计决策各有考据:预分配 + 池空返回 nullptr——内存上限在启动时确定,运行期零 mmap、零扩容,池空说明容量规划错了,告警暴露而不是静默扩张;free list 不加锁——把池做成每采集线程一个(thread-local),分配释放闭环在同线程,ptmalloc 的 arena 迁移问题从结构上消失;归还走 RAII——unique_ptr<Frame, FrameDeleter> 包一层(ch02 的自定义删除器第三次上岗),业务代码拿到的仍是智能指针,谁也逃不掉归还纪律。

搭配一句使用哲学:池只给定长、高频、生命周期短的热点对象(Frame、解析上下文、日志块),长尾对象继续用通用分配器——为 3% 的长尾对象做池化,换来的复杂度远超收益。

五、数据说话

80 路全负载压测,四版对照:

版本吞吐(帧/秒)malloc 占 CPURSS 稳定性
事故版(每帧 new/delete)38,00019%持续虚胖
PMR 池(载荷入池)61,0008%稳定
PMR + 定长对象池87,0002%稳定
预分配基线(启动全定,理论参考)89,0001%稳定

三个结论值得划线。其一,38,000 → 87,000,×2.3,业务代码零改动——内存分配策略是隐藏最深也最便宜的性能杠杆。其二,PMR 一项就拿到大部分收益(61,000),定长池再收掉剩下的「本体分配」尾巴,第二梯队只在对第一梯队不满意时才值得上。其三,池化版与「全预分配」的理论基线只差 2%——这说明剩下的瓶颈已经不在内存,继续在这条线投入就是过度优化,perf 的下一个热点才是该去的地方(它指向了 ch12 要面对的交付与平台差异)。

六、面试怎么答

Q1:new 和 malloc 的区别? 三层递进:语义层(new = 分配 + 构造 + 类型安全 + 失败抛 bad_alloc,malloc 只给裸内存);机制层(operator new 可重载,new 表达式不可);配合层(new[] 与 delete[] 配对、placement new 只构造不分配需显式析构)。能主动讲「new 的两层解耦是对象池的法理基础」,这题就从背诵题变成设计题。

Q2:什么是 placement new?什么场景用? 定义:new (ptr) T(...) 在既有内存上构造,不分配。配对纪律:显式调用析构 + 内存归还自己的管理者。场景:对象池、内存 arenas、std::vector 的实现内部。陷阱:忘了显式析构(资源泄漏)或对非池内存 placement new 后又 delete(double free 风格混乱)。

Q3:内存碎片是怎么产生的?怎么应对? 内外两种碎片分别定义(块内浪费 / 空洞不连续)。成因讲工况:变长对象 + 高频分配释放 + 多线程。应对按强度递进:减少分配次数(复用/缓冲区重用)→ 定长化(池/arena,把变长折叠回定长)→ 换分配器(tcmalloc/jemalloc 的线程缓存)。能接 RSS 虚胖与 malloc_stats 诊断手段的是实操派。

Q4:高频小对象分配怎么优化? 方法论四步:先 profile 定位(perf/massif/malloc_stats,别凭感觉池化)→ PMR 起步(标准轮子,业务零改动)→ 不满意再定长池(侵入式 free list + placement new + thread-local 免锁)→ RAII 包装归还。最后补边界:池只给热点定长对象,长尾交给通用分配器——过度池化是新的技术债。

七、落地清单

  • 分配问题永远先 profile(perf/malloc_stats/heap profile),拿数字再动手
  • 热路径载荷优先挂 PMR resource,业务零改动换分配策略
  • 定长热点对象(Frame/日志块/解析上下文)用对象池:预分配、池空告警、运行期零系统调用
  • 池接口封装 placement new 与显式析构的配对,用户侧只见 acquire/destroy
  • 池内存 aligned_alloc 按 alignof/缓存行对齐,x86 的「碰巧对」不是对齐
  • 分配与释放在同一线程闭环(thread-local 池),跨线程归还走专门的回收队列
  • 归还纪律用 RAII 兜底:自定义删除器的 unique_ptr 是池对象的唯一合法出口
  • 池化收益逼近理论基线(±2%)即停手,下一个瓶颈在别处

内存这条线全部收口。但网关终究要交付到形形色色的工控机上:ARM 的边缘盒子、客户自带的 x86 工控机、老旧的 Debian 7——代码在开发机上跑得再好,交付链路才是最后一公里。ch10 的工具链纪律已经埋了雷,下一章正面拆解:交叉编译与静态链接。