多线程下 malloc 性能骤降?从锁竞争到分配器优化全解析

发布时间:2026/10/21 2:53:15
多线程下 malloc 性能骤降?从锁竞争到分配器优化全解析 我调试过多线程服务时碰过不少次这样的怪事8 核 32 线程的进程压测时 CPU 没吃满吞吐却死活上不去。perf top 一看排第一的居然是malloc相关的函数直接吃掉二三十个点的 CPU。当时我第一反应是“不可能吧分配个内存能有多贵”后来顺着锁竞争和内存分配器机制一层层查下去才明白多线程场景下 malloc 慢不是玄学而是一套完全可以讲清楚的原理问题。这篇文章我打算从 malloc 在用户态和内核态之间的完整链路讲起再用一组实测数据展示多线程并发分配内存时性能到底怎么退化最后给出定位手段和切实可行的优化方案。内容偏底层但我会尽量用大白话拆开讲适合正在写 C/C 服务、做性能调优或者只是好奇“为什么线程一多分配内存就慢”的朋友。1. 一次 malloc 调用的完整旅程1.1 你以为 malloc 只是“拿一块内存”很多写应用层代码的同学对 malloc 的认知就是“从堆里要一块内存”完事。实际上一次 malloc 调用远不止这么简单它可能走完下面几条链路中的一条命中线程本地缓存tcache直接返回全程无锁几十纳秒搞定。命中进程级空闲链表bin需要加锁操作堆管理结构耗时可能上百纳秒。空闲链表里没有合适块从堆顶top chunk切割同样要加锁。堆顶空间也不够触发brk系统调用扩展堆或者用mmap映射一块新内存这里就进入内核态了耗时可能到微秒级。也就是说malloc 并不是一个纯用户态函数它的尾巴始终拖着一个“可能随时找内核要内存”的系统调用。brk和mmap都涉及进程地址空间的修改内核要持锁处理页表、VMA虚拟内存区域等结构代价天然就高。问题在于单线程时这种代价低频可控多线程时系统调用叠加锁竞争慢就会被放大。1.2 进程级堆结构的锁竞争glibc 的分配器ptmalloc2不管怎么优化最终所有线程共享的仍然是那几个管理堆的空闲链表。为了保证两个线程不会同时改同一个链表节点分配器必须在修改这些结构时加锁。我举个生活中的类比有一家只有一个窗口的银行柜台所有客户排队办理业务每次办业务都要先抢一个号拿锁抢不到就等着。单线程等于只有一个客户随到随办多线程等于一堆客户同时涌进来抢号系统大部分时间花在协调“谁先办”而不是真正办业务。早期 glibc 的 malloc 就是这种全局单锁设计多线程并发分配时所有线程都在等同一把锁性能自然惨不忍睹。后来 ptmalloc2 引入了多 arena 设计每个 arena 有独立的锁和空闲链表相当于多开了几个业务窗口才算缓解了这个问题。但“缓解”不等于“解决”arena 数量是有限的线程数一旦超过 arena 数量依然会有人排队。1.3 缓存一致性协议带来的隐形开销锁竞争不仅意味着“等待”还带来 CPU 层面的缓冲一致性协议开销。当一个锁变量被多个 CPU 核心轮流修改时缓存一致性协议比如 MESI会让这个变量对应的缓存行在不同核心之间来回失效、同步。每一次“我改了你要重新拉”的动作都有代价而且代价不比实际分配内存低。有一种说法是“锁竞争严重的场景下malloc 慢的根源不是分配本身而是缓存行乒乓”。这句话有道理因为访问同一个锁变量时所有核心都在争夺同一个缓存行的所有权导致内存总线上全是同步流量有效计算能力被严重稀释。这也是为什么多线程 malloc 慢的现象里CPU 占用看着很高但真正做的“有用功”却很少。2. 现代 malloc 的线程优化手段2.1 glibc 的 arena 机制是怎么设计的ptmalloc2 的核心设计是多 arena 模型。主线程使用 main_arena后续新线程创建时会尝试获取 main_arena 的锁如果锁被占用就创建新的 arena。arena 数量有上限规则大致是CPU 核数小于 8 时上限为核数 * 2大于等于 8 时上限为核数 * 8。每个 arena 有自己独立的堆结构、锁和空闲链表从而把竞争压力分散到多个“子堆”上。这个设计解决了一部分问题但也引入了新麻烦arena 数量太多时内存碎片率会升高因为每个 arena 都独立管理自己的堆某个线程用完的内存无法被其他线程的 arena 复用。我在实际项目中见过一个极端案例32 线程每线程各持有一个 arena内存碎片率比单线程场景硬生生高了 10 个百分点。2.2 tcache无锁分配的“快车道”glibc 从 2.26 版本开始引入了 tcache这是最大的一个性能拐点。tcache 是每个线程私有的缓存池里面按大小分类存放最近释放的内存块。因为线程私有所以分配和释放都不需要加锁直接取链表头就行。tcache 的默认上限是 1032 字节64 个槽位每个槽位最多缓存 7 个 chunk也就是说最常见的几十到几百字节的小对象分配都走这条无锁快车道。单线程场景下 tcache 命中率达到 99% 以上多线程场景下只要每个线程的分配/释放模式是“自己分配自己释放”锁竞争也能被大量规避。但我必须提醒一个坑tcache 只是把内存“缓存”在线程手里并不会真正归还给操作系统。线程越多tcache 里积压的内存总量就越可观。你可能会看到进程的 RSS 持续上涨但实际业务内存根本没用到那么多这就是 tcache 在囤货。后面我会讲怎么回收。2.3 jemalloc 和 tcmalloc 的差异化思路既然 glibc 的多线程性能不够极致业界自然有人做了更好的轮子。tcmallocGoogle的核心是每个线程维护一个 ThreadCache分配优先走本地缓存中央堆只做兜底而且 ThreadCache 支持批量从中央堆搬运内存减少跨线程交互。jemallocFreeBSD 引入后来被 Facebook 大规模使用则强调按大小类和 CPU 数量划分 arena支持 per-CPU 绑定在多核大内存场景下表现尤其稳定。对比下来jemalloc 和 tcmalloc 共同的设计哲学是尽量减少全局共享结构把分配路径做到线程本地化、无锁化。与 glibc 相比它们在多线程扩展性上普遍好一截。用 LD_PRELOAD 就能无缝替换应用代码一行都不用改后面实测部分我会给出具体数据。3. 实测多线程 malloc 的真实表现3.1 测试程序怎么设计才“公平”光讲原理不够我用一套最贴近真实业务的小测试来量化“多线程 malloc 到底慢多少”。测试程序的设计思路是N 个线程各自循环执行“分配 32 字节 → 写脏数据 → 释放”每个线程循环 100 万次。之所以选 32 字节是为了直接命中 tcache 管理的小对象区间这也是服务端程序最常分配的尺寸之一。编译命令和核心代码长这样#include stdio.h #include stdlib.h #include string.h #include pthread.h #include sys/time.h #define LOOP_COUNT 1000000 void* worker(void* arg) { for (int i 0; i LOOP_COUNT; i) { void* p malloc(32); if (p) { memset(p, 0xAB, 32); free(p); } } return NULL; } int main(int argc, char* argv[]) { int n atoi(argv[1]); pthread_t tids[n]; struct timeval t0, t1; gettimeofday(t0, NULL); for (int i 0; i n; i) pthread_create(tids[i], NULL, worker, NULL); for (int i 0; i n; i) pthread_join(tids[i], NULL); gettimeofday(t1, NULL); double sec (t1.tv_sec - t0.tv_sec) (t1.tv_usec - t0.tv_usec) / 1000000.0; printf(threads%d time%.3fs\n, n, sec); return 0; }gcc -O2 -pthread test_malloc.c -o test_malloc测试机器是一台 8 核 x86_64 的 Linux 服务器glibc 版本 2.31内存 32 GB。为了减少调度噪声我把线程绑定到了不同 CPU 上并用taskset限定进程只使用 0-7 号核。3.2 线程数扩展性测试结果我分别跑了 1、2、4、8、16 线程结果如下耗时取 3 次运行的中位数线程数总耗时秒相比单线程10.381.00x20.471.24x40.822.16x81.443.79x162.636.92x理想情况下线程数翻倍总耗时应该下降或至少保持不变因为总任务被分摊。可实际上从 2 线程开始性能就明显退化8 线程时总耗时已经是单线程的 3.8 倍16 线程时更是达到 6.9 倍。也就是说多线程分配内存不仅没有加速反而让总工作量变大了几倍。这类现象背后的主要推手就是锁竞争和缓存行乒乓。再从另一个角度算一下1 线程时每个 mallocfree 的平均开销约 190 纳秒8 线程时每个线程每个分配的耗时达到 180 纳秒1.44 秒 × 8 线程 ÷ 100 万次单次耗时其实没有爆炸性增长但 8 个线程的耗时叠加起来整体吞吐就垮了。这说明并不是“分配本身变贵了很多”而是并发导致的额外开销挤占了有效吞吐。3.3 分配器横向对比glibc vs tcmalloc vs jemalloc同样的测试程序我用LD_PRELOAD分别加载 tcmalloc 和 jemalloc在 8 线程场景下重新跑一遍结果差异非常悬殊分配器8线程耗时秒相对 glibcglibc malloc1.441.00xtcmalloc0.290.20xjemalloc0.250.17x这个结果其实在预期之内但第一次跑出来的时候我还是被震到了仅靠换一个分配器同一个程序 8 线程总耗时从 1.44 秒直接降到 0.25 秒快了接近 6 倍。jemalloc 和 tcmalloc 之所以能获胜核心就是它们的线程本地缓存设计得更加彻底并且对锁的使用粒度更细、更少。有意思的是单线程场景下三者差距没那么大glibc 甚至不算差。所以我的结论是如果你的程序在线程数较少比如 2-4 线程、分配频率不高的场景下跑glibc 完全够用一旦线程数上了 8 或者分配释放非常频繁建议认真考虑换分配器。4. 定位与优化从“慢”到“快”的完整路径4.1 先定位perf 和内存统计工具怎么用优化之前第一步永远是确认瓶颈真的在 malloc 上而不是空转、IO 或其他原因。我惯用的做法是两步走。第一步用perf top看热点。在程序运行的同时执行perf top -p 进程号观察有没有malloc、free、_int_malloc、_int_free、pthread_mutex_lock这类符号出现在热点前列并且 CPU 占比明显偏高。如果确实如此瓶颈基本就锁定了。第二步用环境变量开启 glibc 的内存调试统计或者用malloc_info接口导出堆状态# 开启 glibc 内部一致性检查与统计会拖慢性能仅诊断用 MALLOC_CHECK_3 ./test_malloc 8更实用的方式是用gdb调用malloc_info输出当前进程的 arena、bin 等分布情况或者直接看/proc/进程号/status里的VmRSS变化趋势。这些数据能帮你判断内存是消耗在业务上还是被分配器“截留”了。4.2 见效最快的几种优化手段定位确认后按“成本从低到高”排序我会依次尝试下面这些手段调小 tcache 或直接限制 arena 数量。如果你用的是 glibc 且线程数很多可以设置MALLOC_ARENA_MAX4或更小减少多 arena 碎片。但注意这个操作是一把双刃剑arena 少了锁竞争可能变严重arena 多了碎片变多。需要实测调参。对高频小对象做对象池。业务代码里最典型的场景是“某种结构体频繁创建销毁”比如网络连接对象、消息缓存。自己维护一个无锁队列或线程本地对象池分配和回收都走池子完全不触碰 malloc效果立竿见影。我见过一个网关程序只做了一层连接对象池整体 TPS 提升了近 30%。调整分配尺寸避开 tcache 边界。tcache 对 1032 字节以内的对象有效超过则无缓存可走。如果你的对象大小只比边界大几个字节可以试着用__attribute__((aligned))或结构体补齐把它塞回 tcache 能覆盖的区间提升分配命中率。换分配器。用LD_PRELOAD/path/to/libtcmalloc.so直接启动程序一行命令验证 tcmalloc 或 jemalloc 是否解决问题。网上说的“换分配器要重编代码”其实是误解绝大多数情况下动态链接直接替换即可只有静态链接场景需要重新编译。4.3 什么时候该自己写分配器什么时候不应换换分配器不是万能的我自己也踩过坑某次把一个大量使用realloc的程序切到 tcmalloc结果性能反而变差了。原因是 tcmalloc 对realloc的某些路径处理并不比 glibc 好尤其是跨 size class 的大块迁移。所以选分配器之前最好先用真实业务的分配模式做个小验证而不是盲目替换。自己写分配器则是最后的选择。只有当业务有非常明确的内存模式比如只分配固定大小的对象、生命周期可预期、线程私有特性强时对象池或专用分配器才有优势。否则维护成本、调试成本都很高尤其是接入 valgrind、ASan 这类内存检测工具时自有分配器经常造成“假阳性”让人排查到怀疑人生。我的经验法则是80% 的场景换一个成熟分配器就能解决15% 的场景加对象池只有不到 5% 的场景值得独立设计分配策略。5. 常见误区与避坑清单5.1 容易出现误判的几种情况内存分配性能问题最常见的误判是把所有“内存上涨”都当成内存泄漏。多线程 tcache 的环境下某个线程频繁分配又释放但释放后的内存仍然留在 tcache 里不会还给进程堆多次循环后 RSS 平稳上涨很多人第一反应就是“泄漏了”。这时候应该先用malloc_info看 tcache 和 bin 里的存量再下结论不要一上来就套 valgrind。第二个误判是把“慢”全归因于 malloc。有些时候耗时高是因为线程本身争抢业务锁malloc 只是被连带拖累。判断方法很简单用perf record -g抓调用栈看 malloc 是被谁调进来的上层有没有锁等待如果调用栈里大量出现 futex说明真正的瓶颈可能是业务锁或调度而不是分配器。第三个误判是盲目调大MALLOC_ARENA_MAX。很多人以为 arena 越多越好实际上线程数远大于核数时大量 arena 会导致内存碎片化加剧。正确的调参姿势是先观察当前线程数和 NUMA 拓扑再把 arena 数从默认值往下调或往上调逐一跑压测对比。5.2 关于内存回收的实操心得glibc 的 tcache 和 bin 会把不用的内存握在手里想强制还给操作系统可以调用malloc_trim(0)。这个函数会遍历所有 arena把最顶部的空闲堆内存归还内核。我在一个长连接服务里用过它程序每个小时执行一次malloc_trim(0)RSS 高峰值下降了约 15%效果相当明显。注意它只在堆顶有空闲区时才有用堆中间的空洞是没法通过它回收的。还有一个经验是malloc_trim在 glibc 下可能触发比较大的锁操作因为要扫描所有 arena。所以不要放在高频率的热路径里建议放到后台定时任务或低峰期执行。5.3 一点长远建议多线程 malloc 的性能问题解决手段其实就两条一是减少分配次数二是减少跨线程共享。无论你最终选择换分配器、写对象池还是调整参数背后的原则都是同一个。日常写代码时多留个心眼高频率的临时对象尽量复用避免写“分配一次、用完就丢”的坏习惯线程之间尽量不要互相传递和释放对方分配的内存。架构层养成这些习惯远比出了问题再调分配器来得省事。我个人在实际操作中最深的一次体会是排查 malloc 性能问题看原理文档十遍不如亲自跑一遍 perf 和横向对比测试。原理帮你指方向数据帮你做决策。这篇文章里的测试代码和命令都是可以直接复制跑的你回项目里照着试一遍很快就能看到自己程序在多线程分配上的真实瓶颈。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询