找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4281

积分

0

好友

557

主题
发表于 1 小时前 | 查看: 3| 回复: 0

一个长期运行的推理网关,跑了两天,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 步:

  1. request2size:请求字节数换算成 chunk 大小——加 8 字节头空间、16 字节对齐,最小 32 字节。

  2. arena_get:选择或创建 arena 并加锁,首次分配即建自己的 arena。

  3. fastbin:请求不超过 128 字节(默认 mxfast)时查 fastbinsY,LIFO 取头部,命中即返回。arena 隔离下锁基本无竞争。(2.26 起 tcache 排在这步前:每线程 64 桶、每桶 7 块的无锁缓存,漏接才轮到 fastbin。)

  4. smallbin 直接匹配:请求在 1KB 内时,从对应桶尾部取块,大小精确。这里与常见教程有个差异:源码里这步排在 unsorted 主循环之前——_int_malloc 在 fastbin 后立即查 smallbin,unsorted 遍历只负责整理。

  5. unsorted bin:last remainder 优先——链表尾部常是刚切分剩下的块,尺寸往往正合适。

  6. unsorted bin 精确匹配:遍历中 size 恰好等于请求,直接摘走。

  7. unsorted bin 遍历整理:不匹配的块按大小分拣回 smallbins/largebins,上限 MAX_ITERS=10000

  8. largebin best-fit:从对应桶向上扫找不小于请求的最小块,切出请求部分,余下不少于 32 字节的 remainder 回 unsorted。

  9. top chunk 切割:剩余不小于 nb+32 就切,剩下的是新 top。

  10. mmap:请求不小于阈值(默认 128KB,动态上限 32MB)且 mmap 数未超限(65536)时直接映射,free 即 munmap 归还。

  11. sysmalloc:向 OS 要内存——主 arena 用 sbrk,非主 arena 用 mmap 申请新 heap 段,再从新 top 切出请求。

  12. 返回 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。先画堆布局:锚点在哪,答案就在哪。

声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。

如果你想深入了解这类底层机制背后的设计思想,不妨看看 技术文档 板块中关于内存管理和系统架构的深度解析,那里有更多避坑指南与源码剖析。




上一篇:微软AI收入近70%由OpenAI贡献:这背后藏着什么危机?
下一篇:找副业越来越迷茫?你可能缺的不是机会,而是一个判断标准
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-7 05:32 , Processed in 1.035933 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表