第14章:内存映射文件
# 第14章:内存映射文件
内存映射文件(在内核术语中称为“节”(Section))是一种对象,它能够将文件内容无缝映射到内存中。此外,它还能在进程间高效地共享内存。这些功能带来了诸多好处,本章将对其进行探讨。
在本章中:
- 简介
- 映射文件
- 共享内存
- Micro Excel 2应用程序
- 其他内存映射函数
- 数据一致性
# 简介
文件映射对象在Windows系统中随处可见。当加载一个镜像文件(可执行文件(EXE)或动态链接库(DLL))时,它会通过内存映射文件被映射到内存中。通过这种映射,对底层文件的访问是间接进行的,即通过标准指针访问内存。当代码需要在镜像中执行时,最初的访问会引发页面错误异常,内存管理器会负责从文件中读取数据并将其放入物理内存,然后修正用于映射该内存的相应页表,此时调用线程就可以访问代码或数据了。这一切对应用程序来说都是透明的。
在第11章中,我们研究了各种与输入/输出(I/O)相关的用于读取和(或)写入数据的API,如ReadFile
和WriteFile
。设想一下,有段代码需要在文件中搜索某些数据,而这种搜索需要在文件中来回查找。使用I/O API的话,即便情况最好,操作也很不方便,这涉及多次调用ReadFile
(事先要分配一个缓冲区)和SetFilePointer(Ex)
。另一方面,如果有一个指向文件的“指针”,那么对文件进行移动和操作就会容易得多:无需分配缓冲区,无需调用ReadFile
,而且任何文件指针的更改都仅仅转化为指针运算。所有其他常见的内存函数,如memcpy
、memset
等,对内存映射文件同样适用。
# 映射文件
映射现有文件所需的步骤是,首先使用常规的CreateFile
调用打开文件。CreateFile
的访问掩码必须与对文件所需的访问权限相匹配,例如读和(或)写权限。打开这样一个文件后,调用CreateFileMapping
函数创建文件映射对象,并在其中提供文件句柄:
HANDLE CreateFileMapping(
_In_ HANDLE hFile,
_In_opt_ LPSECURITY_ATTRIBUTES lpFileMappingAttributes,
_In_ DWORD flProtect,
_In_ DWORD dwMaximumSizeHigh,
_In_ DWORD dwMaximumSizeLow,
_In_opt_ LPCTSTR lpName);
2
3
4
5
6
7
CreateFileMapping
的第一个参数是文件句柄。如果这个内存映射文件(MMF)对象要映射一个文件,那么应该提供一个有效的文件句柄。如果文件映射对象要用于创建由页面文件支持的共享内存,那么应该指定INVALID_HANDLE_VALUE
,这表明使用页面文件。本章稍后会研究不由特定文件支持的共享内存。
接下来,标准的SECURITY_ATTRIBUTES
指针通常可以设置为NULL
。flProtect
参数指定当(稍后)将物理存储用于此文件映射对象时应使用的页面保护方式。这应该是稍后所需的最宽松的页面保护。常见的示例有PAGE_READWRITE
(可读可写)和PAGE_READONLY
(只读)。表14-1总结了flProtect
的有效值以及创建/打开与文件映射对象一起使用的文件时相应的有效访问权限值。
表14-1:CreateFileMapping的保护标志
MMF保护标志 | 文件的最小访问标志 | 注释 |
---|---|---|
PAGE_READONLY | GENERIC_READ | 不允许对内存/文件进行写入操作 |
PAGE_READWRITE | GENERIC_READ 和GENERIC_WRITE | - |
PAGE_WRITECOPY | GENERIC_READ | 等效于PAGE_READONLY |
PAGE_EXECUTE_READ | GENERIC_READ 和GENERIC_EXECUTE | - |
PAGE_EXECUTE_READWRITE | GENERIC_READ 、GENERIC_WRITE 和GENERIC_EXECUTE | - |
PAGE_EXECUTE_WRITECOPY | GENERIC_READ 和GENERIC_EXECUTE | 等效于PAGE_EXECUTE_READ |
表14-1中的一个值可以与以下描述的一些标志组合使用(还有更多标志,但这里列出的是官方文档记录的标志):
SEC_COMMIT
- 仅适用于由页面文件支持的MMF(hFile
为INVALID_HANDLE_VALUE
),表示当一个视图映射到进程地址空间时,所有映射的内存都必须提交。此标志与SEC_RESERVE
互斥,如果两者都未指定,则SEC_COMMIT
为默认值。无论如何,它对由特定文件支持的MMF没有影响。SEC_RESERVE
- 与SEC_COMMIT
相反。任何视图最初都是保留状态,因此实际的提交操作必须通过VirtalAlloc
调用显式执行。SEC_IMAGE
- 指定提供的文件是一个可移植可执行文件(PE文件)。它应该与PAGE_READONLY
保护标志组合使用,但映射是根据PE文件中的节进行的。此标志不能与任何其他标志组合。SEC_IMAGE_NO_EXECUTE
- 与SEC_IMAGE
类似,但该PE文件不用于执行,仅用于映射。SEC_LARGE_PAGES
- 仅适用于由页面文件支持的MMF。表示映射时使用大页面。这需要第13章中所述的SeLockMemoryPrivilege
权限。它还要求对MMF的任何视图以及视图大小都是大页面大小的倍数。此标志必须与SEC_COMMIT
组合使用。SEC_NOCACHE
和SEC_WRITECOMBINE
- 很少使用的标志,通常是因为设备驱动程序为了正常运行而需要它们。
CreateFileMapping
的接下来两个参数使用两个32位值指定MMF的大小,这两个值应被视为一个64位值。如果MMF要以只读访问方式映射现有文件,则将这两个值都设置为零,这实际上将MMF的大小设置为文件的大小。
如果要写入相关文件,则将大小设置为文件的最大大小。一旦设置,文件大小就不能超过此值,实际上文件大小会立即增长到指定大小。如果MMF由页面文件支持,那么该大小表示内存块的大小,在创建MMF时,系统中的页面文件必须能够容纳这个大小。
CreateFileMapping
的最后一个参数是对象的名称。它可以为NULL
,也可以命名,就像其他命名对象类型(例如事件、信号量、互斥锁)一样。有了名称,就很容易与其他进程共享该对象。最后,该函数返回内存映射文件对象的句柄,如果失败则返回NULL
。
以下示例基于一个数据文件创建一个仅用于读取访问的内存映射文件对象(省略了错误处理):
HANDLE hFile = ::CreateFile(L"c:\\mydata.dat", GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, 0, nullptr);
HANDLE hMemFile = ::CreateFileMapping(hFile, nullptr,
PAGE_READONLY, 0, 0, nullptr);
::CloseHandle(hFile);
2
3
4
5
最后一行可能会让人担心。关闭文件句柄可以吗?这不会关闭文件,导致文件映射对象无法访问文件吗?事实证明,MMF不能依赖客户端长时间保持文件句柄打开,它会复制文件句柄以确保文件不会被关闭。这意味着关闭文件句柄是正确的做法。
一旦创建了内存映射文件对象,进程就可以使用返回的句柄,通过调用MapViewOfFile
函数将文件的全部或部分数据映射到其地址空间中:
LPVOID MapViewOfFile(
_In_ HANDLE hFileMappingObject,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwFileOffsetHigh,
_In_ DWORD dwFileOffsetLow,
_In_ SIZE_T dwNumberOfBytesToMap);
2
3
4
5
6
MapViewOfFile
函数获取MMF句柄,并将文件(或其部分内容)映射到进程地址空间。dwDesiredAccess
可以是表14-2中描述的一个或多个标志的组合。
表14-2:MapViewOfFile的映射标志
期望的访问权限 | 描述 |
---|---|
FILE_MAP_READ | 映射以进行读访问 |
FILE_MAP_WRITE | 映射以进行写访问 |
FILE_MAP_EXECUTE | 映射以进行执行访问 |
FILE_MAP_ALL_ACCESS | 与MapViewOfFile 一起使用时,等效于FILE_MAP_WRITE |
FILE_MAP_COPY | 写时复制访问。任何写入操作都会获得一个私有副本,在取消映射视图时会丢弃该副本 |
FILE_MAP_LARGE_PAGES | 使用大页面进行映射 |
FILE_MAP_TARGETS_INVALID | 将视图中的所有位置设置为控制流防护(Control Flow Guard,CFG)的无效目标。默认情况下,视图是CFG的有效目标(有关CFG的更多信息,请参阅第16章) |
dwFileOffsetHigh
和dwFileOffsetLow
构成从何处开始映射的64位偏移量。该偏移量必须是分配粒度(在所有Windows版本和架构上均为64KB)的倍数。最后一个参数dwNumberOfBytesToMap
指定从偏移量开始要映射多少字节。将其设置为零表示映射到文件映射的末尾。
该函数返回映射内存在调用者地址空间中的虚拟地址。调用者可以对该指针执行所有标准内存操作(受映射约束限制)。一旦不再需要映射的视图,就应该使用UnmapViewOfFile
函数取消映射:
BOOL UnmapViewOfFile(_In_ LPCVOID lpBaseAddress);
lpBaseAddress
是MapViewOfFile
返回的相同值。一旦取消映射,lpBaseAddress
指向的内存就不再有效,任何访问都会导致访问冲突。
# filehist应用程序
命令行应用程序filehist
(文件直方图)用于统计文件中每个字节(0到255)出现的次数,实际上是构建文件中字节值的直方图分布。该应用程序使用内存映射文件构建,将视图映射到进程地址空间,然后使用普通指针访问这些值。该应用程序可以处理任何大小的文件,通过将有限的视图映射到进程地址空间、处理数据、取消映射,然后映射文件中的下一个数据块来实现。
不带参数运行该应用程序会显示以下内容:
C:\>filehist.exe
Usage: filehist [view size in MB] <file path>
Default view size is 10 MB
2
3
视图大小是可配置的,默认值为10MB(选择这个值没有特殊原因)。以下是对一个大文件使用默认视图大小的示例:
C:\>filehist.exe file1.dat
File size: 938857496 bytes
Using view size: 10 MB
Mapping offset: 0x0, size: 0xA00000 bytes
Mapping offset: 0xA00000, size: 0xA00000 bytes
Mapping offset: 0x1400000, size: 0xA00000 bytes
Mapping offset: 0x1E00000, size: 0xA00000 bytes
...
Mapping offset: 0x36600000, size: 0xA00000 bytes
Mapping offset: 0x37000000, size: 0xA00000 bytes
Mapping offset: 0x37A00000, size: 0x55D418 bytes
0xB3: 445612 ( 0.05 %)
0x9E: 460881 ( 0.05 %)
0x9F: 469939 ( 0.05 %)
0x9B: 496322 ( 0.05 %)
0x96: 546899 ( 0.06 %)
0xB5: 555019 ( 0.06 %)
...
0x0F: 11226199 ( 1.20 %)
0x7F: 11755158 ( 1.25 %)
0x01: 14336606 ( 1.53 %)
0x8B: 14824094 ( 1.58 %)
0x48: 20481378 ( 2.18 %)
0xFF: 72242071 ( 7.69 %)
0x00: 342452879 (36.48 %)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
值0显然占主导地位。如果我们将视图大小增加到400MB,会得到以下结果:
C:\>filehist.exe 400 file1.dat
File size: 938857496 bytes
Using view size: 400 MB
Mapping offset: 0x0, size: 0x19000000 bytes
Mapping offset: 0x19000000, size: 0x19000000 bytes
Mapping offset: 0x32000000, size: 0x5F5D418 bytes
0xB3: 445612 ( 0.05 %)
0x9E: 460881 ( 0.05 %)
...
0x48: 20481378 ( 2.18 %)
0xFF: 72242071 ( 7.69 %)
0x00: 342452879 (36.48 %)
2
3
4
5
6
7
8
9
10
11
12
main
函数首先要做的是处理一些命令行参数:
int wmain(int argc, const wchar_t* argv[]) {
if (argc < 2) {
printf("Usage:\tfilehist [view size in MB] <file path>\n");
printf("\tDefault view size is 10 MB\n");
return 0;
}
DWORD viewSize = argc == 2 ? (10 << 20) : (_wtoi(argv[1]) << 20);
if (viewSize == 0)
viewSize = 10 << 20;
2
3
4
5
6
7
8
9
10
接下来,我们需要一个数组来存储值和计数:
struct Data {
BYTE Value;
long long Count;
};
Data count[256] = { 0 };
for (int i = 0; i < 256; i++)
count[i].Value = i;
2
3
4
5
6
7
8
现在我们可以打开文件,获取其大小,并创建一个指向该文件的文件映射对象:
HANDLE hFile = ::CreateFile(argv[argc - 1], GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, 0, nullptr);
if (hFile == INVALID_HANDLE_VALUE)
return Error("Failed to open file");
LARGE_INTEGER fileSize;
if (!::GetFileSizeEx(hFile, &fileSize))
return Error("Failed to get file size");
HANDLE hMapFile = ::CreateFileMapping(hFile, nullptr, PAGE_READONLY, 0, 0, nullptr);
if (!hMapFile)
return Error("Failed to create MMF");
::CloseHandle(hFile);
2
3
4
5
6
7
8
9
10
11
12
13
14
文件以只读方式打开,因为我们不打算对文件中的任何内容进行更改。内存映射文件(MMF)以PAGE_READONLY
访问权限打开,这与文件的GENERIC_READ
访问权限兼容。接下来,我们需要根据文件大小和选定的视图大小循环多次,并处理数据:
auto total = fileSize.QuadPart;
printf("File size: %llu bytes\n", fileSize.QuadPart);
printf("Using view size: %u MB\n", (unsigned)(viewSize >> 20));
LARGE_INTEGER offset = { 0 };
while (fileSize.QuadPart > 0) {
auto mapSize = (unsigned)min(viewSize, fileSize.QuadPart);
printf("Mapping offset: 0x%llX, size: 0x%X bytes\n", offset.QuadPart, mapSize);
auto p = (const BYTE*)::MapViewOfFile(hMapFile, FILE_MAP_READ,
offset.HighPart, offset.LowPart, mapSize);
if (!p)
return Error("Failed in MapViewOfFile");
// 执行工作
for (DWORD i = 0; i < mapSize; i++)
count[p[i]].Count++;
::UnmapViewOfFile(p);
offset.QuadPart += mapSize;
fileSize.QuadPart -= mapSize;
}
::CloseHandle(hMapFile);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
只要还有字节需要处理,就会调用MapViewOfFile
从当前偏移量映射文件的一部分,映射大小为视图大小和剩余待处理字节数中的较小值。处理完数据后,取消映射视图,增加偏移量,减少剩余字节数,然后重复循环。
最后一步是显示结果。首先按计数对数据数组进行排序,然后按顺序显示所有内容:
// 按升序排序
std::sort(std::begin(count), std::end(count),
[](const auto& c1, const auto& c2) {
return c2.Count > c1.Count;
});
// 显示结果
for (const auto& data : count) {
printf("0x%02X:%10llu(%5.2f%%)\n", data.Value, data.Count,
data.Count * 100.0 / total);
}
2
3
4
5
6
7
8
9
10
11
静态C++数组可以像vector
一样使用std::sort
进行排序。由于C++数组没有方法,因此需要使用全局的std::begin
和std::end
函数为数组提供迭代器。
# 共享内存
进程彼此隔离,每个进程都有自己的地址空间、句柄表等。大多数时候,这正是我们所需要的。然而,在某些情况下,进程之间需要以某种方式共享数据。Windows提供了许多进程间通信(Interprocess Communication,IPC)机制,包括组件对象模型(Component Object Model,COM)、Windows消息、套接字、管道、邮件槽、远程过程调用(Remote Procedure Call,RPC)、剪贴板、动态数据交换(Dynamic Data Exchange,DDE)等。每种机制都有其优缺点,但上述所有机制的共同特点是必须将内存从一个进程复制到另一个进程。
内存映射文件是另一种进程间通信机制,并且是所有机制中最快的,因为它不需要进行数据复制(实际上,在同一台计算机上的进程之间进行通信时,其他一些进程间通信机制在底层会使用内存映射文件)。一个进程将数据写入共享内存,所有其他拥有同一文件映射对象句柄的进程都可以立即看到这些内存——由于每个进程都将相同的内存映射到自己的地址空间,因此无需进行数据复制。
共享内存基于多个进程能够访问同一个文件映射对象。该对象可以通过第2章中描述的三种方式中的任何一种进行共享。最简单的方法是为文件映射对象指定一个名称。共享内存本身可以由一个特定文件(传递给CreateFileMapping
的有效文件句柄)进行备份,在这种情况下,即使文件映射对象被销毁,数据仍然可用;或者由分页文件进行备份,在这种情况下,一旦文件映射对象被销毁,数据就会被丢弃。这两种方式的工作原理基本相同。
我们将从使用第2章中的“基本共享(Basic Sharing)”应用程序开始。在那里,我们研究了基于对象名称的共享功能,但现在我们可以深入了解共享本身的细节。图14-1展示了该应用程序的两个实例在运行,在一个进程中写入数据,在另一个进程中读取数据时会显示相同的数据,因为它们使用了相同的文件映射对象。
图14-1:“基本共享”的多个实例
在CMainDlg::OnInitDialog
函数中创建文件映射对象,并由分页文件进行备份:
m_hSharedMem = ::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr,
PAGE_READWRITE, 0, 1 << 12, L"MySharedMemory");
2
内存映射文件(MMF)以读写访问权限创建,其大小为4KB。它的名称(“MySharedMemory”)将用于与其他进程共享该对象。第一次使用该对象名称调用CreateFileMapping
时,会创建该对象。后续任何使用相同名称的调用只会获取已存在的文件映射对象的句柄。这意味着其他参数实际上并未使用。例如,第二个调用者无法为内存指定不同的大小——内存大小由最初的创建者决定。
或者,一个进程可能想要打开一个现有文件映射对象的句柄,如果该对象不存在则失败。这就是OpenFileMapping
的作用:
HANDLE OpenFileMapping(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCTSTR lpName);
2
3
4
dwDesiredAccess
参数是表14-2中访问掩码的组合。bInheritHandle
指定返回的句柄是否可继承(有关句柄继承的更多信息,请参阅第2章)。最后,lpName
是要查找的命名内存映射文件(MMF)。如果不存在具有给定名称的文件映射对象,该函数将失败(返回NULL
)。
在大多数情况下,使用CreateFileMapping
更为方便,特别是当多个基于相同可执行映像的进程想要共享时——第一个进程创建对象,所有后续进程只需获取现有对象的句柄——无需同步创建和打开操作。
有了文件映射对象句柄后,通过以下函数向内存写入数据:
LRESULT CMainDlg::OnWrite(WORD, WORD, HWND, BOOL &) {
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_WRITE, 0, 0, 0);
if (!buffer) {
AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
return 0;
}
CString text;
GetDlgItemText(IDC_TEXT, text);
::wcscpy_s((PWSTR)buffer, text.GetLength() + 1, text);
::UnmapViewOfFile(buffer);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
对MapViewOfFile
的调用与filehist
应用程序中的调用没有太大区别。使用FILE_MAP_WRITE
来获取对映射内存的写访问权限。偏移量为零,映射大小也指定为零,这意味着一直映射到末尾。由于共享内存只有4KB大小,这不是问题,并且在任何情况下,所有内容都会向上舍入到最近的页边界。写入数据后,调用UnmapViewOfFile
取消映射视图与进程地址空间的关联。
读取数据非常相似,只是使用不同的访问标志:
LRESULT CMainDlg::OnRead(WORD, WORD, HWND, BOOL &) {
void* buffer = ::MapViewOfFile(m_hSharedMem, FILE_MAP_READ, 0, 0, 0);
if (!buffer) {
AtlMessageBox(m_hWnd, L"Failed to map memory", IDR_MAINFRAME);
return 0;
}
SetDlgItemText(IDC_TEXT, (PCWSTR)buffer);
::UnmapViewOfFile(buffer);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
我们可以创建一个新的应用程序,它也可以使用共享内存。memview
应用程序会监控共享内存中数据的变化,并显示出现的任何新数据。
首先,必须打开文件映射对象。在这种情况下,我决定使用OpenFileMapping
,因为这是一个监控应用程序,不应该能够确定共享内存的大小或备份文件:
int main() {
HANDLE hMemMap = ::OpenFileMapping(FILE_MAP_READ, FALSE, L"MySharedMemory");
if (!hMemMap) {
printf("File mapping object is not available\n");
return 1;
}
2
3
4
5
6
接下来,我们需要将内存映射到进程地址空间:
WCHAR text[1024] = { 0 };
auto data = (const WCHAR*)::MapViewOfFile(hMemMap, FILE_MAP_READ, 0, 0, 0);
if (!data) {
printf("Failed to map shared memory\n");
return 1;
}
2
3
4
5
6
“监控”是基于每隔一定时间(以下代码中为1秒)读取一次数据。text
局部变量存储共享内存中的当前文本。将其与新数据进行比较,并在需要时进行更新:
for (;;) {
if (::_wcsicmp(text, data) != 0) {
// 文本已更改,更新并显示
::wcscpy_s(text, data);
printf("%ws\n", text);
}
::Sleep(1000);
}
2
3
4
5
6
7
8
在这个简单的示例中,循环是无限的,但很容易想出一个合适的退出条件。你可以尝试一下,每当任何正在运行的“基本共享”实例向共享内存写入新字符串时,观察文本的更新情况。
如果你打开“进程资源管理器(Process Explorer)”并查找文件映射对象的其中一个句柄,你会发现内存映射文件(MMF)对象的句柄总数反映了使用共享内存的进程总数。如果你有两个“基本共享”实例和一个memview
实例,那么预期会有三个句柄(图14-2)。
图14-2:进程资源管理器中共享的对象
# 带文件备份的共享内存
“基本共享增强版(Basic Sharing+)”应用程序展示了共享内存可能由分页文件以外的文件进行备份的用法。该应用程序基于“基本共享”应用程序。图14-3展示了应用程序启动时的窗口。
图14-3:“基本共享增强版”应用程序窗口
你可以指定要使用的文件,也可以将编辑框留空,在这种情况下将使用分页文件作为备份(与“基本共享”应用程序等效)。点击“创建”按钮会创建文件映射对象。如果指定的文件存在,其大小将决定文件映射对象的大小。如果文件不存在,则会按照CreateFileMapping
中指定的大小(4KB,与“基本共享”应用程序一样)创建该文件。文件大小会立即变为4KB。
一旦创建了文件映射对象,用户界面(UI)焦点就会转移到数据编辑框以及读取和写入按钮上,这与“基本共享”应用程序相同。如果你现在启动另一个“基本共享增强版”实例,它将自动进入编辑模式,并禁用“创建”按钮。这是在进程启动时通过调用OpenFileMapping
实现的。如果文件映射对象存在,允许用户选择文件就没有意义了,因为这不会产生任何效果。
CMainDlg::OnInitDialog
函数会尝试打开已存在的文件映射对象:
m_hSharedMem = ::OpenFileMapping(FILE_MAP_READ | FILE_MAP_WRITE,
FALSE, L"MySharedMemory");
if (m_hSharedMem)
EnableUI();
2
3
4
如果成功,会调用EnableUI
函数来禁用文件名编辑框和“创建”按钮,并启用数据编辑框以及“读取”和“写入”按钮。点击(启用状态下的)“创建”按钮,会按要求创建文件映射对象:
LRESULT CMainDlg::OnCreate(WORD, WORD, HWND, BOOL&) {
CString filename;
GetDlgItemText(IDC_FILENAME, filename);
HANDLE hFile = INVALID_HANDLE_VALUE;
if (!filename.IsEmpty()) {
hFile = ::CreateFile(filename, GENERIC_READ | GENERIC_WRITE, 0,
nullptr, OPEN_ALWAYS, 0, nullptr);
if (hFile == INVALID_HANDLE_VALUE) {
AtlMessageBox(*this, L"Failed to create/open file",
IDR_MAINFRAME, MB_ICONERROR);
return 0;
}
}
m_hSharedMem = ::CreateFileMapping(hFile, nullptr, PAGE_READWRITE,
0, 1 << 12, L"MySharedMemory");
if (!m_hSharedMem) {
AtlMessageBox(m_hWnd, L"Failed to create shared memory",
IDR_MAINFRAME, MB_ICONERROR);
EndDialog(IDCANCEL);
}
if (hFile != INVALID_HANDLE_VALUE)
::CloseHandle(hFile);
EnableUI();
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
如果指定了文件名,会调用CreateFile
来打开或创建文件。它使用OPEN_ALWAYS
标志,意思是“如果文件不存在则创建,否则打开”。文件句柄会传递给CreateFileMapping
以创建文件映射对象。最后,关闭文件句柄(如果之前打开过),并调用EnableUI
将应用程序置于数据编辑模式。
# 微型Excel 2应用程序
第13章中的微型Excel应用程序展示了如何预留一大块内存区域,然后仅提交应用程序正在积极使用的页面。我们可以将这种方法与内存映射文件相结合,这样内存还能有效地与其他进程共享。图14-3展示了该应用程序的运行情况。
图14-4:微型Excel 2应用程序
在调用MapViewOfFile
时映射一大块内存区域而不提交的秘诀在于,在CreateFileMapping
中使用SEC_RESERVE
标志。这会使映射区域仅被预留,这意味着直接访问会导致访问冲突。为了提交页面,需要调用VirtualAlloc
函数。
让我们来研究一下为了通过文件映射支持此功能,我们需要对微型Excel做出哪些更改。首先是文件映射对象的创建:
bool CMainDlg::AllocateRegion() {
m_hSharedMem = ::CreateFileMapping(INVALID_HANDLE_VALUE, nullptr,
PAGE_READWRITE | SEC_RESERVE, TotalSize >> 32, (DWORD)TotalSize,
L"MicroExcelMem");
if (!m_hSharedMem) {
AtlMessageBox(nullptr, L"Failed to create shared memory",
IDR_MAINFRAME, MB_ICONERROR);
EndDialog(IDCANCEL);
return false;
}
m_Address = ::MapViewOfFile(m_hSharedMem, FILE_MAP_READ | FILE_MAP_WRITE,
0, 0, TotalSize);
CString addr;
addr.Format(L"0x%p", m_Address);
SetDlgItemText(IDC_ADDRESS, addr);
SetDlgItemText(IDC_CELLADDR, addr);
return true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
在对话框初始化时会调用AllocateRegion
。对CreateFileMapping
的调用将页面文件用作备份(这是SEC_RESERVE
支持的唯一场景),并请求SEC_RESERVE
标志以及PAGE_READWRITE
。为文件映射对象指定一个名称,以便于与其他进程共享。
接下来,调用MapViewOfFile
来映射整个共享内存(TotalSize = 1 GB
)。当然,也可以只映射部分内存,对于32位进程来说,这实际上是个非常好的主意,因为其地址空间范围有限。由于SEC_RESERVE
标志,整个区域被预留,而不是提交。
从任何单元格写入和读取数据的方式与原始微型Excel完全相同:初次写入尝试会导致访问冲突异常,捕获该异常后,调用VirtualAlloc
显式提交包含特定单元格的页面,然后返回EXCEPTION_CONTINUE_EXECUTION
,告知处理器再次尝试访问。为方便起见,此处重复写入和处理异常的代码:
LRESULT CMainDlg::OnWrite(WORD, WORD, HWND, BOOL&) {
int x, y;
auto p = GetCell(x, y);
if (!p)
return 0;
WCHAR text[512];
GetDlgItemText(IDC_TEXT, text, _countof(text));
try {
::wcscpy_s((WCHAR*)p, CellSize / sizeof(WCHAR), text);
}
except (FixMemory(p, GetExceptionCode())) {
// nothing to do: this code is never reached
}
return 0;
}
int CMainDlg::FixMemory(void* address, DWORD exceptionCode) {
if (exceptionCode == EXCEPTION_ACCESS_VIOLATION) {
::VirtualAlloc(address, CellSize, MEM_COMMIT, PAGE_READWRITE);
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
如果运行第二个微型Excel 2应用程序,你会发现另一个进程中可以看到相同的信息,因为它们使用的是相同的映射内存。不过要注意,每个进程中1GB区域的映射地址不太可能相同。这完全没问题,而且并不影响两个进程看到完全相同的内存这一事实(图14-5)。
图14-5:两个微型Excel 2实例共享内存
如果你想在VMMap中查看这种内存布局,需要查看的正确内存“类型”是“可共享(Shareable)”(图14-6)。
图14-6:VMMap中由页面文件支持的共享内存
# 其他内存映射函数
MapViewOfFile
的扩展版本允许选择映射的目标地址:
LPVOID MapViewOfFileEx(
_In_ HANDLE hFileMappingObject,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwFileOffsetHigh,
_In_ DWORD dwFileOffsetLow,
_In_ SIZE_T dwNumberOfBytesToMap,
_In_opt_ LPVOID lpBaseAddress);
2
3
4
5
6
7
除了最后一个参数,所有参数都与MapViewOfFile
相同。最后一个参数是请求的映射地址。该地址必须是系统分配粒度(64KB)的倍数。如果指定地址加上请求的映射大小已被进程地址空间占用,该函数可能会失败。这就是为什么几乎总是最好将NULL
作为值指定,让系统定位一个空闲区域,这样该函数就与MapViewOfFile
相同。
为什么要设置特定的地址呢?一个常见的情况(至少对于系统而言)是当一个可移植可执行文件(PE,Portable Executable)必须由多个进程映射时(SEC_IMAGE
标志)。这用于PE映像,因为代码中可能包含指向PE映像范围内其他位置的指针(地址)。如果映射到不同的地址,那么部分代码需要更改。这种情况通常发生在需要重定位动态链接库(DLL,Dynamic Link Library)时(下一章会讨论)。
对于数据而言,也可以存储指向映射区域内其他位置的指针,但这不是个好主意,因为MapViewOfFileEx
可能会失败。最好在数据中存储偏移量,这样它们就与地址无关。
MapViewOfFile
的另一个变体是为映射使用的物理内存选择首选的非统一内存访问(NUMA,Non-Uniform Memory Access)节点:
LPVOID MapViewOfFileExNuma(
_In_ HANDLE hFileMappingObject,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwFileOffsetHigh,
_In_ DWORD dwFileOffsetLow,
_In_ SIZE_T dwNumberOfBytesToMap,
_In_opt_ LPVOID lpBaseAddress,
_In_ DWORD nndPreferred);
2
3
4
5
6
7
8
MapViewOfFileExNuma
通过一个首选的NUMA节点扩展了MapViewOfFileEx
(有关NUMA的更多信息,请参阅第13章)。
Windows 10版本1703(RS2)引入了MapViewOfFile2
:
PVOID MapViewOfFile2(
_In_ HANDLE FileMappingHandle,
_In_ HANDLE ProcessHandle,
_In_ ULONG64 Offset,
_In_opt_ PVOID BaseAddress,
_In_ SIZE_T ViewSize,
_In_ ULONG AllocationType,
_In_ ULONG PageProtection);
2
3
4
5
6
7
8
此函数通过将NUMA_NO_PREFERRED_NODE
(-1)作为首选NUMA节点传递给更扩展的函数MapViewOfFileNuma2
来内联实现:
PVOID MapViewOfFileNuma2(
_In_ HANDLE FileMappingHandle,
_In_ HANDLE ProcessHandle,
_In_ ULONG64 Offset,
_In_opt_ PVOID BaseAddress,
_In_ SIZE_T ViewSize,
_In_ ULONG AllocationType,
_In_ ULONG PageProtection,
_In_ ULONG PreferredNode);
2
3
4
5
6
7
8
9
这些函数以及本节后面的其他函数需要导入库mincore.lib
。目前的文档错误地指定为kernel32.lib
。
这些函数添加了第二个参数(hProcess
),用于标识要映射视图的进程(原始函数始终作用于当前进程)。当然,使用GetCurrentProcess
完全合法。如果相关进程不同,则该句柄必须具有PROCESS_VM_OPERATION
访问掩码。这些函数的一个优点是偏移量可以指定为单个64位数字,而不是原始函数中的两个32位值。
AllocationType
参数可以为0(用于正常提交的视图),或MEM_RESERVE
用于预留视图而不提交。此外,如果要使用大页面进行映射,可以指定MEM_LARGE_PAGES
。在这种情况下,文件映射对象必须使用SEC_LARGE_PAGES
标志创建,并且调用者必须具有SeLockMemoryPrivilege
权限。
其余参数与MapViewOfFileExNuma
中的参数相同(尽管顺序不同)。返回的地址仅在目标进程地址空间中有效。当需要与另一个进程共享一些内存,而该进程对打开所需的文件映射对象、要映射的区域等一无所知时,这些函数会很有用。这意味着文件映射对象可以不命名创建,这样更难受到干扰。需要传递给目标进程的唯一信息是结果地址,如果BaseAddress
不为NULL
,甚至可以预先定义该地址。将单个指针值传递给另一个进程比传递更复杂的信息要容易得多。例如,可以使用窗口消息,甚至可以使用第12章中演示的DLL中的共享变量。
可以在目标进程中使用UnmapViewOfFile
正常取消映射视图,或者在映射进程中使用UnmapViewOfFile2
:
BOOL UnmapViewOfFile2(
_In_ HANDLE Process,
_In_ PVOID BaseAddress,
_In_ ULONG UnmapFlags
);
2
3
4
5
UnmapFlags
通常为零,但还可以有另外两个值。详细信息请查阅文档。另一个变体是UnmapViewOfFileEx
,其工作方式与UnmapViewOfFile2
类似,但始终作用于调用进程。
需要使用MapViewOfFile2
的通用Windows平台(UWP,Universal Windows Platform)进程有自己的版本MapViewOfFile2FromApp
。与Virtual
系列中的类似函数一样,如果在UWP应用中编译,MapViewOfFile2
会内联实现为调用MapViewOfFile2FromApp
。详细信息请查阅文档。
还有另一个MapViewOfFile
变体,在Windows 10版本1803(RS4)中引入:
PVOID MapViewOfFile3(
_In_ HANDLE FileMapping,
_In_opt_ HANDLE Process,
_In_opt_ PVOID BaseAddress,
_In_ ULONG64 Offset,
_In_ SIZE_T ViewSize,
_In_ ULONG AllocationType,
_In_ ULONG PageProtection,
_Inout_ MEM_EXTENDED_PARAMETER* ExtendedParameters,
_In_ ULONG ParameterCount);
2
3
4
5
6
7
8
9
10
这是一个“超级函数”,结合了其他变体的功能,其中属性通过MEM_EXTENDED_PARAMETER
结构数组指定。请参阅第13章中对VirtualAlloc2
的讨论,因为这里使用的是相同的结构。
不出所料,UWP进程也有另一个变体MapViewOfFile3FromApp
,实现方式与前面描述的类似。
最后,Windows 10版本1809(RS5)为CreateFileMapping
添加了一个变体:
HANDLE CreateFileMapping2(
_In_ HANDLE File,
_In_opt_ SECURITY_ATTRIBUTES* SecurityAttributes,
_In_ ULONG DesiredAccess,
_In_ ULONG PageProtection,
_In_ ULONG AllocationAttributes,
_In_ ULONG64 MaximumSize,
_In_opt_ PCWSTR Name,
_Inout_updates_opt_(ParameterCount) MEM_EXTENDED_PARAMETER* ExtendedParameters,
_In_ ULONG ParameterCount);
2
3
4
5
6
7
8
9
10
此函数目前似乎没有文档说明,但它使用相同的MEM_EXTENDED_PARAMETER
结构。例如,为从此文件映射对象进行的所有映射指定首选NUMA节点可以这样做:
HANDLE hFile = ...;
MEM_EXTENDED_PARAMETER param = { 0 };
param.Type = MemExtendedParameterNumaNode;
param.ULong = 1; //NUMA node 1
HANDLE hMemMap = ::CreateFileMapping2(hFile, nullptr, FILE_MAP_READ,
PAGE_READONLY, 0, 0, nullptr, ¶m, 1);
2
3
4
5
6
# 数据一致性
文件映射对象在数据一致性方面提供了多项保证。
- 即使来自多个进程,同一数据/文件的多个视图也能保证同步,因为各个视图都映射到相同的物理内存。唯一的例外是映射网络上的远程文件时。在这种情况下,不同机器上的视图可能并非始终同步。同一机器上的视图仍会保持同步。
- 映射同一文件的多个文件映射对象不能保证同步。一般来说,使用两个或更多文件映射对象映射同一文件不是个好主意。最好以独占访问方式打开相关文件,这样就不会有其他进程可以访问该文件(至少在打算写入时)。
- 如果一个文件被一个文件映射对象映射,同时又以正常I/O方式(
ReadFile
、WriteFile
等)打开,I/O操作所做的更改通常不会立即反映在映射到文件中相同位置的视图中。应避免这种情况。
# 总结
内存映射文件对象灵活且快速,无论是映射特定文件还是仅共享由页面文件备份的内存,都能提供共享内存功能。它们效率非常高,我认为它们是Windows中我最喜欢的功能之一。
在下一章中,我们将把注意力转向动态链接库(DLL),它是Windows的重要组成部分。