C++ 文件映射:在文件内创建视图

前言:

通常,经过磁盘的IO是异常缓慢的,使用 ReadFile() 来读取文件效率低下。因为 ReadFile() 的工作流程是这样的:

磁盘文件(慢速) –> 拷贝至内存缓冲区 –> 业务逻辑:读取/使用(第2次拷贝)

直接使用 ReadFile() 确实方便,但当遇到超大文件业务处理或者是IO密集型业务的时候就显得力不从心了。那么,Windows 为我们提供了 FileMapping 这样一种概念(Linux 平台称作 “mmap”),来将文件直接映射至内存的某一片区域,上层应用通过内存操作,直接存取数据,其流程如下:

内存(磁盘文件)  <–内存操作–> 业务逻辑

这样一来,有效解决了IO操作的效率。这次,我们讨论在文件内建立内存映射视图。

场景:

如果我们想要浏览一个并非从文件起始位置开始的文件视图,我们首先必须要创建文件映射对象。这个对象的大小就是该文件中你想要预览的内容外加偏移大小。例如,如果我们需要预览一个文件内从第 131,072  字节(128 KB) 开始向后 1 KB 字节的内容,我们必须要创建一个至少 132,096 字节(129 KB) 的内存映射视图。这个视图从文件内第 131,072 字节(128 KB) 开始并延伸至少 1,024 字节。本实例假定操作系统文件分配颗粒为 64 KB。(实际上 Windows 系统给出的默认值便是 64 KB)

文件映射块影响着我们视图的开始位置。一个文件视图的起始位置必须是分配颗粒(64 KB)的整数倍。因此,我们想要查看的数据应该是视图内文件偏移与分配块求模。视图的大小就是数据与分配颗粒求模的偏移,外加上你想要使用的数据大小。

举个例子,我们使用 GetSystemInfo() 这样一个 API 来确定到分配颗粒的大小实际上就是 64 KB。因此,我们要通过内存映射来使用一个 138,240 字节(135 KB) 文件中的 1KB 数据,分为以下几件事:

  • 创建一个至少 139,264 字节(136 KB) 的内存映射对象。
  • 计算偏移量,文件偏移量是文件分配粒度小于所需偏移量的最大倍数。文件从偏移量开始起始。在本例中,视图从 128 KB 起始。视图的大小便是 136 KB – 128 KB (8 KB)。
  • 在视图内创建一个偏移量为 7 KB 的指针指向我们感兴趣的 1 KB 的内容。

如果我们想要访问的数据超越了分配颗粒边界,我们应该创建一个大于分配颗粒大小的视图。这避免将数据打散成零散的碎片部分。

下面是上面例子的 C++ 代码实现。

代码清单:

/*
   这份代码演示了文件映射,特别是使用分配颗粒来关联视图。
*/

#include <windows.h>
#include <stdio.h>
#include <tchar.h>

#define BUFFSIZE 1024 // 每次想要访问的文件数据大小

#define FILE_MAP_START 138240 // 要读数据的文件的起始位置点 (135K)

/*    测试文件。下面的代码创建并填充这份文件。
      因此,不需要事先准备好该文件。           */

TCHAR * lpcTheFile = TEXT("fmtest.txt"); // 要填充的文件

int main(void)
{
  HANDLE hMapFile;      // 文件映射句柄
  HANDLE hFile;         // 文件句柄
  BOOL bFlag;           // 结果标志位
  DWORD dBytesWritten;  // 实际写入字节数
  DWORD dwFileSize;     // 临时用来存放文件大小
  DWORD dwFileMapSize;  // 文件映射的大小
  DWORD dwMapViewSize;  // 文件映射视图的大小
  DWORD dwFileMapStart; // 文件映射视图的起始位置
  DWORD dwSysGran;      // 分配颗粒大小
  SYSTEM_INFO SysInfo;  // 系统信息结构,用来获取分配颗粒的大小
  LPVOID lpMapAddress;  // 指向文件映射基址的指针
  char * pData;         // 指向数据的指针
  int i;                // 循环计数
  int iData;            // 成功时,包含了数据的第一个 int。
  int iViewDelta;       // 视图内数据起始位置偏移

  // 创建测试文件。 使用 "Create Always" 来覆盖任何存在的文件,
  // 数据会在下面重新创建。
  hFile = CreateFile(lpcTheFile,
                     GENERIC_READ | GENERIC_WRITE,
                     0,
                     NULL,
                     CREATE_ALWAYS,
                     FILE_ATTRIBUTE_NORMAL,
                     NULL);

  if (hFile == INVALID_HANDLE_VALUE)
  {
    _tprintf(TEXT("文件句柄是 NULL。\n"));
    _tprintf(TEXT("目标文件:%s\n"),
             lpcTheFile);
    return 4;
  }

  // 获得系统分配颗粒大小
  GetSystemInfo(&SysInfo);
  dwSysGran = SysInfo.dwAllocationGranularity;

  // 现在来计算一些变量值。文件偏移用64位值来计算,然后将低32位用于函数调用。
  // 要计算文件映射从哪开始, 将数据的偏移四舍五入至最接近分配颗粒的数值。
  
  dwFileMapStart = (FILE_MAP_START / dwSysGran) * dwSysGran;
  _tprintf (TEXT("文件映射从文件内第 %ld 字节开始。\n"),
          dwFileMapStart);

  // 计算文件映射视图大小。
  dwMapViewSize = (FILE_MAP_START % dwSysGran) + BUFFSIZE;
  _tprintf (TEXT("文件映射视图大小:%ld 字节。\n"),
            dwMapViewSize);

  // 文件映射对象有多大呢?
  dwFileMapSize = FILE_MAP_START + BUFFSIZE;
  _tprintf (TEXT("文件映射对象大小:%ld 字节。\n"),
          dwFileMapSize);

  // 我们想要的数据并不在文件映射视图的起始位置,因此我们要计算偏移。
  iViewDelta = FILE_MAP_START - dwFileMapStart;
  _tprintf (TEXT("数据在视图里面 %d 字节处。\n"),
            iViewDelta);

  // 现在创建一个含有合适数据的文件用来测试。
  // 我们在文件内提供单独的4字节偏移来方便观察。
  // 注意,代码不对存储数据做完整性检查!
  // 一个整形(INT)是 4 字节的, 因此,指向所需数据的指针应该是文件中所需偏移量的四分之一。

  for (i=0; i<(int)dwSysGran; i++)
  {
    WriteFile (hFile, &i, sizeof (i), &dBytesWritten, NULL);
  }

  // 验证是否正确写入这么多字节。
  dwFileSize = GetFileSize(hFile,  NULL);
  _tprintf(TEXT("文件大小:%10d\n"), dwFileSize);

  // 为文件创建内存映射对象。
  // 强烈建议首先判断文件是否是空的(0字节),因为空文件映射到内存当中毫无意义。
  hMapFile = CreateFileMapping( hFile,          // 当前的文件句柄
                NULL,                           // 默认安全结构
                PAGE_READWRITE,                 // 给予读写权限
                0,                              // 文件映射对象大小,高32位
                dwFileMapSize,                  // 文件映射对象大小,低32位
                NULL);                          // 文件映射名称,此处灵活使用可以实现跨进程通信。

  if (hMapFile == NULL)
  {
    _tprintf(TEXT("文件映射句柄为 NULL:Last Error:%d\n"), GetLastError() );
    return (2);
  }

  // 映射视图,并测试结果。

  lpMapAddress = MapViewOfFile(hMapFile,            // 文件映射对象句柄
                               FILE_MAP_ALL_ACCESS, // 读写权限
                               0,                   // 文件偏移大小高32位
                               dwFileMapStart,      // 文件偏移大小低32位
                               dwMapViewSize);      // 要映射的视图大小
  if (lpMapAddress == NULL)
  {
    _tprintf(TEXT("文件映射视图基址为 NULL:Last Error:%d\n"), GetLastError());
    return 3;
  }

  // 计算数据实际位置
  pData = (char *) lpMapAddress + iViewDelta;

  // 抽取数据,pData 是字节型指针,先强制转换为整形指针,再获取其值。
  iData = *(int *)pData;

  _tprintf (TEXT("在当前指针处的数据是 %d,\n%s文件偏移的四分之一处。\n"),
            iData,
            iData*4 == FILE_MAP_START ? TEXT("在") : TEXT("不在"));

  // 关闭文件映射和打开的文件

  bFlag = UnmapViewOfFile(lpMapAddress);
  bFlag = CloseHandle(hMapFile); // 关闭文件映射对象

  if(!bFlag)
  {
    _tprintf(TEXT("\n发生错误:%ld,无法关闭文件映射对象。"),
             GetLastError());
  }

  bFlag = CloseHandle(hFile);   // 关闭文件本身

  if(!bFlag)
  {
    _tprintf(TEXT("\n发生错误:%ld,无法关闭文件。"),
           GetLastError());
  }

  return 0;
}

使用文件映射确实挺复杂,要读取数据,首先得计算各类偏移,还要判断读取位置是否越界,遇到超长文件更需要做分段映射操作。相比 ReadFile() 可是麻烦了几个数量级。但是,对于IO密集的业务来说,优质的IO操作代码,还是必要的。

参考文献:

  • MSDN,  Creating a View Within a File,  https://msdn.microsoft.com/zh-cn/library/windows/desktop/aa366548(v=vs.85).aspx

12 responses to “C++ 文件映射:在文件内创建视图”

  1. 小i,我又回来了,找了好久终于找到你的域名了。。我是以前的空城旧梦,还记得我么——。——,好多年了,你的站还在,而我,又重新开始了。曾经认识的几个朋友的站基本都没了,就剩你了。。

    1. 哈哈,欢迎回来。
      我一直都在这里。

  2. 手机访问该优化啦 ❓

  3. 多来点技术文章 😛

Leave a Reply

Your email address will not be published. Required fields are marked *