虚拟存储器·十
扫描二维码
随时随地手机看文章
第10章 虚拟存储器
关键词:虚拟存储器,动态存储器分配,地址空间与多级页表,空闲链表,伙伴系统
为了更加有效地管理存储器并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟存储器(VM)。虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的、私有地址空间。通过一个很清晰的机制,虚拟存储器提供了三个重要的能力:它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存;它为每个进程提供了一致的地址空间,从而简化了存储器管理;它保护了每个进程的地址空间不被其它进程破坏。
10.1 物理和虚拟地址
计算机系统的主存被组织成一个M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(physical address,PA)。第一个字节的地址为0,接下来的字节地址为1,再下一个为2,以此类推。给定这种简单的结构,CPU访问存储器的最自然的方式就是使用物理地址。我们把这种方式称为物理寻址(physical addressing)。
早期的PC使用物理寻址,而且诸如数字信号处理器、嵌入式微控制器以及Cray超级计算机这样的系统仍然继续使用这种寻址方式及。然而,为通用设计的现代处理器使用的是虚拟寻址(virtual addressing)。
根据虚拟寻址,CPU通过生成一个虚拟地址(virtual address,VA)来访问主存,这个虚拟地址在被送到存储器之前先转换成适当的物理地址。讲一个虚拟地址转换为物理地址的任务叫做地址翻译(address translation)。就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片叫做MMU(memory management unit,存储器管理单元)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容是由操作系统管理的。
10.2 地址空间
地址空间(address space)是一个非负整数地址的有序集合:{0,1,2……}。
如果地址空间中的整数是连续的,我们说它是一个线性地址空间(linear address space)。为了简化讨论,总是假设使用的是线性地址空间。在一个带虚拟存储器的系统中,CPU从一个有N=2^n个地址的空间中生成虚拟地址,这个地址空间被称为虚拟地址空间(virtual address space):{0,1,2,……,N-1}。
一个地址空间的大小是由表示最大地址所需要的位数来描述的。例如,一个包含N=2^n个地址的虚拟地址空间就叫做一个n位地址空间。
一个系统还有一个物理地址空间(physical address space),它与系统中物理存储器的M个字节相对应:{0,1,2,……,M-1}。
10.3 虚拟存储器作为缓存的工具
1 虚拟存储器基本概念
虚拟存储器被组织为一个由存放在磁盘上的N个连续的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,这个唯一的虚拟地址是作为到数组的缩印的。磁盘上数组的内容被缓存在主存中。和存储器层次结构中的其它缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟存储器分割为称为虚拟也(virtual page,VP)的大小固定的块,来处理这个问题。每个虚拟页的大小为P=2^p字节。类似地,物理存储器被分割为物理页(physical
page,PP),大小也为P字节(物理页也被称为页帧,page frame)。
在任何时刻,虚拟页面的集合都分为三个不相交的子集:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
缓存的:当前缓存在物理存储器中的已分配页。
未缓存的:没有缓存在物理存储器中的已分配页。
2 DRAM高速缓存的组织结构
在存储器层次结构中,DRAM缓存的位置对它的组织结构有很大的影响。回想一下:DRAM比SRAM要慢大约10倍,而磁盘要比DRAM慢大约100 000多倍。因此DRAM缓存中的不命中比起SRAM缓存中的不命中要昂贵的多,因为DRAM缓存不命中要由磁盘来服务,而SRAM缓存不命中通常是由基于DRAM的主存来服务的。而且,从磁盘的一个山区读取第一字节的时间开销比起读这个扇区中后面的字节要慢大约100
000倍。归根到底,DRAM缓存的组织结构完全是由巨大的不命中开销驱动的。
因为大的不命中处罚和访问第一字节的开销,虚拟页趋向于很大,典型地是4-8KB。由于大的不命中处罚,DRAM缓存是全相联的,也就是说,任何虚拟页都可以放置在任何的物理页中。不命中时的替换策略也很重要,因为替换错了虚拟页的处罚也非常之高。因此,比起硬件对SRAM缓存,操作系统对DRAM缓存使用了更复杂精密的替换算法。最后,因为对磁盘的访问时间很长,DRAM缓存总是使用写回(write-back),而不是直写(write-through)。
3 页表
页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护也表的内容,以及在次番禺DRAM之间来回传送页。
页面中的话,就可以直接引用数据,如果发生了也不命中,即缺页,就需要进行页面调度。常用的方法有按需页面调度。
尽管在整个运行过程中引用的不同页面的综述可能超出物理存储器总的大小,但是局部性原则保证了在任意时刻,这些页面将趋向于在一个较小的活动页面(active page)集合上工作,这个集合叫做工作集(working set)或者常驻集合(resident set)。在初始开销,也就是将工作页面调度到存储器中,之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。
只要我们的程序有良好的局部性,虚拟存储器系统就能工作的相当好。但是,当然不是所有的程序都能展现良好的时间局部性。如果工作集的大小超过了物理存储器的大小,那么程序将产生一种不幸的状态,叫做颠簸(thrashing)。这时页面将不断换进换出。
10.4 虚拟存储器作为存储器管理工具
虚拟地址是一个有用的机制,因为它大大简化了存储器管理,并提供了一种简单自然的保护存储器的方法。
多个虚拟页面可以映射到同一个共享的物理页面上。
虚拟存储器作用:
简化链接:独立的地址空间允许每个进程为它的存储器映像使用相同的基本格式,而不管代码和数据实际存放在物理存储器的何处。
简化共享:独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般而言,每个进程都有自己私有的代码、数据、堆以及栈区域,是不和其它进程共享的。在这种情况下,操作系统创建页表,将相应的虚拟页映射到不同的物理页面。操作系统通过将不同进程中的适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的一个拷贝。
简化存储器分配:虚拟存储器为用户进程提供一个简单的分配额外存储器的机制。
简化加载:虚拟存储器也使加载可执行文件和已共享目标文件到存储器中变得容易。映射一个连续虚拟页面的集合到任意一个文件中的任意一个位置的概念叫做存储器映射(memory mapping)。
10.5 地址翻译
PTE:页表条目
多级页表:第一,如果一级页表中的一个PTE是空的,那么相应的二级页表就不会存在。这表现出一种巨大的潜在节约,因为对于一个典型的程序,4GB的虚拟地址空间的大部分都会是未分配的。第二,只有一级页表才需要总是在主存中。虚拟存储器系统可以在需要时创建,并页面调入或调出二级页表,这就减少了主存的压力。只有最经常使用的二级页表才需要缓存在主存中。
10.6 动态存储器分配
1 动态存储器分配
一个动态存储器分配器维护者一个进程的虚拟存储器区域,称为堆(heap)。在大多数的Unix系统中,堆是一个请求二进制零的区域,它紧接在未初始化的bss区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护者一个变量brk(读作“break”),它指向栈的顶部。
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟存储器组块(chunk),要么是已分配的,要么是空闲的。已分配块显式地保留为供应用使用。空闲块保持空闲,知道它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用显式执行的,要么是存储器分配器自身隐式执行的。
2 分配器有两种基本风格
显式分配器:要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc分配一个块,并通过调用free函数来释放一个块,C++中的new和delete操作符与C中的malloc和free相当。
隐式分配器:在另一个方面,要求分配器检测何时一个已分配块不再被程序使用,然后就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程就做垃圾收集(garbage collection)。诸如,Lisp、ML以及Java之类的高级语言就依靠垃圾收集来释放已分配的块。
使用动态存储器分配的重要原因是它们经常到程序实际运行时,才知道某些数据结构的大小。
造成对利用率很低的主要原因是一种称为碎片(fragmentation)的现象,当虽然有未使用的存储器但不能用来满足分配请求是,就发生这种现象。包括:内部碎片(internal fragmentation)和外部碎片(external fragmentation)。
内部碎片的量化是简单明了的,它就是已分配块和它们的有效载荷之差的和。因此在任意时刻,内部碎片的数量只取决于请求的模式和分配器的实现方式。
外部碎片是当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以处理这个请求时发生的。因为外部碎片难以量化和不可能预测的,所以分配器典型地采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。
隐式空闲链表,空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。
很重要的一点就是意识到系统对齐要求和分配器对块格式的选择对分配器上的最小块大小有强制的要求。没有已分配块或者空闲块可以比这个最小值还小。
3 放置分配的块(分配策略)
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大、可以防止所请求块的空闲块。分配器执行者中搜索的方式是由配置策略(placement policy)所确定的。一些常见的策略是首次适配(first fit)、下一次适配(next fit)和最佳适配(best fit)。
首次适配:从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配器检查每个空闲块,选择匹配所需请求大小的最小空闲块。
首次匹配的一个优点是它趋于将大的空闲块保留在表的后面。缺点是它区域在靠近链表起始处留下空闲块的“碎片”,这就增加了对较大块的搜索时间。最佳适配比首次适配和下一次适配的利用率要高一些。然而在简单空闲链表组织结构中,比如隐式空闲链表中,使用最佳匹配的缺点是它对堆进行彻底的搜索。在后面会有更加精细负载的分离式空闲链表组织,它实现了最佳适配策略,而不需要进行彻底的堆搜索。
4 合并空闲块
假碎片(fault fragmentation):当分配器释放一个分配块时,可能有其它空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,这里有许多可用的空闲快被分割成为小的,无法使用的空闲块。
立即合并(immediate coalescing):每次一个块被释放,就合并所有相邻块。这种方式有可能会产生一种形式的抖动,块会反复地合并,然后马上分割。反复地分配和释放一个块将产生大量不必要的分割和合并。所以快速的分配器通常会选择某种形式的推迟合并。
延迟合并(deferred coalescing):等到某个稍晚的时候再合并。如,可以在直到某个请求失败后,然后扫描整个堆,合并所有的空闲块。
5 隐式空闲链表
给定一个到头部的隐式空闲链表,唯一的选择将是搜索整个链表,记住前面的位置,直到我们达到当前块。使用隐式空闲链表,这意味着每次调用free的时间都与堆的大小成线性关系。即使使用更复杂精细的空闲链表组织,搜索时间也不会是常数。
Knuth提出了一种聪明而通用的技术,叫做边界标记(boundary tag),允许在常数时间内进行对前面块的合并。这种思想,是在每个块的结尾处添加一个脚部(footer边界标记),其中部步就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态,需要注意的是,前面一个块的脚部总是位于当前块起始位置的前一个字的距离处。
边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的。然而,它也存在一个潜在的缺陷,要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的存储器开销。幸运的是,有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要脚部。回想一下,当我们试图在存储器中合并当前块以及前面的块和后面的块时,只有在前面的块时空闲时,才会需要用到它的脚部。如果我们把前面块的已分配/空闲位存放在当前块中多出来的低位中,那么已分配的块就不需要脚部了,这样我们就可以将这个多出来的空间用作有效载荷了。
6 显式空闲链表
因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的(尽管对于堆块数量预先知道是很小的特殊的分配器来说,它是比较好的).
一种更好的方法是将空闲块组织为某种形式的数据结构。根据定义,程序是不需要一个空闲块的主体,所以实现这个数据结构的指针可以村贩子啊这些空闲块的主题里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(祖先)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块综述的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们在空闲链表中对块排序所选择的策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用的块。在这种情况下,释放一个块,可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它祖先的地址。在这种情况下,释放一个块需要线性时间的搜索,来定位合适的祖先。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的存储器利用率,接近最佳适配的利用率。
一般而言,显式链表的缺点是空闲块必须足够大,以包含所需要的指针,以及头部和可能的脚部,这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。
7 分离的空闲链表
一个单项的空闲链表的分配器需要与空闲块数量成线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储(segregated storage),维护多个空闲链表,其中每个链表中的块有大致相等的大小。
主要有两种:简单分离存储(simple segregated storage)和分离适配(segregated fit)
简单分离存储(simple segregated storage):使用简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。其优点是分配和释放都很简单,缺点是容易造成内部和外部碎片。因为空闲块是不会被分割的,所以可能会造成内部碎片。更糟的是,某些引用模式会引起极多的外部碎片,因为是不会合并空闲块的。
分离适配(segregated fit):分配器维护一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些快的大小是大小类的成员。下面一种简单的版本是:为了分配一个块,我们必须确定请求类的大小,并且对适当的空闲链表做首次适配,查找合适的块。如果找到一个,我们(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到,我们就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果没有空闲链表中有合适的块,那么就向操作系统请求额外的对存储器,从这个新的对存储器中分配出一个块,将剩余的部分放置在最大的大小类中。要释放一个块,我们执行合并,并将结果方知道相应的空闲链表中。这种方法既快速,对存储器的使用也很有效率。搜索时间减少了,因为搜索被限制在堆的某个部分,而不是整个堆。存储器利用了得到了改善,因为有一个有趣的事实:对分离空闲链表的简单的首次适配搜索相当于整个堆的最佳适配搜索。
8 伙伴系统
伙伴系统(buddy system)是分离适配的一种特例,其中每个大小类都是2的幂。基本的死里是假设一个对的大小是2^m个字。我们为每个块大小2^k维护一个分离空闲链表,其中 0=<k<=m。请求块大小向上舍入到最接近2的幂。最开始时,只有一个大小为2^m个字的空闲块。
伙伴系统分配器的主要优点是它的快速搜索和快速合并。主要缺点是要求块的大小为2的幂可能导致显著的内部碎片。因此伙伴系统分配器不适合通用目的的工作服在。然而,对于某些与应用相关的工作负载,其中块大小预先知道是2的幂,伙伴系统分配器就很有吸引力了。
10.7 垃圾收集
Mark&Sweep垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成。标记阶段标记处根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。
想了解更多,请看后面的参考书籍。
10.8 C语言中常见的与存储器有关的错误
1 间接引用坏指针
在晋城的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常终止这个程序。而且,虚拟存储的某些区域是只读的。试图写这些区域将造成以保护异常终止这个程序。
间接引用坏指针的一个常见示例的经典是scanf错误。做正确的事情的方法是传递给scanf一个格式串和变量的地址:
scanf(“%d”,&val)
而C程序员初学者,则很容易传递val的内容,如
scanf(“%d”,val)
在这种情况下,scanf将把val的内容解释为一个地址,并试图讲一个字写到这个位置。在最好的情况下,程序立即以异常终止。最为糟糕的情况下,val的内容对应于虚拟存储器的某个合法的读/写区域,于是我们就覆盖了存储器,这通常会造成灾难性的、令人困惑的后果。
2 读未初始化的存储器
虽然.bbs存储器位置(诸如未初始化的全局C变量)总是被加载器初始化为零,但是对于对存储器却并不是这样的。
3 允许栈缓冲区溢出
如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)。
4 假设指针和他们指向的对象是相同大小的
5 造成错位错误
错位(off-by-one)错误是另一种常见的覆盖性错误发生的原因:
6 引用指针,而不是它所指向的对象
如果我们不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是期望操作指针所指向的对象。
7 误解指针运算
忘记了指针的算术操作是以他们指向的对象的大小为单位来进行的。
8 引用不存在的变量
不理解栈的规则,有时会引用不在合法的本地变量。
9 引用空闲堆块中的数据
一个相似的错误是引用已经被释放了的堆块中的数据。
10 引起存储器泄露
10.9 虚拟存储器的几个关键概念
一个关键的经验教训是,即使虚拟存储器是由系统自动提供,它也是一种有限的存储器资源,应用程序必须精明地管理它。正如我们从对动态存储分配器的研究种学到的那样,管理虚拟存储器资源可能包括一些微妙的时间和空间的平衡。另一个关键的经验教训是,在C程序中很容易犯与存储器有关的错误。坏的指针值,释放已经空闲了的块、不恰当的强制类型转换和只恨运算,以及覆盖堆结构,这些只是可能给我们带来麻烦的许多方式中的一小部分。实际上,与存储器有关的错误很讨厌,这是导致Java产生的一个重要原因,Java取消了取变量地址的能力,完全控制了动态存储分配器,从而严格控制了对虚拟存储器的访问。
10.10 小结
虚拟存储器是对主存的一个抽象。支持虚拟存储器的处理器通过使用一种叫做虚拟殉职的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
虚拟存储器提供三个重要的功能:第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟存储器缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘拷贝到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟存储器简化了存储器管理,进而又简化了链接、在进程间共享数据、进程的存储器分配,以及程序加载。最后虚拟存储器通过在每条页表条目中加入保护位,从而简化了存储器保护。
地址翻译的过程将虚拟存储器组块(chunk)和磁盘上的文件块关联起来,来初始化虚拟存储器组块,这个郭晨刚成为存储器映射。存储器映射为共享数据、创建新的进程以及加载程序,提供了一种高效的机制。应用可以使用mmap函数来手工的创建和删除虚拟地址空间的区域,然而,大多数程序依赖于动态存储分配器,例如malloc,它管理虚拟地址空间区域内一个称为堆的区域。动态存储器分配器是一个有系统级感觉的应用程序,它直接操作存储器,而无需类型系统的很多帮助。分配器有两种类型:显式分配器要求应用显式地释放它们的存储器块;隐式分配器(垃圾收集器)自动释放任何无用的和不可达的块。
对于C程序员来说,管理和使用虚拟地址储存器是一件困难和容易出错的任务。常见的错误示例包括:间接引用坏指针、读取未初始化的存储器,允许栈缓冲区溢出,假设指针和它们指向的对象大小相同,引用指针而不是它所指向的对象,误解指针运算,引用不存在的变量,以及引起存储器泄露等问题。[1]
PS:对于10.8部分,C语言中常见的错误这一方面还需要看更加专业的书,本参考是也只是泛泛而谈,还需要结合其它书籍来参考,如《C和指针》[2]《C陷阱与缺陷》[3]。如后面参考文献所示。
参考文献
布赖恩特, O'Hallaron D, et al. 深入理解计算机系统[M]. 中国电力出版社, 2004.
Bryant R, David Richard O H, David Richard O H. Computer systems: a programmer's perspective[M]. Upper
Saddle River: Prentice Hall, 2003.
Reek K A. Pointers on C[M]. Addison-Wesley Longman Publishing Co., Inc., 1997.
Koenig A. C traps and pitfalls[M]. Pearson Education India, 1988.