一个长期运行的推理网关,跑了两天,RSS 从 1.4GB 爬升到 4.9GB。malloc/free 次数基本均衡,堆内空闲空洞占了一半以上,RSS 却纹丝不动。malloc_trim(0) 调了,M_TRIM_THRESHOLD 调小了,什么都没变。
free 掉的内存为什么还不了 OS?答案不在 free,在 malloc——内存以哪条路径进堆,决定了它有没有资格回去。这篇文章把 ptmalloc 的分配路径完整走一遍:12 步,从 fastbin 到 mmap;再看长生命与短生命对象混用时,Top Chunk 如何被高地址处的长生命对象钉死(Pinned);再落到两个真正管用的旋钮:mallopt 调优与 arena 隔离。
进堆的三条路:bin、top chunk 与 mmap
线程默认轮转分配到各自的 arena,上限 8×CPU 核数(M_ARENA_MAX)。每个 arena 管理自己的堆(主 arena 挂在数据段,其余为 mmap 出来的 heap 段),请求进来走三条路:
malloc(n)
│
┌───────────────┼───────────────────┐
│ │ │
bin 系 top chunk mmap
fastbins 高地址兜底区 大请求独立映射
unsorted bin 切割式分配 free 即 munmap
smallbins
largebins
三条路的归还资格完全不同:bin 空闲只能等复用;top chunk 尾部满足条件时可收缩;mmap 块 free 即 munmap。碎片问题的全部争议都藏在这张资格表里。
分配路径的 12 步
入口是 __libc_malloc(glibc malloc.c):尺寸换算、取 arena 加锁,进 _int_malloc 主流程。按 glibc 2.31、x86-64 口径,路径拆成 12 步:
-
request2size:请求字节数换算成 chunk 大小——加 8 字节头空间、16 字节对齐,最小 32 字节。
-
arena_get:选择或创建 arena 并加锁,首次分配即建自己的 arena。
-
fastbin:请求不超过 128 字节(默认 mxfast)时查 fastbinsY,LIFO 取头部,命中即返回。arena 隔离下锁基本无竞争。(2.26 起 tcache 排在这步前:每线程 64 桶、每桶 7 块的无锁缓存,漏接才轮到 fastbin。)
-
smallbin 直接匹配:请求在 1KB 内时,从对应桶尾部取块,大小精确。这里与常见教程有个差异:源码里这步排在 unsorted 主循环之前——_int_malloc 在 fastbin 后立即查 smallbin,unsorted 遍历只负责整理。
-
unsorted bin:last remainder 优先——链表尾部常是刚切分剩下的块,尺寸往往正合适。
-
unsorted bin 精确匹配:遍历中 size 恰好等于请求,直接摘走。
-
unsorted bin 遍历整理:不匹配的块按大小分拣回 smallbins/largebins,上限 MAX_ITERS=10000。
-
largebin best-fit:从对应桶向上扫找不小于请求的最小块,切出请求部分,余下不少于 32 字节的 remainder 回 unsorted。
-
top chunk 切割:剩余不小于 nb+32 就切,剩下的是新 top。
-
mmap:请求不小于阈值(默认 128KB,动态上限 32MB)且 mmap 数未超限(65536)时直接映射,free 即 munmap 归还。
-
sysmalloc:向 OS 要内存——主 arena 用 sbrk,非主 arena 用 mmap 申请新 heap 段,再从新 top 切出请求。
-
返回 user pointer:chunk 起始后 16 字节处(prev_size 与 size 两个头字段之后)。
_int_malloc 主干语义浓缩如下(概念重构,非逐行原文):
/* glibc malloc.c _int_malloc 语义重构(glibc 2.31, x86-64) */
static void *_int_malloc(mstate av, size_t nb) {
/* 步 3: fastbin, nb <= 128B, LIFO 无合并 */
if (nb <= get_max_fast())
if ((p = *fastbin(av, nb)) != NULL)
return fastbin_take(av, p);
/* 步 4: smallbin 直接精确匹配(源码顺序: 在 unsorted 主循环之前) */
if (in_smallbin_range(nb) && last(bin_at(av, smallbin_index(nb))) != bin_at(av, smallbin_index(nb)))
return take_smallbin(av, nb);
/* 步 5-7: unsorted 主循环: last remainder / 精确匹配 / 分拣整理 */
for (it = 0; it < MAX_ITERS; ++it) {
victim = last(unsorted);
if (chunksize(victim) == nb) return victim; /* 步 6: 精确命中 */
bin_at(victim, chunksize(victim)); /* 步 7: 分拣回 bins */
}
/* 步 8: largebin best-fit + 切分, remainder 回 unsorted */
/* 步 9: top chunk 切割 */
if (chunksize(av->top) >= nb + MINSIZE)
return split_top(av, nb);
/* 步 10: 大请求直接 mmap, free 即归还 */
if (nb >= mp_.mmap_threshold && mp_.n_mmaps < mp_.n_mmaps_max)
return mmap_alloc(nb);
/* 步 11: sysmalloc, sbrk 或 mmap 新 heap 段 */
}
注意 1 到 8 步全在堆内打转,只有 9(top 切割)和 10(mmap)直接面对“归还 OS”。这是理解 Pinned 的地基:堆内路径越深,块离“能归还”越远。
Top Chunk 被钉死:空洞为什么回不到 OS
free 的归还规则只有一条:只有 top chunk 的空闲能还给 OS。_int_free 把块挂进 bins,仅当释放块与 top 相邻、合并后超过 trim 阈值(默认 128KB)才触发 systrim/heap_trim——主 arena 用 sbrk 回缩,非主 arena munmap 掉 heap 段尾部页。LPC 2018《Limitations of Tuning glibc malloc》说得直白:sbrk 得到的内存,只有数据段末尾出现足够大的连续空闲才能归还系统。
于是堆布局长这样:
低地址 高地址
┌────────┬────────┬────────────────┬────────┬────────┬─────────┐
│ 短命 │ 短命 │ 长生命对象(锚点) │ 短命 │ 短命 │ top │
│ 空洞 │ 已用 │ 永不释放 │ 空洞 │ 已用 │ chunk │
└────────┴────────┴────────────────┴────────┴────────┴─────────┘
▲ ▲ ▲
可被新请求复用 过不去的坎 只能收缩尾部
长生命对象分配在堆的高地址侧(启动后期分配的缓冲池、缓存,没到 mmap 阈值所以留在堆里),永不释放。它下面的空洞,哪怕合并成几十 MB 连续空闲,都被锚点隔在 top chunk 之外。sbrk 只能从堆顶往回缩,空洞永远够不到顶。于是三件事同时成立:
malloc_trim(0) 无效——只处理 top 尾部那一段,碰不到锚点之下的空洞;
- 调小
M_TRIM_THRESHOLD 无效——收缩的前提是 top 之下就是可合并空闲,锚点截断了这条路;
- RSS 停在峰值,堆里却躺着大块没人能用的空洞。
这就是 Pinned。我见过不止一个服务死在这上面:缓存池和请求级临时对象共用同一 arena,RSS 与运行时长严格正相关,restart 才复位。排查到底,堆布局图里就是一根锚点钉在中间。
反直觉的点:短生命对象在低地址反复腾挪本身健康——释放的空间会被新请求复用,只是没被复用也还不回去。致命的组合是时序:短生命对象先分配占低地址、长生命对象后分配占高地址,一旦定型,堆顶锁死,此后所有 trim 全部空转。
治理:让长生命对象不进堆
动手前分清两类目标:想让“能还的”还得更快,调 M_TRIM_THRESHOLD 有戏;想让“不能还的”能还,唯一的路是改分配路径——让长生命对象根本不进堆。三个旋钮的边界如下:
| 旋钮 |
默认值 |
有效 |
无效 |
| M_TRIM_THRESHOLD |
128KB |
top 相邻空闲的收缩 |
锚点隔开的空洞 |
| M_MMAP_THRESHOLD |
128KB(动态上限 32MB) |
大块 mmap、free 即归还 |
已进堆的锚点 |
| M_ARENA_MAX |
8×核数 |
隔离生命周期、限制 arena 数 |
单线程内的混用 |
策略一:把长生命对象 mmap 化。 M_MMAP_THRESHOLD 压到 32KB 左右,长生命对象(通常几十 KB 起步)直接走 mmap,free 即 munmap,不进堆、不产生锚点:
/* 长生命对象 mmap 化;注意:手动设置会禁用 glibc 的动态阈值调整 */
mallopt(M_MMAP_THRESHOLD, 32 * 1024);
代价要算清:每次 mmap/munmap 是两次系统调用加页表操作,页要清零、TLB 要刷,只对“长生命、少分配、大尺寸”的对象划算。反证同样成立:阈值压太低,等于把 128KB 附近所有常规大块全赶去 mmap,反复分配释放时页清零开销吃掉全部收益——LPC 实测把阈值拉到 32MB 后大块分配性能与默认差距不大,说明 128KB 是“分配频率与归还及时性”的折中,别乱动。
策略二:arena 隔离。 长生命对象放到专用线程分配(首次 malloc 为该线程建独立 arena),短生命对象留主线程 arena,空洞至少有机会与 top 相邻合并。更彻底的是对象池——长生命对象从池里取,池子用 mmap 打底。治理的尽头是复用,不是回收。
代价:arena 每多一个就多一组 bins 和一个 top,本身占虚拟内存;短线程风暴会顶到上限,各 top 各自为政反而放大碎片。适合线程数稳定、生命周期清晰的服务,不适合线程池疯狂伸缩的负载。
策略三:M_TRIM_THRESHOLD 只调高不调低。 默认 128KB 是合理起点;调小让 top 相邻空闲还得更勤,但 sbrk/munmap 频率上升。判断标准:top 相邻空闲是不是常态,是才调。
我的固定顺序:先画堆布局找锚点——长生命对象就 mmap 化,线程结构问题就 arena 隔离,trim 参数只配收尾。任何 trim 类参数只能加速“本来就能还”的内存,Pinned 的解在设计期,不在参数表里。
总结
调参治不了被钉死的 top chunk——ptmalloc 的回收能力天生只在堆顶那一段。分配器治理的第一问不是“怎么回收”,而是“谁在阻止回收”:把长生命对象移出堆,空洞才有机会回到 OS。
排查 RSS 只涨不降的服务,先别碰 mallopt。先画堆布局:锚点在哪,答案就在哪。
声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。
如果你想深入了解这类底层机制背后的设计思想,不妨看看 技术文档 板块中关于内存管理和系统架构的深度解析,那里有更多避坑指南与源码剖析。