Linux 性能调优系列(十八): 程序申请了1GB内存,为什么系统却说它没用?
1 | 作者:李晓辉 |
上一篇我们讲了 CPU 缓存:缓存没命中,请求才会一路走到内存。那么问题来了:走到内存以后,又发生了什么?
先看一个现象。你在 8 GB 的机器上,让程序 malloc 了 1 GB,ps 一看:
1 | VSZ 1050000 |
VSZ 一个多 GB,RSS 才 1 MB 出头。这 1 GB 到底有没有”拿到”?
这一篇我们就从这个现象出发,把内存这一层讲清楚:地址怎么翻译、缺页是怎么回事、为什么 TLB 会拖慢数据库、大页怎么用、又该怎么限制一个进程的内存。
进程为什么不直接碰物理内存
内核把内存切成固定大小的块,叫页(page)。x86_64 上标准页是 4 KiB。物理内存里装一页数据的位置,叫页框(page frame)。关键点:进程看到的地址全是虚拟的。 每个进程都有一套私有的虚拟地址空间,内核用一张页表,把”虚拟页”映射到”物理页框”。
flowchart LR
subgraph A["进程 A 的虚拟地址空间"]
A1["虚拟地址 0x7f00...1000"]
end
subgraph B["进程 B 的虚拟地址空间"]
B1["虚拟地址 0x7f00...1000"]
end
A1 -- "进程 A 的页表" --> P1["物理页框 #1001"]
B1 -- "进程 B 的页表" --> P2["物理页框 #2087"]两个进程里完全相同的虚拟地址,落在不同的物理页框上,互相看不见。这套机制一举解决了两件事:
- 隔离:进程只能访问内核映射给它的页框,越界直接触发异常;
- 灵活:进程拿到的是一大片”连续”的虚拟地址,物理上可以东一块西一块,也可以暂时不在内存里。
虚拟地址空间分成两半:低半部分是用户空间,放进程自己的代码和数据;高半部分是内核空间,放内核。
老资料常说”内核映射进每个进程,所以系统调用开销小”。这在今天要打个折扣:开启了 KPTI(防 Meltdown)的 CPU 上,用户态页表里基本不再映射内核,只留一小段入口。可以这样查你的机器:
1 | [root@localhost ~]# cat /sys/devices/system/cpu/vulnerabilities/meltdown |
虚拟地址空间到底有多大
很多资料会说”64 位系统的地址空间是 2⁶⁴,也就是 16 EiB”。这是指针的宽度,不是你能用的空间。
x86_64 实际只使用其中一部分:
| 模式 | 有效虚拟地址位 | 总虚拟空间 | 其中用户态 | 页表层级 |
|---|---|---|---|---|
| 四级分页(主流) | 48 位 | 256 TiB | 128 TiB | 4 级 |
| 五级分页(新 CPU) | 57 位 | 128 PiB | 64 PiB | 5 级 |
剩下的高位不是随便”保留”,而是要求做符号扩展(规范地址):高位必须和最高有效位一致,否则 CPU 直接报异常。所以你会看到用户地址总是 0x00007f...,内核地址总是 0xffff...。
查看你的机器:
1 | [root@localhost ~]# lscpu | grep -i 'address sizes' |
两个小提醒:
- 物理位数和虚拟位数是两回事。
45 bits physical表示这台机器最多能寻址 32 TiB 物理内存;48 bits virtual表示用四级分页。装多少内存和虚拟地址空间多大,没有关系; - 即使 CPU 和内核支持五级分页,默认 mmap 也只会返回 47 位以内的地址,是为了兼容那些把高位拿来存标记的程序。应用要主动请求高地址,才会用到更大的空间。
一个虚拟地址是怎么拆开的
以 4 KiB 页为例,一个虚拟地址分成两部分:
| 部分 | 位数 | 作用 |
|---|---|---|
| 虚拟页号(VPN) | 36 位(四级)或 45 位(五级) | 找到是哪一页 |
| 页内偏移 | 12 位(2¹² = 4096) | 页内的第几个字节 |
页号部分再按每 9 位切一段,每一段对应一级页表:
flowchart LR
VA["虚拟地址<br/>9 + 9 + 9 + 9 + 12 位"] --> L4["PML4 表<br/>CR3 寄存器指向它"]
L4 --> L3["PDPT 表"]
L3 --> L2["PD 表"]
L2 --> L1["PT 表"]
L1 --> PG["物理页框 4 KiB<br/>加上 12 位页内偏移"]五级分页就是在最顶上再多一层 PML5。每一张表有 512 项(2⁹),每项 8 字节,刚好占满一个 4 KiB 页。
页表为什么要分层
如果不分层,一张平铺的页表要多大?算一下:
- 四级:2³⁶ 个条目 × 8 字节 = 2³⁹ 字节 = 512 GiB;
- 五级:2⁴⁵ 个条目 × 8 字节 = 2⁴⁸ 字节 = 256 TiB。
每个进程一张这样的表,显然不可能。所以内核用多级结构:只为进程真正用到的那部分地址范围,才分配对应的目录和页表。一个只用了几十 MB 的进程,页表也就几十 KB。
代价是:翻译一次地址,要逐级查表,四级就是最多 4 次内存访问。这很慢。
TLB:给地址翻译加一层缓存
为了避免每次都查四级表,CPU 里有一块专用的硬件缓存,叫 TLB(转换后备缓冲区),缓存最近用过的”虚拟页到物理页”映射。
- TLB 命中:直接拿到物理地址,几乎没有额外开销;
- TLB 未命中:硬件要走一遍多级页表(page walk),再把结果填回 TLB。
程序有局部性,连续访问同一页的数据,第二次起就能命中。但如果程序在很大的内存范围里到处跳,TLB 就会频繁未命中。
可以这样观察:
1 | [root@localhost ~]# perf stat -e dTLB-load-misses,iTLB-load-misses sleep 1000000 |
和上一篇一样:虚拟机里需要开启 vPMU,否则这些事件显示
<not supported>,并不代表没有未命中。
进程占了多少内存:VSZ、RSS、PSS
回到开头的问题。进程申请内存时,内核只是给了它一段虚拟地址,并不会立刻分配物理页框。等进程真正去读写这块内存时,才会触发分配。
所以工具里有两个口径:
| 指标 | 含义 |
|---|---|
| VSZ / VIRT | 进程申请的虚拟内存总量 |
| RSS / RES | 当前真正映射在物理内存里的部分 |
1 | [root@localhost ~]# ps -o pid,vsz,rss,minflt,majflt,comm -p 1019 |
动手做个实验
写一个”先申请、后使用”的小程序:
1 | // vm_demo.c |
1 | [root@localhost ~]# gcc -O1 -o vm_demo vm_demo.c |
你会看到:
- 第一次:VSZ 约 1 GiB,RSS 只有几 MB;
- 第二次:RSS 涨到约 512 MiB,
minflt(次要缺页)也随之增加。
如果你的 THP 是 always,第二次的 minflt 增量可能比”512 MiB ÷ 4 KiB ≈ 13 万次”小得多,因为一次缺页就能拿到一个 2 MiB 大页。这个现象后面讲 THP 时你会再遇到,具体数字以你的实测为准。
为什么申请了这么多也不报错
1 | [root@localhost ~]# cat /proc/sys/vm/overcommit_memory |
默认值 0 是启发式超配:内核只拒绝”明显离谱”的申请,其余先答应下来。所以才有**”申请成功,用的时候才 OOM”**这种情况。
RSS 的一个陷阱:共享页重复计数
多个进程共用同一个库,这个库的物理页只有一份,但每个进程的 RSS 里都会把它算一遍。所以:
把所有进程的 RSS 加起来,往往比真实占用大。
想看进程的”公平份额”,用 PSS(共享页按使用者数量均摊):
1 | [root@localhost ~]# grep -E '^(Rss|Pss|Shared_|Private_)' /proc/1019/smaps_rollup |
限制进程的内存:cgroup v2 与 systemd
RHEL 9、10 用的是 cgroup v2,内存限制的参数是这几个:
| 参数 | 作用 |
|---|---|
MemoryHigh | 软上限:超过后被限流、加快回收,不杀进程 |
MemoryMax | 硬上限:超过且回收不下来,触发 cgroup 内的 OOM |
MemorySwapMax | 限制可使用的 swap |
MemoryLow / MemoryMin | 保护:内存紧张时尽量不回收这部分 |
老资料里的
MemoryLimit=是 cgroup v1 的参数,已经被弃用,别再用了。数值支持K、M、G、T后缀,以 1024 为基数。
对现有服务设置
1 | # 立即生效并持久化(写入 /etc/systemd/system.control/) |
set-property 会立即生效,不需要 daemon-reload。如果你是手写 drop-in 文件,才需要 systemctl daemon-reload。
经验:优先配
MemoryHigh让它先被限流,再用MemoryMax兜底。只设MemoryMax的话,进程会直接撞墙被杀,没有缓冲。
缺页:不是错误,是常态
进程访问一个页表里还没有有效映射的虚拟页,CPU 会触发缺页异常,进入内核处理。名字叫”错误”,其实大多数时候是正常流程。
flowchart TD
A["进程访问虚拟地址"] --> B{"TLB 命中?"}
B -- 是 --> Z["直接得到物理地址"]
B -- 否 --> C{"页表里有有效映射?"}
C -- 是 --> D["硬件填充 TLB,继续执行"]
C -- 否 --> E["触发缺页异常,进入内核"]
E --> F{"数据在哪里?"}
F -- "新分配一页,或已在页缓存" --> G["次要缺页:建立映射即可"]
F -- "在磁盘或 swap 上" --> H["主要缺页:先做磁盘 I/O"]
F -- "地址非法" --> I["SIGSEGV,进程崩溃"]次要缺页(minor)
不需要磁盘 I/O。典型情况:
- 首次访问刚申请的内存,内核分配一个新页框;
- 数据其实已经在内存里(比如页缓存),只是这个进程的页表还没建立映射;
- 写时复制(fork 之后第一次写)。
开销小,一般是微秒量级。
主要缺页(major)
必须从磁盘把数据读回来:页面被换出到 swap,或者程序、库的代码还没加载进内存。要等磁盘,开销是毫秒量级,比次要缺页高几个数量级。
怎么观察
1 | # 每个进程累计的缺页数(从进程启动开始计) |
判断标准:
minflt很高通常不用担心,那是程序在正常地”开荒”;majflt持续偏高才是危险信号,说明系统在频繁地等磁盘,常见原因是内存不足引发了 swap。
为什么要用大页
回到 TLB。TLB 的条目数是固定且很小的,通常只有几百到一两千条。每条只管一页:
1 | TLB 覆盖的内存 = 条目数 × 页大小 |
假设某个 TLB 有 1500 条(实际因 CPU 型号和页大小而异,这里只是帮你建立量级概念):
| 页大小 | TLB 能覆盖的内存 |
|---|---|
| 4 KiB | 约 6 MiB |
| 2 MiB | 约 3 GiB |
数据库这类进程动辄占几十 GB,用 4 KiB 页时 TLB 几乎永远覆盖不过来,未命中非常多。换成 2 MiB 页,同样的 TLB 条目能覆盖 512 倍的内存。
大页还有第二个好处:页表查得更短。
- 2 MiB 页:页目录(PD)的条目直接指向这个页,少查一级 PT,页内偏移是 21 位;
- 1 GiB 页:PDPT 的条目直接指向,少查两级,页内偏移是 30 位。
x86_64 支持 2 MiB 和 1 GiB 两种大页,RHEL 默认大页大小是 2 MiB:
1 | grep -i hugepagesize /proc/meminfo |
RHEL 有两种大页机制:静态大页(HugeTLB)和透明大页(THP)。
静态大页 HugeTLB:先预留,再使用
静态大页要提前预留,而且只有显式请求的进程才能用。预留出来的内存,普通进程就用不了了,free 里会把它算作 used。
方式一:启动时预留(推荐)
启动时内存还没碎片化,成功率最高。用 grubby 写进所有内核条目:
1 | # 预留 10 个默认大小(2 MiB)的大页 |
要同时预留 2 MiB 和 1 GiB 两种大页:
1 | grubby --update-kernel=ALL \ |
每个 hugepages= 都归属于它前面最近的 hugepagesz=,所以顺序很重要。漏写第二个 hugepagesz=1G,那个 hugepages=1 就会被当成继续设置 2 MiB 页,这是个很隐蔽的坑。
重启后验证:
1 | cat /proc/cmdline |
1 GiB 大页需要 CPU 支持,可用
grep -o -m1 pdpe1gb /proc/cpuinfo确认。1 GiB 大页也强烈建议只在启动时预留,运行时很难凑出连续的 1 GiB 空间。
方式二:运行时预留
1 | # 全局设置:预留 20 个 2 MiB 页 |
预期能看到:
1 | HugePages_Total: 20 |
在多 NUMA 节点的机器上,可以按节点分别预留:
1 | echo 20 > /sys/devices/system/node/node2/hugepages/hugepages-2048kB/nr_hugepages |
注意两点:
- 写入的是总数,不是增量。 再写一次
20,结果还是 20,不会变成 40; - 预留可能不足额。 内存碎片多时,内核凑不出足够的连续空间,实际数量会少于你写的。写完一定要读回确认。
用 numastat 看每个节点的情况:
1 | numastat -cm | egrep 'Node|Huge' |
这里的单位是 MB,20 个 2 MiB 的大页显示为 40。
持久化运行时预留:
1 | echo "vm.nr_hugepages = 20" > /etc/sysctl.d/90-hugepages.conf |
进程怎么用大页
进程要显式请求,常见方式:
mmap()加MAP_HUGETLB标志(匿名映射,不需要挂载 hugetlbfs);- 在 hugetlbfs 上创建文件再
mmap(),这种才需要挂载,RHEL 上 systemd 会自动挂在/dev/hugepages; shmget()加SHM_HUGETLB。
动手验证:没预留会怎样
1 | // hugetlb_demo.c |
1 | gcc -O1 -o hugetlb_demo hugetlb_demo.c |
这个实验能直观说明:静态大页是”要才有,没预留就没有”,不会像普通内存那样自动兜底。
透明大页 THP:内核自动帮你做
THP 不需要预留,内核会自动给符合条件的内存区域用 2 MiB 页,应用不用改代码。它主要作用于匿名内存。
1 | cat /sys/kernel/mm/transparent_hugepage/enabled |
RHEL 9 默认是
always。RHEL 10 请以你机器上的实际输出为准。
三种模式
| 模式 | 含义 |
|---|---|
always | 内核尽可能为符合条件的区域使用大页 |
madvise | 只对进程用 madvise(MADV_HUGEPAGE) 显式标记的区域使用 |
never | 禁用 |
临时切换与持久化
1 | # 临时切换,重启失效 |
怎么确认 THP 真的在工作
1 | # 系统总量 |
thp_fault_fallback 很高,说明内核想给大页但凑不出连续的 2 MiB,退回了小页,这通常是内存碎片的信号。
THP 的代价:延迟抖动
THP 的优点是省心,缺点是不可预测:
- 同步压缩:缺页时如果凑不出连续 2 MiB,内核可能当场整理内存,进程就卡在缺页里。这是延迟尖刺的常见来源;
- 内存膨胀:只用了几 KiB,却被分配了一整个 2 MiB 页。
控制压缩行为的是 defrag:
1 | cat /sys/kernel/mm/transparent_hugepage/defrag |
| 取值 | 行为 |
|---|---|
always | 缺页时一定同步压缩,延迟风险最大 |
defer | 不同步压缩,交给后台线程慢慢整理 |
madvise | 只对 madvise 标记的区域同步压缩(常见默认值) |
never | 从不同步压缩,凑不出就用小页 |
对延迟敏感的场景,可以保留 THP,但把同步压缩关掉:
1 | echo never > /sys/kernel/mm/transparent_hugepage/defrag |
数据库该怎么办
很多数据库厂商会建议关闭 THP,原因就是上面的延迟抖动。但”关了之后用什么”因产品而异:
- Oracle、PostgreSQL 等支持静态 HugeTLB,可以关 THP 并配置静态大页;
- Redis、MongoDB 一般只是建议把 THP 设成
never,并不使用静态大页。
以你用的数据库的官方文档为准,不要一刀切。
怎么选:一张决策图
flowchart TD
A["应用占内存大,<br/>且 TLB 未命中偏高?"] -- 否 --> N["保持默认,不要折腾"]
A -- 是 --> B{"对延迟抖动敏感?<br/>或厂商要求关闭 THP?"}
B -- 是 --> C["THP 设 madvise 或 never<br/>应用支持的话用静态 HugeTLB<br/>启动时预留"]
B -- 否 --> D["THP 用 always 或 madvise<br/>压测前后对比再决定"]记住一点:大页不是万能加速器。 如果 TLB 未命中本来就不高,换大页收益很小,还可能引入新问题。先测量,再调。
常见误区
误区一:VSZ 大说明内存吃紧。
VSZ 只是申请量,看 RSS,更准确地看 PSS。
误区二:把所有进程的 RSS 加起来就是总占用。
共享页会被重复计算,求和会偏大。
误区三:缺页多就是有问题。
次要缺页是正常的”开荒”,持续的主要缺页才值得警惕。
误区四:MemoryLimit= 还能用。
那是 cgroup v1 的旧参数,现在用 MemoryMax= 和 MemoryHigh=。
误区五:nr_hugepages 写多少就加多少。
写入的是总数,而且可能因碎片而不足额。
误区六:预留了大页,所有进程都能用。
只有显式请求的进程才能用,预留的内存对普通进程不可用。
误区七:THP 一定更快。
它可能带来同步压缩的延迟尖刺。
命令速查
| 目的 | 命令 |
|---|---|
| 虚拟、物理地址位数 | lscpu | grep -i 'address sizes' |
| 是否支持五级分页 | grep -o -m1 la57 /proc/cpuinfo |
| 进程内存与缺页 | ps -o pid,vsz,rss,minflt,majflt,comm -p PID |
| 进程 PSS | grep -E '^(Rss|Pss)' /proc/PID/smaps_rollup |
| 系统缺页速率 | sar -B 1 |
| 命令的缺页统计 | /usr/bin/time -v cmd |
| TLB 未命中 | perf stat -e dTLB-load-misses cmd |
| 设置内存上限 | systemctl set-property svc MemoryMax=1G |
| 验证内存上限 | systemctl show svc -p MemoryMax |
| 大页大小与数量 | grep -i huge /proc/meminfo |
| 运行时预留大页 | sysctl vm.nr_hugepages=N |
| 启动时预留大页 | grubby --update-kernel=ALL --args="hugepages=N" |
| 各 NUMA 节点大页 | numastat -cm | egrep 'Node|Huge' |
| THP 模式 | cat /sys/kernel/mm/transparent_hugepage/enabled |
| THP 使用量 | grep AnonHugePages /proc/meminfo |
| THP 压缩行为 | cat /sys/kernel/mm/transparent_hugepage/defrag |
课后思考
- 程序
malloc了 1 GiB 但没有使用,为什么 RSS 几乎不涨?什么时候才会涨? - 为什么把所有进程的 RSS 相加,可能大于物理内存?该用什么指标代替?
- 一次五级页表的地址翻译,最多要访问几次内存?用 2 MiB 大页后变成几次?
- 运行时执行
echo 20 > nr_hugepages两次,最终是 20 还是 40?读回来发现少于 20,原因是什么? - 某数据库偶尔出现几十毫秒的延迟尖刺,THP 是
always,你会先看哪几个指标来判断是不是 THP 引起的?
参考手册
1 | man 5 proc # smaps_rollup、meminfo 等字段 |
