先从我最近碰到的一个真实问题说起。有个内部服务连接数一直涨不上去,我把线程池从 4 调到 16,本以为吞吐能翻几倍,结果核心延迟反而从 8ms 飙升到 23ms,CPU 也莫名其妙打满。用 perf 抓热点,排在前面的全是 malloc 相关函数。这个情况太典型了:单线程阶段我们几乎从不在意 malloc 的耗时,可一旦多个线程同时去堆里抢内存,分配器就成了隐藏的全局瓶颈。今天这篇我打算从原理到实测,把“malloc 多线程下变慢”这件事彻底拆开,讲清楚它到底慢在哪一步、怎么量化慢了多少、以及哪些优化手段真的有用。无论你是写服务的后端开发,还是做中间件的系统程序员,这篇应该都能帮你省下不少排查时间。
1. 用一个最短的压测程序,先让问题现形
1.1 复现多线程 malloc 竞争的最小代码
我不喜欢一上来就堆概念,先给一段几乎人人都能跑起来的最小复现程序。逻辑非常简单:启动 N 个线程,每个线程各自循环做固定次数的 malloc 和 free,分配大小固定为 64 字节,最后统计总耗时。
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <time.h> #define LOOP 2000000 void *worker(void *arg) { long long sum = 0; for (int i = 0; i < LOOP; i++) { char *p = (char *)malloc(64); p[0] = 1; sum += p[0]; free(p); } return (void *)sum; } int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s nthreads\n", argv[0]); return 1; } int nthreads = atoi(argv[1]); pthread_t tid[64]; struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); for (int i = 0; i < nthreads; i++) { pthread_create(&tid[i], NULL, worker, NULL); } for (int i = 0; i < nthreads; i++) { pthread_join(tid[i], NULL); } clock_gettime(CLOCK_MONOTONIC, &end); double dur = (end.tv_sec - start.tv_sec) + (end.tv_nsec - start.tv_nsec) / 1e9; printf("threads=%d, time=%.3fs\n", nthreads, dur); return 0; }编译命令很简单,在 x86_64 Linux 环境下用gcc -O2 -pthread -o bench bench.c即可。关键点在于每个线程的分配次数完全一致,这样我们比较的是“并发能力”,而不是“总工作量不同”。有的读者可能会问:为什么不让每个线程多运行几轮、取平均?当然可以,我自己测试时会用循环包裹并取三次最小值,但单轮趋势已经足够说明问题。
1.2 单线程 vs 多线程的结果对比
我用一台 8 核的测试机跑出来的数据大概长这样:
| 线程数 | 平均耗时(秒) | 相对单线程倍数 |
|---|---|---|
| 1 | 0.42 | 1x |
| 2 | 0.95 | 2.3x |
| 4 | 2.31 | 5.5x |
| 8 | 4.82 | 11.5x |
| 16 | 9.63 | 22.9x |
注意,这里每个线程分配的总量一样,理论上如果有 8 个核并行跑,8 线程应该比单线程快或至少差不多。但实际结果反而是 8 个线程完成同样工作总量要慢 10 倍以上。而且线程数越大,完成同样的总分配次数所需时间越高,说明瓶颈不是 CPU 算力,而是串行化的某个公共资源。这个趋势在 glibc 较低版本和较高版本上都能复现,只是斜率略有差异。
有经验的朋友看到这个结果可能第一反应是“你分配得太频繁了,真实场景不会这么极端”。确实,这个测试是为了放大分配器压力,但现实服务在高峰期每秒执行几百万次小分配非常普遍,现象只会更隐蔽:单个 malloc 耗时从几十纳秒涨到几微秒,业务接口的 P99 延迟立刻就变差。
1.3 程序里看到的“慢”到底发生在哪一步
一次 malloc 调用可以拆成三段:进入分配器内部、从空闲内存块列表取一块、返回给调用者。free 也是对称的。在多线程竞争场景下,最容易被卡住的是第一段——多个线程同时进入分配器的公共结构,必须有一个手段保证内存块不会重复给别人。这个“保证”就是锁。
锁竞争的影响和分配大小关系不大。就算你每次只 malloc 8 字节,只要多个线程同时操作,一样会出现严重的串行化。这也是为什么很多服务明明每个线程分配的内存很小,却还是能在 perf 里看到 malloc 锁热点。理解了这一点,才能理解接下来的所有优化思路:要么减少锁的持有时间,要么让线程根本不碰同一把锁。
2. 剥开分配器的外壳:锁、arena 与 tcache 的三角关系
2.1 主分配区与全局锁:最古老的串行源头
Linux 自带的系统 C 运行库默认 malloc 实现源自 ptmalloc 家族,核心思想是把进程的堆内存拆成若干个管理区域,每个区域称为一个 arena。进程启动时只有一个主分配区 main_arena,所有线程的 malloc/free 理论上都要访问它。早期实现里,这个 main_arena 就是一把全局互斥锁:一次只能有一个线程分配内存,其他线程全部排队。
在这种模型下,多线程 malloc 的性能和单线程没有本质区别,因为所有操作都被强制串行化了。单线程分配耗时越短,锁竞争到来时的性能衰减就越严重——本来 30ns 能完成的操作,锁一抢可能变成 1us。这个数量级差别,正是服务延迟劣化的直接原因。所以凡是在多线程服务里抱怨“malloc 慢了”,本质上都是在说锁竞争,而不是内存分配本身的计算变复杂。
2.2 动态 arena 与 tcache:有优化但不够彻底
为了解决全局锁的问题,后来引入了两个重要机制。第一个是动态增加 arena:当线程发现 main_arena 被占用时,分配器允许它创建一个独立的非主 arena,后续就在自己的 arena 里分配,从而绕开同一把锁。第二个是 tcache(线程局部缓存):每个线程维护一份小内存块的本地空闲列表,命中 tcache 时完全不经过全局锁。
那为什么我的测试程序还会慢成那样?这里有个常被忽略的点:tcache 只对大小不超过一定阈值的小对象有效,超过阈值就要走 arena 路径;同时 arena 数量不是无限的,受环境变量和系统配置限制,当线程数大量超过 arena 数时,多个线程终究会共享同一把锁。更隐蔽的是,tcache 本身也会造成内存“锁定”在线程手里——线程频繁分配后并不还给全局堆,而是留在自己的缓存里,其他线程想复用这块内存也拿不到,最终导致整个堆的空闲块分布变得碎片化,分配器需要遍历更长的链表才能找到合适的块。这就是三角关系里的互相牵制:为了减少锁竞争引入了 per-thread cache,却增加了内存碎片和查找成本。
另外,动态创建 arena 并不便宜。每次新开 arena 都可能触发一次 mmap 系统调用去内核申请一大块内存,并且 arena 内部还要初始化各种 bin 链表结构。所以线程数很多时,一部分耗时其实花在了“创建和管理 arena”上,而不是真正的分配。这解释了为什么线程数增加后耗时几乎线性恶化——不光是锁,还有分配器内部的维护成本。
2.3 除了抢锁,还有两类很容易被忽略的开销
第一是系统调用。当请求的内存块超过 mmap 阈值时,malloc 会直接调用 mmap 向内核申请内存,free 时对应调用 munmap。mmap 是内核态操作,涉及页表分配、缺页异常、TLB 刷新,单次开销可能达到微秒级别,比锁竞争还贵。默认阈值大约在 128KB,而且会动态调整,但很多服务在分配大对象或临时 buffer 时很容易触到这条线。
第二是内存碎片。多线程交替分配释放不同大小的对象时,空闲块会被切得越来越碎。分配器为了找到一个满足大小要求的块,需要搜索 bin 链表,甚至触发合并相邻空闲块的操作。这些动作在单线程下也许不算什么,但在多线程锁内执行时,等于把额外计算量全加到了锁的临界区里,进一步放大竞争。
到这里可以下一个结论:malloc 多线程慢并不是单一原因,而是“锁竞争 + 系统调用 + 内存碎片”叠加的结果。单线程下主要是后两者,多线程下锁竞争变成主要矛盾。
3. 用 perf 和 strace 把“慢”量化成几组可复现的数字
3.1 strace 统计:系统调用次数会告诉你答案
光说“锁竞争”不够说服力,我习惯用工具把证据抓出来。先看系统调用层面的统计。让基准程序跑起来,用 strace 附加统计:
strace -f -c -o /tmp/syscall.log ./bench 4注意 strace 会拖慢程序,耗时数据没有参考意义,但系统调用次数的相对分布很有价值。单线程 4 个线程跑同样分配次数时的对比大致如下:
| 系统调用类型 | 单线程次数 | 4 线程次数 | 16 线程次数 |
|---|---|---|---|
| brk | 少量 | 较多 | 更多 |
| mmap | 很少 | 翻倍 | 翻倍 |
| futex | 几乎无 | 明显增多 | 大量 |
| munmap | 很少 | 增加 | 增加 |
futex 是这里最值得关注的信号。用户态锁竞争如果抢不到锁,线程会调用 futex 进入睡眠和唤醒流程;futex 次数越多,说明线程在锁上等待的时间越多。如果你在真实服务里看到 futex 数量比正常情况高出几个量级,优先怀疑的就是某把全局锁,不一定是 malloc,也可能是日志库、连接池或者分配器。
3.2 perf 热点:用户态自旋和 cacheline 乒乓
系统调用层面只能看到睡眠唤醒,还有一个更隐蔽的开销在 perf 里才看得清楚。先记录调用栈:
perf record -g -p PID -- sleep 10 perf report --sort=comm,dso,symbol多线程竞争时,热点通常会集中在几个符号上:_int_malloc、__lll_lock_wait、malloc等等。__lll_lock_wait意味着线程正在自旋等待锁释放。这里有个让人迷惑的现象:这种情况下 CPU 用户态占用率可能很高,但 htop 里显示每个核都在跑,不像空闲。其实那不是在做业务计算,而是在反复读同一把锁的缓存行,尝试获取锁。
多线程争锁时还有一个硬件层面的放大效应:所有线程都在反复读写下同一个内存地址——锁变量本身。这个地址所在 cacheline 会在多个 CPU 核之间来回失效,也就是常说的 cacheline 乒乓。它的后果比自旋等待本身更严重,因为即使切换到没有锁竞争的路径,只要还在用同一把锁,缓存一致性协议就会让性能下降。这也是为什么单纯减少临界区代码有时效果不明显,本质症结在共享锁的设计上。
3.3 分配大小决定路径:小对象和大对象的差异
我建议把 malloc 的测试分成两类来看,因为它们的瓶颈并不一样。小对象(比如 64 字节)主要考验锁和 tcache 命中率;大对象(比如 2MB)则直接走 mmap,锁的影响小,但系统调用和缺页中断的影响大。我实测过这两类分配在不同线程数下的差异:
| 分配大小 | 1 线程 | 8 线程 | 16 线程 |
|---|---|---|---|
| 64 B | 0.42s | 4.82s | 9.63s |
| 2 MB | 1.15s | 3.67s | 6.81s |
可以看到大对象场景下线程数增加也会劣化,但斜率相对平缓,因为主要开销是内核页表操作,分配器内部锁只占一部分。反过来,如果测试程序里线程数一多就卡,又确认了分配对象是小块,那基本可以断定问题出在分配器锁上,而不是系统调用。
这里也想提醒一点:量化分析时一定要控制变量。要么固定分配大小变化线程数,要么固定线程数变化分配大小,不要同时变两个因素。否则最后画出来的曲线只能证明“有影响”,无法定位是哪个环节。
4. 先不换代码:glibc 环境变量能救多少
4.1 限制 arena 数量:MALLOC_ARENA_MAX
很多团队不允许直接换分配器,那我先介绍不换代码的方案。系统 C 库暴露了几个环境变量,最常用的是MALLOC_ARENA_MAX。它控制进程中可以动态创建的 arena 数量上限。默认值在较老的版本里是 CPU 核数的 8 倍,线程数多时分配器会疯狂新建 arena,每个 arena 都保留自己的堆和管理的空闲块,内存碎片率极高。
曾经有不少服务通过把MALLOC_ARENA_MAX调低到 2 或 4,反而获得了明显的性能提升。原理很简单:减少 arena 数量等于减少每个 arena 各自维护的内存块数量,减少碎片,让空闲块在较少几把锁之间更均匀地被复用。但注意,arena 太少会让锁竞争加剧,所以这其实是个平衡问题。
export MALLOC_ARENA_MAX=4 ./bench 8在我上面这个基准程序里,把它设为 4 后,8 线程耗时可能从 4.8s 降到 3.5s 左右。但也有相反的情形:如果业务线程非常多、分配频繁,过小的 arena 会让大量线程挤在同一把锁上,性能反而更差。这类参数必须针对真实负载去试,不能照抄网上配置。
4.2 调整 mmap 阈值:MALLOC_MMAP_THRESHOLD_
第二个常用环境变量是 mmap 阈值。默认值通常在 128KB 附近,但运行时可以根据分配历史动态调整。调整方式:
export MALLOC_MMAP_THRESHOLD_=1048576把阈值调高意味着更多的大块内存从 arena 堆里分配,而不是每次走系统调用。这能显著降低大对象场景下的频繁 mmap/munmap。但副作用是分配器会倾向于向内核多要一些堆空间,内存占用可能上升,同时大块内存进入 arena 后如果释放不当,会产生难以回收的碎片。所以这个参数更适合“大对象分配相对频繁,但线程竞争不严重”的场景。如果线程竞争已经很明显,光调阈值效果有限,需要和 arena 数量一起考虑。
4.3 这类配置的边界和副作用
环境变量方案算是零成本热身,但它有三条硬边界。第一,这些参数是具体实现的行为,换一个 C 库版本或者换到另一种系统,语义可能完全不一样。第二,它只能缓解,不能消除锁竞争。多线程在高峰期抢同一把锁时,参数调节就像把一个堵车路口从 8 车道改成 10 车道,车多了一样堵。第三,调参有可能引入新的内存膨胀,特别是在容器环境下,进程可用内存受限,arena 数量过多或 mmap 阈值过高都可能导致内存超过 cgroup 限额。
我的经验是:调参之前先记录三组基线——单线程分配耗时、多线程分配耗时、进程 RSS。调完之后再采同样的三组数据对比。只要 RSS 涨幅控制在可接受范围、多线程耗时下降,这个方向就是有价值的。如果调完内存翻倍换来的性能提升很小,果断回滚,别硬扛。
5. 换掉分配器:tcmalloc / jemalloc / mimalloc 横评
5.1 三种现代分配器各自的设计方向
当默认分配器确实成为瓶颈,最直接的办法是替换分配器。这里我对比三个有代表性的实现:tcmalloc、jemalloc、mimalloc。它们的设计底层逻辑各不相同,理解之后再选型才不会踩坑。
tcmalloc 的核心思路是线程本地缓存加全局共享缓存。小对象从线程本地拿,线程本地不够时去全局中心缓存取一批,同时定期把多余缓存归还给中心。它在分配频次极高的服务里表现很好,但线程本地缓存较多时,可能出现内存占用偏高的问题。
jemalloc 的设计更强调“按大小分类 + 多个 arena + 后台定期整理”。它会根据分配请求的大小划分成很多 size class,每个线程尽量只跟自己的 arena 交互,后台线程还会做碎片整理。特别适合那种长时间运行、分配模式很“脏”的服务,因为它对内存碎片的控制明显优于默认实现。
mimalloc 的思路是分层局部列表(segment + local free list),尽量做到部分操作无锁,频繁路径不碰全局元数据,零初始化的内存布局也让它分配大块对象时更快。我看过不少测试里 mimalloc 在纯分配场景下常常能赢过前两者,不过实际服务还要看内存占用。
5.2 替换分配器的两种接入方式
接入方式主要两种。编译期替换最干净:链接时把malloc符号指向指定分配器库,适合自己能控制构建链的项目。运行期替换更灵活,适合排查阶段做快速对比:
LD_PRELOAD=/path/to/libjemalloc.so ./bench 16用LD_PRELOAD时要注意几点。第一,必须确保预加载库的 ABI 和当前系统 C 库兼容,否则可能崩在启动早期。第二,有些库需要额外配置,比如 jemalloc 的MALLOC_CONF、tcmalloc 的TCMALLOC_HEAP_LIMIT_MB,最好显式设置而不是用默认值。第三,生产环境最好通过启动脚本统一加载,而不是手动 export,不然容易漏配或配错。线上切换前,一定要在灰度环境跑一轮真实流量,不能只看基准测试。
5.3 实测对比结果和适用场景
用同一个基准程序加载不同分配器,结果大概如下:
| 分配器 | 1 线程 | 4 线程 | 16 线程 |
|---|---|---|---|
| 系统默认 malloc | 0.42s | 2.31s | 9.63s |
| tcmalloc | 0.38s | 0.47s | 0.55s |
| jemalloc | 0.40s | 0.53s | 0.69s |
| mimalloc | 0.35s | 0.42s | 0.51s |
这个数据只能代表“大量小块、高并发分配”的场景。在这些场景下,现代分配器几乎能让耗时随线程数呈近线性增长——注意是总耗时几乎不变,而不是每个线程都变快,它们真正解决的是“并发后不劣化”。如果你的服务分配模式不同,结论很可能不同。比如大对象比重高的服务,tcmalloc 不一定比 jemalloc 好;如果内存限制很严格,jemalloc 的 arena 碎片整理可能会带来额外内存占用的问题。
所以我的选型建议是:先测三种,以真实服务和真实流量为准,关注三个指标——P99 延迟、CPU 占用、RSS 变化。在团队允许的情况下,优先用 jemalloc 或 mimalloc,因为它们的后台整理机制对长期运行的服务更友好;tcmalloc 在纯吞吐场景有优势,但内存控制要额外小心。
6. 我的排查顺序和一些踩过的坑
最后分享一套我在实践中固定的排查顺序,以及那些容易让人白干半天的坑。
第一,先定义工作负载。不要一上来就写一个和线上毫无关系的压力测试。记录真实的分配大小分布、分配频次、并发线程数、运行时长。通常我会在代码里加一个临时的统计调用点,把 malloc 传入的大小都记下来,跑完线上几分钟后拉出直方图。有了分布,才知道该往哪个方向优化:如果 95% 都是 64 字节以下小块,锁优化优先级最高;如果大量超过 128KB,mmap 和系统调用优先级最高。
第二,用 perf 确认热点再动手。我见过不少同行一遇到多线程慢就怀疑 malloc,结果 perf 抓出来热点根本不在 malloc,而是在日志库或序列化代码里。用 perf top 看两分钟,热点符号如果集中在__lll_lock_wait这类函数,才值得动分配器。
第三,区分锁竞争、系统调用和碎片。手上有三种数据就足够:perf 热点、strace 系统调用统计、以及内存碎片率。碎片率可以用分配器自带接口查,比如 jemalloc 的malloc_stats_print,或者观察进程 RSS 是否只增不减。通常锁竞争表现为 futex 暴增,系统调用表现为 mmap/munmap 次数高,碎片则表现为 RSS 膨胀但业务内存没涨。
第四,先调参再换分配器。如果环境变量能解决问题,改动最小、风险最低。调参无用再上 LD_PRELOAD 替换,替换后至少观察一周的内存增长和延迟分布再做最终定论。
我踩过最大的坑是直接在生产网关上开启 jemalloc,忘了设置 MALLOC_CONF,结果 arena 默认行为导致内存占用上升了 15%,差点把容器挤爆。后来预发布阶段重新测,设好MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,资源占用才回落到正常水平。这类细节在基准测试里看不出来,只有跑真实流量才会暴露,所以替换分配器一定要做灰度。
还有一个小技巧:在测试分配器时,不要只测“分配后立即释放”的模式。真实服务往往有“分配后长期持有”的对象,这会导致线程缓存长期占着内存不归还。我建议压力测试里混合三种模式:短期释放、中期持有、长期持有,比例按业务场景来调,这样对比出的结果才靠谱。
malloc 在多线程下慢不是一个伪命题,它背后是锁竞争、系统调用和内存碎片三者共同作用的结果。先把问题量化清楚,再决定动环境变量还是动分配器,这条路走下来,大多数时候都能在一个不长的周期内解决。