计算机系统篇之虚拟内存(3):如何使用内存映射文件在进程之间实现数据共享
Author: stormQ
Created: Sunday, 11. October 2020 10:30AM
Last Modified: Friday, 13. November 2020 07:33PM
本文介绍了内存映射的基本概念,并描述了在 Linux 系统中如何利用内存映射在父子进程间或任意进程间进行数据共享。
Linux 通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。
虚拟内存区域可以映射到以下两种类型对象中的一种:
Linux 文件系统中的普通文件
一个虚拟内存区域可以映射到一个普通文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到 CPU 第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果虚拟内存区域比文件区要大,那么就用零来填充这个区域的余下部分。
匿名文件
一个虚拟内存区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU 第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面。如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意:在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也称为请求二进制零的页(demand-zero page)。
无论在哪种情况下,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件(swap file)或交换空间(swap space)之间换来换去。需要注意的是,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
在 Linux 系统中,我们可以通过mmap
函数将一个虚拟内存区域映射到一个共享的匿名文件,从而实现父子进程间的数据共享。
需要注意的是,以匿名文件作为内存映射对象的方式,仅适用于父子进程间的数据共享,并且该匿名文件必须是共享对象(即必须指定MAP_SHARED
选项)。也就是说,共享匿名文件的共享范围仅限于父子进程间。
mmap
函数定义在sys/mman.h
头文件中(即使用时需要包含头文件: #include <sys/mman.h>
),其函数原型为:
void *mmap (void *__addr, size_t __len, int __prot, int __flags, int __fd, __off_t __offset));
注:
参数__addr
,表示程序要求内核以__addr
的值为起始地址创建一个新的虚拟内存区域,但最终的起始地址由内核决定。为了可移植性,该参数的值通常被定义为NULL
。
参数__len
,表示将参数__fd
所指定对象的一个连续的片映射到该虚拟内存区域时,该连续的片大小即为__len
(单位:Bytes)。
参数__prot
,表示新映射的虚拟内存区域的访问权限。注意:该参数的值不能与文件打开模式相冲突。比如:如果以只读模式打开文件,并且要将新创建的虚拟内存区域映射到该文件,那么该参数不可以指定可写或可执行权限;否则,mmap
函数会失败,并报Permission denied
错误。
可用选项:
PROT_EXEC
,表示这个区域内的页面由可以被 CPU 执行的指令组成,即可执行权限。
PROT_READ
,表示这个区域内的页面可读。
PROT_WRITE
,表示这个区域内的页面可写。
PROT_NONE
,表示这个区域内的页面不能被访问。
注意: 除PROT_NONE
以外,其他三个选项之间可以通过|
(或运算符)进行组合。比如:PROT_READ | PROT_WRITE,表示虚拟内存区域的访问权限为可读可写。为了可移植性,最好明确指定访问权限对应的所有选项。
参数__flags
,表示被映射对象的类型。常用选项(其他选项可通过man mmap
命令查看):
MAP_ANONYMOUS
,表示被映射对象是一个匿名对象。该选项可以与MAP_SHARED
或MAP_PRIVATE
(同时只能与其中一个)进行按位与。比如:MAP_ANONYMOUS | MAP_SHARED,表示被映射对象是一个(仅限于父子进程之间)共享的匿名文件(在 Linux 内核版本 2.4 及其以后的版本才支持共享的匿名文件)。
MAP_SHARED
,表示被映射对象是一个共享对象。
MAP_PRIVATE
,表示被映射对象是一个私用的、写时复制的对象。
参数__fd
,表示被映射对象的文件描述符。如果被映射对象是匿名文件,那么该参数的值被忽略,并且为了可移植性通常被定义为 -1。
参数__offset
,表示从距文件开始处的偏移量(单位:Bytes)。如果被映射对象是匿名文件,那么该参数的值被忽略,并且通常被定义为 0。如果要映射整个普通文件,该参数的值一定是 0。
返回值类型为void *
,表示新创建的虚拟内存区域的起始地址。该函数出错时,返回值为MAP_FAILED
。
注意:
MAP_SHARED
被指定时,意味着:
如果被映射对象是普通文件,那么:1)一个进程对该虚拟内存区域内容的修改对其他也映射该区域的进程可见;2)对该虚拟内存区域内容的修改会被内核自动(也可以通过手动方式)同步到普通文件中,即该方式可用于持久化数据。
如果被映射对象是匿名文件,那么:1)父进程对该虚拟内存区域内容的修改对其子进程可见,反之亦然;2)对该虚拟内存区域内容的修改不会被同步到匿名文件中,即该方式不能用于持久化数据。
MAP_PRIVATE
被指定时,意味着:
无论被映射对象是普通文件还是匿名文件,都有:1)一个进程对该虚拟内存区域内容的修改对其他进程不可见;2)对该虚拟内存区域内容的修改不会被内核同步到普通文件或匿名文件中。
下面语句的作用:让内核创建一个新的大小为length
字节的可读可写、共享、请求二进制零的虚拟内存区域。
mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
接下来,实现一个最简单的父子进程间数据共享的示例。在该示例中,首先父进程将一个虚拟内存区域映射到一个共享的匿名文件,然后子进程写一些数据到该匿名文件中,最后父进程在子进程退出后从该匿名文件中读取相同长度的数据。也就是说,该示例未涉及父子进程同时访问共享数据的情形,也就不必使用进程间的同步措施。
源码,vm1_main.cpp:
#include <unistd.h>
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <errno.h>
int Wait(int *status)
{
int pid = 0;
do
{
pid = wait(status);
} while (-1 == pid && EINTR == errno);
if (nullptr != status && WIFEXITED(*status))
{
*status = WEXITSTATUS(*status);
}
return pid;
}
int main()
{
auto addr = static_cast<int *>(mmap(NULL, 16,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0));
if (MAP_FAILED == addr)
{
perror("mmap failed, reason:");
return EXIT_FAILURE;
}
printf("page size:%ld bytes\n", sysconf(_SC_PAGE_SIZE));
const auto iter_count = 10;
if (fork() == 0)
{ /*in child process*/
for (int i = 0; i < iter_count; i++)
{
addr[i] = i;
}
return EXIT_SUCCESS;
}
int status = 1;
Wait(&status);
-1 == status ? printf("child exit status:unknown\n")
: printf("child exit status:%d\n", status);
for (int i = 0; i < iter_count; i++)
{
printf("%d ", addr[i]);
}
printf("\n");
return EXIT_SUCCESS;
}
编译:
$ g++ -o vm1_main vm1_main.cpp -g
运行:
$ ./vm1_main
page size:4096 bytes
child exit status:0
0 1 2 3 4 5 6 7 8 9
从运行结果中可以看出:1)该系统的页面大小为 4KB;2)父进程正常读取了子进程所写入匿名文件的数据,即实现了父子进程间数据共享。
在 Linux 系统中,我们可以通过mmap
函数将一个虚拟内存区域映射到一个共享的 Linux 普通文件,从而实现任意进程间的数据共享。
下面语句的作用:让内核创建一个新的可读可写、共享的虚拟内存区域,并将文件描述符fd
指定的对象的一个连续的片映射到这个新的虚拟内存区域。连续的对象片的大小为length
字节,从距文件开始处偏移量为offset
字节的地方开始。
mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset);
源码,vm2_main.cpp(实现写一些数据到内存映射文件):
#include <unistd.h>
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
int main(int argc, char *argv[])
{
if (argc < 4)
{
printf("Usage, needed args: backed-file length offset\n");
return EXIT_FAILURE;
}
int fd = open(argv[1], O_RDWR);
if (-1 == fd)
{
perror("open failed, reason:");
return EXIT_FAILURE;
}
const auto length = atoi(argv[2]);
const auto offset = atoi(argv[3]);
auto addr = static_cast<int *>(mmap(NULL, length,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, offset));
if (MAP_FAILED == addr)
{
perror("mmap failed, reason:");
return EXIT_FAILURE;
}
close(fd);
printf("page size:%ld bytes\n", sysconf(_SC_PAGE_SIZE));
const auto iter_count = length;
for (int i = 0; i < iter_count; i++)
{
addr[i] = i;
}
printf("\n");
return EXIT_SUCCESS;
}
源码,vm3_main.cpp,用于实现从内存映射文件读取相同的数据。vm3_main.cpp 将 vm2_main.cpp 中的语句addr[i] = i;
替换为printf("%d ", addr[i]);
,其他相同。
编译:
$ g++ -o vm2_main vm2_main.cpp -g
$ g++ -o vm3_main vm3_main.cpp -g
内存映射文件为 a.txt,其初始内容为(ASCII 码,共 110 字节):
$ cat a.txt
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
1234512345
运行:
$ ./vm2_main ./a.txt 10 0
page size:4096 bytes
$ ./vm3_main ./a.txt 10 0
page size:4096 bytes
0 1 2 3 4 5 6 7 8 9
从运行结果中可以看出,一个进程写了一些数据到内存映射文件,而另一个进程从该内存映射文件中正常读取了这些数据,即实现了无亲缘关系进程间的数据共享。
另外,我们也可以通过od
命令查看修改后的 a.txt 的内容:
$ od -tu4 a.txt
0000000 0 1 2 3
0000020 4 5 6 7
0000040 8 9 171258931 875770417
0000060 858927413 822752564 892613426 875770417
0000100 842074677 825570355 892613426 858927370
0000120 842085684 171258931 875770417 858927413
0000140 822752564 892613426 875770417 2613
0000156
注:最左侧一列为八进制(默认格式)的地址,其余为数据。-tu4
选项表示将文件中的内容看作int
类型(占用 4 字节)的数组。
上一篇:计算机系统篇之异常控制流(10):Chapter 8 Exceptional Control Flow 章节习题与解答