Skip to content
团子云技术 Lite 1.048596
Go back

brpc 里 ptmalloc 反超 tcmalloc:不是玄学,是 bthread 的锅

最近我在 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 的用户态协程模型:

这种模式导致海量的跨线程释放(cross-thread deallocation)。对 tcmalloc 来说,跨线程释放的内存放不进当前线程的本地缓存,只能退还给全局的 Central Cache,触发自旋锁竞争(spinlock contention)。并发一高,性能急剧下降。

而现代 ptmalloc(自带多个 arena 的版本)在这种跨线程释放的争抢下,锁退化和分配开销反而比 tcmalloc 退化到 Central Cache 的路径更小。

原因二:brpc 在热点路径上几乎没用 malloc

翻 brpc 源码会发现,框架在关键路径上基本绕开了标准的 malloc/new:

也就是说,高频热点代码里的小内存分配,已经被框架自己接管了。你测试中真正落到 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:

调整测试环境。想测 jemalloc 的真实实力,务必加上 MALLOC_CONF="background_thread:true"。另外确认业务代码中,bthread 切换前后是否发生了对象的跨线程传递和释放。

几点心得

受限于笔者的背景,以上梳理主要来自资料分析和推理,还没有在真实业务流量上复测。如果有理解不对的地方,欢迎指正。


Share this post on:

Previous Post
从 /proc/self/maps 说起:堆、协程栈、缺页中断与 OOM
Next Post
malloc(15) 实际给了你 32 字节:拆一段侥幸存活的 C++ 代码