众所周知,CPU 不能直接和硬盘进行交互。CPU 所作的一切运算,都是通过 CPU 缓存间接与内存进行操作的。若是 CPU 请求的内存数据在物理内存中不存在,那么 CPU 就会报告「缺页错误(Page Fault)」,提示内核。
在内核处理缺页错误时,就有可能进行磁盘的读写操作。这样的操作,相对 CPU 的处理是非常缓慢的。因此,发生大量的缺页错误,势必会对程序的性能造成很大影响。因此,在对性能要求很高的环境下,应当尽可能避免这种情况。
此篇介绍缺页错误本身,并结合一个实际示例作出一些实践分析。这里主要在 Linux 的场景下做讨论;其他现代操作系统,基本也是类似的。
一、内存页和缺页错误
1、分页模式
我们在前作内存寻址中介绍了 CPU 发展过程中内存寻址方式的变化。现代 CPU 都支持分段和分页的内存寻址模式。出于寻址能力的考虑,现代操作系统,也顺应着都支持段页式的内存管理模式。当然,虽然支持段页式,但是 Linux 中只启用了段基址为 0 的段。也就是说,在 Linux 当中,实际起作用的只有分页模式。
具体来说,分页模式在逻辑上将虚拟内存和物理内存同时等分成固定大小的块。这些块在虚拟内存上称之为「页」,而在物理内存上称之为「页帧」,并交由 CPU 中的 MMU 模块来负责页帧和页之间的映射管理。
引入分页模式的好处,可以大致概括为两个方面:
允许虚存空间远大于实际物理内存大小的情况。这是因为,分页之后,操作系统读入磁盘的文件时,无需以文件为单位全部读入,而可以以内存页为单位,分片读入。同时,考虑到 CPU 不可能一次性需要使用整个内存中的数据,因此可以交由特定的算法,进行内存调度:将长时间不用的页帧内的数据暂存到磁盘上。
减少了内存碎片的产生。这是因为,引入分页之后,内存的分配管理都是以页大小(通常是 4KiB,扩展分页模式下是 4MiB)为单位的;虚拟内存中的页总是对应物理内存中实际的页帧。这样一来,在虚拟内存空间中,页内连续的内存在物理内存上也一定是连续的,不会产生碎片。
2、缺页错误
当进程在进行一些计算时,CPU 会请求内存中存储的数据。在这个请求过程中,CPU 发出的地址是逻辑地址(虚拟地址),然后交由 CPU 当中的 MMU 单元进行内存寻址,找到实际物理内存上的内容。若是目标虚存空间中的内存页(因为某种原因),在物理内存中没有对应的页帧,那么 CPU 就无法获取数据。这种情况下,CPU 是无法进行计算的,于是它就会报告一个缺页错误(Page Fault)。
因为 CPU 无法继续进行进程请求的计算,并报告了缺页错误,用户进程必然就中断了。这样的中断称之为缺页中断。在报告 Page Fault 之后,进程会从用户态切换到系统态,交由操作系统内核的 Page Fault Handler 处理缺页错误。
1、缺页错误的分类和处理
基本来说,缺页错误可以分为两类:硬缺页错误(Hard Page Fault)和软缺页错误(Soft Page Fault)。这里,前者又称为主要缺页错误(Major Page Fault);后者又称为次要缺页错误(Minor Page Fault)。当缺页中断发生后,Page Fault Handler 会判断缺页的类型,进而处理缺页错误,最终将控制权交给用户态代码。
若是此时物理内存里,已经有一个页帧正是此时 CPU 请求的内存页,那么这是一个软缺页错误;于是,Page Fault Hander 会指示 MMU 建立相应的页帧到页的映射关系。这一操作的实质是进程间共享内存——比如动态库(共享对象),比如 mmap 的文件。
若是此时物理内存中,没有相应的页帧,那么这就是一个硬缺页错误;于是 Page Fault Hander 会指示 CPU,从已经打开的磁盘文件中读取相应的内容到物理内存,而后交由 MMU 建立这份页帧到页的映射关系。
不难发现,软缺页错误只是在内核态里轻轻地走了一遭,而硬缺页错误则涉及到磁盘 I/O。因此,处理起来,硬缺页错误要比软缺页错误耗时长得多。这就是为什么我们要求高性能程序必须在对外提供服务时,尽可能少地发生硬缺页错误。
除了硬缺页错误和软缺页错误之外,还有一类缺页错误是因为访问非法内存引起的。前两类缺页错误中,进程尝试访问的虚存地址尚为合法有效的地址,只是对应的物理内存页帧没有在物理内存当中。后者则不然,进程尝试访问的虚存地址是非法无效的地址。比如尝试对 nullptr 解引用,就会访问地址为 0x0 的虚存地址,这是非法地址。此时 CPU 报出无效缺页错误(Invalid Page Fault)。操作系统对无效缺页错误的处理各不相同:Windows 会使用异常机制向进程报告;nix 则会通过向进程发送 SIGSEGV 信号(11),引发内存转储。
2、缺页错误的原因
之前提到,物理内存中没有 CPU 所需的页帧,就会引发缺页错误。这一现象背后的原因可能有很多。
例如说,进程通过 mmap 系统调用,直接建立了磁盘文件和虚拟内存的映射关系。然而,在 mmap 调用之后,并不会立即从磁盘上读取这一文件。而是在实际需要文件内容时,通过 CPU 触发缺页错误,要求 Page Fault Handler 去将文件内容读入内存。
又例如说,一个进程启动了很久,但是长时间没有活动。若是计算机处在很高的内存压力下,则操作系统会将这一进程长期未使用的页帧内容,从物理内存转储到磁盘上。这个过程称为换出(swap out)。在 nix 系统下,用于转储这部分内存内容的磁盘空间,称为交换空间;在 Windows 上,这部分磁盘空间,则被称为虚拟内存,对应磁盘上的文件则称为页面文件。在这个过程中,进程在内存中保存的任意内容,都可能被换出到交换空间:可以是数据内容,也可以是进程的代码段内容。
二、Linux 查看缺页错误
ps 是一个强大的命令,我们可以用 -o 选项指定希望关注的项目。比如
- min_flt: 进程启动至今软缺页中断数量;
- maj_flt: 进程启动至今硬缺页中断数量;
- cmd: 执行的命令;
- args: 执行的命令的参数(从 00 开始);
- uid: 执行命令的用户的 ID;
- gid: 执行命令的用户所在组的 ID。
因此,我们可以用 ps -o min_flt,maj_flt,cmd,args,uid,gid 1
来观察进程号为 1 的进程的缺页错误。
# ps -o min_flt,maj_flt,cmd,args,uid,gid 1
MINFL MAJFL CMD COMMAND UID GID
5833661 55 /usr/lib/systemd/systemd -- /usr/lib/systemd/systemd -- 0 0
结合 watch 命令,则可关注进程当前出发缺页中断的状态。
# watch -n 1 -difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"
Every 1.0s: ps -o min_flt,maj_flt,cmd,args,uid,gid 1
MINFL MAJFL CMD COMMAND UID GID
5833664 55 /usr/lib/systemd/systemd -- /usr/lib/systemd/systemd -- 0 0
你还可以结合 sort 命令,动态观察产生缺页错误最多的几个进程。
# watch -n 1 "ps -eo min_flt,maj_flt,cmd,args,uid,gid | sort -nrk1 | head -n 8"
Every 1.0s: ps -eo min_flt,maj_flt,cmd,args,uid,gid | sort -nrk1 | head -n 8
972911995 1 /data/blackbox_exporter/bla /data/blackbox_exporter/bla 1005 1005
356965558 0 CmsGoAgent-Worker start CmsGoAgent-Worker start 0 0
113476933 1575 /usr/lib/systemd/systemd-jo /usr/lib/systemd/systemd-jo 0 0
53831282 17 /bin/prometheus --config.fi /bin/prometheus --config.fi 0 0
25127180 0 /bin/mysqld_exporter --coll /bin/mysqld_exporter --coll 65534 65534
21715344 350 /usr/bin/containerd /usr/bin/containerd 0 0
10796997 0 /usr/bin/containerd-shim-ru /usr/bin/containerd-shim-ru 0 0
10043755 0 /usr/bin/containerd-shim-ru /usr/bin/containerd-shim-ru 0 0
评论区