下一个是一个非常常见的优化,许多操作系统都实现了它,同时它也是后面一个实验的内容。这就是copy-on-write fork,有时也称为COW fork。
当Shell处理指令时,它会通过fork创建一个子进程。fork会创建一个Shell进程的拷贝,所以这时我们有一个父进程(原来的Shell)和一个子进程。Shell的子进程执行的第一件事情就是调用exec运行一些其他程序,比如运行echo。现在的情况是,fork创建了Shell地址空间的一个完整的拷贝,而exec做的第一件事情就是丢弃这个地址空间,取而代之的是一个包含了echo的地址空间。这里看起来有点浪费。
所以,我们最开始有了一个父进程的虚拟地址空间,然后我们有了子进程的虚拟地址空间。在物理内存中,XV6中的Shell通常会有4个page,当调用fork时,基本上就是创建了4个新的page,并将父进程page的内容拷贝到4个新的子进程的page中。
但是之后,一旦调用了exec,我们又会释放这些page,并分配新的page来包含echo相关的内容。所以对于这个特定场景有一个非常有效的优化:当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。所以这里,我们可以设置子进程的PTE指向父进程对应的物理内存page。
当然,再次要提及的是,我们这里需要非常小心。因为一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性,所以这里我们需要更加小心一些。为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE的标志位都设置成只读的。
在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。因为父进程和子进程都会继续运行,而父进程或者子进程都可能会执行store指令来更新一些全局变量,这时就会触发page fault,因为现在在向一个只读的PTE写数据。
在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。
所以现在,我们拷贝了一个page,将新的page映射到相应的用户地址空间,并重新执行用户指令。重新执行用户指令是指调用userret函数(注,详见6.8),也即是lec06中介绍的返回到用户空间的方法。
学生提问:我们如何发现父进程写了这部分内存地址?是与子进程相同的方法吗?
Frans教授:是的,因为子进程的地址空间来自于父进程的地址空间的拷贝。如果我们使用了特定的虚拟地址,因为地址空间是相同的,不论是父进程还是子进程,都会有相同的处理方式。
学生提问:对于一些没有父进程的进程,比如系统启动的第一个进程,它会对于自己的PTE设置成只读的吗?还是设置成可读写的,然后在fork的时候再修改成只读的?
Frans教授:这取决于你。实际上在lazy lab之后,会有一个copy-on-write lab。在这个lab中,你自己可以选择实现方式。当然最简单的方式就是将PTE设置成只读的,当你要写这些page时,你会得到一个page fault,之后你可以再按照上面的流程进行处理。
学生提问:因为我们经常会拷贝用户进程对应的page,内存硬件有没有实现特定的指令来完成拷贝,因为通常来说内存会有一些读写指令,但是因为我们现在有了从page a拷贝到page b的需求,会有相应的拷贝指令吗?
Frans教授:x86有硬件指令可以用来拷贝一段内存。但是RISC-V并没有这样的指令。当然在一个高性能的实现中,所有这些读写操作都会流水线化,并且按照内存的带宽速度来运行。
在我们这个例子中,我们只需要拷贝1个page,对于一个未修改的XV6系统,我们需要拷贝4个page。所以这里的方法明显更好,因为内存消耗的更少,并且性能会更高,fork会执行的更快。
学生提问:当发生page fault时,我们其实是在向一个只读的地址执行写操作。内核如何能分辨现在是一个copy-on-write fork的场景,而不是应用程序在向一个正常的只读地址写数据。是不是说默认情况下,用户程序的PTE都是可读写的,除非在copy-on-write fork的场景下才可能出现只读的PTE?
Frans教授:内核必须要能够识别这是一个copy-on-write场景。几乎所有的page table硬件都支持了这一点。我们之前并没有提到相关的内容,下图是一个常见的多级page table。对于PTE的标志位,我之前介绍过第0bit到第7bit,但是没有介绍最后两位RSW。这两位保留给supervisor software使用,supervisor softeware指的就是内核。内核可以随意使用这两个bit位。所以可以做的一件事情就是,将bit8标识为当前是一个copy-on-write page。
当内核在管理这些page table时,对于copy-on-write相关的page,内核可以设置相应的bit位,这样当发生page fault时,我们可以发现如果copy-on-write bit位设置了,我们就可以执行相应的操作了。否则的话,比如说lazy allocation,我们就做一些其他的处理操作。
在copy-on-write lab中,你们会使用RSW在PTE中设置一个copy-on-write标志位。
在copy-on-write lab中,还有个细节需要注意。目前在XV6中,除了trampoline page外,一个物理内存page只属于一个用户进程。trampoline page永远也不会释放,所以也不是什么大问题。但是对于这里的物理内存page,现在有多个用户进程或者说多个地址空间都指向了相同的物理内存page,举个例子,当父进程退出时我们需要更加的小心,因为我们要判断是否能立即释放相应的物理page。如果有子进程还在使用这些物理page,而内核又释放了这些物理page,我们将会出问题。那么现在释放内存page的依据是什么呢?
我们需要对于每一个物理内存page的引用进行计数,当我们释放虚拟page时,我们将物理内存page的引用数减1,如果引用数等于0,那么我们就能释放物理内存page。所以在copy-on-write lab中,你们需要引入一些额外的数据结构或者元数据信息来完成引用计数。
学生提问:我们应该在哪存储这些引用计数呢?因为如果我们需要对每个物理内存page的引用计数的话,这些计数可能会有很多。
Frans教授:对于每个物理内存page,我们都需要做引用计数,也就是说对于每4096个字节,我们都需要维护一个引用计数(似乎并没有回答问题)。
学生提问:我们可以将引用计数存在RSW对应的2个bit中吗?并且限制不超过4个引用。
Frans教授:讲道理,如果引用超过了4次,那么将会是一个问题。因为一个内存引用超过了4次,你将不能再使用这里的优化了。但是这里的实现方式是自由的。
学生提问:真的有必要额外增加一位来表示当前的page是copy-on-write吗?因为内核可以维护有关进程的一些信息...
Frans教授:是的,你可以在管理用户地址空间时维护一些其他的元数据信息,这样你就知道这部分虚拟内存地址如果发生了page fault,那么必然是copy-on-write场景。实际上,在后面的一个实验中,你们需要出于相同的原因扩展XV6管理的元数据。在你们完成这些实验时,具体的实现是很自由的。