使⽤内存映射⽂件加快读取⼤⽂件的速度.
引⾔
⽂件操作是应⽤程序最为基本的功能之⼀,Win32 API和MFC均提供有⽀持⽂件处理的函数和类,常⽤的有Win32 API的CreateFile()、WriteFile()、ReadFile()和MFC提供的CFile类等。⼀般来说,以上这些函数可以满⾜⼤多数场合的要求,但是对于某些特殊应⽤领域所需要的动辄⼏⼗GB、⼏百GB、乃⾄⼏TB的海量存储,再以通常的⽂件处理⽅法进⾏处理显然是⾏不通的。⽬前,对于上述这种⼤⽂件的操作⼀般是以内存映射⽂件的⽅式来加以处理的,本⽂下⾯将针对这种Windows核⼼编程技术展开讨论。
内存映射⽂件概述
内存⽂件映射也是Windows的⼀种内存管理⽅法,提供了⼀个统⼀的内存管理特征,使应⽤程序可以通过内存指针对磁盘上的⽂件进⾏访问,其过程就如同对加载了⽂件的内存的访问。通过⽂件映射这种使磁盘⽂件的全部或部分内容与进程虚拟地址空间的某个区域建⽴映射关联的能⼒,可以直接对被映射的⽂件进⾏访问,⽽不必执⾏⽂件I/O操作也⽆需对⽂件内容进⾏缓冲处理。内存⽂件映射的这种特性是⾮常适合于⽤来管理⼤尺⼨⽂件的。
在使⽤内存映射⽂件进⾏I/O处理时,系统对数据的传输按页⾯来进⾏。⾄于内部的所有内存页⾯则是由
虚拟内存管理器来负责管理,由其来决定内存页⾯何时被分页到磁盘,哪些页⾯应该被释放以便为其它进程提供空闲空间,以及每个进程可以拥有超出实际分配物理内存之外的多少个页⾯空间等等。由于虚拟内存管理器是以⼀种统⼀的⽅式来处理所有磁盘I/O的(以页⾯为单位对内存数据进⾏读写),因此这种优化使其有能⼒以⾜够快的速度来处理内存操作。
使⽤内存映射⽂件时所进⾏的任何实际I/O交互都是在内存中进⾏并以标准的内存地址形式来访问。磁盘的周期性分页也是由操作系统在后台隐蔽实现的,对应⽤程序⽽⾔是完全透明的。内存映射⽂件的这种特性在进⾏⼤⽂件的磁盘事务操作时将获得很⾼的效益。
需要说明的是,在系统的正常的分页操作过程中,内存映射⽂件并⾮⼀成不变的,它将被定期更新。如果系统要使⽤的页⾯⽬前正被某个内存映射⽂件所占⽤,系统将释放此页⾯,如果页⾯数据尚未保存,系统将在释放页⾯之前⾃动完成页⾯数据到磁盘的写⼊。
对于使⽤页虚拟存储管理的Windows操作系统,内存映射⽂件是其内部已有的内存管理组件的⼀个扩充。由可执⾏代码页⾯和数据页⾯组成的应⽤程序可根据需要由操作系统来将这些页⾯换进或换出内存。如果内存中的某个页⾯不再需要,操作系统将撤消此页⾯原拥⽤者对它的控制权,并释放该页⾯以供其它进程使⽤。只有在该页⾯再次成为需求页⾯时,才会从磁盘上的可执⾏⽂件重新读⼊内存。同样地,当⼀个进程初始化启动时,内存的页⾯将⽤来存储该应⽤程序的静态、动态数据,⼀旦对它
们的操作被提交,这些页⾯也将被备份⾄系统的页⾯⽂件,这与可执⾏⽂件被⽤来备份执⾏代码页⾯的过程是很类似的。图1展⽰了代码页⾯和数据页⾯在磁盘存储器上的备份过程:
图1 进程的代码页、数据页在磁盘存储器上的备份
  显然,如果可以采取同⼀种⽅式来处理代码和数据页⾯,⽆疑将会提⾼程序的执⾏效率,⽽内存映射⽂件的使⽤恰恰可以满⾜此需求。
对⼤⽂件的管理
  内存映射⽂件对象在关闭对象之前并没有必要撤销内存映射⽂件的所有视图。在对象被释放之前,所有的脏页⾯将⾃动写⼊磁盘。通过CloseHandle()关闭内存映射⽂件对象,只是释放该对象,如果
内存映射⽂件代表的是磁盘⽂件,那么还需要调⽤标准⽂件I/O函数来将其关闭。在处理⼤⽂件处理时,内存映射⽂件将表⽰出卓越的优势,只需要消耗极少的物理资源,对系统的影响微乎其微。下⾯先给出内存映射⽂件的⼀般编程流程框图:
图2 使⽤内存映射⽂件的⼀般流程
⽽在某些特殊⾏业,经常要⾯对⼗⼏GB乃⾄⼏⼗GB容量的巨型⽂件,⽽⼀个32位进程所拥有的虚拟地址空间只有232 = 4GB,显然不能⼀次将⽂件映像全部映射进来。对于这种情况只能依次将⼤⽂件的各个部分映射到进程中的⼀个较⼩的地址空间。这需要对上⾯的⼀般流程进⾏适当的更改:
  1)映射⽂件开头的映像。
  2)对该映像进⾏访问。
  3)取消此映像
  4)映射⼀个从⽂件中的⼀个更深的位移开始的新映像。
  5)重复步骤2,直到访问完全部的⽂件数据。
  下⾯给出⼀段根据此描述⽽写出的对⼤于4GB的⽂件的处理代码:
// 选择⽂件
CFileDialog fileDlg(TRUE, "*.txt", "*.txt", NULL, "⽂本⽂件 (*.txt)|*.txt||", this);
fileDlg.m_ofn.Flags |= OFN_FILEMUSTEXIST;
fileDlg.m_ofn.lpstrTitle = "通过内存映射⽂件读取数据";
if (fileDlg.DoModal() == IDOK)
{
 // 创建⽂件对象
 HANDLE hFile = CreateFile(fileDlg.GetPathName(), GENERIC_READ | GENERIC_WRITE,
   0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
 if (hFile == INVALID_HANDLE_VALUE)
 {
  TRACE("创建⽂件对象失败,错误代码:%drn", GetLastError());
  return;
 }
 // 创建⽂件映射对象
 HANDLE hFileMap = CreateFileMapping(hFile, NULL, PAGE_READWRITE, 0, 0, NULL);
 if (hFileMap == NULL)
 {
  TRACE("创建⽂件映射对象失败,错误代码:%drn", GetLastError());
  return;
 }
 // 得到系统分配粒度
 SYSTEM_INFO SysInfo;
 GetSystemInfo(&SysInfo);
 DWORD dwGran = SysInfo.dwAllocationGranularity;
 // 得到⽂件尺⼨
 DWORD dwFileSizeHigh;
 __int64 qwFileSize = GetFileSize(hFile, &dwFileSizeHigh);
 qwFileSize |= (((__int64)dwFileSizeHigh) << 32);
 // 关闭⽂件对象
 CloseHandle(hFile);
 // 偏移地址
 __int64 qwFileOffset = 0;
 // 块⼤⼩
 DWORD dwBlockBytes = 1000 * dwGran;
 if (qwFileSize < 1000 * dwGran)
  dwBlockBytes = (DWORD)qwFileSize;
  while (qwFileOffset > 0)
  {
   // 映射视图
   LPBYTE lpbMapAddress = (LPBYTE)MapViewOfFile(hFileMap,FILE_MAP_ALL_ACCESS,
      (DWORD)(qwFileOffset >> 32), (DWORD)(qwFileOffset & 0xFFFFFFFF),
      dwBlockBytes);
   if (lpbMapAddress == NULL)
   {
    TRACE("映射⽂件映射失败,错误代码:%drn", GetLastError());
    return;
   }
   // 对映射的视图进⾏访问
   for(DWORD i = 0; i < dwBlockBytes; i++)
    BYTE temp = *(lpbMapAddress + i);
    // 撤消⽂件映像
    UnmapViewOfFile(lpbMapAddress);
    // 修正参数
    qwFileOffset += dwBlockBytes;
    qwFileSize -= dwBlockBytes;
  }
  // 关闭⽂件映射对象句柄提交更改是内存条吗
  CloseHandle(hFileMap);
  AfxMessageBox("成功完成对⽂件的访问");
}
在本例中,⾸先通过GetFileSize()得到被处理⽂件长度(64位)的⾼32位和低32位值。然后在映射过程中设定每次映射的块⼤⼩为1000倍的分配粒度,如果⽂件长度⼩于1000倍的分配粒度时则将块⼤⼩设置为⽂件的实际长度。在处理过程中由映射、访问、撤消映射构成了⼀个循环处理。其中,每处理完⼀个⽂件块后都通过关闭⽂件映射对象来对每个⽂件块进⾏整理。CreateFileMapping()、MapViewOfFile()等函数是专门⽤来进⾏内存⽂件映射处理⽤的。
下⾯分别对这些关键函数进⾏说明:
1)CreateFile():CreateFile()函数是⼀个⽤途⾮常⼴泛的函数, 在这⾥的⽤法并没有什么特殊的地⽅,但有⼏点需要注意:⼀是访问模式参数dwDesiredAccess。该参数设置了对⽂件内核对象的访问类型,其允许设置的权限可以为读权限GENERIC_READ、写权限GENERIC_WRITE、读写权限GENERIC_READ | GENERIC_WRITE和设备查询权限0。在使⽤映射⽂件时,只能打开那些具有可读访问权限的⽂件,即只能应⽤GENERIC_READ和GENERIC_READ | GENERIC_WRITE这两种组合;另⼀点需要注意的是共享模式参数dwShareMode。该参数定义了对⽂件内核对象的共享⽅式,其可能的设置为FILE_SHARE_READ、FILE_SHARE_WRITE和0,并可对其组合使⽤。其中,设置为0时不允许共享对象;FILE_SHARE_READ和FILE_SHARE_WRITE分别为在要求只读、只写访问的情况下才允许对象的共享。
由于通过内存映射⽂件可以在多个进程间共享数据,因此在进⾏这种应⽤时应当考虑dwShareMode参数设置对运⾏结果的影响。
2)CreateFileMapping():该函数的作⽤是创建⼀个⽂件映射内核对象,以告知系统⽂件映射对象需要多⼤的物理存储器。创建内存映射⽂件对象对系统资源⼏乎没有什么影响,也不会影响进程的虚拟地址空间。除了需要⽤来表⽰该对象的内部资源之外通常并不⽤为其分配虚拟内存,但是如果内存映射⽂件对象是作共享内存之⽤的话,就要在创建对象时由系统为内存映射⽂件的使⽤在系统页⽂件中保留⾜够的空间。
函数第⼀个参数hFile为标识要映射到进程的地址空间的⽂件的句柄。虽然由于内存映射⽂件的物理存储器是来⾃于磁盘上的⽂件,⽽⾮系统的页⽂件,使创建内存映射⽂件就像保留⼀个地址空间区域并将物理存储器提交给该区域⼀样。第⼆个参数为指向⽂件映射内核对象的SECURITY_ATTRIBUTES结构的指针,由此来决定⼦进程能否继承得到返回的句柄。通常为其传递NULL值,以默认的安全属性来禁⽌返回句柄的被继承。
接下来的参数⽤于⽂件被映射后设定⽂件映像的保护属性。其可能的取值为PAGE_READONLY、PAGE_READWRITE和
PAGE_WRITECOPY。虽然在创建⽂件映射对象时,系统并不为其保留地址空间区域,也不将⽂件的存储器映射到该区域。但是,在系统将存储器映射到进程的地址空间中去时,系统必须确切知道应赋予物理存储器页⾯的保护属性。在设置保护属性时,必须与⽤
CreateFile()函数打开⽂件时所指定的访问标识相匹配,否则将导致CreateFileMapping()的执⾏失败。因此这⾥设置
PAGE_READWRITE属性。除了上述三个页⾯保护属性外,还有4个区(Section)保护属性也可以⼀起组合使⽤:
区保护属性说明
SEC_COMMIT为区中的所有页⾯在内存中或磁盘页⾯⽂件中分配物理存储器
SEC_IMAGE告知系统,映射的⽂件是⼀个可移植的EXE⽂件映像
SEC_NOCACHE告知系统,未将⽂件的任何内存映射⽂件放⼊⾼速缓存,多供硬件设备驱动程序开发⼈员使⽤
SEC_RESERVE对⼀个区的所有页⾯进⾏保留⽽不分配物理存储器
后⾯的两个参数指定了要创建的⽂件映射对象的最⼤字节数的⾼32位值和低32位值,实际也就设定了⽂件的最⼤字节数(最⼤可以处理
16EB的⽂件)。这两个参数可以满⾜确保⽂件映射对象能够得到⾜够的物理存储器这⼀基本条件。在参数设置的⼤⼩⼩于⽂件实际⼤⼩时,系统将从⽂件映射指定的字节数。这⾥将其设置为0,将使所创建的⽂件映射对象将为⽂件的当前⼤⼩,以上两种情况均⽆法改变⽂件的⼤⼩。如果设置的参数⼤于⽂件的实际⼤⼩,系统将会在CreateFileMapping()函数返回前扩展该⽂件。需要指出的是,⽂件映射对象的⼤⼩是静态的,⼀旦创建完毕后将⽆法更改。如果设置的⽂件映射对象尺⼨偏⼩将导致⽆法对⽂件进⾏全⾯的访问。
在本节开始也曾提到过,创建⽂件映射对象是不需要花费什么系统资源的,因此遵循"宁多勿缺"的原
则,⼀般应将⽂件映射对象的⼤⼩设置为⽂件⼤⼩的相同值。函数最后的参数将可以为映射对象命名。如果想打开⼀个已存在的⽂件映射对象,该对象必须要命名。对该名字字符串的要求仅限于未被其它对象使⽤过的名字即可。
CreateFileMapping()在成功执⾏后将返回⼀个指向⽂件映射对象的句柄。如果对⼀个已经存在的⽂件映射对象调⽤了CreateFileMapping()函数,进程将得到⼀个指向现有映射对象的句柄。通过调⽤GetLastError()可以得到返回值
ERROR_ALREADY_EXIST,由此可以判断当前得到的内存映射对象句柄是新创建的还是打开已经存在的。如果系统⽆法创建⽂件映射对象,将导致CreateFileMapping()的执⾏失败,返回N U L L句柄值。
3)MapViewOfFile():当创建了⼀个内存映射⽂件对象并得到其有效句柄后,该句柄即可⽤来在进程的虚拟地址空间中映射⽂件的⼀个映像。在内存映射⽂件对象已经存在的情况下,映像可被任意映射或取消映射。在⽂件映像被映射时,仍然必须由系统来为⽂件的数据保留⼀个地址空间区域,并将⽂件的数据作为映射到该区域的物理存储器进⾏提交。在进程的地址空间中,⼀个⾜够⼤的连续地址空间(通常⾜以覆盖整个⽂件映像)将被指定给此⽂件映像。尽管如此,内存的物理页⾯还是根据在实际使⽤中的需求⽽进⾏分配的。真正分配⼀个对应于内存映射⽂件映像页⾯的物理内存页⾯是在发
⽣该页的缺页中断时进⾏的,这将在第⼀次读写内存页⾯中的任⼀地址时⾃动完成。MapViewOfFile()即负责映射内存映射⽂件的⼀个映像,
函数的第⼀个参数为CreateFileMapping()所返回的内存映射⽂件对象句柄,第⼆个参数指定了对⽂件映像的访问类型,可能取值有FILE_MAP_WRITE、FILE_MAP_READ、FILE_MAP_ALL_ACCESS和FILE_MAP_COPY等⼏种,具体的设置要根据⽂件映射对象允许的保护模式⽽定。根据前⾯代码的设置,这⾥应该使⽤FILE_MAP_ALL_ACCESS参数。这种机制为对象的创建者提供了对映射此对象的⽅式进⾏控制的能⼒。接下来的2个参数分别指定了内存映射⽂件的64位偏移地址的低32位和⾼32位地址,该地址是从内存映射⽂件头位置到映像开始位置的距离。最后的参数指定了视图的⼤⼩,如果设置为0,前⾯的偏移地址将被忽略,系统将会把整个⽂件映射为⼀个映像。MapViewOfFile()如果成功执⾏,将返回⼀个指向⽂件映像在进程的地址空间中的起始地址的指针。如果失败,则返回NULL。在进程中,可以为同⼀个⽂件映射对象创建多个⽂件映像,这些映像可以在系统中共存和重叠,也可以与对应的⽂件映射对象⼤⼩不相⼀致,但不能⼤于⽂件映射对象的⼤⼩。
4)UnmapViewOfFile():当不再需要保留映射到进程地址空间区域中的⽂件映像数据时,可通过调⽤UnmapViewOfFile()函数将其释放。该函数结构⾮常简单,只需要提供映像在进程中的起始地址(区域的基地址)作为参数即可。该函数的输⼊参数为调⽤MapViewOfFile()时所返回的指向⽂件映像在进程的地址空间中的起始地址的指针。在调⽤MapViewOfFile()后,必须确保在进程退出之
前能够执⾏UnmapViewOfFile()函数,否则在进程终⽌之后先前保留的区域将得不到释放,即使再次启动进程重复调⽤MapViewOfFile()系统也总是在进程的地址空间中保留⼀个新的区域,⽽此前保留的所有区域将得不到释放。
⼀种⽐较特殊的情况是,对同⼀个内存映射⽂件映射了两个相同的映像的撤消。前⾯曾经提到过,对于同⼀个内存映射⽂件可以有多个映像,这些映像也可以重叠,因此这种情况的存在是合法的。对于这种情况,虽然从表⾯看上去在单进程的地址空间内是不可能存在两个基地址完全相同的映像的,这将导致⽆法对这它们的区分。但是事实上,由MapViewOfFile()所返回得到的基地址只是⽂件映像在进程地址空间中的起始基地址,因此在映射同⼀内存映射⽂件的两个相同映像时将会产⽣对内存映射⽂件同⼀部分的两个不同基地址的相同映像,可以⽤同样的⽅法调⽤UnmapViewOfFile()将其从进程的地址空间中予以撤消。
5)CloseHandle(): 与Win32的⼤多数对象⼀样,在使⽤完毕之后总是要通过CloseHandle()函数将已打开的内核对象关闭。如果忘记关闭对象,在程序继续运⾏时将会出现资源泄漏。虽然在程序退出运⾏时,操作系统会⾃动关闭在进程中已经打开但未关闭的任何对象。但是在进程的运⾏过程中,势必会积累过多的资源句柄。因此在不再需要使⽤对象的时候通过CloseHandle()将其予以关闭是有意义的。
⼩结
本⽂对内存映射⽂件在⼤⽂件处理中的应⽤作了较为详细的阐述。经实际测试,内存映射⽂件在处理⼤数据量⽂件时表现出了良好的性能,⽐通常使⽤CFile类和ReadFile()和WriteFile()等函数的⽂件处理⽅式具有明显的优势。本⽂所述程序代码在Windows 2000
Professional下由Microsoft Visual C++ 6.0编译通过。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。