西西软件园多重安全检测下载网站、值得信赖的软件下载站!
软件
软件
文章
搜索

首页 → 业内动态 → 业内资讯 → malloc 锁把 8 核吃成 4 核:jemalloc 换上去

malloc 锁把 8 核吃成 4 核:jemalloc 换上去

相关软件相关文章发表评论 来源:本站整理时间:2026/11/5 7:01:35字体大小:A-A+

  • 类型:Mac应用软件大小:5.5M语言:中文 评分:10.0
  • 标签:
立即下载

那台 8 核 16G 的机器是我手上跑得最顺的一台,之前一直用得好好的。直到大促前把 worker 数量从 4 个调到 16 个,QPS 不但没涨,反而从 3200 掉到了 2100CPU 使用率却顶到了 90%。

我第一反应是机器配置不够,升级了带宽、加了机器,都没用。后来用 perf 抓了一把火焰图,一眼看过去,malloc、free 这两个词几乎铺满了整个屏幕,占了将近三成 CPU 采样。这时候我才把事情和"内存分配器"联系起来。

如果你也在排查多线程服务的内存分配瓶颈,或者需要一台配置合适的机器先把环境跑起来做对比测试,可以先看看 terabix(AMD EPYC Genoa + DDR5 内存云主机) 和 LiteServer(高性能 NVMe VPS) 的规格和技术说明,高核数机器上跑多线程服务,分配器和内存通道的差别会被放大得很明显,测试环境选得好,结论才站得住。

一、先别急着换,先确认真的是 malloc 锁

"火焰图里 malloc 占比高"这件事本身不能直接下结论,因为 malloc 是所有内存分配的入口,业务的正常对象创建也会堆在上面。真正要区分的是:CPU 是花在了分配本身的逻辑上,还是花在了争抢同一把锁上。

判断方法很简单,看两个指标:一个是 perf top 里 malloc 的 CPU 占比,如果单线程正常跑只占 2%,多线程之后飙到 30% 以上,那基本可以怀疑是锁竞争;另一个是 /proc/<pid>/status 里的 voluntary_ctxt_switches,线程数翻倍之后这个数字如果跟着暴涨,说明线程在频繁让出 CPU。

更直接的办法是用 perf lock 或者 perf record -e futex 抓一次 futex 调用,如果 futex 的等待时间排在最前面,那就是锁在排队,而不是分配逻辑慢。我们这台机器抓出来的结果正是后者:futex_wait 占了 18% 的 CPU 时间。

多线程服务火焰图显示 malloc 锁竞争占用大量 CPU 时间

二、glibc 的 arena 到底是怎么抢锁的

要说清楚问题,得先说 malloc 本身的机制。glibc 从 2.26 开始引入了 per-thread arena,每个线程按需创建自己的 arena,默认最多是 CPU 核心数乘 8。听起来已经很聪明了,为什么还会抢锁?

问题出在两个地方。

第一个是线程数超过 arena 数量的时候。我们这台机器 16 个 worker 线程,8 核,默认 arena 上限是 64,理论上够用。但 glibc 的 arena 是惰性创建的,而且复用有随机性,实际运行中多个线程可能挤在同一个 arena 上。

第二个更隐蔽:每个 arena 内部还有一把全局锁。arena 里挂着若干 bin(每个尺寸的空闲链表),从哪条链表取、往哪条链表放,都要在同一把锁下完成。这就是所谓的 "malloc contention"。

再叠加上第三个因素free 的时候不一定还回原来的线程。对象在 A 线程分配、B 线程释放是再常见不过的事,这时候要跨 arena 做归还会引入额外开销,锁也就跟着被拉长了。

所以你会看到一个很典型的现象:单线程压测 QPS 一切正常,线程数一多反而变慢;机器核心数翻倍,吞吐不涨甚至下跌。锁竞争把多核的优势全吃掉了。

三、换 jemalloc:安装与验证

jemalloc 是 BSD 许可证的替代实现,核心思路是用 thread cache 代替全局锁:每个线程有自己的缓存,分配时先从本地缓存拿,缓存空了才去中央 arena 取,并且按 size class 分好类,避免一把大锁罩住所有尺寸。这样绝大多数分配路径完全无锁。

Debian/Ubuntu 上直接装:

apt-get install -y libjemalloc2

装完之后有两个方式让它生效。

方式一,做成动态链接的 preload,这是最省事、影响面最小的做法:

LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 /path/to/your_service

方式二,编译时链接。如果是 Go 写的话更简单,直接设环境变量就行:

GODEBUG=madvdontneed=1 GOMEMLIMIT=...,当然如果 Go 版本不支持 GOMEMLIMIT,那 Go 的运行时本身就不依赖 malloc,换 jemalloc 意义不大这类情况就别折腾了。

Python 用 uWSGI、PHP 用 PHP-FPM 这种多进程 + 多线程模型的服务,用 LD_PRELOAD 效果最直接。

验证是否真的用上了,两种办法:pmap -x <pid> 里能看到 libjemalloc 映射进去就对了;或者看 /proc/<pid>/maps。另外 jemalloc 自带统计接口,stats_print 可以在进程收到特定信号时把详细的分配统计打到日志里,排查碎片和 arena 使用率非常有用。

jemalloc 原理示意:thread cache 减少 arena 全局锁竞争

四、MALLOC_CONF 值得调的几个参数

装上只是第一步,参数调不对效果可能只有一半。我们最后用的是这三条:

background_thread:true 让 jemalloc 起一个后台线程做清理(decay)。不开启的话,大批量小对象的回收会在业务线程上同步做,直接把 P99 拉高。开启之后 P99 从 45ms 降到了 12ms。

muzzy_decay_ms:10000 控制脏页回收的延迟时间。默认值是 0,也就是脏页立即归还给操作系统;改成 10 秒意味着 jemalloc 会把脏页缓存在自己手里一段时间,需要时直接复用,避免反复向内核要内存。对多线程分配的场景,这一条能明显减少 brk/mmap 的系统调用次数。

narenas:4 我们最终限制成 4 个 arena。听起来反直觉,但 jemalloc 的 arena 是按需分配的,线程一多就到处散开,缓存命中率下降反而更慢。限制在核数以内,让每个 arena 承载更多线程,缓存才热得起来。这条需要压测对比,不同业务差别很大。

完整的写法大概是:

MALLOC_CONF=background_thread:true,muzzy_decay_ms:10000,narenas:4

改完之后,同样的压测脚本、同样的 16 个 worker,QPS 从 2100 涨到了 3200,P99 从 180ms 降到 38ms,CPU 使用率反而从 90% 降到了 65%。火焰图再看,malloc 已经退到了 3% 左右。

更换 jemalloc 后压测对比:QPS 与 P99 的实测数据曲线

五、几个容易踩的坑

坑一:以为换完就万事大吉。 jemalloc 解决的是分配路径的锁竞争,不会救回业务代码里的低效分配。如果你的服务每次请求都在 malloc 几十 KB 的临时 buffer,那该优化的是对象复用(tcmalloc 的 tcache 也有类似思路)。分配器是基础设施,不是银弹。

坑二:忘了确认是不是同一个 allocator。 一个服务里可能混着好几套:Python 的 pymalloc、glibc 自己的堆、某些库自带 jemalloc 或者 tcmalloc。实际效果取决于哪一层占比最高,可以用 LD_DEBUG=libs 或者 ltrace 确认,也可以用 jemalloc 的 malloc_message 钩子打印实际分配来源。

坑三:容器里的 cgroup 内存限制。 jemalloc 自己不知道容器被限了多少内存,会按照物理内存的比例来缓存脏页。如果 cgroup 上限只有 1G,物理机有 64G,jemalloc 可能攒下几十 G 的脏页不还,触发 cgroup 里的 OOM Killer。这种情况要给 jemalloc 显式指定 arena 的内存上限,比如用 MALLOC_CONF 配合 narenas 一起压着调,并盯住 cgroup/memory.current 的曲线。

坑四:MALLOC_CONF 拼写错误完全不报错。 这个参数写错了,jemalloc 会启动失败或者直接忽略,你以为生效了其实没有。建议改完之后用 stats_print 打一份完整统计,看 arena 数、tcache 命中率这些真实指标,确认参数真的吃进去了,而不是靠"感觉快了"来判断。

坑五:观察指标不能只看 RSS。 jemalloc 的脏页缓存会让 RSS 看着比 glibc 时高,这是正常的缓存行为,不代表泄漏。真要判断有没有泄漏,得看 jemalloc 统计里的 allocated 总量和 active 总量是不是随时间单调上涨,如果是,基本就是代码里有对象没释放,跟分配器本身无关。

jemalloc 内存统计与排查工具在服务器上的实际使用场景

六、最后说说我的判断标准

这次之后我形成了一个习惯:凡是多线程 + 分配频繁的服务,上线前先做一次分配器基线测试。具体就是同一份压测脚本,分别在 1 个 worker、CPU 核数个 worker、2 倍核数个 worker 下各跑一轮,看 QPS 曲线是不是单调上升。如果 worker 加了一倍而 QPS 不涨甚至下跌,那就去火焰图里找 malloc、free、futex 这几个词。

这个方法不需要改任何业务代码,代价极小,但能在上线前就暴露出一大类"配置很贵却跑不出性能"的问题。我们后来在另外两个 PHP 服务上也用同样的方式查了一遍,都找到了类似的锁竞争,换完之后机器利用率确实上来了那台机器的配置最后没有升级,省下来的预算比省下来的机器费更值钱。

如果你手上的服务也是多线程模型、又经常出现"CPU 不低但吞吐上不去"的情况,建议先用 perf 抓一把火焰图,看看 malloc 那条线有多粗,再决定要不要换分配器。绝大多数情况下,换完的效果会比想象中明显。

    相关评论

    阅读本文后您有什么感想? 已有人给出评价!

    • 8 喜欢喜欢
    • 3 顶顶
    • 1 难过难过
    • 5 囧囧
    • 3 围观围观
    • 2 无聊无聊

    发表评论

    昵称:
    表情: 高兴 可 汗 我不要 害羞 好 下下下 送花 屎 亲亲