0%

内存缺页错误

缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。


转载来源

1
2
作者:Liam Huang
链接:https://liam.page/2017/09/01/page-fault/

前言

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

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

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

内存页和缺页错误

现代 CPU 都支持分段和分页的内存寻址模式。在 Linux 当中,实际起作用的只有分页模式。

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

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

  • 允许虚存空间远大于实际物理内存大小的情况。这是因为,分页之后,操作系统读入磁盘的文件时,无需以文件为单位全部读入,而可以以内存页为单位,分片读入。同时,考虑到 CPU 不可能一次性需要使用整个内存中的数据,因此可以交由特定的算法,进行内存调度: 将长时间不用的页帧内的数据暂存到磁盘上。
  • 减少了内存碎片的产生。这是因为,引入分页之后,内存的分配管理都是以页大小(通常是 4KiB,扩展分页模式下是 4MiB)为单位的;虚拟内存中的页总是对应物理内存中实际的页帧。这样一来,在虚拟内存空间中,页内连续的内存在物理内存上也一定是连续的,不会产生碎片。

缺页错误

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

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

观察缺页错误

通过top 命令查看观察软中断和硬中断的 CPU 使用率,hi(hard interrupt) 表示硬中断、si(soft interrupt) 表示软中断

1
%Cpu(s): 53.3 us,  1.2 sy,  0.0 ni, 44.6 id,  0.9 wa,  0.0 hi,  0.1 si,  0.0 st

通过mpstat -P ALL 2 每隔两秒查看下所有核状态信息,其中%irq为硬中断,%soft为软中断,如果硬中断比较高可以看看是不是大量读取磁盘引起的(iostat -d 1每隔1s 查看磁盘读写速度)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ mpstat -P ALL 2
Linux 3.10.0-862.14.4.el7.x86_64 (region-0-0-122) 2020年02月07日 _x86_64_ (4 CPU)

16时23分31秒 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16时23分33秒 all 52.64 0.00 0.88 0.38 0.00 0.00 0.00 0.00 0.00 46.10
16时23分33秒 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分33秒 1 4.06 0.00 1.52 1.52 0.00 0.00 0.00 0.00 0.00 92.89
16时23分33秒 2 99.50 0.00 0.50 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分33秒 3 5.08 0.00 1.52 0.00 0.00 0.00 0.00 0.00 0.00 93.40

16时23分33秒 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16时23分35秒 all 52.41 0.00 1.01 0.13 0.00 0.13 0.00 0.00 0.00 46.33
16时23分35秒 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分35秒 1 4.57 0.00 2.54 0.51 0.00 0.00 0.00 0.00 0.00 92.39
16时23分35秒 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分35秒 3 3.55 0.00 2.03 0.51 0.00 0.51 0.00 0.00 0.00 93.40

16时23分35秒 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16时23分37秒 all 54.74 0.00 1.52 0.13 0.00 0.25 0.00 0.00 0.00 43.36
16时23分37秒 0 99.50 0.00 0.50 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分37秒 1 9.28 0.00 2.06 0.00 0.00 0.52 0.00 0.00 0.00 88.14
16时23分37秒 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
16时23分37秒 3 8.16 0.00 3.57 0.00 0.00 0.51 0.00 0.00 0.00 87.76

dstat可以查看 CPU 软硬中断次数,hiq、siq分别为硬中断和软中断次数。

1
2
3
4
5
6
7
$ dstat
You did not select any stats, using -cdngy by default.
----total-cpu-usage---- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai hiq siq| read writ| recv send| in out | int csw
62 1 36 0 0 0| 84k 355k| 0 0 | 0 0 |5766 4315
52 1 47 0 0 0| 0 232k| 21k 54k| 0 0 |5279 4004
52 1 47 0 0 0| 0 56k| 15k 22k| 0 0 |5327 4045

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

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

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

1
2
3
$ ps -o min_flt,maj_flt,cmd,args,uid,gid 14434
MINFL MAJFL CMD COMMAND UID GID
2413550 355 /usr/java/jdk1.8.0_181-clou /usr/java/jdk1.8.0_181-clou 981 978

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

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

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

1
2
3
4
5
6
7
8
9
10
11
$ 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 Fri Feb 7 17:18:43 2020

437395316 262 /usr/bin/python2 /opt/cloud /usr/bin/python2 /opt/cloud 0 0
69510242 75 /bin/bash /opt/cloudera/cm- /bin/bash /opt/cloudera/cm- 981 978
23151349 1164 /usr/local/aegis/aegis_clie /usr/local/aegis/aegis_clie 0 0
5772594 1197 /usr/lib/systemd/systemd-jo /usr/lib/systemd/systemd-jo 0 0
2902146 46 /usr/sbin/irqbalance --fore /usr/sbin/irqbalance --fore 0 0
2413598 355 /usr/java/jdk1.8.0_181-clou /usr/java/jdk1.8.0_181-clou 981 978
1591327 619 /usr/java/jdk1.8.0_181-clou /usr/java/jdk1.8.0_181-clou 997 995
1588418 197 /usr/lib/systemd/systemd -- /usr/lib/systemd/systemd -- 0 0

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

我司的某一高性能服务采取了 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。