Lazy page allocation
看一下内存allocation,或者更具体的说brk系统调用,brk系统调用会调整一个指针,该指针指向堆的最顶端。
brk()系统调用会拓展heap的上界,也就是扩大heap的容量。
这意味着,当brk实际发生或者被调用的时候,内核会分配一些物理内存,并将这些内存映射到用户应用程序的地址空间,然后将内存内容初始化为0,再返回brk系统调用。这样,应用程序可以通过多次brk系统调用来增加它所需要的内存。类似的,应用程序还可以通过给brk传入负数作为参数,来减少或者压缩它的地址空间。
eager allocation 和lazy allocation
在最初brk的实现默认是eager allocation。这表示了,一旦调用了brk,内核会立即分配应用程序所需要的物理内存。但是实际上,对于应用程序来说很难预测自己需要多少内存,所以通常来说,应用程序倾向于申请多于自己所需要的内存。这意味着,进程的内存消耗会增加许多,但是有部分内存永远也不会被应用程序所使用到。你或许会认为这里很蠢,怎么可以这样呢?你可以设想自己写了一个应用程序,读取了一些输入然后通过一个矩阵进行一些运算。你需要为最坏的情况做准备,比如说为最大可能的矩阵分配内存,但是应用程序可能永远也用不上这些内存,通常情况下,应用程序会在一个小得多的矩阵上进行运算。所以,程序员过多的申请内存但是过少的使用内存,这种情况还挺常见的。原则上来说,这不是一个大问题。
使用虚拟内存和page fault handler,我们完全可以用某种更聪明的方法来解决这里的问题,这里就是利用lazy allocation。核心思想非常简单,sbrk系统调基本上不做任何事情,唯一需要做的事情就是提升heap的顶部,将heap顶部增加n,其中n是需要新分配的内存page数量。但是内核在这个时间点并不会分配任何物理内存。之后在某个时间点,应用程序使用到了新申请的那部分内存,这时会触发page fault,因为我们还没有将新的内存映射到page table。所以,如果我们解析一个大于旧的堆顶,但是又小于新的堆顶(注,也就是旧的堆顶 + n)的虚拟地址,我们希望内核能够分配一个内存page,并且重新执行指令。
所以,当我们看到了一个page fault,相应的虚拟地址小于当前堆顶,那么我们就知道这是一个来自于heap的地址,但是内核还没有分配任何物理内存。所以对于这个page fault的响应也理所当然的直接明了:在page fault handler中,通过kalloc函数分配一个内存page;初始化这个page内容为0;将这个内存page映射到user page table中;最后重新执行指令。比方说,如果是load指令,或者store指令要访问属于当前进程但是还未被分配的内存,在我们映射完新申请的物理内存page之后,重新执行指令应该就能通过了。
Copy-On-Write Fork
copy-on-write 以下简称(cow) 是一种常用的内存分配优化方式,许多操作系统都实现了它。
cow的目标问题
当Shell处理指令时,它会通过fork创建一个子进程。fork会创建一个Shell进程的拷贝,所以这时我们有一个父进程(原来的Shell)和一个子进程。fork创建了Shell地址空间的一个完整的拷贝,fork会对父进程的地址空间进行完整拷贝。但是随后shell子进程会执行exec,而exec所做的第一件事儿就是放弃该进程的地址空间,这样对原来fork拷贝的地址空间造成了不少浪费。
具体一点:假如我们最开始有了一个父进程的虚拟地址空间,然后我们有了子进程的虚拟地址空间。在物理内存中,假设Shell有4个page,当调用fork时,基本上就是创建了4个新的page,并将父进程page的内容拷贝到4个新的子进程的page中。一旦调用了exec,我们又会释放这些page,并分配新的page来包含echo相关的内容。造成了十足的浪费。
下图左边为父进程子进程的页表,右边为物理地址空间。
cow核心原理
所以
对于这个特定场景有一个非常有效的优化:**当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。**所以这里,我们可以设置子进程的PTE指向父进程对应的物理内存page。
但是如果新创建的子进程指向父进程的相应物理地址空间,那么就会出现写冲突的情况,如果子进程对存储数据进行修改,那么父进程读取到的数据就会是被修改过后的数据。
再次要提及的是,我们这里需要非常小心。因为一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性,所以这里我们需要更加小心一些。为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE(page table entry)的标志位都设置成只读的。
在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。因为父进程和子进程都会继续运行,而父进程或者子进程都可能会执行store指令来更新一些全局变量,这时就会触发page fault,因为现在在向一个只读的PTE写数据。
在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。
在发生page fault的时候会发生Trap,然后由相应的内核来进行处理。
cow在进程释放中的处理
似乎这样就很完美了,即处理了fork中造成的资源浪费,又解决了写冲突的问题。但是我们还是考虑漏了一点。
假如说运行在一个有着cow机制的机器上,并且我也通过父进程fork了一个新的子进程。如果此时我们释放掉父进程,此时的释放地址空间必须格外小心,现在有多个用户进程或者说多个地址空间都指向了相同的物理内存page,举个例子,当父进程退出时我们需要更加的小心,因为我们要判断是否能立即释放相应的物理page。如果有子进程还在使用这些物理page,而内核又释放了这些物理page,我们将会出问题。那么现在释放内存page的依据是什么呢?
我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write 中,需要引入一些额外的数据结构或者元数据信息来完成引用计数。
cow的PTE标志
当发生page fault时,我们其实是在向一个只读的地址执行写操作。内核如何能分辨现在是一个copy-on-write fork的场景,而不是应用程序在向一个正常的只读地址写数据。是不是说默认情况下,用户程序的PTE都是可读写的,除非在copy-on-write fork的场景下才可能出现只读的PTE?
内核必须要能够识别这是一个copy-on-write场景。几乎所有的page table硬件都支持了这一点。我们之前并没有提到相关的内容,下图是一个常见的多级page table。最后两位RSW。这两位保留给supervisor software使用,supervisor softeware指的就是内核。内核可以随意使用这两个bit位。所以可以做的一件事情就是,将bit8标识为当前是一个copy-on-write page。