你好,我是海纳。
这节课是对前面所有课程的一次总结和回顾。前面我们介绍了很多内存管理的相关机制,其实都是为了把这节课的故事讲完整。在前面的课程里,我们了解了进程内部的分布,但也留下了三个关键的问题没有讲清楚:
这三个问题,虽然看上去相互之间关系不大,但实际上它们背后都依赖页中断机制。
页中断和普通的中断一样,它的中断服务程序入口也在IDT中(第2节课的内容),但它是由MMU产生的硬件中断。页中断有两类重要的类型:写保护中断和缺页中断。正是这两类中断在整个系统的后台默默地工作着,就像守护神一样支撑着内存系统正常工作。
大多数时候,我们即使不知道它们的存在,程序也能正常地运行。但是有时候,程序写得不好就有可能造成中断频繁发生,从而带来巨大的性能下降。面对这种情况,我们第一时间就应该想到统计页中断。因为除了页中断本身会带来性能下降之外,统计页中断也可以反推程序的运行特点,从而为进一步分析程序瓶颈点,提供数据和思路。
讲到这,我想你应该意识到掌握页中断的必要性了,其实这也是我们这节课的学习目标,同时我们还将借此解决上面提到的三个问题。好,不啰嗦了,我们先了解下页中断有哪些类型吧。
在之前的课程里,我们介绍了页表映射的原理,也提到过页表项里定义了页的读写属性等等。如果物理页不在内存中,或者页表未映射,或者读写请求不满足页表项内的权限定义时,MMU单元就会产生一次中断。
我们在第2节课中详细介绍了中断机制和IDT的结构,并且在介绍中断向量时提到过页中断的向量是14。所以,操作系统在启动以后,它会把处理页中断的程序入口地址,设置到IDT的14号中断描述符里。在Linux系统上,页中断服务程序的名称是do_page_fault。
当中断发生以后,CPU会自动地在栈里存放一个错误码,来区分页中断的类型,还会把发生页中断的虚拟地址放到CR2寄存器,这样,中断服务程序就可以清楚地知道是什么原因导致的中断,然后才能做出相应的处理。
根据中断来源的不同,页中断大致可以分为以下几种类型:
从这个表格里,你会发现,页中断服务程序根据不同的情况,兢兢业业地为整个系统的内存管理,默默做着贡献。接下来,我们就带着这节课开头提出的三个问题,来看看页中断是怎么工作的。我们先从第一个问题,fork的原理是什么开始吧。
我们前面说,父进程和子进程不仅可以访问共有的变量,还可以各自修改这个变量,并且这个修改对方都看不见。这其实是fork的一种写时复制机制,这一点我们在第5节课中模糊提到过,而里面起关键作用的就是写保护中断。下面我们来看看这到底是怎么一回事。
实际上,操作系统为每个进程提供了一个进程管理的结构,在偏理论的书籍里一般会称它为进程控制块(Process Control Block,PCB)。具体到Linux系统上,PCB就是task_struct这个结构体。它里面记录了进程的页表基址,打开文件列表、信号、时间片、调度参数和线性空间已经分配的内存区域等等数据。
其中,描述线性空间已分配的内存区域的结构对于内存管理至关重要,我们先来看一下这个结构。在Linux源码中,负责这个功能的结构是vm_area_struct,后面简称vma。内核将每一段具有相同属性的内存区域当作一个单独的内存对象进行管理。vma中比较重要的属性我列在下面:
struct vm_area_struct {
unsigned long vm_start; // 区间首地址
unsigned long vm_end; // 区间尾地址
pgprot_t vm_page_prot; // 访问控制权限
unsigned long vm_flags; // 标志位
struct file * vm_file; // 被映射的文件
unsigned long vm_pgoff; // 文件中的偏移量
...
}
在操作系统内核里,fork的第一个动作是把PCB复制一份,但类似于物理页等进程资源不会被复制。这样的话,父进程与子进程的代码段、数据段、堆和栈都是相同的,这是因为它们拥有相同的页表,自然也有相同的虚拟空间布局和对物理内存的映射。如果父进程在fork子进程之前创建了一个变量,打开了一个文件,那么父子进程都能看到这个变量和文件。
fork的第二个动作是复制页表和PCB中的vma数组,并把所有当前正常状态的数据段、堆和栈空间的虚拟内存页,设置为不可写,然后把已经映射的物理页面的引用计数加1。这一步只需要复制页表和修改PTE中的写权限位可以了,并不会真的为子进程的所有内存空间分配物理页面,修改映射,所以它的效率是非常高的。这时,父子进程的页表的情况如下图所示:
在上图中,物理页括号中的数字代表该页被多少个进程所引用。Linux中用于管理物理页面,和维护物理页的引用计数的结构是mem_map和page struct。
这两个动作执行完后,fork调用就结束了。此时,由于有父进程和子进程两个PCB,操作系统就会把两个进程都加入到调度队列中。当父进程得到执行,它的IP寄存器还是指向fork调用中,所以它会从这个调用中返回,只不过返回值是子进程的PID。当子进程得到执行时,它的IP寄存器也是停在fork调用中,它从这个调用中返回,其返回值是0。
接下来,就是写保护中断要发挥作用的地方了。不管是父进程还是子进程,它们接下来都有可能发生写操作,但我们知道在fork的第二步操作中,已经将所有原来可写的地方都变成不可写了,所以这时必然会发生写保护中断。
我们刚才说,Linux系统的页中断的入口地址是do_page_fault,在这个函数里,它会继续判断中断的类型。由于发生中断的虚拟地址在vma中是可写的,在PTE中却是只读的,可以断定这是一次写保护中断。这时候,内核就会转而调用do_wp_page来处理这次中断,wp是write protection的缩写。
在do_wp_page中,系统会首先判断发生中断的虚拟地址所对应的物理地址的引用计数,如果大于1,就说明现在存在多个进程共享这一块物理页面,那么它就需要为发生中断的进程再分配一个物理页面,把老的页面内容拷贝进这个新的物理页,最后把发生中断的虚拟地址映射到新的物理页。这就完成了一次写时复制(Copy On Write, COW)。具体过程如下图所示:
在上图中,当子进程发生写保护中断后,系统就会为它分配新的物理页,然后复制页面,再修改页表映射。这时老的物理页的引用计数就变为1,同时子进程中的PTE的权限也从只读变为读写。
当父进程再访问到这个地址时,也会触发一次写保护中断,这时系统发现物理页的引用计数为1,那就只要把父进程PTE中的权限,简单地从只读变为读写就可以了。这个过程比较简单,我就不画图了,你可以自己思考一下。
fork之后如果要执行新的程序,那么就需要执行execve这个系统调用。它的主要作用是加载可执行程序并运行。接下来我们就看看这个函数背后的故事。
接着来说这节课开始时所提到的第二个问题,未映射页面是如何自动变成正常页面的?我们将通过execve的例子来进行分析。
execve的作用是使当前进程执行一个新的可执行程序,它的原型如下所示:
#include <unistd.h>
int execve(const char* filename, const char* argv[],
const char* envp[])
其中execve的第一个参数是可执行程序的文件名,第二个参数用来传递命令行参数,第三个参数用来传递环境变量。
execve的执行步骤如下所示:
我们在第3节课讲了section与内存中的segment的对应关系。execve的实现并不负责将文件内容加载到物理页中,它只建立了这种文件section,与内存区域的映射关系就结束了。真正负责加载文件内容的是缺页中断,接下来,我们就看看缺页中断是如何加载物理页的。
在execve的执行步骤中,我们讲了,内核为可执行程序创建一个vma结构体实例,然后将它的vm_file属性设成第2步所打开的文件,这就建立起了内存区域和文件的映射关系。这个内核区域的区间首地址、区间尾地址和控制权限,都是由第3步解析的信息决定的。例如.text段被加载到的内存首地址,也就是链接时所决定的起始地址,它就决定了内存代码段的起始地址。
由于第1步把页表都清空了,这就导致CPU在加载指令时会发现代码段是缺失的,此时就会产生缺页中断。
Linux内核用于处理缺页中断的函数是do_no_page,如果内核检查,当前出现缺页中断的虚拟地址所在的内存区域vma(虚拟地址落在该内存区域的vm_start和vm_end之间)存在文件映射(vm_file不为空),那就可以通过虚拟内存地址计算文件中的偏移,这就定位到了内存所缺的页对应到文件的哪一段。然后内核就启动磁盘IO,将对应的页从磁盘加载进内存。一次缺页中断就这样被解决了。
到这里,第二个问题的答案你就都搞清楚了。可执行程序的加载不是一次性完成的,而是由缺页中断根据需要,将文件的内容以页为单位加载进内存的,一次只会加载一页。
搞清楚了execve背后的原理,我们再来分析mmap的原理,你就很容易理解了,因为它背后的机制仍然是围绕着vm_area_struct这个核心结构,由页中断来完成各种功能。
在回答这节课开始提出的第三个问题,也就是mmap的功能十分强大,这些强大的能力是怎么完成的前,我们先回顾下第3节课的内容,mmap根据映射的类型,有四种最常用的组合:
我们接下来针对这四种情况依次进行分析。
私有匿名映射是最简单的情况,在调用mmap时,只需要在文件映射区域分配一块内存,然后创建这块内存所对应的vma结构,这次调用就结束了。
当访问到这块虚拟内存时,由于这块虚拟内存都没有映射到物理内存上,就会发生缺页中断,但这一次的缺页中断与execve时的缺页中断不一样,这次是匿名映射,所以关联文件属性为空。此时,内核就会调用do_anonymous_page来分配一个物理内存,并将整个物理页全部初始化为0,然后在页表里建立起虚拟地址到物理地址的映射关系。
在内核中,如果有一个进程打开了一个文件,PCB中就会有一个struct file结构与这个文件对应。struct file结构是与进程相关,假如进程A与进程B都打开了文件f,那么进程A中就会有一个struct file结构,进程B中也会有一个。
Linux的文件系统中有一个叫做inode的结构,这个结构与具体的磁盘上的文件是一一对应的,也就是说对于同一个文件,整个内核中只会有一个inode结构。所以进程A与进程B的file struct结构都有一个指针指向inode结构,这就将file struct与inode结构联系起来了。
在inode结构中,有一个哈希表,以文件的页号为key,以物理内存页为value。当进程A打开了文件f,然后读取了它的第4页,这时,内核就会把4和这个物理页,放入这个哈希表中。当进程B再打开文件f,要读取它的第4页时,因为f的第4页的内容已经被加载到物理页中了,所以就不用再加载一次了。只需要将B的虚拟地址与这个物理页建立映射就可以了,如下图所示:
我要提醒你的是,哈希表在现代的Linux内核中,已经被优化成了Radix tree和最小堆的一种优化的数据结构,它们比哈希表有更好的时间效率,所以你在阅读不同版本的Linux内核代码时要注意这个变化。
如果文件是只读的话,那这个文件在物理页的层面上其实是共享的。也就是进程A和进程B都有一页虚拟内存被映射到了相同的物理页上。但如果要写文件的时候,因为这一段内存区域的属性是私有的,所以内核就会做一次写时复制,为写文件的进程单独地创建一份副本。这样,一个进程在写文件时,并不会影响到其他进程的读。
对于共享库文件,代码段的私有属性其实并不影响它在所有进程间共享;但如果数据段在执行的过程发生变化,内核就可以通过写时复制机制为每个进程创建一个副本。这就是对于共享库文件要选择私有文件映射的根本原因。
这里我们就有这样一个结论:私有文件映射的只读页是多进程间共享的,可写页是每个进程都有一个独立的副本,创建副本的时机仍然是写时复制。
在私有文件映射的基础上,共享文件映射就很简单了:对于可写的页面,在写的时候不进行复制就可以了。这样的话,无论何时,也无论是读还是写,多个进程在访问同一个文件的同一个页时,访问的都是相同的物理页面。
在这节课之前,你可能会觉得共享匿名映射在父子进程间通讯是最简单的,因为父子进程共享了相同的mmap的返回值,看上去最直观。但实际上,从内核的角度说,它却是最复杂的。
原因是mmap并不真正分配物理内存,它只是分配了一段虚拟内存,也就是说只在PCB中创建了一个vma结构而已。这就导致fork在复制页表的时候,页表中共享匿名映射区域都是未映射状态。
请你设想一下,如果内核不做特殊处理的话,在父进程因为访问共享内存区域而遇到缺页中断时,内核为它分配了物理页面,等子进程再访问共享内存区域时,内核也没有办法知道子进程的虚拟内存,应该映射到哪个物理页面上,因为缺页中断只能知道当前进程是谁,以及发生问题的虚拟地址是什么,这些信息不足够计算出,是否有其他进程已经把共享内存准备好了。
在内核中使用虚拟文件系统来解决这个问题之前,早期的Linux内核中并不支持共享匿名映射。虚拟文件并不是真实地在磁盘上存在的。它只是由内核模拟出来的,但是它也有自己的inode结构。这样一来,内核就能在创建共享匿名映射区域时,创建一个虚拟文件,并将这个文件与映射区域的vma关联起来。
当fork创建子进程时,子进程会复制父进程的全部vma信息。接下来发生的事情就和共享文件映射完全一样了,我们就不再重复了。
至此,我们才终于把mmap的核心原理分析清楚。第三个问题的答案也就很清楚了:mmap的功能之所以十分强大,主是因为操作系统综合使用写保护中断、缺页中断和文件机制来实现mmap的各种功能。
这节课,我们先介绍了页中断产生的原因,大致可以分为缺页中断、写保护中断和非法访问造成的中断等等。
接下来,我们深入地分析了fork的原理。fork在执行时,子进程只会复制父进程的PCB和页表,并且把所有页表项都设为只读,这个过程并不会复制真正的物理页。只有当父子进程其中一个对页进行写操作的时候,才会复制一个副本出来。这种机制被称为写时复制。
execve是一种系统调用,用于加载并运行一个可执行文件。它会打开文件,并做好文件的section与内存segment的映射,这种映射关系维护在vm_area_struct中,然后就清空页表退出执行了。
当指令真正访问到内存的时候,由于页表被清空,这时会产生缺页中断,然后,内核就使用vma中的文件映射关系,去磁盘上读取相应的内容,将它放到物理页中,最后建立好虚拟地址到物理地址的映射。这是一种按需加载的机制。
我们分析了mmap背后的页中断原理,根据映射的类型,我们还介绍了它常用的4种组合和作用。其中:
最后,你要特别注意的一点是,Linux内核为了优化性能,还引入了大量的结构,这使得研究内存管理的源代码变得非常困难。我们这里主要介绍了设计思路,而不会涉及到具体的细节,如果想研究Linux内存管理的源码的话,你还可以继续参考《Understanding the Linux Virtual Memory Manager 》、《Linux内核设计与实现》和《深入理解LINUX内核 》等资料。
通过这节课的学习,我相信我已经帮你建立起了基本的骨架,填充更多细节的任务就交给你自己完成吧。
我们先将第3节课的mmap的例子,映射方式修改为MAP_PRIVATE。请你结合本节课所学的知识分析它的运行结果。欢迎你在留言区分享你的想法和收获,我在留言区等你。
#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid;
char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE| MAP_ANONYMOUS, -1, 0);
if (!(pid = fork())){
sleep(1);
printf("child got a message: %s\n", shm);
sprintf(shm, "%s", "hello, father.");
exit(0);
}
sprintf(shm, "%s", "hello, my child");
sleep(2);
printf("parent got a message: %s\n", shm);
return 0;
}
好啦,这节课到这就结束啦。欢迎你把这节课分享给更多对计算机内存感兴趣的朋友。我是海纳,我们下节课再见!
评论