Virtual Memory Primitives for User Program
安德鲁·阿普尔(Andrew Appel)和李凯(Kai Li)
普林斯顿大学计算机科学系
摘要
传统上,内存管理单元(MMUS)被操作系统用于实现磁盘分页的虚拟内存。一些操作系统允许用户程序指定页面的保护级别(不可访问、只读、读写),并允许用户程序处理保护违规。但这些机制不总是坚固、高效,也不总是与应用程序的需求匹配。
我们调查了几种使用页面保护技术的用户级算法,并分析了它们的共同特点,试图回答以下问题:“操作系统应该为用户进程提供什么虚拟内存基元,当前的操作系统如何提供它们?”
1引言
虚拟内存的“传统”目的是通过只允许常访问的地址空间子集驻留在物理内存中,来增加用户程序可见的地址空间的大小。但是,虚拟内存已经被用于许多其他目的。操作系统可以在进程之间共享页面,使指令空间只读(从而保证可重入性),使内存的部分区域在需求时清零或在写入时复制,等等。实际上,操作系统可以利用页面保护硬件执行一大类“技巧”。
现代操作系统也允许用户程序执行此类技巧,方法是允许用户程序为保护违规提供“处理程序”。例如,Unix允许用户进程指定每当生成段错误信号时执行特定子例程。当程序访问超出其合法虚拟地址范围的内存时,用户提供的信号处理程序可以生成用户友好的错误消息,而不是令人不安的“段错误:core dumped”。
这个简单的用户模式故障处理程序示例是“危险的”,因为它可能会使操作系统和硬件设计者相信用户模式故障处理程序不需要高效地进入(这对于“优雅的错误关闭”示例来说肯定是正确的)。但是,用户模式故障处理程序有更有趣的应用。这些应用程序对页面保护和故障处理机制进行了相当严格的测试,并且应该被操作系统实现者理解。
本文描述了几种利用页面保护技术的算法。在许多情况下,这些算法可以用“传统”的分页硬件替代曾经使用过的“特殊”微码。在共享内存多处理器上,这些算法利用页面保护硬件实现了中粒度同步,并且开销较低,以避免具有显著开销的同步指令序列。
我们对多个系统进行了基准测试,以分析当今的操作系统对用户级页面保护技术的支持情况。最后,从这些算法中我们总结了有关页面保护成本、内存映射机制的效用、翻译缓冲区的清理、页面大小以及操作系统实现的其他方面的经验教训。
2 虚拟内存基元(Virtual memory primitives)
我们将描述的每个算法都需要操作系统提供以下一些虚拟内存服务:
TRAP:在用户模式下处理页面错误陷阱;
PROT1:降低页面的可访问性;
PROTN:降低 N 个页面的可访问性;
UNPROT:增加页面的可访问性;
DIRTY:返回自上次调用以来被修改的页面列表。
MAP2:在同一地址空间中以不同的保护级别将同一物理页面映射到两个不同的虚拟地址。
最后,一些基于更小PAGESIZE的算法可能会比通常用于磁盘分页的PGAESIZE更有效率。
我们区分“降低页面的可访问性”和“降低一批页面的可访问性”之间的区别有一个特定的原因。同时更改多个页面的保护的成本可能不会比更改一个页面的保护的成本高出多少。我们描述的几种算法只在大批量中保护页面(使其更不可访问)。因此,如果操作系统的实现无法有效地降低一个页面的可访问性,但可以以较低的每页成本降低大批量页面的可访问性,则对于某些算法来说这是足够的。
我们不会对单个页面与多个页面的解除保护进行区分,因为我们描述的所有算法都不会同时解除保护多个页面。一些多线程算法要求一个线程在其他线程对页面出现错误时仍能访问特定页面。对于这种问题有很多解决方案(稍后将描述),但一个简单且高效的解决方案是将页面映射到多个虚拟地址:在一个地址上页面是可访问的,而在另一个地址上它会出现错误。出于效率原因,这两个不同的虚拟地址应该在同一个页表中,这样就不需要在线程之间进行昂贵的页表上下文切换。
用户程序可以使用PROTN、TRAP和UNPROT跟踪脏页面;我们将DIRTY列为单独的基元,因为操作系统直接提供此服务可能更有效率。
3 虚拟内存应用(Virtual memory application)
在本节中,我们展示了一些应用示例,这些示例使用虚拟内存基元来代替软件测试、特殊硬件或微码。页面保护硬件可以高效地在地址上测试简单的谓词,否则每次获取和/或存储可能需要额外的一两条指令;这是一个相当大的节省,因为获取和存储操作确实非常常见。我们调查了几种算法,以便我们可以尝试得出关于用户程序对操作系统和硬件的需求的一般结论。
并发垃圾回收
并发实时的复制式垃圾回收算法可以利用页面错误机制在收集器线程和mutator线程之间实现中等粒度的同步。页面机制提供了足够粗糙以保证高效的同步,同时又足够精细以使延迟较低。该算法基于Baker的顺序实时复制收集器算法。
Baker的算法将内存堆分为两个区域:from-space和to-space。在收集开始时,所有对象都位于from-space中,而to-space为空。从寄存器和其他全局根开始,收集器追踪从根可达的对象图,将每个可达对象复制到to-space中。从from-space中的对象指针通过将其指向旧对象的to-space副本进行转发。当然,一些from-space中的对象永远不会被复制到to-space中,因为从未有指针指向它们;这些对象是垃圾。
一旦寄存器被转发,mutator线程就可以恢复执行。可达对象在mutator分配新对象的同时逐渐从from-space中复制出来。每当mutator分配新对象时,它都会调用收集器来从from-space中复制更多对象。Baker的算法保持以下不变性:
- 收集器只看到其寄存器中的to-space指针。
- 新区域中的对象仅包含to-space指针(因为新对象是从寄存器初始化的)。
- 扫描区域中的对象仅包含to-space指针。
- 未扫描区域中的对象包含from-space和to-space指针。
为了满足mutator只看到其寄存器中的to-space指针这一不变性,从对象中获取的每个指针都必须进行检查,以查看它是否指向from-space。如果是,就将from-space对象复制到to-space并更新指针:然后将指针返回给mutator。这种检查需要硬件支持才能高效实现,因为否则每次获取都必须执行一些额外的指令。此外,mutator和收集器必须交替进行:它们无法真正并发地操作,因为它们可能同时尝试将相同的对象复制到不同的位置。
与从内存中获取的每个指针进行检查相反,并发收集器[4]使用虚拟内存页面保护来检测from-space内存引用并同步收集器和mutator线程。为了同步mutator和collector线程,该算法将未扫描区域页面的虚拟内存保护设置为“无访问权限”。每当mutator尝试访问未扫描的对象时,它将收到一个页面访问陷阱。收集器捕获陷阱并扫描该页面上的对象,根据需要复制from-space对象并转发指针。然后,它取消保护页面并将mutator定位到故障指令处。对于mutator来说,该页面始终只包含to-space指针,因此mutator只会将to-space指针获取到其寄存器中。收集器还与mutator并发执行,扫描未扫描区域中的页面并在每次扫描完成后取消保护它们。同时扫描的页面越多,mutator收到的页面访问陷阱就越少。由于mutator不需要额外的操作与collector同步,因此编译器无需进行重组。多个处理器和mutator线程几乎无需额外的工作即可适应。此算法需要TRAP、PROTN、UNPROT和MAP2。需要陷阱来检测从未扫描区域获取的内容;需要对多个页面进行保护以在完成翻转时将整个to-space设置为不可访问;对于每个页面的扫描,需要取消保护。此外,由于用户模式处理程序处理页面所需的时间与页面大小成正比,因此可能适合使用较小的页面大小以减少延迟。我们需要对同一页面进行多次映射,以便垃圾回收器在对mutator不可访问的情况下扫描页面。在第5节中讨论了多映射的替代方法。
共享虚拟内存
访问保护分页机制已被用于在计算机网络上实现共享虚拟内存,在没有共享内存的多计算机系统上实现共享虚拟内存,以及基于互连网络的多处理器上实现共享虚拟内存。共享虚拟内存的基本思想是使用分页机制来控制和维护页面级别的单写多读一致性。图1显示了一个典型系统的系统架构。在多计算机系统中,系统中的每个节点由处理器和其内存组成。节点通过快速消息传递网络连接在一起。
该系统为所有处理器提供一个大的一致共享内存地址空间。任何处理器都可以随时访问任何内存位置。共享内存地址空间的大小可以达到处理器所提供的内存地址空间的大小,该地址空间在任何时候都是一致的,也就是说,读操作返回的值始终与最近写入相同地址的写操作的值相同。
该系统的地址空间被划分为页面。标记为“只读”的页面可以在多个处理器的物理内存中同时存在副本。但是,当前正在写入的页面只能存在于一个处理器的物理内存中(proudcer-cosumer)。如果处理器希望写入当前驻留在其他处理器上的页面,则必须获取页面的最新副本,然后通知其他处理器使其副本无效。内存映射管理器将其本地内存视为其关联处理器的SVM地址空间的大缓存,就像传统的虚拟内存一样。共享内存本身实际上是虚拟存在的。当内存引用导致页面错误时,即当包含内存位置的页面不在处理器的当前物理内存中时,内存映射管理器将从磁盘或另一个处理器的内存中检索页面。
该算法使用了TRAP、PROT1和UNPROT。陷阱处理程序需要访问仍然受客户线程保护的内存。内存映射(MAP2)可能需要访问内存。此外,较小的页面大小可能是合适的。
并发检查点
访问保护页面错误机制已成功地用于使检查点成为并发和实时操作。这种用于共享内存多处理器的算法与目标程序并发运行,以小而固定的时间间隔中断目标程序,并且对于被检查点程序及其编译器是透明的。该算法通过使用分页机制,允许检查点中最耗时的操作与正在进行检查点的程序的运行重叠,从而实现了效率。
首先,停止被检查点程序中的所有线程。接下来,保存程序的可写主内存空间(包括堆、全局变量和每个线程的堆栈)。此外,为每个线程保存足够的状态信息,以便可以重新启动。最后,重新启动线程。
与一次性将可写主内存空间全部保存到磁盘不同,该算法通过使用访问保护页面错误机制来避免这种长时间等待。首先,将整个地址空间的可访问性设置为“只读”。此时,重新启动了被检查点程序的线程,并且一个复制线程顺序扫描地址空间,将页面复制到单独的虚拟地址空间。当复制线程完成复制一页时,将其访问权限设置为“读/写”。
当用户线程对只读页面进行读取内存引用时,它们的运行速度与没有进行检查点时一样快。如果程序的线程在复制之前写入页面,则会发生写内存访问错误。此时,复制线程立即复制页面并将页面的访问权限设置为“读/写”,然后重新启动出错的线程。
在DEC Firefly多处理器上使用了几个基准程序来测量此算法的性能。测量结果显示,约90%的检查点工作与目标程序同时进行,而且没有线程被中断超过1秒。
这种方法也适用于进行增量检查点;保存自上次检查点以来发生更改的页面。与保护所有页面为“只读”不同,该算法可以仅保护自上次检查点以来已更改的“脏”页面。Feldman和Brown通过使用可逆执行来实现了用于调试系统的顺序版本,并提出并实现了系统调用DIRTY。
该算法使用了TRAP、PROT1、PROTN、UNPROT和DIRTY;中等大小的页面大小可能是适当的。
分代垃圾回收
分代垃圾回收是内存保护的一个重要应用,是一种非常高效的算法,依赖于LISP和其他编程语言中动态分配记录的两个属性:
- 年轻的记录比年长的记录更有可能很快死亡。如果一条记录已经存活了很长时间,那么它很可能会继续存活下去:新的记录很可能是计算的临时中间值的一部分。
#感觉这也是局部性原理的一种体现
- 年轻的记录倾向于指向年长的记录,因为在LISP和函数式编程语言中,分配记录的行为也会将其初始化为指向已经存在的记录。
属性1表明,垃圾收集器的大部分工作应该集中在年轻的记录上,属性2提供了一种实现这一点的方式。分配的记录将被保留在内存的几个不同区域Gi中,称为代。同一代中的记录具有相似的年龄,而Gi代中的所有记录都比Gi+1代中的记录年长。根据上述第2条观察,对于i < j,Gi到Gj的指针应该非常少或没有。收集器通常会在最年轻的代中进行收集,因为其中垃圾的比例最高。为了在一个代中执行收集,收集器需要了解指向该代的所有指针;这些指针可以在机器寄存器中、全局变量中和堆栈上。然而,由于上述第2条属性的存在,较旧的代中很少有这样的指针。
较老的代可以指向较年轻的代的唯一方式是通过对已经存在的记录进行赋值。为了检测这样的赋值,必须检查对堆对象的每次修改,以查看是否违反了第2条属性。这种检查可以通过特殊硬件完成,也可以通过编译器完成。在后一种情况下,需要两个或更多的指令。幸运的是,在Lisp、Smalltalk和类似的语言中,非初始化的赋值很少发生,但是进行检查的指令序列的开销(没有特殊硬件)仍然在总执行时间的5–10%左右。
虚拟内存硬件可以检测对旧对象的赋值。如果存在DIRTY功能,收集器可以检查已修改的页面,从而推导出从旧一代指向年轻一代的指针,并对其进行处理。在没有这种服务的情况下,收集器可以使用页面保护机制:可以将较旧的代写保护,以便任何对它们的存储操作都会引发陷阱。用户陷阱处理程序可以将触发陷阱的页面地址保存在垃圾收集器的列表中:然后必须取消保护该页面,以允许存储指令的执行。在垃圾收集时,收集器将需要扫描陷阱列表上的页面,以查找可能指向最年轻一代的指针。这种算法的变体已经表现出相当良好的性能:随着堆和内存的增大,这种方案开始占据主导地位,超过其他技术。这种技术使用了TRAP、PROT1和UNPROT特性,或者只使用DIRTY。此外,由于用户模式处理程序处理页面的时间与页面大小无关,而垃圾收集器最终扫描页面的时间与页面大小成正比,因此使用较小的页面大小可能是适当的。
持久存储
持久存储是一种从一个程序调用到下一个程序调用持续存在的动态分配堆。程序的执行可以遍历持久存储中的数据结构,就像它在自己的堆中一样。它可以修改持久存储中的对象,甚至使它们指向自己的新分配的对象;然后可以提交这些修改到持久存储中,或者可以中止操作,在这种情况下,对持久存储没有净影响。在执行之间(和期间),持久存储被保留在稳定的存储设备(如磁盘)上,以便“数据库”不会消失。
在持久存储中对指针的遍历与主内存中的取值和存储一样快是很重要的;理想情况下,持久存储中的数据结构不应该被程序的编译代码与内存中的数据结构区分开。这可以通过使用虚拟内存来实现:持久存储是一个内存映射的磁盘文件;通过持久存储进行指针遍历就像在内存中进行指针遍历一样,如果对存储的新部分进行检查,则会发生页面错误。
#这是怎么做到一样快的呢?
然而,当持久存储中的对象被修改时,永久镜像必须在提交时才能被修改。在内存中的镜像被修改时,并且只有在提交时,“脏”页面(可能包括一些新创建的页面)才会被写回磁盘。为了减少新页面的数量,适合在提交时进行垃圾回收。
数据库是一个存储管理系统,可以提供锁定对象、带有中止/提交的事务、检查点和恢复等功能。虚拟内存技术与数据库实现的整合已经被长期研究。编译程序可以非常快速、轻松地遍历其堆中的数据,因为每个访问操作只是一个编译的取值指令。在传统数据库中遍历数据要慢得多,因为每个操作都是通过过程调用完成的;访问过程确保事务的同步和中止能力。持久存储可以被增强以清晰地处理并发和锁定:这样的系统(有时被称为面向对象的数据库)可以通过取值指令快速遍历,同时也可以提供同步和锁定:通过使用垃圾回收器将相关对象分组到同一页中、将小对象与大对象区别对待等方式可以提高访问的效率
这些方案需要使用TRAP和UNPROT,以及文件映射与写时复制(如果其他情况下不可用,可以使用PROT、UNPROT和MAP来模拟)。
扩展可寻址性
持久存储可能变得非常庞大,包含的对象数量超过了(例如)32位指针所能寻址的范围。现代硬盘驱动器(特别是光盘)肯定可以容纳如此大的数据库,但传统处理器使用32位地址。然而,在对持久存储进行的任何一次程序运行中,访问的对象数量可能少于32位指针所能表示的范围。
解决这个问题的一种方法是修改持久存储机制,使内存中的对象使用32位地址,而磁盘上的对象使用64位地址。每个磁盘页面的长度恰好是内存页面的两倍。当从磁盘将页面加载到内存时,其64位磁盘指针将通过一个转换表被翻译成32位内存指针。当其中一个32位内存指针第一次被解引用时,可能会发生页面错误;错误处理程序会从磁盘中加载另一个页面,并将其转换为短指针。
转换表仅针对在单个执行中访问的对象有条目:这就是为什么32位指针就足够的原因。内存中的指针可能指向尚未访问的页面;这样的页面在内存中未分配,但转换表中有一条目显示其未翻译内容存储在哪个(64位指针)磁盘页面上。
在内存中使用短指针和在磁盘上使用长指针,并且仅为一个会话中使用的对象子集创建转换表的想法起源于Smalltalk-80的LOOM系统。使用页面错误机制来实现这一点是较近期的方法。该算法使用TRAP、UNPROT、PROT1或PROTiV(在多线程环境中)和可能与较小的页面大小很好地配合工作。
堆溢出检测
进程或线程的堆栈需要防止溢出访问。在大多数系统中使用的一种众所周知且实用的技术是将堆栈顶部以上的页面标记为无效或无法访问。对这些页面的任何内存访问都将导致页面错误。操作系统可以捕获这样的错误并通知用户程序发生了堆栈溢出。在大多数Unix实现中,堆栈页面在首次使用之前不会被分配;操作系统对页面错误的响应是分配物理页面、标记它们为可访问,并在不通知用户进程的情况下恢复执行(除非超出资源限制)。
#Lab: trap运用到的思想
这种技术需要TRAP、PROTbJ和UNPROT。但由于这类错误非常罕见(大多数进程不使用太多的堆栈空间),效率并不是问题。
相同的技术可以用于在垃圾收集系统中检测堆溢出。通常,在这种系统中,堆溢出是通过对每个内存分配执行比较和条件分支来检测的。通过让用户进程在由警戒页面终止的内存区域中分配新记录,可以消除比较和条件分支。当达到可分配内存的末尾时,会触发页面错误陷阱,调用垃圾收集器。通常情况下,不需要重新调整内存保护,因为在收集后可以重新使用相同的分配区域。因此,这种技术需要PROT1和TRAP。
在这里,TRAP的效率是一个问题。一些语言实现可能会频繁地分配新的单元,比如每50条指令分配一次。在分代垃圾收集器中,分配区域的大小可能非常小,以使最年轻的代完全适应数据缓存;例如,一个64KB的分配区域可以容纳16k个列表单元。在一个非常频繁分配的系统中(例如将活动记录保存在堆上的系统),这样的数据比例非常小,因此垃圾收集时间本身会很短。因此,我们有:
堆溢出之前执行的指令数:
(64k/8) X 50 = 400k。
使用比较和分支的额外指令数:
(64k/8) X 2 = 16k。
如果一个陷阱处理需要1200个周期(通常情况下见第4节),那么这种技术将将开销从4%降低到0.3%,是一个值得的节省。如果一个陷阱需要更长的时间,那么这种技术将不会那么高效。
由于还有其他有效的技术可以减少堆限制检查的开销,例如在展开的循环中组合几个连续分配的限制检查,因此这种虚拟内存的应用可能是本文讨论的最不有趣的之一。
4原始VM的性能(VM primitive performance)
我们在本文中描述的几乎所有算法都可以归类为两种类别。第一类算法是以大批量保护页面的形式进行,然后在每次页面故障陷阱时取消保护一个页面。第二类算法是对页面进行保护,然后在线程或进程完成对页面的使用后立即取消保护。由于PROT1或PROTN、TRAP和UNPROT总是一起使用,因此一个操作系统中,如果其中一个操作非常高效,而其他操作速度很慢,那么该系统将不会很有竞争力。
我们进行了两项整体用户模式虚拟内存性能的测量。第一项是PROT1、TRAP和UNPROT的总和,通过以下基准程序的100次重复来测量:
- 访问一个随机保护的页面,并且
- 在故障处理程序中,保护另一个页面并取消保护故障页面。
重复此过程100次以获取更准确的计时。
第二项测量是PROTiN、TRAP和UNPROT的总和。基准程序测量:
- 保护100个页面,
- 以随机顺序访问每个页面,并且
- 在故障处理程序中,取消保护故障页面。
在开始计时之前,这两个程序都会写入每个页面,以消除填充缓存和TLB的瞬时效应。
我们比较了在几个平台上执行这些基准测试时的Ultrix、Sun OS和Mach的性能。作为校准,我们还展示了单条指令(ADD)的时间,使用一个包含18个加法、一个比较和一个分支的20条指令循环进行测量。我们还显示了一个不改变任何内存保护的陷阱处理程序的时间;这对于堆溢出检测是有用的。结果如表1所示。请注意,此基准测试不是“整体操作系统吞吐量”基准测试,并且不应受到磁盘速度的影响;它测量的是用于用户级程序的由CPV处理的虚拟内存服务的性能。
对于每个操作,以微秒为单位给出了经过的时间。对于Mach,我们同时测量了异常端口机制(“exc”)和外部页面接口(“xp”)的时间。TRAP的时间是操作系统开销,加上标准库中提供的用户模式陷阱处理程序的部分。对于PROT1和PROTN基准测试,我们显示每页的时间。MAP 2表示系统是否支持在不同地址映射相同的页面;请参阅第4节。PAGE SIZE是操作系统报告的页面大小。
我们还尝试在同一个进程中使用SunOS和Ultrix的共享内存操作(SIMNOP),以及在Mach中使用VM-MAP,将物理页面映射到两个不同的虚拟地址。SunOS和Mach允许这样做,但Ultrix不允许我们在同一个进程中的两个不同地址附加(SLLMAT)相同的共享内存对象。
显然,即使在相同的硬件上,这些操作系统的性能也存在很大的差异。这表明,某些或所有这些系统都可能存在相当大的改进空间。此外,一些操作系统的几个版本在mprotect调用后没有正确刷新其翻译缓冲区,这表明许多操作系统的实现者并未认真对待这一特性。
这些操作系统服务的效率至关重要。这里的论点比虚无的“效率是好的”要具体得多。对于磁盘分页,页面故障通常意味着需要等待磁盘转到正确的扇区,这通常需要20毫秒。因此,3到5毫秒的故障处理开销几乎不会被视为故障处理延迟的贡献者。但在本文中调查的算法中,故障将完全在CPU内部处理。例如,我们实现了一个垃圾收集器,每个字的to-space大约执行10条指令。对于20 MIPS机器上的4096字节页面大小(1024个字),处理故障的计算时间将约为10 * 0.05 * 1024,即约500微秒。如果操作系统的故障处理和页面保护开销为1200微秒(平均值),那么操作系统显然是瓶颈。
如果程序表现出良好的引用局部性,那么垃圾收集故障将很少发生,并且操作系统的开销将不那么重要。但对于必须满足对延迟严格的实时程序来说,即使偶尔发生的“慢故障”也会引起问题。例如,如果客户端程序不能被中断超过一毫秒,那么500微秒的故障处理器计算时间就没有足够的空间来容纳1200微秒的操作系统开销!(当考虑到连续的多个故障时,这个问题会变得更加复杂;参见[11]进行分析。)图4(#应该是图2吧)显示了每个处理器在保护一个页面、处理故障和取消保护一个页面所需时间内可以完成的ADD指令数量。
我们的基准测试显示,在实现虚拟内存原语方面存在广泛的效率差异。基于Intel 80386处理器的机器运行N2i/2操作系统(一个用于iPSC/2超立方体多处理器系统的简单操作系统)在我们的基准测试中表现最佳。它的标准化基准性能约为最差性能者(在Sparcstatlion上的Mach)的十倍。显然,这些原语不一定就会慢。硬件和操作系统设计者应该将内存保护性能视为设计过程中的一个重要权衡因素。
5系统设计问题(System design issue)
通过我们对虚拟内存应用的调查,我们可以从中学到一些关于硬件和操作系统设计的重要经验教训。大多数应用以类似的方式使用虚拟内存:这清楚地说明了需要哪些支持,同样重要的是,哪些是不必要的。
TLB一致性
这里介绍的许多算法将内存分批次变得不太可访问,并且一次性使内存中的一个页面变得更容易访问。这对于多处理器特别有好处,因为它解决了TLB(转换后备缓冲区)一致性问题。当一个页面变得更易访问时,TLB中的过时信息是无害的,最多只会导致一个次要的、容易修复的TLB未命中或TLB故障。但是,当一个页面变得不太可访问时,TLB中的过时信息可能导致对页面的非法访问。为了防止这种情况发生,必须清除该页面可能驻留的每个TLB中的页面。这种"清除"可以通过软件中断每个其他处理器并请求其从TLB中清除页面来完成,也可以通过各种基于总线的硬件方案来完成。
如果要中断的处理器很多,软件清除可能非常昂贵。我们对解决清除问题的方法是对清除进行批处理:同时涵盖多个页面的软件清除的成本不比单个页面的清除成本更高:当将开销(中断进程以通知它们有关清除的情况)摊销到多个页面上时,每个页面的成本变得可以忽略不计。本文描述的保护页面的算法“无意中”利用了批量清除的优势。
#批处理最早应该诞生于20世纪50、60年代。主要是为了解决计算机系统中 CPU 和 IO 设备之间速度不匹配的问题。
批处理之所以被提出,是因为这里描述的算法的结构,但它也可以解决传统的磁盘分页的清除问题。在磁盘分页中,页面变得不太可访问(即被“分页出”)以释放物理页面,以便其他虚拟页面重新使用。如果操作系统可以保持大量未使用的物理页面,那么它可以批量执行分页出(以补充储备);这将使清除成本在整个批次上摊销。因此,虽然曾经声称软件解决方案效果不错,但可能需要硬件辅助,但是通过批处理,硬件可能是不必要的。
最佳页面大小
在这里描述的许多算法中,页面错误完全在 CPU 中处理,并且错误处理时间(不包括开销)是页面大小的一个小常数。
当页面错误发生在物理内存和磁盘之间进行分页时,磁盘旋转和磁头移动会造成数十毫秒的延迟。在页面错误处理程序中增加几毫秒的计算开销几乎不会被注意到(特别是如果没有其他进程准备执行)。因此,出于许多原因,包括动态 RAM 的寻址特性,页面传统上相当大,错误处理开销很高。
然而,对于由 CPU 中的用户算法完全处理的用户处理的错误,不存在这种固有的延迟。要减半每个错误的时间(不包括陷阱时间),只需减半页面大小。这里描述的各种算法可能在不同的页面大小下表现最佳。
可以通过使用小页面大小的硬件来实现页面大小的变化。(在 VMP: Virtual Memory Management System系统中,转换缓冲区和缓存是相同的,具有 128 字节的线大小[8]:这种体系结构可能非常适合本文描述的许多算法。)对于 PROT 和 UNPROT 操作,将使用小页面;对于磁盘分页,将使用连续的多页块(如今在大多数系统上都很常见)。
当使用小页面时,迅速捕获和更改页面保护尤其重要,因为这种开销与页面大小无关,而实际计算(通常)花费的时间与页面大小成正比。
访问受保护页面
许多算法在多处理器上运行时,需要一种用户模式服务例程访问页面的方式,而客户线程则无法访问。这些算法包括并发垃圾回收、扩展地址能力、共享虚拟内存和数据压缩分页。
实现用户模式访问受保护页面有几种方式(我们使用并发垃圾回收算法来说明):
- 在同一地址空间中的不同地址(以及不同级别的保护)上多次映射相同页面。垃圾回收器在 to-space 中的页面上有访问权限,而mutator将 to-space 视为受保护的。
- 可以提供一个系统调用来复制内存到受保护区域和从受保护区域复制内存。垃圾回收器在每个页面上需要调用此调用三次:一次在从 from-space 复制记录到 to-space 时,一次在扫描 to-space 页面之前,一次在扫描后,使页面对mutator可访问之前。这种解决方案不太理想,因为复制所有这些东西的效率不高。
- 在允许进程之间共享页面的操作系统中,垃圾回收器可以在与mutator不同的重量级进程中运行,并具有不同的页表。这种技术的问题在于,每个垃圾回收页面陷阱都需要两个昂贵的重量级上下文切换。但是,在多处理器上,可能足以对另一个处于正确上下文的处理器进行 RPC,这个选项可能更具吸引力。
- 垃圾回收器可以在操作系统内核中运行。这可能是最有效的,但也许这不是垃圾回收器的适当位置;它可能导致内核不可靠,并且每种编程语言都有一个不同的运行时数据格式,垃圾回收器必须理解。
我们主张,对于具有物理地址缓存的计算机体系结构,同一地址空间中的多重虚拟地址映射是一种清晰高效的解决方案。它不需要重量级上下文切换、数据结构复制,也不需要在内核中运行。这样做的一个小缺点是,每个物理页面将需要在页表中有两个不同的条目,根据页表条目大小与页面大小的比率,这将增加物理内存需求高达1%。
#页表的雏形
对于虚拟地址缓存,多重虚拟地址映射方法存在缓存一致性的潜在问题,因为在一个映射中的更新可能存在于缓存中,而另一个映射包含过时的数据。在并发垃圾回收算法的背景下,这个问题很容易解决。当垃圾回收器扫描页面时,mutator无法访问页面;因此,在mutator的地址中,该页面的缓存行不会填充。扫描页面后,垃圾回收器应该刷新该页面的缓存行(可能使用缓存刷新系统调用)。此后,垃圾回收器将永远不会引用该页面,因此永远不会出现不一致的危险。
- 扩展地址可寻性和数据压缩分页仅使用PROT1来移除不活跃的页面:可以使用第5节中描述的批处理技术来替代。
- 基于虚拟内存的堆溢出检测甚至可以在没有显式内存保护原语的情况下使用,只要在可访问和不可访问内存之间存在可用的边界(例如,在普通Unix中的“断点”)。
- 脏页跟踪可以通过使用PROTN、TRAP和UNPROT来模拟。
这种行为在高度流水线化的机器上是不可接受的(除非像在 VAX 8800 上那样,有用于“撤销”那些已经完成的后续指令或地址模式副作用的硬件)。事实上,即使在 Motorola 68020 上,使用页面故障来检测堆溢出也是不可靠的。因此,除了堆溢出检测之外,我们介绍的所有算法对硬件来说都不会比普通的磁盘分页带来更多问题,邀请到地狱的信函可以退回给发件人:然而,操作系统必须确保为硬件提供充分的支持,使半同步陷阱处理程序能够正确地恢复故障操作。
#居然看到了Motorala,真是时代的眼泪啊
其他原语
操作系统可以提供其他虚拟内存原语。对于带有事务的持久存储,将页面固定在内存中可能是有用的,这样直到事务完成才会将其写回到后备存储器中。
Mach 的外部分页器接口提供了至少一种我们描述的原语缺少的功能:操作系统可以告诉客户端哪些页面最近使用的最少(因此)即将被分页出去。客户端可以选择销毁这些页面,而不是将它们写入磁盘。这对于数据压缩分页和扩展可寻址性可能特别有用。此外,在具有垃圾回收的系统中,客户端可能知道某个区域只包含垃圾并且可以安全地销毁[12]。
#LRU是吗
总的来说,外部分页器接口避免了操作系统分页器(将不经常使用的页面写入磁盘)不必要地重复进行用户模式故障处理程序正在进行的工作的问题。
6结论
在过去,虚拟内存只是实现大型地址空间和保护一个用户进程免受另一个用户进程影响的工具,但现在它已经发展成为硬件和操作系统接口的用户级组件。我们调查了几种依赖虚拟内存原语的算法;过去并未对这些原语给予足够的关注。在设计和分析新机器和新操作系统的性能时,页面保护和故障处理效率必须作为设计空间的一个参数加以考虑;页面大小是另一个重要参数。相反,对于许多算法来说,TLB硬件的配置(例如在多处理器系统中)可能并不特别重要。
表2显示了这些算法的用途和要求。一些算法逐个保护页面(PROT1),而另一些算法以大批量方式保护页面(PROTN),这种方式更容易高效实现。一些算法在并发运行时需要访问受保护的页面(HMAP)。一些算法仅使用内存保护来跟踪修改过的页面(DIRTY),这项服务可能可以更有效地作为一种原语提供。一些算法可能使用比常用更小的页面大小运行更高效(PAGESIZE)。
许多利用虚拟内存的算法共享几个特征:
- 页面大批量变得不易访问,逐页变得更易访问;这对TLB一致性算法有重要意义。
- 故障处理几乎完全由CPU执行,并且所需时间与页面大小成正比(比例常数相对较小);这对首选页面大小有影响。
- 每个页面故障导致故障页面变得更易访问。
- 故障的频率与客户程序的引用局部性呈负相关:这将使这些算法在长期内保持竞争力。
- 用户模式服务例程需要访问从用户模式客户例程保护的页面。
- 用户模式服务例程不需要检查客户的CPU状态。
除了堆溢出检测外,本文描述的所有算法共享五个或更多个这些特征。
大多数程序在中等时间段内只访问其地址空间的一小部分。这就是使传统磁盘分页有效的原因;以不同的方式,这也使得这里描述的算法效率高。例如,并发垃圾收集算法必须扫描和复制相同数量的数据,而不受mutator访问模式的影响,但mutator的引用局部性降低了故障处理开销。在分代收集算法、并发检查点和持久存储算法中的“写障碍器”利用了局部性,如果一小部分对象占据了大部分更新,则利用了局部性。而共享虚拟内存算法利用了一种特殊的分区引用局部性,其中每个处理器具有不同的局部引用模式。
我们相信,由于这些算法非常依赖于引用局部性,它们将具有良好的可扩展性。随着内存变得越来越大,计算机变得越来越快,程序往往会更积极地使用其地址空间的一小部分,而这些算法的开销将继续减少。重要的是,硬件和操作系统设计者要确保这些算法所需的虚拟内存机制健壮且高效。
致谢
感谢Rick Rashid、David Tarditi和Greg Morrisett将我们的各个版本的基准程序移植到Mach并在Mach机器上运行,并感谢Larry Rogers在SunOS上运行它。Rafael Alonso、Brian Bershad、Chris Clifton、Adam Dingle、Mary Fernandez、John Reppy和Carl Staelin在论文初稿中提出了有益的建议。
Andrew W. Appel部分受NSF资助,资助号为CCR-8806121。Kai Li部分受NSF资助,资助号为CCR-8814265,并受Intel超级计算机系统部门资助。