有耳可听的,就应当听 —《马可福音》
周四的休假团建又没有去,不因别的,只因年前东北行休假太多了,想缓缓…不过真实原因也确实因为假期剩余无几了…思考了一些问题,写下本文。
本文的缘起来自于和同事讨论一个关于缺页中断按需调页的讨论。真可谓是三人行必有我师,最近经常能从一些随意的比划或招架中悟出一丝意义,所以非常感谢周围的信息输出者!甚至从小小学校全员禁言的作业群里,我都能每天重温一首古诗词,然后循此生意,去故意制造另一种真实的意境,然后发个朋友圈?~
感谢大家的信息输入,每次收到的好玩的东西,我都会即时整理并重新再输出。
内容简介
本文描述了一个非常显然但却又很少有人知道其所以然的问题,更重要的是分享一种解决问题的思路。PS:这个问题非常好玩。
不搞悬念,本文解释一个事实,即匿名页缺页中断数量和物理页面的分配数量并不是一致的。即便不考虑共享内存的影响,也并非发生一次匿名页缺页中断,就一定会分配一个独立的物理页面。
问题
问题很简单,我把问题抽象成了下面的代码:
#include <stdlib.h>#include <stdio.h>#include <string.h>#include <unistd.h>#define SIZE 100char *addrs[SIZE];char dest[4096][SIZE];int main(){ int i; for (i = 0; i < SIZE; i++) { addrs[i] = malloc(4096); } // 问题:下面读取一系列malloc出来的内容时,会不会产生缺页中断从而调入物理内存?? for (i = 0; i < SIZE; i++) { // 只读addrs[i],并不写入。 memcpy(dest[i], addrs[i], 4096); } getchar();}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
答案看似是显然的,即会产生缺页并调入物理内存。但在同事那里的现象却并非如此,同事展示的结果是在读取malloc刚刚分配的addrs[i]这些内存时,根本没有任何物理内存调入。
我的直觉是,malloc分配过程是C库控制的,细节比较复杂,可能在malloc之后紧接着该内存就被touch了,进而在读取的时候,内存已经被调入了。我建议使用brk以及不带LOCK flag的mmap这种底层系统调用去看个究竟,而不是用malloc。
无论如何,该问题就此告一段落,毕竟我没有给出一个可以落地的解释,只是猜测一种可能性。我没有看过C库的实现,大致知道点C库里有一个链表维护了malloc类似伙伴系统那样的内存池,但细节我并不知,我也没有看过操作系统缺页中断处理的细节,大致知道个处理流程而已,所以我也真的无法给出进一步的解释。事情因此可以预见的结果就是,翻篇了。
然而,事情正在悄悄地起变化…这是为什么?
一种解释
次日上午,同事拿这个问题去请教了一位泰斗级人物,大家无不敬佩的绝顶高手,即为什么read刚刚malloc的内存,却没有发生调页,而对其write一下再read就调页了?
不愧是大师级人物,随即不假思索地给出了一个非常直接的答案所有的内存刚被分配时都被映射到了同一个全零页面,你读它时读的就是那个页面,你写它时会发生写时拷贝…大致就是这么个答案。
我是事后知道这个答案的,但我第一感觉就是,这个答案是不合理的!既然有Lazy page fault机制,内存初始化时将它们映射到同一个页面的意义何在?净平添开销吗?为什么不让Lazy page fault机制统一处理(此时我还不知道Lazy策略是两个层面上的,即When What和How)。当然了,我指的是用户态进程的user内存,对于内核内存而言,确实有这个一个预先映射的机制,毕竟内核(这里说的是Linux内核)内存是常驻的。
既然不认可这个答案,我的答案是什么呢?很简单,我认为这是一个常识,即内核不会为用户进程新分配但没有touch(read或者write)的内存映射任何物理页面。
我决定抽空好好看看这个问题了。耗时下班晚饭后的一个半夜,总结出了下面的文字。
这里先剧透下 细节&答案:
0.谁的解释都没有错,表达的侧重不同,统一看待结果收获更多;
1.分配的虚拟地址空间只要没有被read和write过,内核便不会将其映射到任何物理页面;
2.分配的虚拟地址空间首先被read touch时会发生缺页中断,内核会将其映射到系统保留的zeropage页面,该页面没有写权限;
3.分配的虚拟地址空间第一次被write touch时会发生缺页中断,内核会分配一个独立的物理页面与之建立映射。
.
正确的表达应该是:
所有刚被分配的内存在第一次read的时候,page-fault会将其映射到了同一个全零页面,你读它时读的就是那个页面,你写它时会发生写时拷贝
测试分析的过程
为了便于步步深入,每一步都要很简单。因此我把上面的代码分解为3个步骤:
#include <stdlib.h>#include <stdio.h>#include <string.h>#include <unistd.h>#define SIZE 100char *addrs[SIZE];char dest[4096][SIZE];int main(){ int i; // step 1:先把addrs读的目标内存调入内存,消除误解 printf("step 1\n"); for (i = 0; i < SIZE; i++) { memset(dest[i], 1, 4096); } getchar(); // step 2:使用malloc分配一个page的内存 printf("step 2\n"); for (i = 0; i < SIZE; i++) { addrs[i] = malloc(4096); } getchar(); // step 3:只读malloc分配出来的内存 printf("step 3\n"); for (i = 0; i < SIZE; i++) { memcpy(dest[i], addrs[i], 4096); } getchar();}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
每一个步骤都用getchar来隔离,为了方便我们观测统计数据。编译的时候记着使用-O0,消除编译器带来的误解。现在我们运行./a.out
root@debian:/mnt/toy# ./a.out step 1
- 1
- 2
这个时候在另一个终端来看一下其page fault情况,再进一步观测我们的实验结果前,有必要先解释一下page fault的两种类型,即major page fault和minor page fault。
那么到底major page fault(主缺页中断)和minor page fault(次缺页中断)有什么区别呢?区别在于从哪里把页面调入:
- major page fault:缺页时,需要把数据从磁盘读入新分配的物理页面,比minor fault多了一个IO过程。
- minor page fault:缺页时,仅仅为一个空闲物理页面增加一个映射,即分配一个匿名页面。
为了简单直白,我使用swapoff -a将所有交换关闭,这样便不会有换入换出的磁盘IO操作,并且我也不会去map文件,因此本文的实验将不会涉及major page fault。
好的,让我们看看具体的page fault情况:
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'` MAJFL MINFL 0 79
- 1
- 2
- 3
然后在a.out界面敲入回车,再次观测malloc之后的统计数据:
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'` MAJFL MINFL 0 180
- 1
- 2
- 3
嗯,多了大概100个中断,调入了100个页面。此时我们可以通过/proc/ps -e|grep a.out|awk '{print $1}'
/status中的vmRSS字段来确认物理内存的增持情况(如果你发现了异常,先不要惊慌,带着问题听我把故事讲完)。再次键入回车进入step 3后继续观测malloc的内存被read后的统计数据:
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'` MAJFL MINFL 0 181
- 1
- 2
- 3
确实在read malloc分配的内存时并没有新的缺页中断,因此便没有发生调页!
发生了什么?为什么在malloc的时候发生了调页,而在memcpy的时候却没有?按照操作系统Lazy页面调度的原则来讲,只是malloc内存的话,并不会映射物理页面啊!为此我们打印出更多的细节:
#include <stdlib.h>#include <stdio.h>#include <string.h>#include <unistd.h>#define SIZE 100char *addrs[SIZE];char dest[4096][SIZE];int main(){ int i; char *last = NULL; // step 1: printf("step 1\n"); for (i = 0; i < SIZE; i++) { memset(dest[i], 1, 4096); } getchar(); // step 2: printf("step 2\n"); for (i = 0; i < SIZE; i++) { int j, gap; char *ae; addrs[i] = malloc(4096); ae = addrs[i]; gap = last?(ae-last):0; // 我们看一下连续两次malloc的地址是否连续 printf("alloc:%p gap:%0x\n", addrs[i], gap); // 如果不连续,我们看看中间有什么 for (j = 0; i < 3 && j < gap; j++) { printf("%0x ", ae[j]); } last = ae; printf("\n"); } getchar(); // step 3: printf("step 3\n"); for (i = 0; i < SIZE; i++) { memcpy(dest[i], addrs[i], 4096); } getchar();}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
打印出的结果如下(删减了大部分的0,不然太大):
root@debian:/mnt/toy# ./a.out step 1step 2alloc:0x55813ea46830 gap:0alloc:0x55813ea47840 gap:1010# 4096个字节属于malloc的内容# 0x1010-0x1000 = 0x10个字节是什么?0 0 0 0 0 0 0 0 ffffffc1 ffffffe7 1 0 0 0 0