Rust 笔记 10:glibc ptmalloc2 Arena 碎片化

一、问题背景

某长期运行的 Rust 异步服务实例 RSS 达到 9GB,远超正常水位。该服务基于 Tokio 异步运行时,部署在容器环境中。

二、排查过程

2.1 代码层面梳理

通过代码审查,先梳理服务中可能占用较多内存的对象,包括:

  • 全局缓存中的业务数据
  • 文件映射型只读数据(大数据文件 mmap)

经分析这些都不足以解释 9GB RSS。文件映射型只读数据后续也通过 Pss_File 进一步排除。

2.2 smaps_rollup 全局概览

命令与原理

cat /proc/<pid>/smaps_rollup

smaps_rollup/proc/<pid>/smaps 的聚合版本(Linux 4.14+),将进程所有虚拟内存区域(VMA)的指标累加成一份汇总。相比逐段遍历 smaps 的上千行输出,rollup 一次读取即可获得进程内存全貌,性能开销极小,适合作为排查的第一步。

核心字段含义:

字段 含义
Rss 常驻物理内存,含共享部分
Pss 按比例均摊共享页后的真实占用
Pss_Anon 匿名页(堆分配)的 Pss
Pss_File 文件映射页的 Pss(mmap 的文件会体现在此)
Private_Dirty 私有脏页,进程自己写入的堆内存

实际输出

Rss:             9256116 kB
Pss_Anon:        9234168 kB
Pss_File:          16698 kB
Private_Dirty:   9234168 kB

结论

  • 99.7% 内存是 Anonymous Private Dirty(匿名私有脏页) → 全部来自堆分配(malloc/alloc)
  • Pss_File 仅 16MB → 排除文件映射型只读数据
  • 9GB 纯堆内存对该服务不正常,正常数据结构总计不超过几百 MB

2.3 smaps 段级定位

命令与原理

cat /proc/<pid>/smaps | awk '/^[0-9a-f]/{addr=$1} /Rss/{if($2>100000) print addr, $2 "kB"}'

/proc/<pid>/smaps 为每个 VMA 段提供独立的指标节。上述 awk 命令提取所有 RSS > 100MB 的段,帮助识别大内存块的分布模式。

每段首行格式为 起始地址-结束地址 权限 偏移 设备 inode 路径,其中:

  • 路径为空 → 匿名映射(堆、mmap(MAP_ANONYMOUS))
  • 路径为 .so → 共享库
  • 路径为文件 → 文件映射

实际输出(节选)

558d2284d000-558d2ab32000 125200kB
7f4204000000-7f420c000000 131072kB
7f420c000000-7f4214000000 131072kB
7f4220000000-7f4228000000 131072kB
...(共 52+ 个类似段)

结论

52+ 个 128MB(131072kB)对齐的匿名段,这是 glibc ptmalloc2 arena 的典型指纹:

  • 每个 arena 在 64 位系统上以 128MB 为单位 mmap
  • 段地址在 7fXX_X000_0000 区间,为 mmap 区域
  • 全部是匿名映射(无关联文件路径)

2.4 验证:gdb + malloc_trim

# 记录前
grep -E '^(VmRSS|RssAnon)' /proc/<pid>/status

# 强制归还空闲页
gdb -p <pid> -batch -ex 'call (int)malloc_trim(0)' -ex 'detach'

# 记录后
grep -E '^(VmRSS|RssAnon)' /proc/<pid>/status

malloc_trim(0) 会遍历所有 arena,对已 free 但未归还 OS 的页面调用 madvise(MADV_DONTNEED)

结果:RSS 大幅下降,确认是 glibc 保留了大量已释放内存未归还操作系统。

注意:gdb -p 会短暂停顿目标进程,建议只在测试环境、低峰窗口或可接受短暂停顿的实例上执行。

三、根因分析

3.1 glibc ptmalloc2 Arena 机制

glibc 的 malloc 实现(ptmalloc2)为减少多线程锁争用,为每个线程分配独立的 arena:

  • arena 数量:默认 MALLOC_ARENA_MAX = 8 × get_nprocs()
  • arena 大小:每个 arena 通过 mmap(MAP_ANONYMOUS) 分配 128MB 虚拟空间
  • 内存归还条件
条件 是否自动触发
main arena 顶部连续空闲 > 128KB 是(sbrk 收缩)
某个 mmap'd arena 整段 100% 空闲 是(munmap)
arena 内部有散落空闲页 不触发
显式调用 malloc_trim(0) 手动触发 madvise

关键点:glibc 在正常 free() 路径中不会调用 madvise。空闲内存留在 arena free list 中等待复用,而非归还 OS。这是设计上的取舍——避免频繁系统调用,但对长期运行服务造成内存膨胀。

3.2 Tokio 异步运行时加剧碎片化

Thread A (worker-1): malloc → 分配到 Arena #3
         ↓ task work-stealing
Thread B (worker-4): free → 归还到 Arena #3 的 free list
         ↑ 但 Arena #3 里散落着其他活跃对象
         → 128MB 段无法 munmap
         → 空闲页也不会 madvise 归还

Tokio 的 work-stealing 调度器使得:

  1. Task 在线程 A 分配内存,被偷到线程 B 后释放 → 跨 arena 碎片
  2. 各 arena 内部交错存在活跃和空闲对象 → 整段无法回收
  3. 长期运行后,每个 arena 都"膨胀"到 128MB 但实际利用率很低

3.3 容器化场景的额外放大

glibc get_nprocs() 用于计算 MALLOC_ARENA_MAX 默认值:

glibc 版本 行为
< 2.33 /proc/cpuinfo(宿主机),不感知 cgroup
>= 2.33 读 cgroup v1/v2 CPU quota;但仅设 cpuset.cpus 无 quota 时仍读宿主机

例:宿主机 64 核、容器限 4 核 → arena max = 8 × 64 = 512,远超实际需要。

验证方法:

ldd --version                              # glibc 版本
nproc                                      # 容器感知的核数
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us    # cgroup v1 quota
cat /sys/fs/cgroup/cpu.max                 # cgroup v2

四、解决方案

方案 做法 优点 缺点
切 jemalloc(推荐) Cargo.toml 加 tikv-jemallocator,main.rs 设 #[global_allocator] 根治;自带 decay 机制 10s 自动归还空闲页 需改代码发版
定期 malloc_trim tokio spawn 后台 task 每几分钟调用 libc::malloc_trim(0) 不改 allocator 治标;trim 时有短暂 CPU 开销
MALLOC_ARENA_MAX 环境变量 启动时 export MALLOC_ARENA_MAX=4 无需改代码,立即生效 可能影响高并发下 malloc 吞吐

jemalloc 接入示例

# Cargo.toml
[dependencies]
tikv-jemallocator = "0.6"
// main.rs 顶部
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

jemalloc 优势:

  • background_thread:后台线程自动执行 dirty page decay
  • per-CPU cache:减少跨线程争用
  • page run 机制:更积极地合并空闲区域并归还 OS

如果短期内无法发版,可以先通过 MALLOC_ARENA_MAX 限制 arena 数量,或在可控窗口调用 malloc_trim(0) 作为临时止血方案。长期建议切换到更适合长生命周期服务的 allocator。

五、诊断工具速查

工具 用途 命令
smaps_rollup 内存总览(首选) cat /proc/<pid>/smaps_rollup
smaps + awk 段级定位大内存块 cat /proc/<pid>/smaps \| awk '...'
gdb + malloc_trim 验证 arena 保留 gdb -p <pid> -batch -ex 'call (int)malloc_trim(0)'
glibc 版本 确认 arena 行为 ldd --version
cgroup 感知核数 确认容器是否正确限制 nproc + cgroup 文件

六、结论

  • 根因:glibc ptmalloc2 在 Tokio 多线程 + 容器化场景下,创建了过多 arena(52+),且空闲内存不主动归还 OS,导致 RSS 持续膨胀到 9GB
  • 验证malloc_trim(0) 后 RSS 大幅下降,确认内存并非真正泄漏,而是 allocator 保留
  • 方案:切换 jemalloc 从根本上解决,其 decay 机制确保空闲页自动归还
知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇