最近我在 brpc 上做了一轮内存分配器对比测试,对象是 tcmalloc、jemalloc 和 glibc 自带的 ptmalloc。结果有点反直觉:专门为多线程优化的 tcmalloc 和 jemalloc,双双跑输了”上古”的 ptmalloc。
第一反应是怀疑测试写错了。但排查之后发现,这大概率不是 bug,而是 brpc 的架构本身就决定了这个结果。这里把我梳理出的几层原因整理一下。
原因一:bthread 的 M:N 模型打穿了 Thread-Local Cache
tcmalloc(Thread-Caching Malloc)和 jemalloc 之所以快,核心依赖是线程本地缓存(Thread-Local Cache,TLS)。它们有一个隐含假设:一个线程分配的内存,大概率由同一个线程释放,这样分配和释放都能走无锁的本地路径。
但 brpc 用的是 bthread,一种 M:N 的用户态协程模型:
- 一个处理 RPC 请求的 bthread 可能在 pthread A 上开始执行,分配内存;
- 遇到网络 I/O 或锁阻塞时让出(yield);
- I/O 就绪后,这个 bthread 可能被调度到 pthread B 上恢复执行,释放内存。
这种模式导致海量的跨线程释放(cross-thread deallocation)。对 tcmalloc 来说,跨线程释放的内存放不进当前线程的本地缓存,只能退还给全局的 Central Cache,触发自旋锁竞争(spinlock contention)。并发一高,性能急剧下降。
而现代 ptmalloc(自带多个 arena 的版本)在这种跨线程释放的争抢下,锁退化和分配开销反而比 tcmalloc 退化到 Central Cache 的路径更小。
原因二:brpc 在热点路径上几乎没用 malloc
翻 brpc 源码会发现,框架在关键路径上基本绕开了标准的 malloc/new:
- 所有协程上下文(bthread id)、RPC 任务、Socket 结构体,全部走
butil::ResourcePool——这是 brpc 针对协程深度定制的高效对象池; - 所有网络数据的接收、拼接、解析,全部走
butil::IOBuf(内部是 Block 链表)。
也就是说,高频热点代码里的小内存分配,已经被框架自己接管了。你测试中真正落到 malloc 上的,往往是框架之外的业务逻辑,或者是较大块的内存分配。而在大块内存的管理上,tcmalloc 的优势并不明显,管理元数据的开销甚至会拖累性能。
原因三:ptmalloc 自己也进化了
如果你的发行版够新(glibc >= 2.26,比如 CentOS 8 / Ubuntu 18.04 以后),ptmalloc 已经引入了 tcache(Thread Cache)机制。它吸收了 tcmalloc 的核心思想,小块内存同样在线程本地做无锁分配。加上 ptmalloc 与系统底层集成度更高,很多常规场景下两者的差距已经被大幅拉平,某些特定指令集上 ptmalloc 甚至表现更好。
原因四:tcmalloc / jemalloc 的默认参数吃亏
tcmalloc 和 jemalloc 开箱即用的默认配置,并不适合所有场景,尤其是高并发框架:
jemalloc 的 madvise 陷阱。默认情况下,jemalloc 可能非常激进地用 madvise(..., MADV_DONTNEED) 把空闲内存归还给操作系统。高并发下这会产生海量缺页中断(page fault)和 CPU 消耗。通常要配置环境变量、开启后台清理线程并调大 decay 时间才能发挥实力:
MALLOC_CONF="background_thread:true,dirty_decay_ms:30000,muzzy_decay_ms:30000"
tcmalloc 的版本陷阱。很多通过 apt/yum 安装的 tcmalloc,其实是较老的 gperftools 版本。Google 内部后来开源了全新的 TCMalloc(TCMalloc-modern),两者在跨线程释放的优化上天壤之别。用旧版 gperftools 跑不过新 ptmalloc,很正常。
怎么验证
如果遇到类似的反常结果,几条验证路径:
抓 CPU 火焰图。测试运行时用 perf 生成 flamegraph:
- 如果看到大量
TCMalloc_Central_FreeList::Insert相关的自旋锁,就印证了跨线程释放导致 tcmalloc 性能崩塌; - 如果看到大量
sys_madvise或page_fault,说明 jemalloc/tcmalloc 在频繁向 OS 归还内存。
调整测试环境。想测 jemalloc 的真实实力,务必加上 MALLOC_CONF="background_thread:true"。另外确认业务代码中,bthread 切换前后是否发生了对象的跨线程传递和释放。
几点心得
- 分配器性能不是绝对属性,它取决于负载的分配/释放模式和线程亲和性。脱离具体负载谈”哪个 allocator 更快”,没有意义。
- 框架自带的内存池会架空通用分配器。评估 allocator 之前,先搞清楚框架的热点路径上真正调用 malloc 的是什么。
- 默认参数不是为高并发 RPC 服务的。jemalloc 拿出真实实力需要调
MALLOC_CONF,用默认参数得出的结论只能代表默认参数。 - 数字反常时先找机制。ptmalloc 反超不是玄学,背后每一层都有明确的机制可以验证。
受限于笔者的背景,以上梳理主要来自资料分析和推理,还没有在真实业务流量上复测。如果有理解不对的地方,欢迎指正。