Linux系统下深究一个malloc/brk/sbrk新内存后的page fault问题

https://blog.csdn.net/dog250/article/details/80331590

周四的休假团建又没有去,不因别的,只因年前东北行休假太多了,想缓缓…不过真实原因也确实因为假期剩余无几了…思考了一些问题,写下本文。

  本文的缘起来自于和同事讨论一个关于缺页中断按需调页的讨论。真可谓是三人行必有我师,最近经常能从一些随意的比划或招架中悟出一丝意义,所以非常感谢周围的信息输出者!甚至从小小学校全员禁言的作业群里,我都能每天重温一首古诗词,然后循此生意,去故意制造另一种真实的意境,然后发个朋友圈?~

  感谢大家的信息输入,每次收到的好玩的东西,我都会即时整理并重新再输出。

内容简介

本文描述了一个非常显然但却又很少有人知道其所以然的问题,更重要的是分享一种解决问题的思路。PS:这个问题非常好玩。

  不搞悬念,本文解释一个事实,即匿名页缺页中断数量和物理页面的分配数量并不是一致的。即便不考虑共享内存的影响,也并非发生一次匿名页缺页中断,就一定会分配一个独立的物理页面。

问题

问题很简单,我把问题抽象成了下面的代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *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    100
char *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    100
char *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 1

step 2
alloc:0x55813ea46830 gap:0

alloc:0x55813ea47840 gap:1010
# 4096个字节属于malloc的内容
# 0x1010-0x1000 = 0x10个字节是什么?
0 0 0 0 0 0 0 0 ffffffc1 ffffffe7 1 0 0 0 0 0 
alloc:0x55813ea48850 gap:1010
...
0 0 0 0 0 0 0 0 ffffffb1 ffffffd7 1 0 0 0 0 0 
alloc:0x55813ea49860 gap:1010

alloc:0x55813ea4a870 gap:1010
...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

我们发现一次malloc所用的内存并不仅仅是malloc参数所指示的那样,还多了16字节的元数据,这应该就是C库维护的malloc使用的内存池链表(应该是类似内核中伙伴系统那般被维护的)!显然,按照我们希望malloc一次分配一个页面的情况来讲,下图展示了虚拟内存的布局: 
这里写图片描述

这个和我们上面的page fault测试统计数据完全符合!看样子是解释了问题。即malloc实际分配内存之后写C库内存池元数据导致了调页,待到后来read数据时,页面已经被调入了


  让我们再深入一点。

  既然C库的malloc使用的是已经从OS分配的现成的内存,因此进程heap(brk指示其边界)的扩展就是C库进行的咯,我们再次看个究竟:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // 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++) {
                char *ae;
                addrs[i] = malloc(4096);
                // 获取当前的brk
                ae = sbrk(0);
                printf("alloc:%p brk now:%p\n", addrs[i], ae);
        }
        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

运行结果是:

root@debian:/mnt/toy# ./a.out 
step 1

step 2
alloc:0x55b9fc3d8830 brk now:0x55b9fc3f9000
alloc:0x55b9fc3d9840 brk now:0x55b9fc3f9000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc3f6a10 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f7a20 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f8a30 brk now:0x55b9fc41a000
alloc:0x55b9fc3f9a40 brk now:0x55b9fc41a000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc417c20 brk now:0x55b9fc41a000
alloc:0x55b9fc418c30 brk now:0x55b9fc41a000
alloc:0x55b9fc419c40 brk now:0x55b9fc43b000
alloc:0x55b9fc41ac50 brk now:0x55b9fc43b000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc438e30 brk now:0x55b9fc43b000
alloc:0x55b9fc439e40 brk now:0x55b9fc43b000
alloc:0x55b9fc43ae50 brk now:0x55b9fc45c000
alloc:0x55b9fc43be60 brk now:0x55b9fc45c000


step 3

root@debian:/mnt/toy# 
  • 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

把打印去掉,通过strace,我们可以看到同样的事实:

mprotect(0x7fa4d6916000, 16384, PROT_READ) = 0
mprotect(0x56298dbba000, 4096, PROT_READ) = 0
mprotect(0x7fa4d6b43000, 4096, PROT_READ) = 0
munmap(0x7fa4d6b32000, 54668)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
brk(NULL)                               = 0x56298dc70000
brk(0x56298dc91000)                     = 0x56298dc91000
write(1, "step 1\n", 7step 1
)                 = 7
fstat(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
read(0, 
"\n", 1024)                     = 1
write(1, "step 2\n", 7step 2
)                 = 7
# 注意!虽然调用了SIZE次malloc,但是C库函数仅仅发起了3次brk调用
brk(0x56298dcb2000)                     = 0x56298dcb2000
brk(0x56298dcd3000)                     = 0x56298dcd3000
brk(0x56298dcf4000)                     = 0x56298dcf4000
read(0, 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

可以看得到,malloc完全受C库的管理而不是操作系统内存子系统的管理,C库会批量从操作系统申请内存纳入自己的管辖,其分配的地址永远处在当前brk之下的位置!总结来讲就是,操作系统内存子系统为C库服务,C库为编程者服务,编程者一般并不和操作系统内存子系统直接打交道。


让我们回到malloc内存缺页中断调页的问题。

  既然malloc受C库的管理,且可能在填充元数据的时候发生调页,那么我们直接使用brk系统调用呢?来试试:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()      
{       
        int i;
        // 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++) {
                char *ae;
                addrs[i] = sbrk(4096);
        }
        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

它将造就下面的虚拟内存布局: 
这里写图片描述 
我们把程序跑一下看看,3个阶段的page fault分别是:

# 阶段1
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     81
  • 1
  • 2
  • 3
  • 4

初始,调入了dest页面。

# 阶段2
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     82
  • 1
  • 2
  • 3
  • 4

brk直接分配内存,没有发生缺页中断,没有页面调入。

# 阶段3
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    182
  • 1
  • 2
  • 3
  • 4

只是读取了最新brk的页面,发生了缺页中断,最新的SIZE个brk页面被调入!

  可见,同样都是分配SIZE个页面,使用malloc和使用brk是完全不同的,前者不受自己控制,内存不连续,中间有管理元数据,而后者则完全是自己向操作系统获取的,不管是哪种方式,所获取的都是虚拟内存,只是C库的malloc在不经意间可能会由于其管理工作而调页,而brk则不会,只有在你写或者读(写和读的调页细节并不同,且听我把故事讲完)的时候才会触发缺页中断,Lazy调页。

继续分析下去

继续下去,还有得玩。

  我们来看一下使用malloc能不能故意造出自己想要的缺页中断效果,比如我就希望即使使用malloc也要在实际内存时才发生缺页。为此我们只需要营造下面的虚拟内存布局即可:

这里写图片描述

考虑到内存对齐因素,malloc内存池元数据16字节,那么按照16字节对齐是一个最小的选择了,因此为了达到读内存时必然发生缺页中断,比如读取至少1个页面才能让malloc内存池链表项的边界覆盖页面边界,即,比如至少读取4096字节的数据,此外,malloc分配的内存大小则为4096*2+4096/2-16字节。

  我们的预期是,在malloc执行的时候,会有100个缺页中断,因为此时写元数据会调入100个页面,然后在读这些malloc出来的内存时,会有50个缺页中断,因为有一半的页面已经在malloc的时候由于元数据写而调入了。让我们来看看是不是这样:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // 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++) {
                addrs[i] = malloc(4096+4096/2-16);     
        }
        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

三个阶段的输出分别为:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    181
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

OK,符合预期!如果仅仅将代码中malloc换成calloc,我们预期会在step 2的时候调入所以的页面,因为calloc会即时将页面用0填充,会触发调页。来看下结果:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

嗯,就是这个结果!

  现如今,我们已经可以按照自己的意愿来控制缺页中断调页的次数了,我们确实可以控制,而且还是在下面两个条件都满足的前提下做到的:

  • 没有看Linux内核源码;
  • 没有看glibc的malloc源码。

我们只是通过一些黑盒小trick做到的这一切。这很有意思,这也是我写作本文想分享的一种处理问题的方式。从最开始拿到问题,一直到这里,没有任何的source code,没有,一点也没有。

  好像还漏掉了什么,让我们来补齐。


嗯,这就是mmap。

  C库的malloc在分配超过一定大小的内存(MMAP_THRESHOLD)时,将不再通过其内存池预分配的链表(类似伙伴系统)中取,而是直接通过mmap系统调用来向操作系统内核来索要。详见malloc的manual page:

Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When allocating blocks of memory larger 
than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by 
default, but is adjustable using mallopt(3). Allocations performed using mmap(2) are unaffected by the RLIMIT_DATA resource limit (see getrlimit(2)).

这种方式更进一步证明,在你操作实际内存前,malloc并没有对新内存进行任何映射,代码如下:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

// 将SIZE从100加大到1000,更加容易在maps中观察到新的vma
#define SIZE    1000
char *addrs;
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:调入拷贝目标
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:分配内存
        printf("step 2\n");
         // 直接分配,大小已经足够大,用strace可以确认使用了mmap。
        addrs = malloc(4096*SIZE);
        getchar();
        // step 3:读内存
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs + i*4096, 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

然后打印三个阶段的缺页中断统计:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    320
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    322
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0   1322
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

符合预期!此时通过查看/proc/ps -e|grep a.out|awk '{print $1}'/maps,可以看出heap的变化和malloc分配小内存时的不同:

root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
... 
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
# 并没有扩展既有的heap,而是另辟途径,申请一块全新的vma
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...                   
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

以上可以看出,malloc大内存时,会直接使用mmap在进程的地址空间中开辟一个新的虚拟地址段,而反观malloc申请小内存,则会直接使用brk来延展当前的heap。

  应该快到结尾处了。通篇我们都在使用ps -o maj_flt -o min_flt这种方式来观测中断,我一直故意避开具体的调页情况的观测,就好像发生一次缺页中断就会调入一个独立的物理页面,从而造成空闲物理页面少一个一样,此外,我也一直都在用读操作触发缺页中断,写操作仅限于C库对元数据的写以及calloc的写,这么做是必要的,因为我不想复杂的事情一开始就揉在一起,理清了缺页中断,让我们进一步分析缺页中断后调页的情况。

  但是具体调页如何观测呢?

  一般而言,发生了缺页中断,就会调入内存一个页面,此时空闲物理内存就会少一个页面。但是这是错误的直觉!你可以通过free命令或者直接看相关进程的/proc/ps -e|grep a.out|awk '{print $1}'/status文件的VmRSS/RssAnon等字段的变化来观测调页情况。你会观察到下面的情况:

  • 如果是read造成的100个缺页中断,RssAnon并没有增加,这意味着没有新的独立物理页面被分配;
  • 如果是write造成的100个缺页中断,RssAnon会增加相应的个数,剩余空闲物理内存会减少。

为什么会这样?这是下面两个小节的内容。

  OK,写到这里,基本上本文的上半部分已经结束了,我们了解到page fault和虚拟内存分配读写的关系,后面,在本文的下半部分,我来解释本文的第二个论题,即新分配的虚拟地址空间的read操作和write操作对物理内存管理系统的影响有什么不同

深入到内核页表看个究竟

这个小节你可以看作是干货,也可以看成是废话。

  前置知识很重要。本文所有的用例都是基于X86-64平台,内核版本为3.10.0-862.2.3.el7.x86_64以及4.9.0-3-amd64,本节的用例在3.10.0-862.2.3.el7.x86_64内核做实验。因此有必要先简单说一下X86-64平台虚拟内存到物理内存的映射。

  和X86 32位平台非常类似,下面一张图就能说明:

这里写图片描述

理解了这个之后,我们就可以再来一个简单的用例,来观测一下进程虚拟内存的实际映射情况。

  直接上一个代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv)
{
        char *p1, *p2, *p3;
        char c;
        int i;

        p1 = sbrk(0);
        // 我们脱离C库的管理,直接切入内核。
        p2 = sbrk(4096);
        p3 = sbrk(4096);

        printf("p1:%p  p2:%p  p3:%p\n", p1, p2, p3);

        memset(p3, 'a', 4096);
        // 1.此处观察p3的地址对应的页表项以及实际读出物理页面确认内容是不是'a'
        // 2.观察和p3虚拟地址紧邻着的上一个4096大小的地址空间的页表项。
        getchar();

        c = p2[120];  // read p2
        // 3.再次实验上述的第2点
        getchar();

        memset(p2, 'b', 4096);
        // 4.再次实验上述的第2点
        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

为了完成代码注释中的观测实验,可以写一个kernel module,然后在里面去dump特定进程的页表。然而有现成的工具不用,不是自找苦吃吗?嗯,这里将用crash来完成观测实验。

  crash如何安装这里不展开,我在Centos 7上的安装方法是:

  • 安装crash
yum install crash
  • 1
  • 安装debuginfo
yum install yum-utils
debuginfo-install kernel
  • 1
  • 2

工具就绪后,运行编译好的上述代码:

[root@localhost toy]# ./a.out 
p1:0xc7d000  p2:0xc7d000  p3:0xc7e000
  • 1
  • 2

另起一个终端,运行crash:

[root@localhost ~]# crash 

crash 7.2.0-6.el7
.......

      KERNEL: /usr/lib/debug/lib/modules/3.10.0-862.2.3.el7.x86_64/vmlinux
    DUMPFILE: /dev/crash
        CPUS: 1
        DATE: Thu May 17 10:15:44 2018
      UPTIME: 04:21:57
LOAD AVERAGE: 0.29, 0.08, 0.07
       TASKS: 117
    NODENAME: localhost.localdomain
     RELEASE: 3.10.0-862.2.3.el7.x86_64
     VERSION: #1 SMP Wed May 9 18:05:47 UTC 2018
     MACHINE: x86_64  (2194 Mhz)
      MEMORY: 1 GB
         PID: 1765
     COMMAND: "crash"
        TASK: ffff9be1f9289fa0  [THREAD_INFO: ffff9be1f67b4000]
         CPU: 0
       STATE: TASK_RUNNING (ACTIVE)

crash> ps |grep a.out
   1764   1258   0  ffff9be1f9288000  IN   0.0    4224    472  a.out
  • 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

好了,接下来我们来step by step看看代码中p2,p3指针内存页表的细节。

  开始!

  • step 1:找到进程的task_struct以及其pgd指示的CR3值:
crash> set 1764
    PID: 1764
COMMAND: "a.out"
   TASK: ffff9be1f9288000  [THREAD_INFO: ffff9be1eb3b4000]
    CPU: 0
  STATE: TASK_INTERRUPTIBLE 
crash> px ((struct task_struct *)0xffff9be1f9288000)->mm->pgd
$1 = (pgd_t *) 0xffff9be1f8e20000  # 这里的pgd是虚拟地址!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • step 2:将pgd转换为物理地址,读出PGD表项:
crash> vtop -c 1764 -k 0xffff9be1f8e20000
VIRTUAL           PHYSICAL        
ffff9be1f8e20000  38e20000  # 注意,这个38e20000就是PGD的物理地址      

PML4 DIRECTORY: ffffffff9b00e000
PAGE DIRECTORY: 3e22f067
   PUD: 3e22fc38 => 3e230067
   PMD: 3e230e38 => 393a1063
   PTE: 393a1100 => 8000000038e20063
  PAGE: 38e20000

      PTE         PHYSICAL  FLAGS
8000000038e20063  38e20000  (PRESENT|RW|ACCESSED|DIRTY|NX)

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700e38800 38e20000                0 ffff9be1f5f8ea40  1 1fffff00000000
crash>  px (0xc7e000>>39) & 0x1ff # 取a.out输出的p3指针,按照转换图取PGD索引
$2 = 0x0 # PGD索引为0
crash> px 0x38e20000+$2*8 # 找到PGD表项
$3 = 0x38e20000
crash> rd -p 0x38e20000 # 读出PGD表项
        38e20000:  8000000037f4d067                    g..7....
crash> pte 8000000037f4d067 # 将内存翻译成PTE格式
      PTE         PHYSICAL  FLAGS
8000000037f4d067  37f4d000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
  • 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

OK,此时我们知道,PUD是Present的。

  • step 3:循着PGD表项读出PUD表项:
crash> px (0xc7e000>>30) & 0x1ff # 按照转换图得到PUD索引
$4 = 0x0
crash> px 0x37f4d000+$4*8 # 找到PUD表项
$5 = 0x37f4d000
crash> rd -p 0x37f4d000 # 读出PUD表项
        37f4d000:  0000000037318067                    g.17....
crash> pte 0000000037318067 # 翻译PUD表项
  PTE     PHYSICAL  FLAGS
37318067  37318000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • step 4:循着PUD表项读出PMD表项:
# 这里不再给注释
crash> px (0xc7e000>>21) & 0x1ff
$6 = 0x6
crash> px 0x37318000+$6*8
$7 = 0x37318030
crash> rd -p 0x37318030
        37318030:  000000003883d067                    g..8....
crash> pte 000000003883d067
  PTE     PHYSICAL  FLAGS
3883d067  3883d000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • step 5:循着PMD表项读出PTE:
crash> px (0xc7e000>>12) & 0x1ff
$8 = 0x7e
crash> px 0x3883d000+$8*8
$9 = 0x3883d3f0
crash> rd -p 0x3883d3f0
        3883d3f0:  800000003b9e3867                    g8.;....
crash> pte 800000003b9e3867
      PTE         PHYSICAL  FLAGS
800000003b9e3867  3b9e3000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

此时的地址0x3b9e3000就是物理页面的位置了,由于我们brk申请了整个页面,因此页面偏移为0,接下来就是直接dump内存了。(严谨点讲,需要用 addr&0x0fff 找出页面偏移的,但是0xc7e000这个地址是页面对齐的,也就不再费事了!)

  • step 6:dump整个页面的内存:
crash> rd -p 0x3b9e3000
        3b9e3000:  6161616161616161                    aaaaaaaa
  • 1
  • 2

哇!

  • step 7:看看p2对应的页表 
    由于p2和p3在虚拟地址上相邻4096个字节(一个页面),因此只需要做最后一步即可,即将p3的PTE索引向前移1个单位。但是我们依然按照正规的方式来一遍:
crash> px (0xc7d000>>12) & 0x1ff
$10 = 0x7d
crash> px 0x3883d000+$10*8
$11 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  0000000000000000                    ........
crash> pte 0000000000000000 
PTE  PHYSICAL  FLAGS
 0       0     (no mapping) # nothing!!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

连页表项都没有,何来的内容!请问何来的内容?!现在让我们的a.out向前一步走,即在a.out的运行终端敲入回车,再次dump p2的页表项。

  • step 8:a.out读取一下p2指针4096字节范围内的内容之后再次看p2的PTE:
crash> rd -p 0x3883d3e8
        3883d3e8:  800000003df22225                    
crash> pte 800000003df22225
      PTE         PHYSICAL  FLAGS
800000003df22225  3df22000  (PRESENT|USER|ACCESSED|NX)
  • 1
  • 2
  • 3
  • 4
  • 5

看看吧,只要读了一下p2的内容,PTE就Present了!这个时候,我们可以读一下其内容:

crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........
  • 1
  • 2

内容为全0!

  • step 9:让a.out更进一步,拷贝’b’到p2后再次读取内容: 
    在a.out的终端上敲入回车,然后看PTE指示页面的内容:
crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........
  • 1
  • 2

这是为什么?为什么没有显示’b’字符,为什么还是全0?为此,不得不把最后一步重新来一遍了,即从读取PTE开始:

crash> px (0xc7d000>>12) & 0x1ff
$13 = 0x7d
crash> px 0x3883d000+$13*8 # 循着PMD找到PTE
$14 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  800000002dac8867  # 注意!这个PTE和之前不同了
crash> pte 800000002dac8867
      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
crash> rd -p 0x2dac8000 # 对p2进行写操作前后,其PTE指示的页面不同了!!
        2dac8000:  6262626262626262                    bbbbbbbb
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

重做一遍终于还是找到了,过程中PTE发生了变化。可以用vtop直接dump p2的虚拟地址看一下:

crash> vtop -c 1764 -u 0xc7d000
VIRTUAL     PHYSICAL        
c7d000      2dac8000        

   PML: 38e20000 => 8000000037f4d067
   PUD: 37f4d000 => 37318067
   PMD: 37318030 => 3883d067
   PTE: 3883d3e8 => 800000002dac8867
  PAGE: 2dac8000

      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)

      VMA           START       END     FLAGS FILE
ffff9be1eb39f440     c7d000     c7f000 8100073 

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700b6b200 2dac8000 ffff9be1c7c7eaf1      c7d  1 1fffff00080068 uptodate,lru,active,swapbacked
# 结果正是2dac8000!和手工dump的结果完全一致!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

我们前面绕了那么大一圈,其实直接用vtop命令就可以把整个MMU转换过程看得一清二楚。但是通过上述手工dump PTE的过程,更加熟悉了不是吗?

  但是这里出现一个问题!为什么在对p2进行只读操作时,和对它进行写入之后,其PTE指示的物理页面是不同的页面?

# 对p2内存只读操作之后 
crash> rd -p 0x3883d3e8 
3883d3e8: 800000003df22225 
———————————————— 
# 对p2内存写操作之后 
crash> rd -p 0x3883d3e8 
3883d3e8: 800000002dac8867

此外,其PTE对应的flags也不同,对其只读的时候,没有置入RW标志,即此时该页面是不可写的。

  是时候揭示谜底了!

  在对新分配的虚拟地址空间第一次读操作时,page fault确实会调入一个页面,然而对于这种读操作,所有的进程调入的都是同一个zeropage页面。对于这种第一次读操作,内核会将这同一个zeropage映射给被读的虚拟地址页面。这最大限度地发挥了Lazy策略的品性!

内核代码解释这一切(what->how)

到目前,我们已经知道所有事实,从用户态统计到内核crash工具分析,然而要想知道内核是如何做的,即从waht导出how,就要看一眼内核源码了,这里的目标非常明确,直接看do_anonymous_page的逻辑即可:

static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
        unsigned long address, pte_t *page_table, pmd_t *pmd,
        unsigned int flags)
{
    ...

    /* Use the zero-page for reads */
    if (!(flags & FAULT_FLAG_WRITE)) {
        // 只读情况直接从zeropage里拿即可。
        entry = pte_mkspecial(pfn_pte(my_zero_pfn(address),
                        vma->vm_page_prot));
        page_table = pte_offset_map_lock(mm, pmd, address, &ptl);
        if (!pte_none(*page_table))
            goto unlock;
        goto setpte;
    }
    ...
    // 非只读,才会实际从物理池里分配页面
    page = alloc_zeroed_user_highpage_movable(vma, address);
    ...
    // 如果读fault映射了zeropage,将不会递增AnonRSS计数器。
    inc_mm_counter_fast(mm, MM_ANONPAGES);
    page_add_new_anon_rmap(page, vma, address);
setpte:
    ...
}
  • 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

我们看看什么是zeropage:

/*
 * ZERO_PAGE is a global shared page that is always zero: used
 * for zero-mapped memory areas etc..
 */
extern unsigned long empty_zero_page[PAGE_SIZE / sizeof(unsigned long)];
#define ZERO_PAGE(vaddr) (virt_to_page(empty_zero_page))
...
static int __init init_zero_pfn(void)
{
    zero_pfn = page_to_pfn(ZERO_PAGE(0));
    return 0;
}
...
static inline unsigned long my_zero_pfn(unsigned long addr)
{
    extern unsigned long zero_pfn;
    return zero_pfn;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

所有谜底已经揭开!从一个实验用例,到统计分析,到crash工具分析内核状态,到内核源码确认,这就完成了一个解决问题的闭环,但貌似还缺少点什么…接下来还有一个形而上的分析。

how->why

之所以将第一次以read方式touch到的虚拟地址空间对应的物理页面映射到一个全局的zeropage,是在按需调页更进一步地加强了Lazy品性!从而更加有效地落实写时拷贝策略,将不得已而分配的物理页面真真地推迟到最后那一刻,从而将无谓的浪费行为降低到最少!

  如果说按需调页的page fault机制已经实现了Lazy品性,那么深究起来它做得还不够好,说它做得不够好是因为page fault机制忽略了按需调页两个层面中的一面:

  1. 当touch一个从未touch过的虚拟页面的时候,需要调入一个物理页面;
  2. 当调入一个物理页面时,是不是可以和其它的进程共享该物理页面;

第1点说的是按需分配,不得已时才分配,page fault做到了(注意,PTE本身也是按需调入的),第2点说的是尽力压缩,非要分配时,能不能尽量少分配,两者都做到了,Lazy策略才能达到真正按需调页思路的极致。

结论 & 评价

结论很明确:

Linux系统并不会对新分配未touch的虚拟内存映射任何物理页面; 
以read方式首次touch时会映射共享的zeropage页面; 
以write方式首次touch时会分配新的物理页面并映射之; 
以write方式在首次read touch之后touch时,写时拷贝会分配新的物理页面并映射之。

为什么是这样可以考虑以下两点:

  • 基于虚拟地址空间的操作系统内存子系统采用的是按需调页策略,这是设计决定的。
  • 参考Linux内核的实现,Linux page fault处理区分对待了匿名页的read fault和write fault。

附:关于PTE的按需调入

在进程新fork出来初始化时,mm_init函数中会调用pgd_alloc,在pgd_alloc中会处理pgd的prepopulate,但是没有pte的populate,pte的populate是在do_page_fault中处理的,可见由于内存分布的局部稠密性全局稀疏性,PTE的Lazy分配时必要的。

  其次,我们看C库增持内存池内存需要通过系统调用进一步调用到的do_brk以及do_mmap,除非你使用了VM_LOCKED,否则就不会prepopulate任何页面。上面两点保证了至少在X86的32位/64位平台,不会对用户地址空间的虚拟内存有任何可读的预映射页面

  若要观测PTE本身的按需调入,参考下面的代码:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

int main(int argc, char **argv)
{
        char *p1, *p2;
        char c;
        int i;

        p1 = sbrk(0);
        // 让p2离开更远的距离,防止p2的PTE和已分配的PTE在一个page内!
        for (i = 0; i < 4096*50; i++) {
                p2 = sbrk(4096);
        }

        printf("p1:%p  p2:%p\n", p1, p2);
        getchar();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

接下来按照上文用crash工具去逐层dump PTE,然后你会发现PMD表项或者PTE所在的页面本身尚未被调入,因此按需调页在Linux的实现中是一个递归调入的过程。

后记

敲吧,门终究会开的! —《马太福音》

Linux系统下深究一个malloc/brk/sbrk新内存后的page fault问题

有耳可听的,就应当听 —《马可福音》


周四的休假团建又没有去,不因别的,只因年前东北行休假太多了,想缓缓…不过真实原因也确实因为假期剩余无几了…思考了一些问题,写下本文。

  本文的缘起来自于和同事讨论一个关于缺页中断按需调页的讨论。真可谓是三人行必有我师,最近经常能从一些随意的比划或招架中悟出一丝意义,所以非常感谢周围的信息输出者!甚至从小小学校全员禁言的作业群里,我都能每天重温一首古诗词,然后循此生意,去故意制造另一种真实的意境,然后发个朋友圈?~

  感谢大家的信息输入,每次收到的好玩的东西,我都会即时整理并重新再输出。

内容简介

本文描述了一个非常显然但却又很少有人知道其所以然的问题,更重要的是分享一种解决问题的思路。PS:这个问题非常好玩。

  不搞悬念,本文解释一个事实,即匿名页缺页中断数量和物理页面的分配数量并不是一致的。即便不考虑共享内存的影响,也并非发生一次匿名页缺页中断,就一定会分配一个独立的物理页面。

问题

问题很简单,我把问题抽象成了下面的代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *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    100
char *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    100
char *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 1

step 2
alloc:0x55813ea46830 gap:0

alloc:0x55813ea47840 gap:1010
# 4096个字节属于malloc的内容
# 0x1010-0x1000 = 0x10个字节是什么?
0 0 0 0 0 0 0 0 ffffffc1 ffffffe7 1 0 0 0 0 0 
alloc:0x55813ea48850 gap:1010
...
0 0 0 0 0 0 0 0 ffffffb1 ffffffd7 1 0 0 0 0 0 
alloc:0x55813ea49860 gap:1010

alloc:0x55813ea4a870 gap:1010
...
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

我们发现一次malloc所用的内存并不仅仅是malloc参数所指示的那样,还多了16字节的元数据,这应该就是C库维护的malloc使用的内存池链表(应该是类似内核中伙伴系统那般被维护的)!显然,按照我们希望malloc一次分配一个页面的情况来讲,下图展示了虚拟内存的布局: 
这里写图片描述

这个和我们上面的page fault测试统计数据完全符合!看样子是解释了问题。即malloc实际分配内存之后写C库内存池元数据导致了调页,待到后来read数据时,页面已经被调入了


  让我们再深入一点。

  既然C库的malloc使用的是已经从OS分配的现成的内存,因此进程heap(brk指示其边界)的扩展就是C库进行的咯,我们再次看个究竟:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // 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++) {
                char *ae;
                addrs[i] = malloc(4096);
                // 获取当前的brk
                ae = sbrk(0);
                printf("alloc:%p brk now:%p\n", addrs[i], ae);
        }
        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

运行结果是:

root@debian:/mnt/toy# ./a.out 
step 1

step 2
alloc:0x55b9fc3d8830 brk now:0x55b9fc3f9000
alloc:0x55b9fc3d9840 brk now:0x55b9fc3f9000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc3f6a10 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f7a20 brk now:0x55b9fc3f9000
alloc:0x55b9fc3f8a30 brk now:0x55b9fc41a000
alloc:0x55b9fc3f9a40 brk now:0x55b9fc41a000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc417c20 brk now:0x55b9fc41a000
alloc:0x55b9fc418c30 brk now:0x55b9fc41a000
alloc:0x55b9fc419c40 brk now:0x55b9fc43b000
alloc:0x55b9fc41ac50 brk now:0x55b9fc43b000
...
# 将要进行下一次brk操作,扩展heap
alloc:0x55b9fc438e30 brk now:0x55b9fc43b000
alloc:0x55b9fc439e40 brk now:0x55b9fc43b000
alloc:0x55b9fc43ae50 brk now:0x55b9fc45c000
alloc:0x55b9fc43be60 brk now:0x55b9fc45c000


step 3

root@debian:/mnt/toy# 
  • 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

把打印去掉,通过strace,我们可以看到同样的事实:

mprotect(0x7fa4d6916000, 16384, PROT_READ) = 0
mprotect(0x56298dbba000, 4096, PROT_READ) = 0
mprotect(0x7fa4d6b43000, 4096, PROT_READ) = 0
munmap(0x7fa4d6b32000, 54668)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
brk(NULL)                               = 0x56298dc70000
brk(0x56298dc91000)                     = 0x56298dc91000
write(1, "step 1\n", 7step 1
)                 = 7
fstat(0, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 4), ...}) = 0
read(0, 
"\n", 1024)                     = 1
write(1, "step 2\n", 7step 2
)                 = 7
# 注意!虽然调用了SIZE次malloc,但是C库函数仅仅发起了3次brk调用
brk(0x56298dcb2000)                     = 0x56298dcb2000
brk(0x56298dcd3000)                     = 0x56298dcd3000
brk(0x56298dcf4000)                     = 0x56298dcf4000
read(0, 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

可以看得到,malloc完全受C库的管理而不是操作系统内存子系统的管理,C库会批量从操作系统申请内存纳入自己的管辖,其分配的地址永远处在当前brk之下的位置!总结来讲就是,操作系统内存子系统为C库服务,C库为编程者服务,编程者一般并不和操作系统内存子系统直接打交道。


让我们回到malloc内存缺页中断调页的问题。

  既然malloc受C库的管理,且可能在填充元数据的时候发生调页,那么我们直接使用brk系统调用呢?来试试:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()      
{       
        int i;
        // 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++) {
                char *ae;
                addrs[i] = sbrk(4096);
        }
        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

它将造就下面的虚拟内存布局: 
这里写图片描述 
我们把程序跑一下看看,3个阶段的page fault分别是:

# 阶段1
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     81
  • 1
  • 2
  • 3
  • 4

初始,调入了dest页面。

# 阶段2
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     82
  • 1
  • 2
  • 3
  • 4

brk直接分配内存,没有发生缺页中断,没有页面调入。

# 阶段3
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    182
  • 1
  • 2
  • 3
  • 4

只是读取了最新brk的页面,发生了缺页中断,最新的SIZE个brk页面被调入!

  可见,同样都是分配SIZE个页面,使用malloc和使用brk是完全不同的,前者不受自己控制,内存不连续,中间有管理元数据,而后者则完全是自己向操作系统获取的,不管是哪种方式,所获取的都是虚拟内存,只是C库的malloc在不经意间可能会由于其管理工作而调页,而brk则不会,只有在你写或者读(写和读的调页细节并不同,且听我把故事讲完)的时候才会触发缺页中断,Lazy调页。

继续分析下去

继续下去,还有得玩。

  我们来看一下使用malloc能不能故意造出自己想要的缺页中断效果,比如我就希望即使使用malloc也要在实际内存时才发生缺页。为此我们只需要营造下面的虚拟内存布局即可:

这里写图片描述

考虑到内存对齐因素,malloc内存池元数据16字节,那么按照16字节对齐是一个最小的选择了,因此为了达到读内存时必然发生缺页中断,比如读取至少1个页面才能让malloc内存池链表项的边界覆盖页面边界,即,比如至少读取4096字节的数据,此外,malloc分配的内存大小则为4096*2+4096/2-16字节。

  我们的预期是,在malloc执行的时候,会有100个缺页中断,因为此时写元数据会调入100个页面,然后在读这些malloc出来的内存时,会有50个缺页中断,因为有一半的页面已经在malloc的时候由于元数据写而调入了。让我们来看看是不是这样:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

#define SIZE    100
char *addrs[SIZE];
char dest[4096][SIZE];
int main()
{
        int i;
        // 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++) {
                addrs[i] = malloc(4096+4096/2-16);     
        }
        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

三个阶段的输出分别为:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    181
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

OK,符合预期!如果仅仅将代码中malloc换成calloc,我们预期会在step 2的时候调入所以的页面,因为calloc会即时将页面用0填充,会触发调页。来看下结果:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0     80
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    231
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

嗯,就是这个结果!

  现如今,我们已经可以按照自己的意愿来控制缺页中断调页的次数了,我们确实可以控制,而且还是在下面两个条件都满足的前提下做到的:

  • 没有看Linux内核源码;
  • 没有看glibc的malloc源码。

我们只是通过一些黑盒小trick做到的这一切。这很有意思,这也是我写作本文想分享的一种处理问题的方式。从最开始拿到问题,一直到这里,没有任何的source code,没有,一点也没有。

  好像还漏掉了什么,让我们来补齐。


嗯,这就是mmap。

  C库的malloc在分配超过一定大小的内存(MMAP_THRESHOLD)时,将不再通过其内存池预分配的链表(类似伙伴系统)中取,而是直接通过mmap系统调用来向操作系统内核来索要。详见malloc的manual page:

Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When allocating blocks of memory larger 
than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by 
default, but is adjustable using mallopt(3). Allocations performed using mmap(2) are unaffected by the RLIMIT_DATA resource limit (see getrlimit(2)).

这种方式更进一步证明,在你操作实际内存前,malloc并没有对新内存进行任何映射,代码如下:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

// 将SIZE从100加大到1000,更加容易在maps中观察到新的vma
#define SIZE    1000
char *addrs;
char dest[4096][SIZE];
int main()
{
        int i;
        // step 1:调入拷贝目标
        printf("step 1\n");
        for (i = 0; i < SIZE; i++) {
                memset(dest[i], 1, 4096);
        }
        getchar();
        // step 2:分配内存
        printf("step 2\n");
         // 直接分配,大小已经足够大,用strace可以确认使用了mmap。
        addrs = malloc(4096*SIZE);
        getchar();
        // step 3:读内存
        printf("step 3\n");
        for (i = 0; i < SIZE; i++) {
                memcpy(dest[i], addrs + i*4096, 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

然后打印三个阶段的缺页中断统计:

root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    320
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0    322
root@debian:/mnt/toy# ps -o maj_flt -o min_flt -p `ps -e|grep a.out|awk '{print $1}'`
 MAJFL  MINFL
     0   1322
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

符合预期!此时通过查看/proc/ps -e|grep a.out|awk '{print $1}'/maps,可以看出heap的变化和malloc分配小内存时的不同:

root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
... 
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
# 并没有扩展既有的heap,而是另辟途径,申请一块全新的vma
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...
root@debian:/mnt/toy# 
root@debian:/mnt/toy# 
root@debian:/mnt/toy# cat /proc/`ps -e|grep a.out|awk '{print $1}'`/maps
...
557e6fb01000-557e6fee9000 rw-p 00000000 00:00 0 
557e71441000-557e71462000 rw-p 00000000 00:00 0                          [heap]
7f1e07f75000-7f1e0835e000 rw-p 00000000 00:00 0 
...                   
root@debian:/mnt/toy# 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

以上可以看出,malloc大内存时,会直接使用mmap在进程的地址空间中开辟一个新的虚拟地址段,而反观malloc申请小内存,则会直接使用brk来延展当前的heap。

  应该快到结尾处了。通篇我们都在使用ps -o maj_flt -o min_flt这种方式来观测中断,我一直故意避开具体的调页情况的观测,就好像发生一次缺页中断就会调入一个独立的物理页面,从而造成空闲物理页面少一个一样,此外,我也一直都在用读操作触发缺页中断,写操作仅限于C库对元数据的写以及calloc的写,这么做是必要的,因为我不想复杂的事情一开始就揉在一起,理清了缺页中断,让我们进一步分析缺页中断后调页的情况。

  但是具体调页如何观测呢?

  一般而言,发生了缺页中断,就会调入内存一个页面,此时空闲物理内存就会少一个页面。但是这是错误的直觉!你可以通过free命令或者直接看相关进程的/proc/ps -e|grep a.out|awk '{print $1}'/status文件的VmRSS/RssAnon等字段的变化来观测调页情况。你会观察到下面的情况:

  • 如果是read造成的100个缺页中断,RssAnon并没有增加,这意味着没有新的独立物理页面被分配;
  • 如果是write造成的100个缺页中断,RssAnon会增加相应的个数,剩余空闲物理内存会减少。

为什么会这样?这是下面两个小节的内容。

  OK,写到这里,基本上本文的上半部分已经结束了,我们了解到page fault和虚拟内存分配读写的关系,后面,在本文的下半部分,我来解释本文的第二个论题,即新分配的虚拟地址空间的read操作和write操作对物理内存管理系统的影响有什么不同

深入到内核页表看个究竟

这个小节你可以看作是干货,也可以看成是废话。

  前置知识很重要。本文所有的用例都是基于X86-64平台,内核版本为3.10.0-862.2.3.el7.x86_64以及4.9.0-3-amd64,本节的用例在3.10.0-862.2.3.el7.x86_64内核做实验。因此有必要先简单说一下X86-64平台虚拟内存到物理内存的映射。

  和X86 32位平台非常类似,下面一张图就能说明:

这里写图片描述

理解了这个之后,我们就可以再来一个简单的用例,来观测一下进程虚拟内存的实际映射情况。

  直接上一个代码:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char **argv)
{
        char *p1, *p2, *p3;
        char c;
        int i;

        p1 = sbrk(0);
        // 我们脱离C库的管理,直接切入内核。
        p2 = sbrk(4096);
        p3 = sbrk(4096);

        printf("p1:%p  p2:%p  p3:%p\n", p1, p2, p3);

        memset(p3, 'a', 4096);
        // 1.此处观察p3的地址对应的页表项以及实际读出物理页面确认内容是不是'a'
        // 2.观察和p3虚拟地址紧邻着的上一个4096大小的地址空间的页表项。
        getchar();

        c = p2[120];  // read p2
        // 3.再次实验上述的第2点
        getchar();

        memset(p2, 'b', 4096);
        // 4.再次实验上述的第2点
        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

为了完成代码注释中的观测实验,可以写一个kernel module,然后在里面去dump特定进程的页表。然而有现成的工具不用,不是自找苦吃吗?嗯,这里将用crash来完成观测实验。

  crash如何安装这里不展开,我在Centos 7上的安装方法是:

  • 安装crash
yum install crash
  • 1
  • 安装debuginfo
yum install yum-utils
debuginfo-install kernel
  • 1
  • 2

工具就绪后,运行编译好的上述代码:

[root@localhost toy]# ./a.out 
p1:0xc7d000  p2:0xc7d000  p3:0xc7e000
  • 1
  • 2

另起一个终端,运行crash:

[root@localhost ~]# crash 

crash 7.2.0-6.el7
.......

      KERNEL: /usr/lib/debug/lib/modules/3.10.0-862.2.3.el7.x86_64/vmlinux
    DUMPFILE: /dev/crash
        CPUS: 1
        DATE: Thu May 17 10:15:44 2018
      UPTIME: 04:21:57
LOAD AVERAGE: 0.29, 0.08, 0.07
       TASKS: 117
    NODENAME: localhost.localdomain
     RELEASE: 3.10.0-862.2.3.el7.x86_64
     VERSION: #1 SMP Wed May 9 18:05:47 UTC 2018
     MACHINE: x86_64  (2194 Mhz)
      MEMORY: 1 GB
         PID: 1765
     COMMAND: "crash"
        TASK: ffff9be1f9289fa0  [THREAD_INFO: ffff9be1f67b4000]
         CPU: 0
       STATE: TASK_RUNNING (ACTIVE)

crash> ps |grep a.out
   1764   1258   0  ffff9be1f9288000  IN   0.0    4224    472  a.out
  • 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

好了,接下来我们来step by step看看代码中p2,p3指针内存页表的细节。

  开始!

  • step 1:找到进程的task_struct以及其pgd指示的CR3值:
crash> set 1764
    PID: 1764
COMMAND: "a.out"
   TASK: ffff9be1f9288000  [THREAD_INFO: ffff9be1eb3b4000]
    CPU: 0
  STATE: TASK_INTERRUPTIBLE 
crash> px ((struct task_struct *)0xffff9be1f9288000)->mm->pgd
$1 = (pgd_t *) 0xffff9be1f8e20000  # 这里的pgd是虚拟地址!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • step 2:将pgd转换为物理地址,读出PGD表项:
crash> vtop -c 1764 -k 0xffff9be1f8e20000
VIRTUAL           PHYSICAL        
ffff9be1f8e20000  38e20000  # 注意,这个38e20000就是PGD的物理地址      

PML4 DIRECTORY: ffffffff9b00e000
PAGE DIRECTORY: 3e22f067
   PUD: 3e22fc38 => 3e230067
   PMD: 3e230e38 => 393a1063
   PTE: 393a1100 => 8000000038e20063
  PAGE: 38e20000

      PTE         PHYSICAL  FLAGS
8000000038e20063  38e20000  (PRESENT|RW|ACCESSED|DIRTY|NX)

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700e38800 38e20000                0 ffff9be1f5f8ea40  1 1fffff00000000
crash>  px (0xc7e000>>39) & 0x1ff # 取a.out输出的p3指针,按照转换图取PGD索引
$2 = 0x0 # PGD索引为0
crash> px 0x38e20000+$2*8 # 找到PGD表项
$3 = 0x38e20000
crash> rd -p 0x38e20000 # 读出PGD表项
        38e20000:  8000000037f4d067                    g..7....
crash> pte 8000000037f4d067 # 将内存翻译成PTE格式
      PTE         PHYSICAL  FLAGS
8000000037f4d067  37f4d000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
  • 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

OK,此时我们知道,PUD是Present的。

  • step 3:循着PGD表项读出PUD表项:
crash> px (0xc7e000>>30) & 0x1ff # 按照转换图得到PUD索引
$4 = 0x0
crash> px 0x37f4d000+$4*8 # 找到PUD表项
$5 = 0x37f4d000
crash> rd -p 0x37f4d000 # 读出PUD表项
        37f4d000:  0000000037318067                    g.17....
crash> pte 0000000037318067 # 翻译PUD表项
  PTE     PHYSICAL  FLAGS
37318067  37318000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • step 4:循着PUD表项读出PMD表项:
# 这里不再给注释
crash> px (0xc7e000>>21) & 0x1ff
$6 = 0x6
crash> px 0x37318000+$6*8
$7 = 0x37318030
crash> rd -p 0x37318030
        37318030:  000000003883d067                    g..8....
crash> pte 000000003883d067
  PTE     PHYSICAL  FLAGS
3883d067  3883d000  (PRESENT|RW|USER|ACCESSED|DIRTY)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • step 5:循着PMD表项读出PTE:
crash> px (0xc7e000>>12) & 0x1ff
$8 = 0x7e
crash> px 0x3883d000+$8*8
$9 = 0x3883d3f0
crash> rd -p 0x3883d3f0
        3883d3f0:  800000003b9e3867                    g8.;....
crash> pte 800000003b9e3867
      PTE         PHYSICAL  FLAGS
800000003b9e3867  3b9e3000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

此时的地址0x3b9e3000就是物理页面的位置了,由于我们brk申请了整个页面,因此页面偏移为0,接下来就是直接dump内存了。(严谨点讲,需要用 addr&0x0fff 找出页面偏移的,但是0xc7e000这个地址是页面对齐的,也就不再费事了!)

  • step 6:dump整个页面的内存:
crash> rd -p 0x3b9e3000
        3b9e3000:  6161616161616161                    aaaaaaaa
  • 1
  • 2

哇!

  • step 7:看看p2对应的页表 
    由于p2和p3在虚拟地址上相邻4096个字节(一个页面),因此只需要做最后一步即可,即将p3的PTE索引向前移1个单位。但是我们依然按照正规的方式来一遍:
crash> px (0xc7d000>>12) & 0x1ff
$10 = 0x7d
crash> px 0x3883d000+$10*8
$11 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  0000000000000000                    ........
crash> pte 0000000000000000 
PTE  PHYSICAL  FLAGS
 0       0     (no mapping) # nothing!!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

连页表项都没有,何来的内容!请问何来的内容?!现在让我们的a.out向前一步走,即在a.out的运行终端敲入回车,再次dump p2的页表项。

  • step 8:a.out读取一下p2指针4096字节范围内的内容之后再次看p2的PTE:
crash> rd -p 0x3883d3e8
        3883d3e8:  800000003df22225                    
crash> pte 800000003df22225
      PTE         PHYSICAL  FLAGS
800000003df22225  3df22000  (PRESENT|USER|ACCESSED|NX)
  • 1
  • 2
  • 3
  • 4
  • 5

看看吧,只要读了一下p2的内容,PTE就Present了!这个时候,我们可以读一下其内容:

crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........
  • 1
  • 2

内容为全0!

  • step 9:让a.out更进一步,拷贝’b’到p2后再次读取内容: 
    在a.out的终端上敲入回车,然后看PTE指示页面的内容:
crash> rd -p 0x3df22000
        3df22000:  0000000000000000                    ........
  • 1
  • 2

这是为什么?为什么没有显示’b’字符,为什么还是全0?为此,不得不把最后一步重新来一遍了,即从读取PTE开始:

crash> px (0xc7d000>>12) & 0x1ff
$13 = 0x7d
crash> px 0x3883d000+$13*8 # 循着PMD找到PTE
$14 = 0x3883d3e8
crash> rd -p 0x3883d3e8
        3883d3e8:  800000002dac8867  # 注意!这个PTE和之前不同了
crash> pte 800000002dac8867
      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
crash> rd -p 0x2dac8000 # 对p2进行写操作前后,其PTE指示的页面不同了!!
        2dac8000:  6262626262626262                    bbbbbbbb
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

重做一遍终于还是找到了,过程中PTE发生了变化。可以用vtop直接dump p2的虚拟地址看一下:

crash> vtop -c 1764 -u 0xc7d000
VIRTUAL     PHYSICAL        
c7d000      2dac8000        

   PML: 38e20000 => 8000000037f4d067
   PUD: 37f4d000 => 37318067
   PMD: 37318030 => 3883d067
   PTE: 3883d3e8 => 800000002dac8867
  PAGE: 2dac8000

      PTE         PHYSICAL  FLAGS
800000002dac8867  2dac8000  (PRESENT|RW|USER|ACCESSED|DIRTY|NX)

      VMA           START       END     FLAGS FILE
ffff9be1eb39f440     c7d000     c7f000 8100073 

      PAGE       PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff3b700b6b200 2dac8000 ffff9be1c7c7eaf1      c7d  1 1fffff00080068 uptodate,lru,active,swapbacked
# 结果正是2dac8000!和手工dump的结果完全一致!
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

我们前面绕了那么大一圈,其实直接用vtop命令就可以把整个MMU转换过程看得一清二楚。但是通过上述手工dump PTE的过程,更加熟悉了不是吗?

  但是这里出现一个问题!为什么在对p2进行只读操作时,和对它进行写入之后,其PTE指示的物理页面是不同的页面?

# 对p2内存只读操作之后 
crash> rd -p 0x3883d3e8 
3883d3e8: 800000003df22225 
———————————————— 
# 对p2内存写操作之后 
crash> rd -p 0x3883d3e8 
3883d3e8: 800000002dac8867

此外,其PTE对应的flags也不同,对其只读的时候,没有置入RW标志,即此时该页面是不可写的。

  是时候揭示谜底了!

  在对新分配的虚拟地址空间第一次读操作时,page fault确实会调入一个页面,然而对于这种读操作,所有的进程调入的都是同一个zeropage页面。对于这种第一次读操作,内核会将这同一个zeropage映射给被读的虚拟地址页面。这最大限度地发挥了Lazy策略的品性!

内核代码解释这一切(what->how)

到目前,我们已经知道所有事实,从用户态统计到内核crash工具分析,然而要想知道内核是如何做的,即从waht导出how,就要看一眼内核源码了,这里的目标非常明确,直接看do_anonymous_page的逻辑即可:

static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
        unsigned long address, pte_t *page_table, pmd_t *pmd,
        unsigned int flags)
{
    ...

    /* Use the zero-page for reads */
    if (!(flags & FAULT_FLAG_WRITE)) {
        // 只读情况直接从zeropage里拿即可。
        entry = pte_mkspecial(pfn_pte(my_zero_pfn(address),
                        vma->vm_page_prot));
        page_table = pte_offset_map_lock(mm, pmd, address, &ptl);
        if (!pte_none(*page_table))
            goto unlock;
        goto setpte;
    }
    ...
    // 非只读,才会实际从物理池里分配页面
    page = alloc_zeroed_user_highpage_movable(vma, address);
    ...
    // 如果读fault映射了zeropage,将不会递增AnonRSS计数器。
    inc_mm_counter_fast(mm, MM_ANONPAGES);
    page_add_new_anon_rmap(page, vma, address);
setpte:
    ...
}
  • 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

我们看看什么是zeropage:

/*
 * ZERO_PAGE is a global shared page that is always zero: used
 * for zero-mapped memory areas etc..
 */
extern unsigned long empty_zero_page[PAGE_SIZE / sizeof(unsigned long)];
#define ZERO_PAGE(vaddr) (virt_to_page(empty_zero_page))
...
static int __init init_zero_pfn(void)
{
    zero_pfn = page_to_pfn(ZERO_PAGE(0));
    return 0;
}
...
static inline unsigned long my_zero_pfn(unsigned long addr)
{
    extern unsigned long zero_pfn;
    return zero_pfn;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

所有谜底已经揭开!从一个实验用例,到统计分析,到crash工具分析内核状态,到内核源码确认,这就完成了一个解决问题的闭环,但貌似还缺少点什么…接下来还有一个形而上的分析。

how->why

之所以将第一次以read方式touch到的虚拟地址空间对应的物理页面映射到一个全局的zeropage,是在按需调页更进一步地加强了Lazy品性!从而更加有效地落实写时拷贝策略,将不得已而分配的物理页面真真地推迟到最后那一刻,从而将无谓的浪费行为降低到最少!

  如果说按需调页的page fault机制已经实现了Lazy品性,那么深究起来它做得还不够好,说它做得不够好是因为page fault机制忽略了按需调页两个层面中的一面:

  1. 当touch一个从未touch过的虚拟页面的时候,需要调入一个物理页面;
  2. 当调入一个物理页面时,是不是可以和其它的进程共享该物理页面;

第1点说的是按需分配,不得已时才分配,page fault做到了(注意,PTE本身也是按需调入的),第2点说的是尽力压缩,非要分配时,能不能尽量少分配,两者都做到了,Lazy策略才能达到真正按需调页思路的极致。

结论 & 评价

结论很明确:

Linux系统并不会对新分配未touch的虚拟内存映射任何物理页面; 
以read方式首次touch时会映射共享的zeropage页面; 
以write方式首次touch时会分配新的物理页面并映射之; 
以write方式在首次read touch之后touch时,写时拷贝会分配新的物理页面并映射之。

为什么是这样可以考虑以下两点:

  • 基于虚拟地址空间的操作系统内存子系统采用的是按需调页策略,这是设计决定的。
  • 参考Linux内核的实现,Linux page fault处理区分对待了匿名页的read fault和write fault。

附:关于PTE的按需调入

在进程新fork出来初始化时,mm_init函数中会调用pgd_alloc,在pgd_alloc中会处理pgd的prepopulate,但是没有pte的populate,pte的populate是在do_page_fault中处理的,可见由于内存分布的局部稠密性全局稀疏性,PTE的Lazy分配时必要的。

  其次,我们看C库增持内存池内存需要通过系统调用进一步调用到的do_brk以及do_mmap,除非你使用了VM_LOCKED,否则就不会prepopulate任何页面。上面两点保证了至少在X86的32位/64位平台,不会对用户地址空间的虚拟内存有任何可读的预映射页面

  若要观测PTE本身的按需调入,参考下面的代码:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

int main(int argc, char **argv)
{
        char *p1, *p2;
        char c;
        int i;

        p1 = sbrk(0);
        // 让p2离开更远的距离,防止p2的PTE和已分配的PTE在一个page内!
        for (i = 0; i < 4096*50; i++) {
                p2 = sbrk(4096);
        }

        printf("p1:%p  p2:%p\n", p1, p2);
        getchar();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

接下来按照上文用crash工具去逐层dump PTE,然后你会发现PMD表项或者PTE所在的页面本身尚未被调入,因此按需调页在Linux的实现中是一个递归调入的过程。

后记

敲吧,门终究会开的! —《马太福音》

展开阅读全文

没有更多推荐了,返回首页