内存管理(一)
2009-10-27 15:31
本质上虚拟内存就是要让一个程序的代码和数据在没有全部载入内存时即可运行。运行过程中,当执行到尚未载入内存的代码,或者要访问还没有载入到内存的数据时,虚拟内存管理器动态地将这部分代码或数据从硬盘载入到内存中。而且在通常情况下,虚拟内存管理器也会相应地先将内存中某些代码或者数据置换到硬盘中,为即将载入的代码或数据腾出空间。
因为内存和硬盘之间的数据传输相对代码执行来说,是非常慢的操作,因此虚拟内存管理器在保证工作正确的前提下,还必须考虑效率因素。比如,它需要优化置换算法,尽量避免就要执行的代码或访问的数据刚被置换出内存,而很久没有访问的代码或数据却一直驻留在内存中。另外它还需要将驻留在内存的各个进程的代码或数据维持在一个合理的数量上,并且根据该进程的性能表现动态调整此数量,等等,使得程序运行时将其涉及的磁盘I/O次数降到尽可能低,以提高程序的运行性能。
本章前一部分着重介绍Windows的虚拟内存管理机制,后一部分则简要介绍Linux的虚拟内存管理机制。
4.1 Windows内存管理
如果从应用程序的角度来看Windows虚拟内存管理系统,可以扼要地归结为一句话。即Win32虚拟内存管理器为每一个Win32进程提供了进程私有且基于页的4 GB(32位)大小的线性虚拟地址空间,这句话可以分解如下:
(1)“进程私有”意味着每个进程都只能访问属于自己的地址空间,而无法访问其他进程的地址空间,也不用担心自己的地址空间会被其他进程看到(父子进程例外,比如调试器利用父子进程关系来访问被调试进程的地址空间,这里不详述)。需要注意的是,进程运行时用到的dll并没有属于自己的虚拟地址空间。而是其所属进程的虚拟地址空间,dll的全局数据,以及通过dll函数申请的内存都是从调用其进程的虚拟地址空间中开辟。
(2)“基于页”是指虚拟地址空间被划分为多个称为“页”的单元,页的大小由底层处理器决定,x86中页的大小为4 KB。页是Win32虚拟内存管理器处理的最小单元,相应的物理内存也被划分为多个页。虚拟内存地址空间的申请和释放,以及内存和磁盘的数据传输或置换都是以页为最小单位进行的。
(3)“4 GB大小”意味着进程中的地址取值范围可以从0x00000000到0xFFFFFFFF。Win32将低区的2 GB留给进程使用,高区的2 GB则留给系统使用。
Win32中用来辅助实现虚拟内存的硬盘文件称为“调页文件”,可以有16个,调页文件用来存放被虚拟内存管理器置换出内存的数据。当这些数据再次被进程访问时,虚拟内存管理器会先将它们从调页文件中置换进内存,这样进程可以正确访问这些数据。用户可以自己配置调页文件。出于空间利用效率和性能的考虑,程序代码(包括exe和dll文件)不会被修改,所以当它们所在的页被置换出内存时,并不会被写进调页文件中,而是直接抛弃。当再次被需要时,虚拟内存管理器直接从存放它们的exe或dll文件中找到它们并调入内存。另外对exe和dll文件中包含的只读数据的处理与此类似,也不会为它们在调页文件中开辟空间。
当进程执行某段代码或者访问某些数据,而这些代码或者数据还没有在内存时,这种情形称为“缺页错误”。缺页错误的原因有很多种,最常见的一种就是已经提到的,即这些代码和数据被虚拟内存管理器置换出了内存,这时虚拟内存管理器在这段代码执行或者这些数据被访问前将它们调入内存。这个操作对开发人员来说是透明的,因此大大简化了开发人员的负担。但是调页错误涉及磁盘I/O,大量的调页错误会大大降低程序的总体性能。因此需要了解缺页错误的主要原因,以及规避它们的方法。
4.1.1 使用虚拟内存
Win32中分配内存分为两个步骤:“预留”和“提交”。因此在进程虚拟地址空间中的页有3种状态:自由(free)、预留(reserved)和提交(committed)。
(1)自由表示此页尚未被分配,可以用来满足新的内存分配请求。
(2)预留指从虚拟地址空间中划出一块区域(region,页的整数倍数大小),划出之后这个区域中的页不能用来满足新的内存分配请求,而是用来供要求“预留”此段区域的代码以后使用。预留时并没有分配物理存储,只是增加了一个描述进程虚拟地址空间使用状态的数据结构(VAD,虚拟地址描述符),用来记录这段区域已被预留。“预留”操作相对较快,因为没有真正分配物理存储。也正因为没有分配真正的物理存储,所以预留的空间并不能够直接访问,对预留页的访问会引起“内存访问违例”(内存访问违例会导致整个进程立刻退出,而不仅仅是中止引起该违例的线程)。
(3)提交,若想得到真正的物理存储,必须对预留的内存进行提交。提交会从调页文件中开辟空间,并修改VAD中的相应项。注意,提交时也并没有立刻从物理内存中分配空间,而只是从磁盘的调页文件中开辟空间。这个空间用做以后置换的备份空间,直到有代码第一次访问这段提交内存中的某些数据时,系统发现并没有真正的物理内存,抛出缺页错误。虚拟内存管理器处理此缺页错误,直到这时才会真正分配物理内存,提交也可以在预留的同时一起进行。需要注意的是,提交操作会从调页文件中开辟磁盘空间,所以比预留操作的时间长。
这也是Win32虚拟内存管理中的demand-paging策略的一个体现,即不到真正访问时,不会为某虚拟地址分配真正的物理内存。这种策略一是出于性能考虑,将工作分段完成,提高总体性能;二是出于空间效率考虑,不到真正访问时,Win32总是假定进程不会访问大多数的数据,因而也不必为它们开辟存储空间或将其置换进物理内存,这样可以提高存储空间(磁盘和物理内存)的使用效率。
设想某些程序对内存有很大的需求,但又不是立即需要所有这些内存,那么一次就从物理存储中开辟空间满足这些还只是“潜在”的需求,从执行性能和存储空间效率来说,都是一种浪费。因为只是“潜在”需求,极有可能这些分配的内存中很大一部分最后都没有真正被用到。如果在申请的时候就一次性为它们分配全部物理存储,无疑会极大地降低空间的利用效率。
另一方面,如果完全不用预留及提交机制,只是随需分配内存来满足每次的请求,那么对一个会在不同时间点频繁请求内存的代码来说,因为在它请求内存的不同时间点的间隙极有可能会有其他代码请求内存。这样这段在不同时间点频繁请求内存的代码请求得到的内存因为虚拟地址不连续,无法很好地利用空间locality特性,对其整体进行访问(比如遍历操作)时就会增加缺页错误的数量,从而降低程序的性能。
预留和提交在Win32中都使用VirtualAlloc函数完成,预留传入MEM_RESERVE参数,提交传入MEM_COMMIT参数。释放虚拟内存使用VirtualFree函数,此函数根据不同的传入参数,与VirtualAlloc相对应,可以释放与虚拟地址区域相对应的物理存储,但该虚拟地址区域还可处于预留状态,也可以连同虚拟地址区域一起释放,该段区域恢复为自由状态。
线程栈和进程堆的实现都利用了这种预留和提交两步机制,下面仅以线程栈为例来说明Win32系统是如何使用这种预留和提交两步机制的。
创建线程栈时,只是一个预留的虚拟地址区域,默认是1 MB(此大小可在CreateThread或在链接时通过链接选项修改),初始时只有前两页是提交的。当线程栈因为函数的嵌套调用需要更多的提交页时,虚拟内存管理器会动态地提交该虚拟地址区域中的后续页以满足其需求,直到到达1 MB的上限。当到达此预留区域大小的上限(默认1 MB)时,虚拟内存管理器不会增加预留区域大小,而是在提交最后一页时抛出一个栈溢出异常,抛出栈溢出异常时该栈还有一页空间可用,程序仍可正常运行。而当程序继续使用栈空间,用完最后一页后,还继续需要存储空间,这时就超过了上限,会直接导致进程退出。
所以为防止线程栈溢出导致整个程序退出,应该注意尽量控制栈的使用大小。比如减少函数的嵌套层数,减少递归函数的使用,尽量不要在函数中使用太大的局部变量(大的对象可以从堆中开辟空间存放,因为堆会动态扩大,而线程栈的可用内存区域在线程创建时就已固定,之后在整个线程生命期间无法扩展)。
另外为了防止因为一个线程栈的溢出导致整个进程退出,可以对可能会产生线程栈溢出的线程体函数加异常处理,捕获在提交最后一页时抛出的溢出异常,并做出相应处理。
4.1.2 访问虚拟内存时的处理流程
对某虚拟内存区域进行了预留并提交之后,就可以对该区域中的数据进行访问了,下图描述了当程序对某段内存访问时的处理流程:
如图4-1所示,当该数据已在物理内存中时,虚拟内存管理器只需将指向该数据的虚拟地址映射为物理指针,即可访问到物理内存中的真正数据。这一步不会涉及磁盘I/O,速度相对较快。
当第一次访问一段刚刚提交的内存中的数据时,因为并没有真正的物理内存分配给它。或者该数据以前已被访问过,但是被虚拟内存管理器置换出了内存。这两种情形都会引发缺页错误,虚拟内存管理器此时会处理这一缺页错误,它先检测此数据是否在调页文件中已有备份空间(exe和dll的代码页和只读数据页情形与此类似,但是其备份空间不在调页文件,而是包含它们的exe或dll文件)。如果是这两种情况,表明访问的数据在磁盘中有备份,接下来虚拟内存管理器就需要在物理内存中找到合适的页,并将存放在磁盘的备份数据置换进物理内存。
图4-1 访问虚拟内存的处理流程
虚拟内存管理器首先查询当前物理内存中是否有空闲页,虚拟内存管理器维护一个称为“页帧数据库”(page-frame database)的数据结构,此数据结构是操作系统全局的,当Windows启动时被初始化,用来跟踪和记录物理内存中每一个页的状态,它会用一个链表将所有空闲页连接起来,当需要空闲页时,直接查找此空闲页链表,如果有,直接使用某个空闲页;否则根据调页算法首先选出某个页。需要指出的是,虚拟内存管理器调页时并不是只调入一个页,为了利用局部特性,它在调入包含所需数据的页的同时,会将其附近的几个页一起调入内存。这里为了简单和清楚起见,假定只调入目标页。但应该意识到Win32调页时的这个特性,因为可以利用它来提高程序效率。这个页将会用来存放即将从磁盘置换进来的页的内容。选出某个内存页后,接着检查此页状态,如果此页自上次调进内存以来尚未被修改过,则直接使用此页(代码页和只读页也可以直接使用);反之,如果此页已被修改过(“脏”),则需要先将此页的内容“写”到调页文件中与此页相对应的备份页中,并随即将此页标为空闲页。
现在,有了一个空闲页用来存放即将要访问的数据。此时,虚拟内存管理器会再次检测,此数据是否是刚被申请的内存且是第一次被访问。如果是,则直接将此空闲页清0使用即可(不必从磁盘中将其备份页的内容读进,因为该备份页中的内容无意义);如果不是,则需要将调页文件中该页的备份页读到此空闲页中,并随即将此页的状态从空闲页改为活动页。
此时,此数据已在物理内存页中,通过虚拟地址映射到物理地址,即就可访问此数据了。
上述为访问成功时的情形,但情形并非总是如此。比如当用户定义了一个数组,而此数组刚好在其所在页的下边界,且此页的下一页刚好是自由或者预留的(不是提交的,即没有真正的物理存储)。当程序不小心向下越界访问此数组,则首先引发缺页错误。随即虚拟内存管理器在处理缺页错误时检测到它也不在调页文件中,这就是所谓的“访问违例”(access violation)。访问违例意味着要访问的地址所在的虚拟内存页还没有被提交,即没有实际的物理存储与之对应,访问违例会直接导致整个进程退出(即crash)。
可以看到,指针越界访问的后果根据运行时实际情况而有所不同。如上所述,当数组并非处于其所在页的边界,越界后还在同一页中,这时只会“误访问”(误读或误写,其中误读只会影响到正在执行的代码;误写则会影响到其他处代码的执行)该页中其他数据,而不会导致整个进程的crash。即使在该数组真的处于其所在页的边界,且越界后指针值落在了其相邻页。但如果此相邻页碰巧也为一个提交页,此时仍然只是“误访问”,也不会导致进程的crash。这也意味着,同一个应用程序的代码中存在着指针越界访问错误,运行时有时crash,但有时则不会。
Microsoft提供了一个监测指针越界访问的工具pageheap,它的原理就是强制使每次分配的内存都位于页的边界,同时强制该页的相邻页为自由页(即不分配其相邻页给程序使用)。这样每次越界访问都会立即引起access violation,导致程序crash。从而使得指针越界访问错误在开发期间一定会被暴露出来,而不会发生某个指针越界访问错误一直隐藏到Release版本,直到最终用户使用时才被发现的情形。
4.1.3 虚拟地址到物理地址的映射
如上所述,在确保访问的数据已在物理内存中后,还需要先将虚拟地址转换为物理地址,即“地址映射”,才能够真正访问此数据。本节讲述Win32中虚拟内存管理器如何将虚拟地址映射为物理地址。
Win32通过一个两层表结构来实现地址映射,因为4 GB虚拟地址空间为每个进程私有,相应地,每个进程都维护一套自己的层次表结构用来实现其地址映射。第一层表称为“页目录”(page directory),实际上就是一个内存页(4 KB = 4 096 byte)。这一页以四个字节为单元分为1 024项,每一项称为一个“页目录项”(Page Directory Entry,PDE);第二层表称为“页表”(page table),共有1 024个页表。页目录中每一个页目录项PDE对应这一层中的某一个页表,每一个页表也占了一个内存页。这一页中的4 KB,即4 096个字节也像页目录那样被分成1 024项,每项4个字节,页表的每一项则称为“页表项”(Page Table Entry,PTE)。每一个页表项PTE都指向物理内存中的某一个页帧,如图4-2所示。
图4-2 页表
已经知道,Win32提供了4 GB(32位)大小的虚拟地址空间。因此每个虚拟地址都是一个32位的整数值,这32位由3个部分组成,如图4-3所示。
图4-3 虚拟地址空间
这三个部分中的第一部分,即前10位为页目录下标,用其可以定位在页目录的1 024项中的某一项。根据定位到的那一项的项值,可以找到第2层页表中的某一个页表。虚拟地址的第二部分,即中间的10位为页表下标,可用来定位刚刚找到的页表的1 024项中的某一项。此项值可以找到物理内存中的某一个页,此页包含此虚拟地址所代表的数据。最后用虚拟地址的第三部分,即最后12位可用来定位此物理页中的特定的字节位置,12位刚好可以定位一个页中的任意位置的字节。
举一个具体的例子,假设在程序中访问一个指针(Win32中的“指针”意味虚拟地址),此指针值为0x2A8E317F,图4-4所示为虚拟地址到物理地址的映射过程。
0x2A8E317F的二进制写法为0010101010,0011100011,000101111111,为了方便起见,将这32位分成10位、10位和12位。第一个10位00101010用来定位页目录中的页目录项,因为页目录项为四个字节,定位前将此10位左移两位,即0010101000(0x2A8)。再用此值作为下标找到对应的页目录项,此页目录项指向一个页表。同样方法再用第二个10位0011100011定位此页表中的页表项。此页表项指向真正的物理内存,然后用最后12位000101111111定位页内的数据(此时这12位不用再左移,因为物理页内定位时,需要能定位到每一个字节。而不像页目录和页表中,只需要定位每4个字节的第1个字节),即为此指针指向的数据。
上面假设的是此数据已在物理内存中,其实,“判断访问的数据是否在内存中”这一步骤,也是在这个地址映射过程中完成的,Win32总是假使数据已在物理内存中,并进行地址映射。页表项中有一位用来标识包含此数据的页是否在物理内存页中,当取得页表项时,检测此位,如果在,就是本节描述的过程,如果不在,则抛出缺页错误,此时此页表项中包含了此数据是否在调页文件中,如果不在,则为访问违例,如果在,此页表项可查出了此数据页在哪个调页文件中,以及此数据页在该调页文件中的起始位置,然后根据这些信息将此数据页从磁盘中调入物理内存中,再继续进行地址映射过程。
已经说过,为了实现虚拟地址空间各进程私有,每个进程都拥有自己的页目录和页表结构,对不同进程而言,页目录中的页目录项值(PDE),以及页表中的页表项值(PTE)都是不同的,因此相同的指针(虚拟地址)被不同的进程映射到的物理地址也是不同的。这也意味着,在不同进程间传递指针是没有意义的。
4.1.4 虚拟内存空间使用状态记录
当通过VirtualAlloc申请一块虚拟内存时,虚拟内存管理器是如何知道哪些内存块是自由的,可以用来满足此次内存请求呢?即Win32虚拟内存如何维护和记录每一个进程的4 GB虚拟内存地址空间的使用状态,如各个区域的状态、大小及起始地址呢?
上一节中,读者也许会认为可以通过遍历页目录和页表中的项值来收集虚拟内存空间的使用状态,但这样做首先有效率问题,因为每次申请内存都需要做一次搜索。但这个方法不仅仅是因为效率有问题,而且还是行不通的,对预留的页来说,虚拟内存管理器并没有为之分配物理存储。所以也就不会为其填写页表项,这时遍历页表无法分辨某块虚拟内存是自由还是预留的。另外即使对提交页来说,遍历页表也无法得到完整的信息,正如4.1.1节中提到的Win32在虚拟内存管理时用到的主要策略demand-paging,即Win32虚拟内存管理器在程序没有实际访问某块内存前,总是假定这块内存不会被访问到,因此不会为这块内存做过多处理,包括不会为其分配真正的物理内存空间,甚至页表,即进程中用来完成虚拟地址到物理地址映射的页表的存储空间也是随需分配的。
Win32虚拟内存管理器使用另外一个数据结构来记录和维护每个进程的4 GB虚拟地址空间的使用及状态信息,这就是虚拟地址描述符树(Virtual Address Descriptor,VAD)。每一个进程都有一个自己的VAD集合,这个集合中的VAD被组织成一个自平衡二叉树,以提高查找的效率。另外只有预留或者提交的内存块才会有VAD,自由的内存块没有VAD(因此不在VAD树结构中的虚拟地址块就是自由的)。VAD的组织如图4-5所示。
图4-5 VAD的组织结构
(1)当程序申请一块新内存时,虚拟内存管理器只需访问VAD树。找到两个相邻VAD,只要小的VAD的上限与大的VAD的下限之间的差值满足所申请的内存块的大小需求,即可使用二者之间的虚拟内存。
(2)当第一次访问提交的内存时,虚拟内存管理器根据上一节描述的流程。即总是假定该数据页已在物理内存中,并进行虚拟地址到物理地址的转换。当找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树。找到包含该地址的VAD,并根据VAD中的信息,比如该内存块的大小、范围,以及在调页文件中的起始位置等,随需生成相应的页表项,然后从刚才发生缺页错误的地方继续进行地址映射。由此可以看出,一个虚拟内存页被提交时,除了在调页文件中开辟一个备份页之外,不会生成包含指向它的页表项的页表,也不会填充指向它的页表项,更不会为之开辟真正的物理内存页,而是直到第一次访问这个提交页时,才会“随需地”从VAD中取得包含该页的整个区域的信息,生成相应页表,并填充相应页的表项。
(3)当访问预留的内存时,虚拟内存管理器也是根据上一节描述的流程进行虚拟地址到物理地址的映射,找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树,找到包含该地址的VAD。这时它会发现此段内存块只是预留的,而没有提交,即并没有对应的真正的物理存储,这时直接抛出访问违例,进程退出。
(4)当访问自由的内存时,虚拟内存管理器还是根据上一节描述的流程进行虚拟地址到物理地址的映射。找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树,发现并没有VAD包含此虚拟地址,此时可以知道该地址所在的虚拟地址页是自由状态,直接抛出访问违例,进程退出。
4.1.5 进程工作集
因为频繁的调页操作引起的磁盘I/O会大大降低程序的运行效率,因此对每一个进程,虚拟内存管理器都会将其一定量的内存页驻留在物理内存中。并跟踪其执行的性能指标,动态调整这个数量。Win32中驻留在物理内存中的内存页称为进程的“工作集”(working set),进程的工作集可以通过“任务管理器”查看,其中“内存使用”列即为工作集大小。图4-6中绿色方框的数字是笔者写作本书时所用Word编辑器的工作集大小,即38740 KB。
工作集是会动态变化的,进程初始时只有很少的代码页和数据页被调入内存。当执行到未被调入内存的代码或者访问到尚未调入内存的数据时,这些代码页或者数据页会被调入物理内存,工作集也随之增长。但工作集不能无限增长,系统为每个进程都定义了一个默认的最小工作集(根据系统物理内存大小,此值可能为20~50 MB)和最大工作集(根据系统物理内存大小,此值可能为45~345 MB)。当工作集到达最大工作集,即进程需要再次调入新页到物理内存中时,虚拟内存管理器会将其原来的工作集中的某些页先置换出内存,然后将需要调入的新页调入内存。
图4-6 工作集