1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com

上一篇我们讲了 CPU 缓存:缓存没命中,请求才会一路走到内存。那么问题来了:走到内存以后,又发生了什么?

先看一个现象。你在 8 GB 的机器上,让程序 malloc 了 1 GB,ps 一看:

1
2
VSZ  1050000
RSS 1200

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
2
3
[root@localhost ~]# cat /sys/devices/system/cpu/vulnerabilities/meltdown
Not affected
# Not affected:不需要 KPTI;Mitigation: PTI:已开启

虚拟地址空间到底有多大

很多资料会说”64 位系统的地址空间是 2⁶⁴,也就是 16 EiB”。这是指针的宽度,不是你能用的空间。

x86_64 实际只使用其中一部分:

模式有效虚拟地址位总虚拟空间其中用户态页表层级
四级分页(主流)48 位256 TiB128 TiB4 级
五级分页(新 CPU)57 位128 PiB64 PiB5 级

剩下的高位不是随便”保留”,而是要求做符号扩展(规范地址):高位必须和最高有效位一致,否则 CPU 直接报异常。所以你会看到用户地址总是 0x00007f...,内核地址总是 0xffff...。

查看你的机器:

1
2
3
4
5
6
[root@localhost ~]# lscpu | grep -i 'address sizes'
Address sizes: 45 bits physical, 48 bits virtual


[root@localhost ~]# grep -o -m1 la57 /proc/cpuinfo
# 有输出,说明 CPU 支持五级分页,我的没输出,就说明不支持

两个小提醒:

  • 物理位数和虚拟位数是两回事。 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
2
3
4
5
6
7
8
9
10
11
12
[root@localhost ~]# perf stat -e dTLB-load-misses,iTLB-load-misses sleep 1000000
^Csleep: Interrupt

Performance counter stats for 'sleep 1000000':

0 dTLB-load-misses
0 iTLB-load-misses

8.438337754 seconds time elapsed

0.000000000 seconds user
0.000925000 seconds sys

和上一篇一样:虚拟机里需要开启 vPMU,否则这些事件显示 <not supported>,并不代表没有未命中。

进程占了多少内存:VSZ、RSS、PSS

回到开头的问题。进程申请内存时,内核只是给了它一段虚拟地址,并不会立刻分配物理页框。等进程真正去读写这块内存时,才会触发分配。

所以工具里有两个口径:

指标含义
VSZ / VIRT进程申请的虚拟内存总量
RSS / RES当前真正映射在物理内存里的部分
1
2
3
[root@localhost ~]# ps -o pid,vsz,rss,minflt,majflt,comm -p 1019
PID VSZ RSS MINFLT MAJFLT COMMAND
1019 8792 6260 856 6 sshd

动手做个实验

写一个”先申请、后使用”的小程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vm_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void) {
size_t sz = 1UL << 30; // 1 GiB
char *p = malloc(sz); // 只是申请,没有使用
printf("pid=%d,已申请 1 GiB,还没碰\n", getpid());
fflush(stdout);
sleep(30);

memset(p, 1, sz / 2); // 真正写入一半
printf("已写入 512 MiB\n");
fflush(stdout);
sleep(30);
return 0;
}
1
2
3
4
5
6
7
8
9
10
[root@localhost ~]# gcc -O1 -o vm_demo vm_demo.c
[root@localhost ~]# ./vm_demo
pid=186943,已申请 1 GiB,还没碰

# 立刻看一次
[root@localhost ~]# ps -o pid,vsz,rss,minflt,majflt,comm -p 186943
PID VSZ RSS MINFLT MAJFLT COMMAND
186943 2444 1708 86 0 vm_demo

# 30 秒后再看一次

你会看到:

  • 第一次:VSZ 约 1 GiB,RSS 只有几 MB;
  • 第二次:RSS 涨到约 512 MiB,minflt(次要缺页)也随之增加。

如果你的 THP 是 always,第二次的 minflt 增量可能比”512 MiB ÷ 4 KiB ≈ 13 万次”小得多,因为一次缺页就能拿到一个 2 MiB 大页。这个现象后面讲 THP 时你会再遇到,具体数字以你的实测为准。

为什么申请了这么多也不报错

1
2
[root@localhost ~]# cat /proc/sys/vm/overcommit_memory
0

默认值 0 是启发式超配:内核只拒绝”明显离谱”的申请,其余先答应下来。所以才有**”申请成功,用的时候才 OOM”**这种情况。

RSS 的一个陷阱:共享页重复计数

多个进程共用同一个库,这个库的物理页只有一份,但每个进程的 RSS 里都会把它算一遍。所以:

把所有进程的 RSS 加起来,往往比真实占用大。

想看进程的”公平份额”,用 PSS(共享页按使用者数量均摊):

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@localhost ~]# grep -E '^(Rss|Pss|Shared_|Private_)' /proc/1019/smaps_rollup
Rss: 6264 kB
Pss: 1841 kB
Pss_Dirty: 1176 kB
Pss_Anon: 1176 kB
Pss_File: 665 kB
Pss_Shmem: 0 kB
Shared_Clean: 4752 kB
Shared_Dirty: 0 kB
Private_Clean: 336 kB
Private_Dirty: 1176 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB

限制进程的内存: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
2
3
4
5
6
7
8
9
# 立即生效并持久化(写入 /etc/systemd/system.control/)
systemctl set-property myapp.service MemoryMax=1G MemoryHigh=900M

# 只想临时生效,重启后恢复
systemctl set-property --runtime myapp.service MemoryMax=1G

# 验证
systemctl show myapp.service -p MemoryMax -p MemoryHigh
cat /sys/fs/cgroup/system.slice/myapp.service/memory.max

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
2
3
4
5
6
7
8
9
10
11
12
# 每个进程累计的缺页数(从进程启动开始计)
ps -o pid,minflt,majflt,comm -p <PID>

# 一个命令跑完后的统计
/usr/bin/time -v ./your_app 2>&1 | grep -i 'page faults'

# 系统级,每秒的缺页(sysstat)
sar -B 1
# 关注 pgfault/s 和 majflt/s

# perf,软件事件,虚拟机里也能用
perf stat -e minor-faults,major-faults ./your_app

判断标准: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
2
grep -i hugepagesize /proc/meminfo
# Hugepagesize: 2048 kB

RHEL 有两种大页机制:静态大页(HugeTLB)和透明大页(THP)。

静态大页 HugeTLB:先预留,再使用

静态大页要提前预留,而且只有显式请求的进程才能用。预留出来的内存,普通进程就用不了了,free 里会把它算作 used。

方式一:启动时预留(推荐)

启动时内存还没碎片化,成功率最高。用 grubby 写进所有内核条目:

1
2
# 预留 10 个默认大小(2 MiB)的大页
grubby --update-kernel=ALL --args="hugepages=10"

要同时预留 2 MiB 和 1 GiB 两种大页:

1
2
grubby --update-kernel=ALL \
--args="hugepagesz=2M hugepages=10 hugepagesz=1G hugepages=1"

每个 hugepages= 都归属于它前面最近的 hugepagesz=,所以顺序很重要。漏写第二个 hugepagesz=1G,那个 hugepages=1 就会被当成继续设置 2 MiB 页,这是个很隐蔽的坑。

重启后验证:

1
2
cat /proc/cmdline
grep -i huge /proc/meminfo

1 GiB 大页需要 CPU 支持,可用 grep -o -m1 pdpe1gb /proc/cpuinfo 确认。1 GiB 大页也强烈建议只在启动时预留,运行时很难凑出连续的 1 GiB 空间。

方式二:运行时预留

1
2
3
# 全局设置:预留 20 个 2 MiB 页
sysctl vm.nr_hugepages=20
grep -i huge /proc/meminfo

预期能看到:

1
2
3
4
HugePages_Total:      20
HugePages_Free: 20
Hugepagesize: 2048 kB
Hugetlb: 40960 kB

在多 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
2
echo "vm.nr_hugepages = 20" > /etc/sysctl.d/90-hugepages.conf
sysctl --system

进程怎么用大页

进程要显式请求,常见方式:

  • mmap() 加 MAP_HUGETLB 标志(匿名映射,不需要挂载 hugetlbfs);
  • 在 hugetlbfs 上创建文件再 mmap(),这种才需要挂载,RHEL 上 systemd 会自动挂在 /dev/hugepages;
  • shmget() 加 SHM_HUGETLB。

动手验证:没预留会怎样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// hugetlb_demo.c
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main(void) {
size_t sz = 4UL << 20; // 4 MiB,即 2 个大页
void *p = mmap(NULL, sz, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
memset(p, 1, sz);
printf("已使用 %zu MiB 大页,pid=%d\n", sz >> 20, getpid());
fflush(stdout);
sleep(60);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
gcc -O1 -o hugetlb_demo hugetlb_demo.c

# 第一步:没有预留,直接跑
sysctl vm.nr_hugepages=0
./hugetlb_demo # 报 mmap: Cannot allocate memory

# 第二步:预留后再跑,另开一个终端观察
sysctl vm.nr_hugepages=20
./hugetlb_demo &
grep -E 'HugePages_(Total|Free|Rsvd)' /proc/meminfo
# HugePages_Free 会比 Total 少 2

这个实验能直观说明:静态大页是”要才有,没预留就没有”,不会像普通内存那样自动兜底。

透明大页 THP:内核自动帮你做

THP 不需要预留,内核会自动给符合条件的内存区域用 2 MiB 页,应用不用改代码。它主要作用于匿名内存。

1
2
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never 方括号里是当前模式

RHEL 9 默认是 always。RHEL 10 请以你机器上的实际输出为准。

三种模式

模式含义
always内核尽可能为符合条件的区域使用大页
madvise只对进程用 madvise(MADV_HUGEPAGE) 显式标记的区域使用
never禁用

临时切换与持久化

1
2
3
4
5
6
# 临时切换,重启失效
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

# 持久化:写进内核启动参数
grubby --update-kernel=ALL --args="transparent_hugepage=madvise"
reboot

怎么确认 THP 真的在工作

1
2
3
4
5
6
7
8
# 系统总量
grep AnonHugePages /proc/meminfo

# 某个进程
grep AnonHugePages /proc/<PID>/smaps_rollup

# 内核统计:成功分配多少、回退到小页多少、压缩停顿多少
grep -E 'thp_fault_alloc|thp_fault_fallback|compact_stall' /proc/vmstat

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
进程 PSSgrep -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

课后思考

  1. 程序 malloc 了 1 GiB 但没有使用,为什么 RSS 几乎不涨?什么时候才会涨?
  2. 为什么把所有进程的 RSS 相加,可能大于物理内存?该用什么指标代替?
  3. 一次五级页表的地址翻译,最多要访问几次内存?用 2 MiB 大页后变成几次?
  4. 运行时执行 echo 20 > nr_hugepages 两次,最终是 20 还是 40?读回来发现少于 20,原因是什么?
  5. 某数据库偶尔出现几十毫秒的延迟尖刺,THP 是 always,你会先看哪几个指标来判断是不是 THP 引起的?

参考手册

1
2
3
4
5
6
7
man 5 proc               # smaps_rollup、meminfo 等字段
man 7 cgroups
man systemd.resource-control
man perf-stat
man numastat
man mmap
man madvise