与虚拟内存一样,内存映射文件可以用来保留一个地址空间的区域,并将物理存储器提交给该区域。它们之间的差别是,物理存储器来自一个已经位于磁盘上的文件,而不是系统的页文件。一旦该文件被映射,就可以访问它,就像整个文件已经加载内存一样。
内存映射文件可以用于3个不同的目的:
• 系统使用内存映射文件,以便加载和执行. e x e和D L L文件。这可以大大节省页文件空间和应用程序启动运行所需的时间。
• 可以使用内存映射文件来访问磁盘上的数据文件。这使你可以不必对文件执行I / O操作,并且可以不必对文件内容进行缓存。
• 可以使用内存映射文件,使同一台计算机上运行的多个进程能够相互之间共享数据。Wi n d o w s确实提供了其他一些方法,以便在进程之间进行数据通信,但是这些方法都是使用内存映射文件来实现的,这使得内存映射文件成为单个计算机上的多个进程互相进行通信的最有效的方法。
内存映射的可执行文件和DLL文件
当线程调用C r e a t e P r o c e s s时,系统将执行下列操作步骤:
1) 系统找出在调用C r e a t e P r o c e s s时设定的. e x e文件。如果找不到这个. e x e文件,进程将无法创建,C r e a t e P r o c e s s将返回FA L S E。
2) 系统创建一个新进程内核对象。
3) 系统为这个新进程创建一个私有地址空间。
4) 系统保留一个足够大的地址空间区域,用于存放该. e x e文件。该区域需要的位置在. e x e文件本身中设定。按照默认设置, . e x e文件的基地址是0 x 0 0 4 0 0 0 0 0(这个地址可能不同于在6 4位Windows 2000上运行的6 4位应用程序的地址),但是,可以在创建应用程序的. e x e文件时重载这个地址,方法是在链接应用程序时使用链接程序的/ B A S E选项。
5) 系统注意到支持已保留区域的物理存储器是在磁盘上的. e x e文件中,而不是在系统的页文件中。
当. e x e文件被映射到进程的地址空间中之后,系统将访问. e x e文件的一个部分,该部分列出了包含. e x e文件中的代码要调用的函数的D L L文件。然后,系统为每个D L L文件调用L o a d L i b r a r y函数,如果任何一个D L L需要更多的D L L,那么系统将调用L o a d L i b r a r y函数,以便加载这些D L L。每当调用L o a d L i b r a r y来加载一个D L L时,系统将执行下列操作步骤,它们均类似上面的第4和第5个步骤:
1) 系统保留一个足够大的地址空间区域,用于存放该D L L文件。该区域需要的位置在D L L文件本身中设定。按照默认设置, M i c r o s o f t的Visual C++ 建立的D L L文件基地址是0 x 1 0 0 0 0 0 0 0(这个地址可能不同于在6 4位Windows 2000上运行的6 4位D L L的地址)但是,你可以在创建D L L文件时重载这个地址,方法是使用链接程序的/ B A S E选项。Wi n d o w s提供的所有标准系统D L L都拥有不同的基地址,这样,如果加载到单个地址空间,它们就不会重叠。
2) 如果系统无法在该D L L的首选基地址上保留一个区域,其原因可能是该区域已经被另一个D L L或. e x e占用,也可能是因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来保留该D L L。
3) 系统会注意到支持已保留区域的物理存储器位于磁盘上的D L L文件中,而不是在系统的页文件中。
如果由于某个原因系统无法映射. e x e和所有必要的D L L文件,那么系统就会向用户显示一个消息框,并且释放进程的地址空间和进程对象。
当所有的. e x e和D L L文件都被映射到进程的地址空间之后,系统就可以开始执行. e x e文件的启动代码。当. e x e文件被映射后,系统将负责所有的分页、缓冲和高速缓存的处理。
在可执行文件或DLL的多个实例之间共享静态数据 (通过定义共享的节)
全局数据和静态数据不能被同一个. e x e或D L L文件的多个映像共享,这是个安全的默认设置。但是,在某些情况下,让一个. e x e文件的多个映像共享一个变量的实例是非常有用和方便的。例如,Wi n d o w s没有提供任何简便的方法来确定用户是否在运行应用程序的多个实例。但是,如果能够让所有实例共享单个全局变量,那么这个全局变量就能够反映正在运行的实例的数量。
内存映射数据文件
操作系统使得内存能够将一个数据文件映射到进程的地址空间中。因此,对大量的数据进行操作是非常方便的。
为了理解用这种方法来使用内存映射文件的功能,让我们看一看如何用4种方法来实现一个程序,以便将文件中的所有字节的顺序进行倒序。
方法1:一个文件,一个缓存
第一种方法也是理论上最简单的方法,它需要分配足够大的内存块来存放整个文件。该文件被打开,它的内容被读入内存块,然后该文件被关闭。文件内容进入内存后,我们就可以对所有字节的顺序进行倒序,方法是将第一个字节倒腾为最后一个字节,第二个字节倒腾为倒数第二个字节,依次类推。这个倒腾操作将一直进行下去直到文件的中间位置。当所有的字节都已经倒腾之后,就可以重新打开该文件,并用内存块的内容来改写它的内容。
这种方法实现起来非常容易,但是它有两个缺点。首先,必须分配一个与文件大小相同的内存块。如果文件比较小,那么这没有什么问题。但是如果文件非常大,比如说有2 G B大,那该怎么办呢?一个3 2位的系统不允许应用程序提交那么大的物理内存块。因此大文件需要使用不同的方法。
第二,如果进程在运行过程的中间被中断,也就是说当倒序后的字节被重新写入该文件时进程被中断,那么文件的内容就会遭到破坏。防止出现这种情况的最简单的方法是在对它的内容进行倒序之前先制作一个原始文件的拷贝。如果整个进程运行成功,那么可以删除该文件的拷贝。这种方法需要更多的磁盘空间。
方法2:两个文件,一个缓存
在第二种方法中,你打开现有的文件,并且在磁盘上创建一个长度为0的新文件。然后分配一个比较小的内部缓存,比如说8 KB。你找到离原始文件结尾还有8 KB的位置,将这最后的8 KB读入缓存,将字节倒序,再将缓存中的内容写入新创建的文件。这个寻找、读入、倒序和写入的操作过程要反复进行,直到到达原始文件的开头。如果文件的长度不是8 KB的倍数,那么必须进行某些特殊的处理。当原始文件完全处理完毕之后,将原始文件和新文件关闭,并删除原始文件。
这种方法实现起来比第一种方法要复杂一些。它对内存的使用效率要高得多,因为它只需要分配一个8 KB的缓存块,但是它存在两个大问题。首先,它的处理速度比第一种方法要慢,原因是在每个循环操作过程中,在执行读入操作之前,必须对原始文件进行寻找操作。第二,这种方法可能要使用大量的硬盘空间。如果原始文件是400 MB,那么随着进程的不断运行,新文件就会增大为400 MB。在原始文件被删除之前,两个文件总共需要占用800 MB的磁盘空间。这比应该需要的空间大400 MB。由于存在这个缺点,因此引来了下一个方法。
方法3:一个文件,两个缓存
如果使用这个方法,那么我们假设程序初始化时分配了两个独立的8 KB缓存。程序将文件的第一个8 KB读入一个缓存,再将文件的第二个8 KB 读入另一个缓存。然后进程将两个缓存的内容进行倒序,并将第一个缓存的内容写回文件的结尾处,将第二个缓存的内容写回同一个文件的开始处。每个迭代操作不断进行(以8 KB为单位,从文件的开始和结尾处移动文件块)。如果文件的长度不是16 KB的倍数,并且有两个8 KB的文件块相重叠,那么就需要进行一些特殊的处理。这种特殊处理比上一种方法中的特殊处理更加复杂,不过这难不倒经验丰富的编程员。
与前面的两种方法相比,这种方法在节省硬盘空间方面有它的优点。由于所有内容都是从同一个文件读取并写入同一个文件,因此不需要增加额外的磁盘空间,至于内存的使用,这种方法也不错,它只需要使用16 KB的内存。当然,这种方法也许是最难实现的方法。与第一种方法一样,如果进程被中断,本方法会导致数据文件被破坏。
下面让我们来看一看如何使用内存映射文件来完成这个过程。
方法4:一个文件,零缓存
当使用内存映射文件对文件内容进行倒序时,你打开该文件,然后告诉系统将虚拟地址空间的一个区域进行倒序。你告诉系统将文件的第一个字节映射到该保留区域的第一个字节。然后可以访问该虚拟内存的区域,就像它包含了这个文件一样。实际上,如果在文件的结尾处有一个单个0字节,那么只需要调用C运行期函数_ s t r r e v,就可以对文件中的数据进行倒序操作。
这种方法的最大优点是,系统能够为你管理所有的文件缓存操作。不必分配任何内存,或者将文件数据加载到内存,也不必将数据重新写入该文件,或者释放任何内存块。但是,内存映射文件仍然可能出现因为电源故障之类的进程中断而造成数据被破坏的问题。
使用内存映射文件
若要使用内存映射文件,必须执行下列操作步骤:
1) 创建或打开一个文件内核对象,该对象用于标识磁盘上你想用作内存映射文件的文件。
2) 创建一个文件映射内核对象,告诉系统该文件的大小和你打算如何访问该文件。
3) 让系统将文件映射对象的全部或一部分映射到你的进程地址空间中。
当完成对内存映射文件的使用时,必须执行下面这些步骤将它清除:
1) 告诉系统从你的进程的地址空间中撤消文件映射内核对象的映像。
2) 关闭文件映射内核对象。
3) 关闭文件内核对象。
步骤1:创建或打开文件内核对象
若要创建或打开一个文件内核对象,总是要调用C r e a t e F i l e函数:
HANDLE CreateFile( PCSTR pszFileName, DWORD dwDesiredAccess, DWORD dwShareMode, PSECURITY_ATTRIBUTES psa, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile);
步骤2:创建一个文件映射内核对象
调用C r e a t e F i l e函数,就可以将文件映像的物理存储器的位置告诉操作系统。你传递的路径名用于指明支持文件映像的物理存储器在磁盘(或网络或光盘)上的确切位置。这时,必须告诉系统,文件映射对象需要多少物理存储器。若要进行这项操作,可以调用C r e a t e F i l e M a p p i n g函数:
HANDLE CreateFileMapping( HANDLE hFile, PSECURITY_ATTRIBUTES psa, DWORD fdwProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, PCTSTR pszName);
步骤3:将文件数据映射到进程的地址空间
当创建了一个文件映射对象后,仍然必须让系统为文件的数据保留一个地址空间区域,并将文件的数据作为映射到该区域的物理存储器进行提交。可以通过调用M a p Vi e w O f F i l e函数来进行这项操作:
PVOID MapViewOfFile( HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, SIZE_T dwNumberOfBytesToMap);
步骤4:从进程的地址空间中撤消文件数据的映像
当不再需要保留映射到你的进程地址空间区域中的文件数据时,可以通过调用下面的函数将它释放:
BOOL UnmapViewOfFile(PVOID pvBaseAddress);
为了提高速度,系统将文件的数据页面进行高速缓存,并且在对文件的映射视图进行操作时不立即更新文件的磁盘映像。如果需要确保你的更新被写入磁盘,可以强制系统将修改过的数据的一部分或全部重新写入磁盘映像中,方法是调用F l u s h Vi e w O f F i l e函数:
BOOL FlushViewOfFile( PVOID pvAddress, SIZE_T dwNumberOfBytesToFlush);
步骤5和步骤6:关闭文件映射对象和文件对象
不用说,你总是要关闭你打开了的内核对象。如果忘记关闭,在你的进程继续运行时会出现资源泄漏的问题。当然,当你的进程终止运行时,系统会自动关闭你的进程已经打开但是忘记关闭的任何对象。但是如果你的进程暂时没有终止运行,你将会积累许多资源句柄。因此你始终都应该编写清楚而又“正确的”代码,以便关闭你已经打开的任何对象。若要关闭文件映射对象和文件对象,只需要两次调用C l o s e H a n d l e函数,每个句柄调用一次:
让我们更加仔细地观察一下这个进程。下面的伪代码显示了一个内存映射文件的例子:
HANDLE hFile = CreateFile(...); HANDLE hFileMapping = CreateFileMapping(hFile, ...); PVOID pvFile = MapViewOfFile(hFileMapping, ...); // Use the memory-mapped file. UnmapViewOfFile(pvFile); CloseHandle(hFileMapping); CloseHandle(hFile);
上面的代码显示了对内存映射文件进行操作所用的“预期”方法。但是,它没有显示,当你调用M a p Vi e w O f F i l e时系统对文件对象和文件映射对象的使用计数的递增情况。这个副作用是很大的,因为它意味着我们可以将上面的代码段重新编写成下面的样子:
HANDLE hFile = CreateFile(...); HANDLE hFileMapping = CreateFileMapping(hFile, ...); CloseHandle(hFile); PVOID pvFile = MapViewOfFile(hFileMapping, ...); CloseHandle(hFileMapping); // Use the memory-mapped file. UnmapViewOfFile(pvFile);
当对内存映射文件进行操作时,通常要打开文件,创建文件映射对象,然后使用文件映射对象将文件的数据视图映射到进程的地址空间。由于系统递增了文件对象和文件映射对象的内部使用计数,因此可以在你的代码开始运行时关闭这些对象,以消除资源泄漏的可能性。
如果用同一个文件来创建更多的文件映射对象,或者映射同一个文件映射对象的多个视图,那么就不能较早地调用C l o s e H a n d l e函数——以后你可能还需要使用它们的句柄,以便分别对C r e a t e F i l e M a p p i n g和M a p Vi e w O f F i l e函数进行更多的调用。
使用内存映射文件来处理大文件
使用内存映射文件在进程之间共享数据
Wi n d o w s总是出色地提供各种机制,使应用程序能够迅速而方便地共享数据和信息。这些机制包括R P C、C O M、O L E、D D E、窗口消息(尤其是W M _ C O P Y D ATA)、剪贴板、邮箱、管道和套接字等。在Wi n d o w s中,在单个计算机上共享数据的最低层机制是内存映射文件。不错,如果互相进行通信的所有进程都在同一台计算机上的话,上面提到的所有机制均使用内存映射文件从事它们的烦琐工作。如果要求达到较高的性能和较小的开销,内存映射文件是举手可得的最佳机制。
数据共享方法是通过让两个或多个进程映射同一个文件映射对象的视图来实现的,这意味着它们将共享物理存储器的同一个页面。因此,当一个进程将数据写入一个共享文件映射对象的视图时,其他进程可以立即看到它们视图中的数据变更情况。注意,如果多个进程共享单个文件映射对象,那么所有进程必须使用相同的名字来表示该文件映射对象。
让我们观察一个例子,启动一个应用程序。当一个应用程序启动时,系统调用C r e a t e F i l e函数,打开磁盘上的. e x e文件。然后系统调用C r e a t e F i l e M a p p i n g函数,创建一个文件映射对象。最后,系统代表新创建的进程调用M a p Vi e w O f F i l e E x函数(它带有S E C _ I M A G E标志),这样, . e x e文件就可以映射到进程的地址空间。这里调用的是M a p Vi e w O f F i l e E x,而不是M a p Vi e w O f F i l e,这样,文件的映像将被映射到存放在. e x e文件映像中的基地址中。系统创建该进程的主线程,将该映射视图的可执行代码的第一个字节的地址放入线程的指令指针,然后C P U启动该代码的运行。
如果用户运行同一个应用程序的第二个实例,系统就认为规定的. e x e文件已经存在一个文件映射对象,因此不会创建新的文件对象或者文件映射对象。相反,系统将第二次映射该文件的一个视图,这次是在新创建的进程的地址空间环境中映射的。系统所做的工作是将相同的文件同时映射到两个地址空间。显然,这是对内存的更有效的使用,因为两个进程将共享包含正在执行的这部分代码的物理存储器的同一个页面。
与所有内核对象一样,可以使用3种方法与多个进程共享对象,这3种方法是句柄继承性、句柄命名和句柄复制。
内存映射文件与数据视图的相关性
附录:
系统允许你映射一个文件的相同数据的多个视图。例如,你可以将文件开头的10 KB映射到一个视图,然后将同一个文件的头4 KB映射到另一个视图。只要你是映射相同的文件映射对象,系统就会确保映射的视图数据的相关性。例如,如果你的应用程序改变了一个视图中的文件内容,那么所有其他视图均被更新以反映这个变化。这是因为尽管页面多次被映射到进程的虚拟地址空间,但是系统只将数据放在单个R A M页面上。如果多个进程映射单个数据文件的视图,那么数据仍然是相关的,因为在数据文件中,每个R A M页面只有一个实例——正是这个R A M页面被映射到多个进程的地址空间。
注意Wi n d o w s允许创建若干个由单个数据文件支持的文件映射对象。Wi n d o w s不能保证这些不同的文件映射对象的视图具有相关性。它只能保证单个文件映射对象的多个视图具有相关性。
然而,当对文件进行操作时,没有理由使另一个应用程序无法调用C r e a t e F i l e函数以打开由另一个进程映射的同一个文件。这个新进程可以使用R e a d F i l e和Wr i t e F i l e函数来读取该文件的数据和将数据写入该文件。当然,每当一个进程调用这些函数时,它必须从内存缓冲区读取文件数据或者将文件数据写入内存缓冲区。该内存缓冲区必须是进程自己创建的一个缓冲区,而不是映射文件使用的内存缓冲区。当两个应用程序打开同一个文件时,问题就可能产生:一个进程可以调用R e a d F i l e函数来读取文件的一个部分,并修改它的数据,然后使用Wr i t e F i l e函数将数据重新写入文件,而第二个进程的文件映射对象却不知道第一个进程执行的这些操作。由于这个原因,当你为将被内存映射的文件调用C r e a t e F i l e函数时,最好将d w S h a r e M o d e参数的值设置为0。这样就可以告诉系统,你想要单独访问这个文件,而其他进程都不能打开它。
只读文件不存在相关性问题,因此它们可以作为很好的内存映射文件。内存映射文件决不应该用于共享网络上的可写入文件,因为系统无法保证数据视图的相关性。如果某个人的计算机更新了文件的内容,其他内存中含有原始数据的计算机将不知道它的信息已经被修改。