windows内存管理(2)
因为工作集的页驻留在物理内存中,因此对这些页的访问不涉及磁盘I/O,相对而言非常快;反之,如果执行的代码或者访问的数据不在工作集中,则会引发额外的磁盘I/O,从而降低程序的运行效率。一个极端的情况就是所谓的颠簸或抖动(thrashing),即程序的大部分的执行时间都花在了调页操作上,而不是代码执行上。
如前所述,虚拟内存管理器在调页时,不仅仅只是调入需要的页,同时还将其附近的页也一起调入内存中。综合这些知识,对开发人员来说,如果想提高程序的运行效率,应该考虑以下两个因素。
(1)对代码来说,尽量编写紧凑代码,这样最理想的情形就是工作集从不会到达最大阀值。在每次调入新页时,也就不需要置换已经载入内存的页。因为根据locality特性,以前执行的代码和访问的数据在后面有很大可能会被再次执行或访问。这样程序执行时,发生的缺页错误数就会大大降低,即减少了磁盘I/O,在图4-6中也可以看到一个程序执行时截至当时共发生的缺页错误次数。即使不能达到这种理想情形,紧凑的代码也往往意味着接下来执行的代码更大可能就在相同的页或相邻页。根据时间locality特性,程序80%的时间花在了20%的代码上。如果能将这20%的代码尽量紧凑且排在一起,无疑会大大提高程序的整体运行性能。
(2)对数据来说,尽量将那些会一起访问的数据(比如链表)放在一起。这样当访问这些数据时,因为它们在同一页或相邻页,只需要一次调页操作即可完成;反之,如果这些数据分散在多个页(更糟的情况是这些页还不相邻),那么每次对这些数据的整体访问都会引发大量的缺页错误,从而降低性能。利用Win32提供的预留和提交两步机制,可以为这些会一同访问的数据预留出一大块空间。此时并没有分配实际存储空间,然后在后续执行过程中生成这些数据时随需为它们提交内存。这样既不浪费真正的物理存储(包括调页文件的磁盘空间和物理内存空间),又能利用locality特性。另外内存池机制也是基于类似的考虑。
4.1.6 Win32内存相关API
在Win32平台下,开发人员可以通过如下5组函数来使用内存(完成申请和释放等操作)。
(1)传统的CRT函数(malloc/free系列):因为这组函数的平台无关性,如果程序会被移植到其他非Windows平台,则这组函数是首选。也正因为这组函数非Win32专有,而且介绍这组函数的资料俯拾皆是,这里不作详细介绍。
(2)global heap/local heap函数(GlobalAlloc/LocalAlloc系列):这组函数是为了向后兼容而保留的。在Windows 3.1平台下,global heap为系统中所有进程共有的堆,这些进程包括系统进程和用户进程。它们对此global heap内存的申请会交错在一起,从而使得一个用户进程的不小心的内存使用错误会导致整个操作系统的崩溃。local heap又被称为“******* heap”,与global heap相对应,local heap为每个进程私有。进程通过LocalAlloc从自己的local heap里申请内存,而不会相互干扰。除此之外,进程不能通过另外的用户自定义堆或者其他方式动态地申请内存。到了Win32平台,由于考虑到安全因素,global heap已经废弃,local heap也改名为“process heap”。为了使得以前针对Windows 3.1平台写的应用程序能够运行在新的Win32平台上,GlobalAlloc/ LocalAlloc系列函数仍然得到沿用,但是这一系列函数最后都是从process heap中分配内存。不仅如此,Win32平台还允许进程除process heap之外生成和使用新的用户自定义堆,因此在Win32平台下建议不使用GlobalAlloc/LocalAlloc系列函数进行内存操作,因此这里不详细介绍这组函数。
(3)虚拟内存函数(VirtualAlloc/VirtualFree系列):这组函数直接通过保留(reserve)和提交(commit)虚拟内存地址空间来操作内存,因此它们为开发人员提供最大的自由度,但相应地也为开发人员内存管理工作增加了更多的负担。这组函数适合于为大型连续的数据结构数组开辟空间。
(4)内存映射文件函数(CreateFileMapping/MapViewOfFile系列):系统使用内存映射文件函数系列来加载.exe或者.dll文件。而对开发人员而言,一方面通过这组函数可以方便地操作硬盘文件,而不用考虑那些繁琐的文件I/O操作;另一方面,运行在同一台机器上的多个进程可以通过内存映射文件函数来共享数据(这也是同一台机器上进程间进行数据共享和通信的最有效率和最方便的方法)。
(5)堆内存函数(HeapCreate/HeapAlloc系列):Win32平台中的每个堆都是各进程私有的,每个进程除了默认的进程堆,还可以另外创建用户自定义堆。当程序需要动态创建多个小数据结构时,堆函数系列最为适合。一般来说CRT函数(malloc/free)就是基于堆内存函数实现的。
1.虚拟内存
虚拟内存相关函数共有4对,即VirtualAlloc/VirtualFree、VirtualLock/VirtualUnlock、VirtualQuery/VirtualQueryEx及VirtualProtect/VirtualProtectEx。其中最重要的是第一对,本节主要介绍这一对。
LPVOID VirtualAlloc(
LPVOID lpAddress,
DWORD dwSize,
DWORD flAllocationType,
DWORD flProtect
);
VirtualAlloc根据flAllocationType的不同,可以保留一段虚拟内存区域(MEM_ RESERVE)或者提交一段虚拟内存区域(MEM_COMMIT)。当保留时,除了修改进程的VAD之外(准确地说是增加了一项),并没有分配其他资源,如调页文件空间或者实际物理内存,甚至没有创建页表项。因此非常快捷,而且执行速度与保留空间的大小没有关系。因为保留仅仅只是让内存管理器预留一段虚拟地址空间,并没有实在的存储(硬盘上的调页文件空间或者物理内存),因此访问保留地址会引起访问违例,这是一种严重错误,会直接导致进程退出;相反,提交虚拟内存时,内存管理器必须从系统调页文件中开辟实际的存储空间,因此速度会比保留操作慢。但是需要注意的是,此时在物理内存中并没有立刻分配空间用来与这段虚拟内存空间相对应,甚至也没有相应的页表项被创建,但是提交操作会相应修改VAD项。只有首次访问这段虚拟地址空间中的某个地址时,由于缺页中断,虚拟内存管理器查找VAD,接着根据VAD的内容,动态创建PTE,然后根据PTE信息,分配物理内存页,并实际访问该内存。由此可见,真正花费时间的操作不是提交内存,而是对提交内存的第一次访问!这种lazy-evaluation机制对程序运行性能是十分有益的,因为如果某个程序提交了大段内存,但只是零星地对其中的某些页进行访问,如果没有这种lazy-evaluation机制,提交大段内存会极大地降低系统的性能。
与之相对,VirtualFree释放内存,它提供两种选择:可以将提交的内存释放给系统,但是不释放保留的虚拟内存地址空间;也可以在释放内存的同时将虚拟内存地址空间一并释放,这样这块虚拟内存地址空间的状态变回初始的自由状态。如果内存是提交状态,VirtualFree因为会释放真正的存储空间而比较慢;如果只是释放保留的虚拟内存地址空间,那么因为只需要修改VAD,该操作会很快。
除此之外,VirtualLock保证某块内存在lock期间一直在物理内存中,因此对该内存的访问不会引起缺页中断。lock的内存用VirtualUnlock解锁。因为VirtualLock会把内存锁定在物理内存中,如果这些内存实际中访问的并不频繁,那么会使得其他经常使用到的内存反而增大了被调页出去的概率,从而降低了系统的整体性能,因此在实际使用中,并不推荐使用VirtualLock/VirtualUnlock函数。VirtualQuery可以获得传入指针所在的虚拟内存块的状态,如包含该指针所在页的虚拟内存区域的基址,以及该区域的状态等。VirtualProtect可用来修改某段区域的提交内存页的存取保护标志。
2.内存映射文件
内存映射文件主要有三个用途,Windows利用它来有效使用exe和dll文件,开发人员利用它来方便地访问硬盘文件,或者实现不同进程间的内存共享。第一种这里不详细介绍,只介绍后两种用途。首先讨论它提供的方便访问硬盘文件的机制,一旦通过这种机制将一个硬盘文件(部分或者全部)映射到进程的一段虚拟地址空间中,读写该文件的内容就像通过指针访问变量一样。假设pViewMem为文件映射到内存的首址,那么:
*pViewMem = 100; //写文件的第1个字节
char ch = *(pViewMem + 50); //读文件的第50个字节内容
下面介绍这种机制的使用步骤。
(1)新建或者打开一个硬盘文件。
此步骤用来获得一个文件对象的句柄,用CreateFile函数来新建或者打开一个文件:
HANDLE CreateFile(
PCSTR pszFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
PSECURITY_ATTRIBUTES psa,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile);
其中pszFileName参数指示该文件的路径名,dwDesiredAccess参数表示该文件内容将会被如何访问,此参数包括0、GENERIC_READ、GENERIC_WRITE,以及GENERIC_ READ | GENERIC_WRITE共4种可能,分别表示“不能读也不能写”(在只为了读取该文件属性时使用)、“只读”、“只写”,以及“既可读也可写”;dwShareMode参数用来限定对该文件的任何其他访问的权限,也包括上述4种类型。剩余的几个参数因为与要讨论的问题关系不大,所以不赘述。
此函数成功时,会返回一个文件对象句柄;否则会返回INVALID_HANDLE_ VALUE。
(2)创建或者打开一个文件映射内核对象。
还需要有一个文件映射内核对象,正是它真正将文件内容映射到内存中。如果已经存在此内核对象,只需通过OpenFileMapping函数将其打开即可,这个函数返回该命名对象的句柄。大多数情况下,需要新建一个文件映射内核对象,此时调用CreateFileMapping函数:
HANDLE CreateFileMapping(
HANDLE hFile,
PSECURITY_ATTRIBUTES psa,
DWORD fdwProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
PCTSTR pszName);
hFile参数是第一个步骤中返回的文件内核对象句柄;psa参数是指明内核对象安全特性的,不详述;fdwProtect参数指明了对映射到内存页中的文件内容的存取权限,这个权限必须与第一个步骤中的文件访问权限对应;dwMaximumSizeHigh和dwMaximumSizeLow参数指明映射的最大的空间大小,因为Windows支持大小达到64位的文件,因此需要两个32位的参数;pszName为内核对象名称。
此步只是创建了一个文件映射内核对象,并没有预留或者提交虚拟地址空间,更没有物理内存页被分配出来存放文件内容。
(3)映射文件的内容到进程虚拟地址空间。
访问文件内容之前,必须将要访问的文件内容映射到内存中,通过MapViewOfFile函数完成:
PVOID MapViewOfFile(
HANDLE hFileMappingObject,
DWORD dwDesiredAccess,
DWORD dwFileOffsetHigh,
DWORD dwFileOffsetLow,
SIZE_T dwNumberOfBytesToMap);
其中参数分别为:用来映射内存映射内核对象的句柄,映射的文件内容到内存内存页的存取权限,需要映射的文件内容的起始部分在文件中的偏移及大小。映射时并不需要一次将整个文件的内容全部映射到内存中。
这个函数的操作包括从进程虚拟地址空间中预留出所需映射大小的一段区域,然后提交。提交时并不是从系统的调页文件中开辟空间用来作为该段区域的备份存储,而是内存映射内核对象所对应的文件的指明区域。与虚拟内存使用的惟一不同就是该段虚拟地址空间区域的备份存储不同,其他都是一样的。同样,此时并没有真正的物理内存开辟出来,直到通过返回的指针访问已经映射到内存中的文件内容时,因为发生缺页错误,系统才会分配物理内存页,并将对应的文件存储中的内容调页到该物理内存页
4)访问文件内容。
现在可以通过MapViewOfFile函数返回的指针来访问该段映射到内存中文件内容,就像本小节演示的那样,通过指针访问硬盘文件内容。
这里需要提醒的是,通过该指针修改文件内容时,修改的结果常常不会立刻反映到文件中,因为实际上是在对调入物理内存页中的数据进行修改。考虑到性能因素,该页并不会每做一次修改就立刻将该修改同步到硬盘文件中。如果需要在某个时候强制将之前所做的修改一次性同步到与之对应的硬盘文件中时,可以通过FlushViewOfFile函数达到这个目的:
BOOL FlushViewOfFile(PVOID pvAddress, SIZE_T dwNumberOfBytesToFlush);
这个函数传入需要将修改同步到硬盘文件中的内存块的起始地址和大小。
(5)取消文件内容到进程虚拟地址空间的映射。
当该段映射到内存中的文件内容访问完毕,不再需要访问时,为了有效地利用系统的资源,应该及时回收该段内存,这时调用UnmapViewOfFile函数:
BOOL UnmapViewOfFile(PVOID pvBaseAddress);
此函数传入MapViewOfFile函数返回的指针,系统回收对应的MapViewOfFile调用时预留并提交的虚拟内存地址空间区域,这样该段区域可被其他申请使用。另外因为对应的备份存储不是系统的调页文件,所以不存在备份存储回收的问题。
(6)关闭文件映射内核对象和文件内核对象。
最后,在完成任务不再使用该文件时,通过CloseHandle(hFile)和CloseHandle (hMapping)来关闭文件并释放内存映射文件的内核对象句柄。
下面接着讨论如何利用内存映射文件内核对象来进行进程间的内存共享。
进程间通过内存映射文件进行内存共享时,该内存映射文件内核对象常常不是基于某一个硬盘文件,而是从系统的调页文件中开辟空间作为临时用做共享的存储空间。因此与单纯地利用内存映射文件来访问硬盘文件内容稍有不同,下面是通过内存映射文件来进行进程间内存共享的步骤。假设有进程A和进程B,进程A通过CreateFileMapping创建一个基于系统调页文件的名为“SharedMem”的内存映射文件内核对象:
HANDLE m_hFileMapA = CreateFileMapping
(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0,
10 * 1024, TEXT("SharedMem"));
需要注意的是,因为现在不再基于普通的硬盘文件,所以不需要调用CreateFile来新建或者打开文件这个步骤,注意此时传入的文件句柄参数为INVALID_HANDLE_VALUE,此参数代表从调页文件中开辟空间作为共享内存。
进程B通过OpenFileMapping打开刚才进程A创建的名为“SharedMem”的内存映射文件内核对象:
HANDLE m_hFileMapB = OpenFileMapping(..., TEXT("SharedMem"));
进程A和进程B都可以用此内存映射文件内核对象将从系统调页文件中开辟的那块存储空间的全部或者部分映射到内存中,然后即可使用。
进程A:
...
PVOID pViewA = MapViewOfFile(m_hFileMapA, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);
...
进程B:
...
PVOID pViewB = MapViewOfFile(m_hFileMapB, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);
...
它们各自对该共享内存的修改都能够及时地被对方看到。另外需要注意的是,它们映射到的虚拟内存空间区域并不一定有相同的起始地址,这是因为它们拥有自己的虚拟地址空间。
还有一个需要引起注意,但很难发现的问题是因为创建基于系统调页文件的内存映射文件内核对象是通过传入hFile为INVALID_HANDLE_VALUE的参数来标记的,而创建或者打开普通硬盘文件失败时的返回值也是INVALID_HANDLE_VALUE,因此诸如下面这段代码存在的bug是很难发现的:
...
HANDLE hFile = CreateFile(...);
HANDLE hMap = CreateFileMapping(hFile, ...);
if (hMap == NULL)
return(GetLastError());
...
这段代码的本意是首先创建或者打开一个普通的硬盘文件,然后创建一个基于此文件的内存映射文件内核对象,而并不是想创建一个基于系统调页文件的该对象。但是可以看到,当第1句CreateFile执行失败时,返回INVALID_HANDLE_VALUE。这个返回值立刻被传入到CreateFileMapping函数,结果创建了一个基于系统调页文件的内存映射文件内核对象。这并不是这段代码的原意,而且也会造成问题。因为基于普通硬盘文件的内存映射文件内核对象的操作往往希望将最后的结果保存在该文件中,而基于系统调页文件的内存映射文件内核对象的操作往往只是关注该数据在执行期的结果,操作完毕后并不保存该结果。当CreateFile失败且程序运行后,程序运行无误。但是当检查结果文件时,会发现该文件要么没有被创建,要么数据没有改动,因为随后的操作都是基于系统调页文件的!
因此当使用基于普通硬盘文件的内存映射文件内核对象时,一定要在CreateFile调用完后检查返回值。
3.堆
分配多个小块内存一般都会选择使用堆函数,比如链表节点和树节点等,堆函数的最大优点就是开发人员不用考虑页边界之类的琐碎事情;劣势就是堆函数的操作相对虚拟内存和内存映射文件来说速度要慢些,而且无法像虚拟内存或者内存映射文件那样直接提交或者回收物理存储。
进程都有一个默认的堆,其初始区域大小默认是1 MB,链接时可以通过/HEAP参数修改此默认值。很多操作的临时存储都使用进程的默认堆,比如绝大多数的Win32函数,进程默认堆的句柄可以通过GetProcessHeap函数获得。
因为程序大部分的内存需求都是从进程默认堆中分配的,而且在多线程情况下还需要考虑线程安全问题。因此对特定的应用,这种情况会造成程序的性能下降。针对这种需求,Win32提供了自定义堆机制。
自定义堆的步骤如下。
(1)创建自定义堆。
与进程默认堆(进程创建时系统自动创建)不同,自定义堆需要开发人员首先通过HeapCreate函数创建:
HANDLE HeapCreate(
DWORD fdwOptions,
SIZE_T dwInitialSize,
SIZE_T dwMaximumSize);
fdwOptions参数可以指明是否需要串行化访问支持(HEAP_NO_SERIALIZE),以及分配和回收内存出错时是否抛出异常(HEAP_GENERATE_EXCEPTIONS)。当该自定义堆会被多个线程同时访问时,需要加上串行化访问支持,但相应的性能会有所下降。
dwInitialSize参数指明该自定义堆创建时提交的存储大小(页大小的倍数),dwMaximumSize参数则指明该自定义堆从进程虚拟地址空间中预留出的区域大小。随着对此自定义堆内存的分配,提交的存储大小随之变大,但此参数限制了增大的极限。另一种情况时是dwMaximumSize为0,此时该自定义堆可以一直增长,直到进程虚拟地址空间用完。
(2)从自定义堆中分配内存。
从自定义堆中分配内存调用函数HeapAlloc(从进程默认堆中分配内存也调用此函数):
PVOID HeapAlloc(
HANDLE hHeap,
DWORD fdwFlags,
SIZE_T dwBytes);
hHeap参数即上一步骤中返回的堆内核对象句柄,fdwFlags可以取HEAP_ ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS和HEAP_NO_SERIALIZE共3个值,HEAP_ZERO_MEMORY指明返回的内存必须全部清0。HEAP_GENERATE_ EXCEPTIONS指明此次分配内存如果失败,需要抛出异常。如果该自定义堆创建时指明过此参数,则其上的内存分配不必再指明此参数;如果堆创建时没有指明,则可以在每次申请时指明。HEAP_NO_SERIALIZE参数指明此次分配不必串行化访问支持。最后的dwBytes参数指明此次分配的内存大小,返回值为分配内存的起始位置。
(3)释放内存。
从堆中释放内存调用HeapFree函数:
BOOL HeapFree(
HANDLE hHeap,
DWORD fdwFlags,
PVOID pvMem);
这个函数的参数意义很明显,无须赘述。需要指出的是,这样释放内存并不能保证所有物理存储被回收,一是因为物理存储以页大小为单位判断是否可以回收;二是Windows设计堆机制时对效率的考虑。
(4)销毁自定义堆。
当程序不再需要使用某个自定义堆时,调用HeapDestroy函数:
BOOL HeapDestroy(HANDLE hHeap);
对堆的销毁有几点需要说明,一是当堆销毁时,所有从该堆分配的内存全部被回收,而不必对那些内存一一进行释放,同时该堆占用物理存储以及虚拟地址空间区域也会被系统回收;二是如果没有显式销毁自定义堆,这些堆会在程序退出时被系统销毁。需要注意的是,线程创建的自定义堆并不会在线程退出时被销毁,而是当整个进程退出时才会被销毁,从资源利用效率角度出发,应该在自定义堆不再被使用时立即销毁;三是进程默认堆不能通过此函数销毁,更严格地说,进程默认堆在进程退出前是不能被销毁的。
自定义堆的其他函数如下。
(1)获得进程所有堆:
DWORD GetProcessHeaps(
DWORD dwNumHeaps,
PHANDLE pHeaps);
此函数返回进程目前所有的堆(包括进程默认堆),传入存放所有堆内核对象句柄的数组,以及数组的大小,返回值为堆数目。
(2)修改分配内存的大小:
PVOID HeapReAlloc(
HANDLE hHeap,
DWORD fdwFlags,
PVOID pvMem,
SIZE_T dwBytes);
这个函数可以修改原来分配的内存块(pvMem)的大小,新的大小由参数dwBytes指明。
(3)查询某块分配内存的大小:
SIZE_T HeapSize(
HANDLE hHeap,
DWORD fdwFlags,
LPCVOID pvMem);
这个函数可以查询到原来分配的一个内存块的大小。当该内存块指针是外部模块传入时,如果需要知道该块确切大小时,这个函数就可以发挥作用。
(4)堆压缩:
UINT HeapCompact(
HANDLE hHeap,
DWORD fdwFlags);
此函数将相邻的回收回来的自由块合并在一起,需要注意的是,这个函数并不能移动已经分配的内存块,即它并不能消除内存碎片