一、问题背景
某长期运行的 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 调度器使得:
- Task 在线程 A 分配内存,被偷到线程 B 后释放 → 跨 arena 碎片
- 各 arena 内部交错存在活跃和空闲对象 → 整段无法回收
- 长期运行后,每个 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 机制确保空闲页自动归还
