做容器或者做服务的同学,可能都听过这句话:“动态链接库是所有进程共享的,根本不占内存。”面试讲起来很顺,但真到了线上,你起了一百个 worker,再敲free -g,心里就开始打鼓了:明明说好共享,可用内存还是一路往下掉,到底是谁在撒谎?
这个疑问我专门花过几个晚上实测。结论是:那句话没错,但说得不完整。动态库确实把同一份代码物理页共享给多个进程,可它省下的只是“物理页框”,进程数增加时,页表、映射、私有数据段、写时复制这些开销一样不少。这篇文章本来是性能系列里塞不下的一块附录,我把它单独成文:先把 ELF 的加载机制讲清楚,再带你做一次多进程实验,最后给一套排查多进程内存占用的方法。
1. 先给结论:动态库省下的是“物理页”,不是免死金牌
1.1 一句话版本
读完这一节你可以记住这句话:动态库让 N 个进程的虚拟地址空间指向同一份物理页,所以代码段的“重复占用”被抹掉了;但每个进程依然需要自己的页表、自己的栈、自己的堆、自己的匿名映射,以及库中少量可写段的私有拷贝。内存优化不能只盯动态库,但也不能完全忽略它。
用生活类比:图书馆买一本书放在公共区域,一万个人借阅,不需要给每个人买一本,这是共享的物理资源。但每个人去图书馆要占一个座位、要办一张读者证,座位和证是私有的。进程的栈、堆、匿名页就是座位和证,页表就是读者证列表。进程多了,座位总数当然上去。
可实际环境里,很多人把 RSS 直接相加,然后指着数字说“动态库吃掉了 2G 内存”。这是最典型的一笔糊涂账:RSS 是站在单个进程视角统计的,其中包含了共享物理页。如果 100 个进程都映射同一个 libc,你把这 100 个 RSS 加起来,等于把同一页物理内存数了 100 遍。需要换用 PSS 概念,那才是对物理占用的近似分摊。
1.2 关于共享的四个常见误区
这里有一个我见过很多次的误区列表,建议先存一下:
| 误区 | 真相 |
|---|---|
| 只要用动态库,内存就不随进程数增长 | 代码段物理页不增长,但页表、栈、堆、私有数据都增长 |
| 单个进程 RSS 高,说明它独占了很多内存 | RSS 含共享页,要结合 Shared/Private 字段看 |
| 把 100 个进程 RSS 相加就是总占用 | 大概率高估,等于把共享物理页重复计数 |
| ASLR(地址随机化)会让动态库共享失效 | 虚拟地址不同不影响物理页共享,页表映射不同而已 |
这些误区后面都会逐个拆开。尤其是最后一个,很多做安全加固的同学会问:既然地址随机化了,库文件映射到不同虚拟地址,那还能共享同一物理页吗?答案是能,页表就是干这个的:它把每个进程的虚拟地址翻译到同一个物理页框。地址随机化改的是虚拟地址,不是物理页映射结果。
2. ELF 加载的共享机制:谁在共享,谁在私有
2.1 动态库在内存里的“四块地”
理解共享,先看一个 .so 文件的内部结构。用 readelf 把 libm 拆开:
readelf -W -l /lib/x86_64-linux-gnu/libm.so.6 | grep LOAD你会看到几个 PT_LOAD 段,每个段有权限位。概括起来,动态库在进程里通常分成这样四块:
| 区域 | 典型节 | 权限 | 是否多进程共享物理页 | 是否写时复制 |
|---|---|---|---|---|
| 代码段 | .text | r-x | 是 | 否 |
| 只读数据段 | .rodata、.gcc_except_table | r-- | 是 | 否 |
| 可写数据段 | .data、.got | rw- | 初始共享,第一次写后变私有 | 是 |
| BSS/TLS 段 | .bss、.tdata、.tbss | rw- | 否,每个进程有私有副本 | 是 |
为什么代码段和只读数据段能共享?因为它们在运行期不可写,内核用只读私有映射把文件页映射进地址空间后,任何进程都不能通过这个映射改写物理页,所以物理帧可以安全地被很多人共享。这也是 Linux 内核中“只读私有映射实际上是共享页缓存”的道理。
可写数据段就惨一点。Linux 对 MAP_PRIVATE 的可写映射采用写时复制:初始时大家都指向文件页,谁写了,内核就给它单独复制一页,改造成私有页。动态库的 .data 通常不大,比如 glibc 的这个段可能几十 KB,但架不住 GOT、TLS 这些跟着进程走的东西。
2.2 mmap 与 page cache:同一个文件页为何能出现在很多进程里
动态库不是“加载”进去的,严格说是 ld.so 用 mmap 把文件映射到进程地址空间。文件内容会以 4KB 页为单位进入 page cache。第一次映射时,磁盘页被读进 page cache;后续任何进程映射同一个文件、同一个页,内核直接把这个物理页填进新进程的页表,不再读盘。
关键在于 page cache 的 key 是(文件 inode, 页偏移)。只要多个进程映射的是同一个 inode 的同一个页,物理页就同一份。这也解释了为什么容器场景容易出问题:如果每个容器镜像里都有/lib/x86_64-linux-gnu/libc.so.6,但文件名相同、inode 不同,page cache 就认为是两个文件,物理页不合并。你看着一百个容器都“占”了 libc 的几百 KB,实际是同一个算法被执行了一百份。
2.3 别忘了页表:共享物理页,但映射关系不共享
这块太容易被忽略了。物理页虽然只存一份,但每个进程都要在自己的页表里维护一条指向它的映射条目。64 位 Linux 下虚拟地址空间按 4KB 页切分,一个 PTE 8 字节,还有 PMD、PUD、PGD 各级目录项。单看每个进程,页表本身可能就占几 MB 内核内存。
你可以直接看系统级数字:
grep -E "PageTables" /proc/meminfo如果宿主机上有几万个进程,这个 PageTables 数字往往比你预期的要大。它不属于任何进程的 RSS,你用 ps 看不到,只能从 MemTotal 里消失。进程越多,这部分“不可共享的内核账本”越大,这就是为什么哪怕动态库把物理页共享到极致,也做不到“起一万个进程等于一个进程”。
2.4 用 PSS 量化真实占用
讲完机制,需要一个能用的统计口径。Linux 提供三个口径:
- RSS:进程驻留物理内存,含共享页,单进程视角。
- USS:只属于该进程的页,不含任何共享页。
- PSS:共享页按共享进程数平均分摊到每个进程,PSS 求和接近真实物理占用。
举一个简单例子:一个 4KB 物理页被 4 个进程共享,每个进程的 smaps 里都有 4KB RSS,但每个进程的 PSS 只算 1KB。因此排查多进程内存占用时,优先看 PSS 而不是 RSS。工具方面,smem 或者手工解析 smaps 都能拿到这三个口径,后面第 5 节会给命令。
3. 实测:让 50 个进程同时加载同一个动态库
3.1 实验设计
理论说完了,做一次能复现的小实验。我写了一个极其简单的 C 程序,目的是把 libm 真正用起来,让它的代码段页面被调入物理内存,而不是停在文件缓存里:
#include <math.h> #include <stdio.h> #include <unistd.h> int main(void) { char stack_buf[4096]; for (int i = 0; i < 200000; i++) { double v = sqrt((double)i) + log((double)(i + 1)); if (v < 0) printf("%s", stack_buf); } pause(); return 0; }编译链接 libm,然后开 50 个实例:
gcc -O2 -o mem_probe mem_probe.c -lm for i in $(seq 1 50); do ./mem_probe & donepause() 让进程保持运行,sqrt/log 确保 libm 的代码段真的被访问。注意:如果程序里只是链接了 libm 但没调用它,ld 可能用 --as-needed 直接把 libm 从依赖里摘掉。
3.2 单进程视角:pmap 能看到什么
随便挑一个进程 ID,直接看映射:
pmap -X 12345 | grep -E "libm|libc|total"我这边测下来,libm 的代码段 RSS 大约是几百 KB 到 1 MB 的量级,libc 大约 1 MB 出头,具体数字跟 glibc 版本、是否触发错误路径有关。注意:这里的 RSS 是“这个进程调用了 libm 之后,驻留在物理内存的库代码页”。50 个进程同样调用,pmap 在 50 个进程里都显示同样大小的 RSS。
但你 snapshot 物理内存,会发现 libm 的代码页并没有翻 50 倍,还是那几百 KB 的物理页被反复映射。pmap 单进程无法告诉你这一点,因为它不知道这个 page 还被谁映射。
3.3 多进程视角:RSS 与 PSS 的账面差异
为了直观,我用脚本把 50 个进程的/proc/<pid>/smaps汇总,重点统计 libm、libc 的 Shared_Clean / Private_Dirty 字段,再算 PSS。结果大致长这样:
| 场景 | 单进程 RSS(约) | 50 进程 RSS 直接相加 | 50 进程 PSS 总账(约) |
|---|---|---|---|
| libc 主映射 | 1.1 MB | 55 MB | 1.2 MB |
| libm 主映射 | 0.4 MB | 20 MB | 0.45 MB |
| 每个进程私有栈/堆/匿名 | 0.3 MB | 15 MB | 15 MB |
| 页表等内核开销 | 不体现在 RSS | 0 | 约 3-5 MB |
我故意没写精确到 KB 的绝对值,因为不同发行版差异太大,但比例是稳定的:加起来的数据里,RSS 直接相加会把共享库的物理页“数 50 遍”,PSS 总账才是接近真实物理内存的数。如果你在线上看到“100 个进程 RSS 加起来 8G,available 掉了 8G”就觉得必须限制进程数,建议先按 PSS 重新算一遍账,结论可能会完全不同。
3.4 什么增长是预期,什么才值得警惕
这次实验真正值得警惕的不是 libc/libm 的代码段,而是每进程私有部分。继续看数据:
- 栈、线程栈、堆、匿名 mmap:这部分每进程一份,进程数线性增长,无法共享。
- GOT/PLT 和 .data:每进程会写时复制少量页,加起来通常只有几十到几百 KB。
- 页表和内核 task_struct、文件描述符表:每进程都有,虽小但堆多了可观。
一句话:如果你优化完发现大头在私有匿名内存,问题不在动态库;如果大头是某个 .so 的 Private_Dirty 异常高,才需要怀疑动态库本身。
4. 共享失效的几种情况:这些坑才是内存暴涨的元凶
4.1 可写数据段和 TLS:每个进程都有自己的私有副本
先说一个反直觉的事实:即使多个进程映射同一个 .so,它的 .data 也不一定是真正共享的。glibc 的 .data 段里有 errno 的访问机制、locale 缓存、IO 缓冲状态等,这些运行期会写,一写就触发写时复制,每进程拿到自己的一份物理页。
还有 TLS(线程本地存储),像 errno 这种指标,说到底是线程局部变量,每个线程都有独立副本。多线程程序里,这份内存还会随着线程数增长,动态库对此没有责任。排查时你看到某个 .so 的 Private_Dirty 几百 KB,别慌,先判断是 GOT、数据段还是 TLS,后者基本是正常的。
4.2 延迟绑定的 GOT/PLT:几 KB 的私有页,但影响判断
Linux 动态链接默认 lazy binding:函数第一次被调用才去解析地址并写入 GOT。GOT 页原本是可写段页,第一次写入就变成私有的。你可能会在 smaps 里看到 libc 的某个映射页出现 Private_Dirty,相当一部分就是 GOT 造成的。
可以用环境变量强制立即绑定:
LD_BIND_NOW=1 ./mem_probe或者链接时加-Wl,-z,now。效果是启动阶段一次性解析完,之后 GOT 会被 mprotect 成只读。从纯内存角度看,强制绑定不一定会显著减少私有页,但会让内存状态确定化,对排查更友好。我在生产上见过有人用 LD_BIND_NOW 修“奇怪偶发内存上涨”,未必真的是内存回归,但行为确实变稳了。
4.3 非 PIC 库与 TEXTREL:共享直接失效
这是我自己第一次踩到的大坑。某个同事自己编译了一个闭源 .so,没加 -fPIC,结果代码段里有文本重定位。这种库加载时,动态链接器没办法让多个进程共享同一份只读代码,因为代码在运行期必须被改写,每个进程都得持有一份私有副本。症状就是:进程数翻倍,这个 .so 的内存线性翻倍,RSS 涨得肉眼可见。
诊断方法:
readelf -d libfoo.so | grep -i textrel如果输出里有 TEXTREL 字段,说明这个库带文本重定位,共享能力已经打了折扣。现代发行版工具链默认 PIC,正规库一般不会,但自己编译的、第三方拿来的旧库很容易出事。遇到这种库,要么重新编译加 -fPIC,要么和供应商确认能不能换版本。
4.4 同名同版本、不同路径的库:容器场景最典型的坑
前面提过 page cache 以 inode 为 key,这里展开说。宿主机上跑 50 个容器,每个容器都带一套/lib/x86_64-linux-gnu/libc.so.6,大小一模一样、版本一模一样,但在宿主看是 50 个不同 inode 的文件。内核不会因为内容相同就自动合并物理页,于是同一份 glibc 代码被缓存 50 份。
这也解释了为什么容器化之后,内存账本变得特别难看。常规解法是统一基础镜像、统一 glibc 版本,或者尽量用 distroless 镜像把依赖收敛;哪怕做不到统一,也要知道这个重复占用其实是可控的“文件页缓存”,可以被回收,但同时也意味着换页时 I/O 压力更大。
4.5 dlopen 同一路径:同一进程内的一份映射
还有一种情况经常被误判:程序用 dlopen 反复加载同一个 .so。只要路径相同、inode 相同,dlopen 在进程内部只会增加引用计数,不会产生新的映射;但如果你把文件复制到新路径再 dlopen,或者用某种方式在内存里生成新文件,那就是新的 inode,物理页自然不能共享。这条对插件系统特别重要:插件数量多、每个插件路径不同、代码又高度相似,累积起来相当可观。
5. 排查多进程内存占用的实操路线
5.1 先看系统账本
遇到“很多进程、内存占用高”的告警,我建议按这个顺序看,而不是直接 top:
free -h grep -E "PageTables|SReclaimable|Cached" /proc/meminfo目的有三个:确认 Available 到底还剩多少;PageTables 是不是异常高,判断是不是进程数过多的元凶;Cached 高能不能回收。很多“内存不足”实际上是文件页缓存可以回收,只是回收需要时间,别上来就杀进程。
5.2 按 PSS 找大头
单机 pid 数量大时,用 ps 的 RSS 排序会把大共享库误判成大内存。可以装 smem:
sudo smem -tk -p -s pss | head -40smem 会列出每进程 USS、PSS、RSS,还能在末尾汇总。你看到 PSS 排名前几的,才是真正物理内存占用高的进程。如果嫌装包麻烦,也可以写几行 Python 解析/proc/*/smaps里的 Pss 字段,效果一样。
5.3 从 smaps 里定位某个共享库到底共享了多少
想确认某个库是不是真共享,不要只看单个进程,要看全部进程对该文件的统计:
grep -A8 "libm-2.31.so" /proc/[0-9]*/smaps | \ grep -E "Shared_Clean|Private_Clean|Private_Dirty|Pss:" | \ awk '{print $1, $2}' | sort | uniq -c | sort -nr命令有点粗糙,但能告诉你:这个库的页在多少进程里被映射,每类统计加在一起是多少。重点看 Private_Dirty,如果它占了很大比例,说明这个库在大量进程里都有私有写入页,要么是 GOT/TLS,要么是库本身有大量可变全局状态。
5.4 优化清单:哪些有效,哪些白忙
最后给一份我实践下来觉得比较实在的清单:
- 统一库版本和路径:容器场景收益最明显,能减少 page cache 重复分布。
- 裁剪依赖:用
ldd看看你的二进制到底拖了哪些库,能用-Wl,--as-needed去掉的就去掉。库文件本身也是内存,少一个库,少一批页。 - 动态库瘦身:strip 掉符号表、用
-fvisibility=hidden减少符号导出,主要减少磁盘和部分虚拟内存,物理驻留收益有限但没坏处。 - 不要静态链接:把 glibc 静态链进二进制,会让每个进程独享一份 libc 代码,比动态库的内存模型差得多。
- LD_BIND_NOW 按需开启:别把它当成万能内存优化,更多是消除不确定性。
- -fPIC 是硬要求:自己编 .so 忘了 PIC,等于亲手杀掉共享能力。
- 关注私有匿名内存:如果堆、栈、对象缓存才是大头,那和动态库没有关系,别一开始就怀疑 .so。
关于“要不要为了省内存去重开一个共享库缓存服务”,我的看法是:Linux 内核页缓存本来就在做这件事,应用层不用再包装一遍。除非你真的碰到大量不同 inode 的重复库文件,否则先统一基础镜像比写缓存服务实在得多。
写到最后说说个人体会。这个附录是我处理容器内存告警时补的作业。那晚我盯着 free 的输出想了很久,后来把总账从“共享库太占内存”修正成“进程私有匿名内存和页表是主犯,动态库只是背锅”,之后对内存优化的判断就清楚多了。以后再遇到“动态库让内存翻倍”的说法,你至少可以先问一句:你测的是 RSS 相加,还是 PSS 总账?这一句,基本就能过滤掉一大半拍脑袋的结论。