程序员的自我修养(七):内存缺页错误

众所周知,CPU 不能直接和硬盘进行交互。CPU 所作的一切运算,都是通过 CPU 缓存间接与内存进行操作的。若是 CPU 请求的内存数据在物理内存中不存在,那么 CPU 就会报告「缺页错误(Page Fault)」,提示内核。

在内核处理缺页错误时,就有可能进行磁盘的读写操作。这样的操作,相对 CPU 的处理是非常缓慢的。因此,发生大量的缺页错误,势必会对程序的性能造成很大影响。因此,在对性能要求很高的环境下,应当尽可能避免这种情况。

此篇介绍缺页错误本身,并结合一个实际示例作出一些实践分析。这里主要在 Linux 的场景下做讨论;其他现代操作系统,基本也是类似的。

内存页和缺页错误

分页模式

我们在前作内存寻址中介绍了 CPU 发展过程中内存寻址方式的变化。现代 CPU 都支持分段和分页的内存寻址模式。出于寻址能力的考虑,现代操作系统,也顺应着都支持段页式的内存管理模式。当然,虽然支持段页式,但是 Linux 中只启用了段基址为 0 的段。也就是说,在 Linux 当中,实际起作用的只有分页模式。

具体来说,分页模式在逻辑上将虚拟内存和物理内存同时等分成固定大小的块。这些块在虚拟内存上称之为「页」,而在物理内存上称之为「页帧」,并交由 CPU 中的 MMU 模块来负责页帧和页之间的映射管理。

引入分页模式的好处,可以大致概括为两个方面:

  • 扩大了 CPU 的寻址空间大小。这是因为,以 4KiB 为页大小时,CPU 的寻址单位从 1Byte 增加到 4KiB;因此,寻址空间扩大了 4096 倍。对于 32bit 的地址总线,寻址空间就从 1MiB 扩大到了 4GiB。
  • 允许虚存空间远大于实际物理内存大小的情况。这是因为,分页之后,操作系统读入磁盘的文件时,无需以文件为单位全部读入,而可以以内存页为单位,分片读入。同时,考虑到 CPU 不可能一次性需要使用整个内存中的数据,因此可以交由特定的算法,进行内存调度:将长时间不用的页帧内的数据暂存到磁盘上。

缺页错误

当进程在进行一些计算时,CPU 会请求内存中存储的数据。在这个请求过程中,CPU 发出的地址是逻辑地址(虚拟地址),然后交由 CPU 当中的 MMU 单元进行内存寻址,找到实际物理内存上的内容。若是目标虚存空间中的内存页(因为某种原因),在物理内存中没有对应的页帧,那么 CPU 就无法获取数据。这种情况下,CPU 是无法进行计算的,于是它就会报告一个缺页错误(Page Fault)。

因为 CPU 无法继续进行进程请求的计算,并报告了缺页错误,用户进程必然就中断了。这样的中断称之为缺页中断。在报告 Page Fault 之后,进程会从用户态切换到系统态,交由操作系统内核的 Page Fault Handler 处理缺页错误。

缺页错误的分类和处理

基本来说,缺页错误可以分为两类:硬缺页错误(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),引发内存转储

缺页错误的原因

之前提到,物理内存中没有 CPU 所需的页帧,就会引发缺页错误。这一现象背后的原因可能有很多。

例如说,进程通过 mmap 系统调用,直接建立了磁盘文件和虚拟内存的映射关系。然而,在 mmap 调用之后,并不会立即从磁盘上读取这一文件。而是在实际需要文件内容时,通过 CPU 触发缺页错误,要求 Page Fault Handler 去将文件内容读入内存。

又例如说,一个进程启动了很久,但是长时间没有活动。若是计算机处在很高的内存压力下,则操作系统会将这一进程长期未使用的页帧内容,从物理内存转储到磁盘上。这个过程称为换出(swap out)。在 *nix 系统下,用于转储这部分内存内容的磁盘空间,称为交换空间;在 Windows 上,这部分磁盘空间,则被称为虚拟内存,对应磁盘上的文件则称为页面文件。在这个过程中,进程在内存中保存的任意内容,都可能被换出到交换空间:可以是数据内容,也可以是进程的代码段内容。

Windows 用户看到这里,应该能明白这部分空间为什么叫做「虚拟内存」——因为它于真实的内存条相对,是在硬盘上虚拟出来的一份内存。通过这样的方式,「好像」将内存的容量扩大了。同样,为什么叫「页面文件」也一目了然。因为事实上,文件内保存的就是一个个内存页帧。在 Windows 上经常能观察到「假死」的现象,就和缺页错误有关。这种现象,实际就是长期不运行某个程序,导致程序对应的内存被换出到磁盘;在需要响应时,由于需要从磁盘上读取大量内容,导致响应很慢,产生假死现象。这种现象发生时,若是监控系统硬错误数量,就会发现在短时间内,目标进程产生了大量的硬错误。

在 Windows XP 流行的年代,有很多来路不明的「系统优化建议」。其中一条就是「扩大页面文件的大小,有助于加快系统速度」。事实上,这种方式只能加大内存「看起来」的容量,却给内存整体(将物理内存和磁盘页面文件看做一个整体)的响应速度带来了巨大的负面影响。因为,尽管容量增大了,但是访问这部分增大的容量时,进程实际上需要先陷入内核态,从磁盘上读取内容做好映射,再继续执行。更有甚者,这些建议会要求「将页面文件分散在多个不同磁盘分区」,并美其名曰「分散压力」。事实上,从页面文件中读取内存页帧本就已经很慢;若是还要求磁盘不断在不同分区上寻址,那就更慢了。可见谣言害死人。

观察缺页错误

Windows 系统

相对于任务管理器,Windows 的资源监视器知之者甚少。Windows 的资源监视器,可以实时显示一系列硬件、软件资源的适用情况。硬件资源包括 CPU、内存、磁盘和网络;软件资源则是文件句柄和模块。用户可以在启动窗口中,以 resmon.exe 启动资源监视器(Vista 里是 perfmon.exe)。或是由开始按钮→所有程序→辅助程序→系统工具→资源监视器打开。

在内存资源监视标签中,有「硬错误/秒」或者「硬中断/秒」的监控项。若是一直打开资源监视器,以该项降序排列所有进程,则在发现程序卡顿、假死时,能观察到大量硬错误爆发性产生。

上图是 Outlook 长时间不适用后,用户主动切换到 Outlook 时的情形。此时 Outlook 呈现假死状态,同时观察到 Outlook 触发了大量的硬缺页错误。

Linux 系统

ps 是一个强大的命令,我们可以用 -o 选项指定希望关注的项目。比如

  • min_flt: 进程启动至今软缺页中断数量;
  • maj_flt: 进程启动至今硬缺页中断数量;
  • cmd: 执行的命令;
  • args: 执行的命令的参数(从 $0$ 开始);
  • uid: 执行命令的用户的 ID;
  • gid: 执行命令的用户所在组的 ID。

因此,我们可以用 ps -o min_flt,maj_flt,cmd,args,uid,gid 1 来观察进程号为 1 的进程的缺页错误。

1
2
3
$ ps -o min_flt,maj_flt,cmd,args,uid,gid 1
MINFL MAJFL CMD COMMAND UID GID
3104 41 /sbin/init /sbin/init 0 0

结合 watch 命令,则可关注进程当前出发缺页中断的状态。

1
watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid 1"

你还可以结合 sort 命令,动态观察产生缺页错误最多的几个进程。

1
2
3
4
5
6
7
8
9
10
11
12
$ 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

3027665711 1 tmux -2 new -s yu-ws tmux -2 new -s yu-ws 19879 19879
1245846907 9082 tmux tmux 20886 20886
1082463126 57 /usr/local/bin/tmux /usr/local/bin/tmux 5638 5638
868590907 2 irqbalance irqbalance 0 0
662275941 289831 tmux tmux 2612 2612
424339087 247 perl ./bin_agent/bin/aos_cl perl ./bin_agent/bin/aos_cl 0 0
200045670 0 /bin/bash ./t.sh /bin/bash ./t.sh 12498 12498
151206845 10335 tmux new -s dev tmux new -s dev 16629 16629

这是公司开发服务器上的一瞥,不难发现,我司有不少 tmux 用户。(笑)

一个硬缺页错误导致的问题

我司的某一高性能服务采取了 mmap 的方式,从磁盘加载大量数据。由于调研测试需要,多名组内成员共享一台调研机器。现在的问题是,当共享的人数较多时,新启动的服务进程会在启动时耗费大量时间——以几十分钟计。那么,这是为什么呢?

因为涉及到公司机密,这里不方便给截图。留待以后,做模拟实验后给出。

top 命令观察,机器卡顿时,CPU 负载并不高:32 核只有 1.3 左右的 1min 平均负载。但是,iostat 观察到,磁盘正在以 10MiB/s 级别的速度,不断进行读取。由此判断,这种情况下,目标进程一定有大量的 Page Fault 产生。使用上述 watch -n 1 --difference "ps -o min_flt,maj_flt,cmd,args,uid,gid <pid>" 观察,发现目标进程确实有大量硬缺页错误产生,肯定了这一推断。

然而,诚然进程需要载入大量数据,但是以 mmap 的方式映射,为何会已有大量同类服务存在的情况下,大量读取硬盘呢?这就需要更加深入的分析了。

事实上,这里隐含了一个非常细小的矛盾。一方面,该服务需要从磁盘加载大量数据;另一方面,该服务对性能要求非常高。我们知道,mmap 只是对文件做了映射,不会在调用 mmap 时立即将文件内容加载进内存。这就导致了一个问题:当服务启动对外提供服务时,可能还有数据未能加载进内存;而这种加载是非常慢的,严重影响服务性能。因此,可以推断,为了解决这个问题,程序必然在 mmap 之后,尝试将所有数据加载进物理内存。

这样一来,先前遇到的现象就很容易解释了。

  • 一方面,因为公用机器的人很多,必然造成内存压力大,从而存在大量换出的内存;
  • 另一方面,新启动的进程,会逐帧地扫描文件;
  • 这样一来,新启动的进程,就必须在极大的内存压力下,不断逼迫系统将其它进程的内存换出,而后换入自己需要的内存,不断进行磁盘 I/O;
  • 故此,新启动的进程会耗费大量时间进行不必要的磁盘 I/O。