Linux 内核技术实战课 · 内存泄漏模块:从 RSS 到 Shmem,把“消失的内存“找回来

Linux 内核技术实战课 · 内存泄漏模块:从 RSS 到 Shmem,把"消失的内存"找回来

作者:资深内核稳定性工程师
实验环境:ecs-fddb-0002(Ubuntu 24.04 LTS / 内核 6.8.0-106-generic / 8C16G)
声明:本文所有命令与观测数据均来自真实服务器实测,未做任何编造


一、开篇:内存泄漏为什么"难找"?

内存泄漏是 Linux 系统中最常见、也最隐蔽的稳定性问题。轻则单个进程 RSS 持续膨胀,重则整机OOM(Out Of Memory)假死,SSH 都连不上。更头疼的是——有时候你top翻遍了也找不到内存去哪了,free却显示shared吃掉几个 G。

本模块通过6 个真实实验,带你建立起一套完整的内存泄漏排查体系:看懂指标 → 定位进程 → 锁定类型 → 找到根因。读完本文,你将能回答以下三个问题:

  1. 进程哪些内存类型"容易泄漏"?
  2. 如何把泄漏"关进笼子"避免整机假死?
  3. 遇到"内存消失了"(top找不到去向)时怎么查?

二、基础篇 · 进程哪些内存类型容易泄漏

2.1 先读懂/proc/meminfo—— 内存的"CT 报告"

内存泄漏排查的第一步,是看懂系统级内存指标。在服务器上执行:

cat/proc/meminfo

以下是本机基线实测数据(节选关键行):

指标本机值与内存泄漏的关系
AnonPages4,477,228 kB (≈4.4 GB)用户态匿名页(堆/匿名映射/栈),这是"最常见泄漏"涨的地方
Mapped126,936 kB被映射到进程地址空间的文件页,文件映射泄漏也在此体现
Shmem2,624 kBtmpfs/共享内存,不属于任何进程的 RSS,是"消失的内存"主角
Slab194,100 kB内核对象缓存总和 =SReclaimable + SUnreclaim
SReclaimable106,084 kB可回收内核 slab(dentry/inode),不算泄漏
SUnreclaim88,016 kB不可回收内核 slab,若只涨不跌 → 内核态泄漏
PageTables13,712 kB进程页表占用的物理内存,进程数/映射多时会涨

记忆口诀:用户态泄漏看AnonPages;共享内存看Shmem;内核态泄漏看SUnreclaim;文件缓存看Cached但一般可回收。

2.2 经典案例:malloc 不 free,RSS 12 秒涨 6 倍

我们写一个最简单的 C 程序——每秒malloc50MB 并逐页写脏(确保物理页真正分配),然后"忘掉"free。用实验 2的真实输出来看:

----- 第 1 次采样 ----- VmRSS: 104144 kB VmSize: 105088 kB ----- 第 2 次采样 ----- VmRSS: 206544 kB VmSize: 207496 kB ----- 第 3 次采样 ----- VmRSS: 308944 kB VmSize: 309904 kB ----- 第 4 次采样 ----- VmRSS: 411344 kB VmSize: 412312 kB ----- 第 5 次采样 ----- VmRSS: 513744 kB VmSize: 514720 kB ----- 第 6 次采样 ----- VmRSS: 616144 kB VmSize: 617128 kB

12 秒,RSS 从 104MB 涨到 616MB,每步约 50MB,与代码逻辑完全一致。这就是"进程内存只涨不跌"的典型泄漏特征。

2.3VmRSSvsVmSize:谁才是"真吃内存"?

  • VmSize(VSZ):进程虚拟地址空间总大小,包含了已mmap尚未分配物理页的区域。光看 VSZ 暴涨未必是真泄漏。
  • VmRSS(RES):实际占用的物理内存(Resident Set Size)。top%MEM基于 RSS 计算。

本例中两者几乎相等,因为我们对每块内存都"写脏"触发了缺页中断,物理页真的被分配了。实战中,RSS 持续增长才是真吃物理内存,VSZ 增长只说明虚拟地址被预留。

2.4 进程哪些内存类型容易"悄悄泄漏"?

按本课程框架,最容易泄漏的 5 类内存

  1. 堆(heap)malloc/new只分配不free—— 最常见,也是本文实验 2 的主角。
  2. 匿名映射mmap(MAP_ANONYMOUS)只 map 不munmap
  3. 文件映射泄漏mmap打开文件后忘记munmap,或持续openclose撑大filp/inodeslab。
  4. 共享内存shmget/shmat后不shmdt/shmctl(IPC_RMID)
  5. 页表/内核对象:漏关 fd、socket、句柄,间接推高PageTablesSUnreclaim

三、案例篇 · 预防内存泄漏导致系统假死

3.1 不加限制的泄漏会怎样?

上面实验 2 的泄漏进程如果不加限制,会一直涨到吃光所有可用内存,触发全局 OOM Killer。全局 OOM 的最大问题是:内核不知道杀谁最合适,可能杀掉你的关键进程(如 Nginx、MySQL),甚至杀掉 SSH 守护进程导致你再也连不上服务器——这就是"假死"。

3.2 用 cgroup 把泄漏"关进笼子"(实验 3 真实数据)

核心思想:给每个易泄漏的服务设置内存上限,泄漏到顶就杀它自己,别连累整机。

在本实验中,我们用systemd-run把泄漏进程放进一个受控的 scope,并设置MemoryMax=512M

dmesg-C# 清空环形缓冲,干净采集systemd-run--scope-pMemoryMax=512M ./leak

真实输出

[leak] step=1 allocated=50 MB pid=12849 [leak] step=2 allocated=100 MB pid=12849 ... [leak] step=10 allocated=500 MB pid=12849 systemd-run 返回码: 137 # 128+9 = SIGKILL,被 OOM Killer 终结

dmesg 中 OOM 记录

[ 2208.355205] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null), cpuset=/,mems_allowed=0, oom_memcg=/system.slice/run-....scope, task=leak,pid=12849,uid=0 [ 2208.355219] Memory cgroup out of memory: Killed process 12849 (leak) total-vm:565924kB, anon-rss:523136kB, file-rss:1536kB, shmem-rss:0kB, UID:0 pgtables:1080kB oom_score_adj:0

3.3 关键解读与生产建议

  • constraint=CONSTRAINT_MEMCG:这是关键证据——OOM 发生在内存 cgroup 内,仅杀该 scope 的进程。整机其他进程、SSH、系统服务完全不受影响。如果是全局 OOM,会是CONSTRAINT_NONE,且可能杀掉任意进程。
  • task=leak pid=12849:谁被杀了、吃了多少内存,一目了然。
  • 返回码 137= 128 + SIGKILL(9),从用户态也能确认"进程是被 kill 掉的"。

生产建议

  1. 对关键服务(Web 服务器、API 网关、数据库代理)在systemd单元中加MemoryMax=/MemoryHigh=
  2. 或者手动写 cgroup v2:echo 512M > /sys/fs/cgroup/<name>/memory.max
  3. 监控 OOM 事件:journalctl -k | grep -i oom或接入 Prometheus + Node Exporter 的node_vmstat_oom_kill指标。
  4. 不要依赖全局 OOM Killer,它是"最后一道防线"而非"预防手段"。

四、案例篇 · Shmem:进程没消耗内存,内存哪去了

4.1 一个真实的"灵异事件"

假设你top看了一遍,所有进程 RSS 加起来不到 4GB,但free -h显示已用 8GB,还剩 2GB 可用——“消失的 2GB 去哪了?”这时候,罪魁祸首很可能是Shmem

4.2 实验 4 真实数据:往/dev/shm写 1GB

基线

free -h (shared): ... shared 2.6Mi ... /proc/meminfo Shmem: Shmem: 2624 kB df -h /dev/shm: tmpfs 7.4G 0 7.4G 0% /dev/shm

写入 1GB 后

free -h (shared): ... shared 1.0Gi ... /proc/meminfo Shmem: Shmem: 1051196 kB df -h /dev/shm: tmpfs 7.4G 1.0G 6.4G 14% /dev/shm

没有任何进程的 RSS 体现这 1GB 内存freeshared列和/proc/meminfoShmem同步上涨,但top里找不到任何一个进程多吃了 1GB。

4.3 再配合 SysV 共享内存验证

我们还用shmget+shmat创建了 256MB SysV 共享内存段,并写脏页面:

写脏前 Shmem: 1051196 kB (来自 /dev/shm 的 1GB) 写脏后 Shmem: 1313280 kB (又涨了 ~256MB)

这里有一个重要细节:shmget创建段时仅分配内核对象,不占物理页;只有shmat真正写脏页面才让物理内存落地。这也是为什么ipcs -m能看到段,但Shmem在写脏前不变。

4.4 实战结论

free -h显示shared很大、但你用top/ps找不到对应的高 RSS 进程时,第一反应去看Shmem

# 三件套快速排查cat/proc/meminfo|grepShmemdf-h/dev/shm# 查看 tmpfs 使用量ipcs-m# 查看 SysV 共享内存段

常见场景:程序把大文件塞进/dev/shm、滥用共享内存做 IPC、容器 tmpfs 卷挂载过大、数据库使用 POSIX 共享内存但不及时清理。


五、分析篇 · 内核内存泄漏基础分析

5.1 什么是内核态内存泄漏?

用户态泄漏(malloc 不 free)进程退出后内存自然回收。但内核态泄漏指内核对象(如文件描述符、socket、inode)在没有进程持有后仍占用 slab 缓存不释放,表现为SUnreclaim持续增长。只有重启才能回收,是更严重的泄漏类型。

5.2 实验 5 真实数据:观测filpslab 暴涨

我们制造了"持有 10 万个打开 fd"的压力,用slabtop/proc/slabinfo观测前后变化。

基线

SUnreclaim: 88296 kB SReclaimable: 107748 kB filp OBJ(基线): 1888

压力中(持有 100,000 个 fd):

filp OBJ(压力中): 101888 ← 约 +10 万,与 fd 数严格对应 SUnreclaim(压力中): 118260 kB ← +约 30MB

进程退出后

filp OBJ(回收后): 1948 SUnreclaim(回收后): 88428 kB ← 回到基线

5.3 判漏准则

上述实验只是"正常压力"(进程退出后 slab 被回收)。真正的内核态泄漏判断标准是:

在无明显业务压力、且对象应已释放的情况下,SUnreclaim(及nr_slab_unreclaimable)持续单调上升、不回落,基本可判定为内核态内存泄漏。

观测手段:

# 1. 看全局趋势watch-n5'grep SUnreclaim /proc/meminfo'# 2. 看哪个 slab 在涨slabtop-o# 3. 精确看某个 slab 的对象数grepfilp /proc/slabinfo# 或 grep sock_inode_cache /proc/slabinfo # socket 泄漏

六、分析篇 · 一步步找到根因(工具链实战)

6.1 工具链全景

从"系统内存少了"到"定位到代码行",需要四级工具链:

系统级(meminfo/free)→ 进程级(smem/top)→ 内存类型级(pmap/smaps)→ 代码级(valgrind/memleak)

6.2 Step 1:smem找出"谁吃 PSS 最多"(实验 6 A1)

传统top的 RSS 会把共享库重复计算,而PSS(Proportional Set Size)按比例分摊共享页,更公平。

smem-spss-r-p

真实输出

PID User Command Swap USS PSS RSS 14420 root /tmp/leak_exp/leak N/A 1.73% 1.73% 1.74% 6114 root [migration/0] N/A 0.15% 0.17% 0.22% 394 root /sbin/multipathd -d -s N/A 0.14% 0.15% 0.18%

泄漏进程 PSS 1.73% 排第一,远超其他进程,一目了然。

6.3 Step 2:pmap -x+smaps锁定内存类型(实验 6 A2-A3)

确认是 PID 14260 后,用pmap看详细内存布局:

pmap-x14260

关键行

total kB 309908 308944 307304 RSS=308944, Dirty=307304

再深入看/proc/<pid>/smaps[heap]段:

Rss: 308944 kB Pss: 307357 kB Private_Dirty: 307304 kB ← 几乎全是私有脏页!

结论:RSS 约 309MB,其中[heap]段的Private_Dirty307MB,说明是私有堆泄漏,即 malloc 不 free 的经典模式。

6.4 Step 3:valgrind定位代码行(实验 6 B)

在测试环境用 valgrind 跑泄漏程序:

valgrind --leak-check=full ./leak

真实输出

==14278== LEAK SUMMARY: ==14278== definitely lost: 12,582,912 bytes in 11 blocks # 12MB,11个泄漏块 ==14278== indirectly lost: 0 bytes in 0 blocks ==14278== possibly lost: 0 bytes in 0 blocks ==14278== still reachable: 0 bytes in 0 blocks

--show-leak-kinds=all还能打印出每次malloc的调用栈,直接指向泄漏代码行。这是用户态泄漏定位的"杀手锏"(缺点:运行慢约 10–20 倍,只适合离线/测试环境)。

6.5 排查套路总结

步骤命令/文件目标
1free -h//proc/meminfo判断是AnonPages↑(用户态)、Shmem↑(共享内存)还是SUnreclaim↑(内核态)
2smem -s pss -r/top找出"吃内存最多"的进程
3pmap -x <pid>//proc/<pid>/smaps区分[heap][stack]mmap文件哪种在涨
4valgrind --leak-check=full/kmemleak/bpftrace用户态用 valgrind、内核态用 slabtop + kmemleak 定位代码行

七、本模块要点速记

  1. 内存泄漏三看AnonPages(用户态)→Shmem(共享内存)→SUnreclaim(内核态)。
  2. RSS 持续增长才是真泄漏,VSZ 增长只是虚拟地址预留。
  3. 用 cgroup 给服务加内存上限MemoryMax=),泄漏只杀自己,不连累整机。
  4. top找不到去向时查Shmemcat /proc/meminfo | grep Shmem+df -h /dev/shm+ipcs -m
  5. SUnreclaim只涨不跌 = 内核态泄漏,用slabtop/grep filp /proc/slabinfo锁定哪个 slab。
  6. 排查工具链smem(找进程)→pmap/smaps(定类型)→valgrind(定位代码行)。

八、下一篇预告

TCP 重传模块:当应用端看到"请求超时"、"连接重置"时,如何从内核态(/proc/net/snmpss -itcpretranstcpdump)区分是网络丢包还是对端异常?我们将用真实发包实验,演示 TCP 重传的完整排查链路。


附录:本文所有实验日志原文见/tmp/ml_log.md,脚本见scripts/目录。欢迎读者在同等内核版本(6.8+)上复现验证。
[exit=0]