在结束虚拟内存的研究之前,我们先研究一下VAX/VMS操作系统中的虚拟内存管理器,它实现的很干净漂亮。
内存管理硬件
在VAX-11中,它给每个进程提供了32位的虚拟地址空间,每个页的大小为512字节。因此虚拟地址由23位VPN和9位的偏移地址组成(VPN:,offset:),同时,VPN的高两位是用来区分所使用的段,所以,这个系统是分页和分段的混合体。
地址空间的上半部分是操作系统代码和数据(S),这里面的数据可以跨进程共享。地址空间的下半部分称为“进程空间”,对于每个进程都是唯一的。
在这种进程空间中的前半部分(称为P0)中,有用户程序和一个向下增长的堆。在进程空间的后半部分(P1),有向上增长的栈。
我们其实也注意到了VAX-11的页size很小,这是由于历史原因而选择的。它带来了一些问题,即线性页表会过大。因此,VMS设计人员的首要目标是要确保VMS的不会用页表占满内存。
这个系统通过两种方式来减少页表对内存的压力。
- 通过将用户地址空间分成两部分,P0和P1,同时为每个进程的每个区域(P0和P1)提供了一个页表。这个页表会存放在系统空间S中,也就是说,堆和栈之间未使用的部分不需要页表空间。同时每个页表都有其对应的基址寄存器和界限寄存器。
- 在分配和增长页表的时候,这些页表是存放在空间S中的。如果内存受到压力时,它会将部分页交换到磁盘中。
将页表放到内核的虚拟内存中意味着地址转换更复杂。因为用户的空间已经被分成了P0和P1,那么当发起内存引用的时候,硬件需要先知道P0和P1在内核空间所在的地址,这就需要访问一次物理内存了,接着再从对应的P0或P1中找到对应的页表项,这可能又需要访问一次物理内存。
幸好,VAX是硬件来管理TLB的,它大多数情况下是可以在TLB中命中缓存,而不需要每次都必须对内存发起访问。
页替换
VAX的页表项(PTE)包含以下位
- 有效位
- 保护字段(4位)
- 脏位
- OS保留的位(5位)
- PFN 物理帧号
其中我们会发现没有引用位,因为VAX是采用分段的FIFO实现的页替换
分段的页替换
VAX的开发人员提出了分段的FIFO(segmented FIFO)替换策略。这个想法很简单:每个进程都有一个可保存在内存中的最大页数,称为驻留大小(Resident Set Size, RSS)。这里的每个页都保存在FIFO列表中,当任意一个进程(进程P)超过了它对应的RSS时,那么这个进程“先入”的页则会被驱逐。
为了提高FIFO的性能,VMS还引入两个二次机会列表(second-chance list)的概念,这是两个列表,一个是干净列表(没有被修改的页),另一个是脏列表(修改过的页)。当页从FIFO列表中驱逐时,会根据这个页的脏位来决定将它放入哪个二次机会列表。
现在如果一个新进程需要一个空闲页,则可以从全局的干净列表中取出一个页来使用。这里可能还会有一个问题,即原来的进程P如果在某个页上发生了页错误,则这个页不会被加入到二次机会列表,而是会直接进行回收。这些全局的二次机会列表越大,分段的FIFO算法就越接近LRU。
页聚集
VMS还采用了另一个方案来优化分页过小的问题。因为将页交换到磁盘中的效率比较低,如果是小页面的话,那交换可能就会有多次。为了让交换的效率更高,VMS增加了一些优化,比较重要的是页聚集(clustering)。
通过聚集,VMS将大批量的页和全局的脏页列表分组到一起,并将它们一举写入磁盘(从而让脏列表变干净)。聚集在大多数系统中都有应用,因为在交换空间中可以随意放置页,所以操作系统如果对页交换进行批量操作的话,可以提高交换的性能。
模拟引用位
实际上,我们可以不需要引用位,就可以知道系统中哪些页在用。VAX上的保护位可以用来模拟引用位。他的基本思想是:先将页表中的其它页标记为不可访问,当进程访问一页时,它会在操作系统中产生一个陷阱,操作系统会检查这个页是否真的可以访问,如果是,则将该页恢复为正常访问(例如,只读或读写)。在替换时,操作系统会检查哪些页的标记是不可用的,这些不可用的标记就意味着这些页是空闲的。
这种引用位“模拟”的关键是可以减少开销。
按需置零和写时复制
VMS中有俩个优化,现在在很多操作系统中都应用广泛:按需置零和写时复制。
按需置零
我们先来理解一个没有按需置零(demand zeroing)的例子,当我需要在我的地址空间来添加一页时,操作系统会进行响应,操作系统会在内存中找到具体的页。然后将这个页添加到该程序的堆中,然后将其置零(相当于是清空数据的操作,避免访问到该内存旧的数据),接着再将其映射到你的地址空间(页表中来记录真实的页帧号)。这种实现比较简单,但是成本会比较高,如果程序从来没有访问过这一页的话,其实是不需要将其置零的。
如果采用按需置零,将一个页添加到地址空间时(malloc,mmap等),操作系统不会去做过多的操作。它会在页表中添加一个条目,同时将这个条目标记为不可访问。
当程序真正对这个页进行读取或者写入时,会陷入到操作系统的陷阱。操作系统会调用对应的异常处理程序(这里会调用缺页处理程序),接着在异常处理程序中会发现这个页是一个按需置零页(即不可访问)。
此时,操作系统会先找到这个页的具体位置,然后将其置零,并将其映射到进程的地址空间。如果进程一直没有对这个页进行访问的话,则这些工作都可以避免,从而提现按需置零的优势。
写时复制
在说明写时复制前,我们首先要知道两个函数:
fork()
和exec()
。需要注意的是
exec()
并不是一个特定的函数, 它是一组函数的统称, 它包括了execl()
、execlp()
、execv()
、execle()
、execve()
、execvp()
。写时复制(copy-on-write, COW)这个优化现在几乎所有的操作系统都有使用。这个想法最早可以追溯到TENEX操作系统,这个想法也很好理解:如果操作系统将一个页面从一个地址空间复制到另一个地址空间,不是实际的去复制它,而是会将其映射到目标地址空间,然后在两个地址空间中都将其标记为只读。如果两个地址空间都只是去读取页面,则相安无事,因为操作系统已经实现了快速复制。
但如果其中一个地址空间尝试写入页面的时候,就会陷入操作系统(触发对应的错误处理程序)。操作系统会发现这个页面是一个写时复制(COW)页面,此时(惰性,lazy)会分配一个新的页面,填充数据,接着将这个新页映射到触发错误处理的地址空间。该进程然后继续运行,现在这个进程有了该页的私人副本。
写时复制成功还有另一些原因。
- 如任何类型的共享库都可以通过写时复制,映射到许多进程的地址空间,从而节省宝贵的内存。
- UNIX系统中的fork和exec也是COW起了重要的作用。如果在fork调用时就创建父进程地址空间的所有副本(代码段,数据段,堆栈等)。如果这个地址空间很大,则很慢,并且数据是密集型的。 而且大多程序调用fork后是为了执行和父进程不一样的任务,因此子进程很可能还会继续调用exec(它用即将执行的程序覆盖调用进程的地址空间)。 那么如果将fork设计成写时复制,就可以让操作系统避免了大量的不必要复制,一方面保留了语义性,另一方面还提高了性能。