1

机制

到目前为止,我们一直假定地址空间非常小,能放入物理内存。事实上,我们假设每个正在运行的进程的地址空间都能放入内存。现在,我们将放开这些假设,假设我们需要支持许多进程同时运行的巨大地址空间。

为了达到这个目的,需要在内存层级(memory hierarchy)上再加一层。为了支持更大的地址空间,操作系统需要把当前没有在用的那部分地址空间找个地方存储起来。一般来说,这个地方有一个特点,那就是比内存有更大的容量。在现代系统中,硬盘(hard disk drive)通常能够满足这个需求。因此,在我们的存储层级结构中,大而慢的硬盘位于底层的内存之上。

那么,我们的关键问题就是:操作系统如何利用大而慢的设备,透明地提供巨大虚拟地址空间的假象?

交换空间

我们要做的第一件事情就是,在硬盘上开辟一部分空间用于物理页的移入和移出。在操作系统中,一般这样的空间称为交换空间(swap space),因为我们将内存中的页交换到其中,并在需要的时候又交换回去。因此,我们会假设操作系统能够以页大小为单元读取或者写入交换空间。为了达到这个目的,操作系统需要记住给定页的硬盘地址(disk address)。

交换空间的大小是非常重要的,它决定了系统在某一时刻能够使用的最大内存页数。简单起见,现在假设它非常大。

在下图的例子中,可以看到一个4页的物理内存和一个8页的交换空间。有3个进程(进程0、进程1和进程2)主动共享物理内存。但3个中的每一个,都只有一部分有效页在内存中,剩下的在硬盘的交换空间中。第4个进程(进程3)的所有页都被交换到硬盘上,因此它目前没有运行。有一块交换空间是空闲的。

image.png

存在位

现在我们在硬盘上有一些空间,需要在系统中增加一些更高级的机制,来支持从硬盘交换页。具体来说,当硬件在PTE中查找时,可能发现页不在物理内存中。硬件判断是否在内存中的方法,是通过页表项中的一条新信息,即存在位(present bit)。如果存在位设置为1,则表示该页存在于物理内存中。如果存在位设置为零,则页不在内存中,而在硬盘上。访问不在物理内存中的页,这种行为通常被称为页错误(page fault)。

在页错误时,操作系统会被唤醒,执行一段称为“页错误处理程序(page-fault handler)”的代码来处理这个问题。

页错误

如果一个页不存在,它已被交换到硬盘,在处理页错误的时候,操作系统需要将该页交换到内存中。那么,问题来了:操作系统如何知道所需的页在哪儿?在许多系统中,页表是存储这些信息最自然的地方。因此,操作系统可以用PTE中的某些位来存储硬盘地址,这些位通常用来存储像页的PFN这样的数据。当操作系统接收到页错误时,它会在PTE中查找地址,并将请求发送到硬盘,将页读取到内存中。

操作系统处理页错误的大致流程如下:

  1. 操作系统必须为将要换入的页找到一个空闲的物理帧,如果没有这样的物理帧,将不得不等待交换算法运行,并从内存中踢出一些页,释放帧供这里使用。
  2. 在获得物理帧后,处理程序发出I/O请求从交换空间读取页。
  3. 当硬盘I/O完成时,操作系统会更新页表,将此页标记为存在,更新PTE的PFN字段以记录新获取页的内存位置,并重试指令。
  4. 下一次重新访问TLB还是未命中,然而这次因为页在内存中,因此会将页表中的地址更新到TLB 中(也可以在处理页错误时更新TLB以避免此步骤)。
  5. 最后的重试操作会在TLB中找到转换映射,从已转换的内存物理地址,获取所需的数据或指令。

交换何时真正发生

到目前为止,我们一直描述的是操作系统会等到内存已经完全满了以后才会执行交换流程,然后才替换(踢出)一个页为其他页腾出空间。这其实有点不切实际,因为操作系统可以更主动地预留一小部分空闲内存。

为了保证有少量的空闲内存,大多数操作系统会设置高水位线(High Watermark,HW)和低水位线(Low Watermark,LW),来帮助决定何时从内存中清除页。原理就是当操作系统发现有少于LW个页可用时,后台负责释放内存的线程会开始运行,直到有HW个可用的物理页,然后该线程会进入休眠状态。这个后台线程有时称为交换守护进程(swap daemon)或页守护进程(page daemon)。

通过同时执行多个交换过程,我们可以进行一些性能优化。例如,许多系统会把多个要写入的页聚集(cluster)或分组(group),同时写入到交换区间,从而提高硬盘的效率。

注:当有一些工作要做的时候,把这些工作放在后台合并执行可以提高效率。

策略

在虚拟内存管理程序中,当内存不够时,由于内存压力(memory pressure),操作系统会换出(paging out)一些页,为要用的页腾出空间。确定要踢出(evict)哪个页(或哪些页)封装在操作系统的替换策略(replacement policy)中。这也是我们将要讨论的问题。

在现代系统中,磁盘访问的成本非常高,即使很小概率的未命中也会拉低正在运行的程序的总体访问时间。因此,操作系统的内存替换策略必须足够聪明,避免内存频繁换进换出对系统造成的性能损耗。

最优替换策略

为了更好地理解一个特定的替换策略是如何工作的,将它与最好的替换策略进行比较是很好的方法。事实证明,替换内存中在最远将来才会被访问到的页,可以让缓存未命中率达到最低。

如果你不得不踢出一些页,为什么不踢出在最远将来才会访问的页呢?道理很简单:在引用最远将来会访问的页之前,你肯定会引用其他页。

我们追踪一个简单的例子,来理解最优策略的决定。假设缓存可以存3个页,一个程序按照以下顺序访问虚拟页:0,1,2,0,1,3,0,3,1,2,1。下表展示了最优策略的执行过程。

image.png

我们同时计算缓存命中率:有6次命中和5次未命中,那么缓存命中率是54.5%。除去强制未命中的话,那么命中率为81.8%。

遗憾的是,正如我们之前在开发调度策略时所看到的那样,未来的访问是无法知道的,你无法为通用操作系统实现最优策略。

简单策略:FIFO

许多早期的系统避免了尝试达到最优的复杂性,采用了非常简单的替换策略。例如,一些系统使用FIFO替换策略。页在进入系统时,简单地放入一个队列。当发生替换时,队列尾部的页被踢出。FIFO有一个很大的优势:实现相当简单。

对于上面的例子,让我们来看看FIFO策略是如何执行的。

image.png

对比FIFO和最优策略,FIFO明显不如最优策略,FIFO命中率只有36.4%(不包括强制性未命中为57.1%)。先进先出根本无法确定页的重要性:即使页0已被多次访问,FIFO仍然会将其踢出,因为它是第一个进入内存的。

另一简单策略:随机

另一个类似的替换策略是随机,在内存满的时候它随机选择一个页进行替换。随机具有类似于FIFO的属性。实现起来很简单,但是它在挑选替换哪个页时不够智能。让我们来看看随机策略在我们使用的例子上的执行流程。

image.png

当然,随机的表现完全取决于多幸运。在上面的例子中,随机比FIFO好一点,比最优的差一点。

利用历史数据:LRU

任何像FIFO或随机这样简单的策略都可能会有一个共同的问题:它可能会踢出一个重要的页,而这个页马上要被引用。因此,FIFO、Random和类似的策略不太可能达到最优,需要更智能的策略。

正如在调度策略所做的那样,为了提高后续的命中率,我们再次通过历史的访问情况作为参考。例如,如果某个程序在过去访问过某个页,则很有可能在不久的将来会再次访问该页。

页替换策略可以使用的一个历史信息是频率(frequency)。如果一个页被访问了很多次,也许它不应该被替换,因为它显然更有价值。页更常用的属性是访问的近期性(recency),越近被访问过的页,也许再次访问的可能性也就越大。

因此,一系列的基于历史的算法诞生了。“最不经常使用”(Least-Frequently-Used,LFU)策略会替换最不经常使用的页。同样,“最少最近使用”(Least-Recently-Used,LRU)策略替换最近最少使用的页面。

为了更好地理解LRU,我们来看看LRU如何在示例引用序列上执行。下表展示了结果,在我们的简单例子中,LRU的表现几乎快要赶上最优策略了。

image.png

如何实现基于历史信息的算法

以LRU为例,为了实现它,我们需要做很多工作。具体地说,在每次页访问时,我们都必须更新一些数据,从而将该页移动到列表的前面(即MRU侧)。为了记录哪些页是最少和最近被使用,系统必须对每次内存引用做一些记录工作。显然,如果不十分小心,这样的记录反而会极大地影响性能。

有一种方法有助于加快速度,就是增加一点硬件支持。例如,硬件可以在每个页访问时更新内存中的时间字段(时间字段可以在每个进程的页表中,或者在内存的某个单独的数组中,每个物理页有一个)。因此,当页被访问时,时间字段将被硬件设置为当前时间。然后,在需要替换页时,操作系统可以简单地扫描系统中所有页的时间字段以找到最近最少使用的页。

遗憾的是,随着系统中页数量的增长,扫描所有页的时间字段只是为了找到最精确最少使用的页,这个代价太昂贵。这就引出了一个问题:我们是否真的需要找到绝对最旧的页来替换?找到近似最旧的页可以吗?

近似LRU

答案是肯定的:从计算开销的角度来看,近似LRU更为可行,实际上这也是许多现代系统的做法。这个想法需要硬件增加一个使用位(use bit,有时称为引用位,reference bit)。系统的每个页有一个使用位,然后这些使用位存储在某个地方(例如,它们可能在每个进程的页表中,或者只在某个数组中)。每当页被引用时,硬件将使用位设置为1。但是,硬件不会清除该位,这由操作系统负责。

操作系统如何利用使用位来实现近似LRU?可以有很多方法,有一个简单的方法称作时钟算法(clock algorithm)。系统中的所有页都放在一个循环列表中。时钟指针(clock hand)开始时指向某个特定的页(哪个页不重要)。当必须进行页替换时,操作系统检查当前指向的页P的使用位是1还是0。如果是1,则意味着页面P最近被使用,因此不适合被替换。然后,将P的使用位设置为0,时钟指针递增到下一页(P + 1)。该算法一直持续到找到一个使用位为0的页,使用位为0意味着这个页最近没有被使用过(在最坏的情况下,所有的页都已经被使用了,那么就将所有页的使用位都设置为0)。

实际上,任何周期性地清除使用位,然后通过区分使用位是1和0来判定该替换哪个页的方法都是可行的。时钟算法只是一个早期成熟的算法,并且具有不重复扫描内存来寻找未使用页的特点,也就是它在最差情况下,只会遍历一次所有内存。

时钟算法还有一些变种的实现。例如在需要进行页替换时随机扫描各页,如果遇到一个页的引用位为1,就清除该位。直到找到一个使用位为0的页,将这个页进行替换。

考虑脏页

时钟算法的一个小修改,是对内存中的页是否被修改的额外考虑。这样做的原因是:如果页已被修改并因此变脏,则踢出它就必须将它写回磁盘,这很昂贵。如果它没有被修改,踢出就没成本。物理帧可以简单地重用于其他目的而无须额外的I/O。因此,一些虚拟机系统更倾向于踢出干净页,而不是脏页。

为了支持这种行为,硬件应该包括一个修改位(modified bit,又名脏位,dirty bit)。每次写入页时都会设置此位,因此可以将其合并到页面替换算法中。例如,时钟算法可以被改变,以扫描既未使用又干净的页先踢出。无法找到这种页时,再查找脏的未使用页面,等等。

其他虚拟内存策略

页面替换不是虚拟内存子系统采用的唯一策略(尽管它可能是最重要的)。例如,操作系统还必须决定何时将页载入内存。该策略有时称为页选择(page selection)策略,它向操作系统提供了一些不同的选项。

对于大多数页而言,操作系统只是使用按需分页(demand paging),这意味着操作系统在页被访问时将页载入内存中,“按需”即可。当然,操作系统可能会猜测一个页面即将被使用,从而提前载入。这种行为被称为预取(prefetching),只有在有合理的成功机会时才应该这样做。例如,一些系统将假设如果代码页P被载入内存,那么代码页P + 1很可能很快被访问,因此也应该被载入内存。

另一个策略决定了操作系统如何将页面写入磁盘。当然,它们可以简单地一次写出一个。然而,许多系统会在内存中收集一些待完成写入,并以一种更高效的写入方式将它们写入硬盘。这种行为通常称为聚集(clustering)写入,或者叫分组写入(grouping),这样做有效是因为硬盘驱动器的性质,执行单次大的写操作,比许多小的写操作更有效。

抖动

还有最后一个问题:当内存就是被超额请求时,操作系统应该做什么,这组正在运行的进程的内存需求是否超出了可用物理内存?在这种情况下,系统将不断地进行换页,这种情况有时被称为抖动(thrashing)。

目前的一些系统采用更严格的方法处理内存过载。例如,当内存超额请求时,某些版本的Linux会运行“内存不足的杀手程序(out-of-memory killer)”。这个守护进程选择一个内存密集型进程并杀死它,从而减少了内存占用。但这种方法可能会遇到问题,例如,如果它杀死了某个服务器,就会导致所有需要显示的应用程序不可用。


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道