mmap共享存储映射(存储IO映射)系列详解
原文地址¶
https://blog.csdn.net/qq_36359022/article/details/79992287
概要¶
mmap共享存储映射又称为存储I/O映射,是Unix共享内存概念中的一种。 在Unix进程间通信中,大致有:
- 管道 pipe(),用于父子进程间通信(不考虑传递描述符)
- FIFO(有名管道) 非父子进程也能使用,以文件打通
- 文件 文件操作,效率可想而知
- 本地套接字 最稳定,也最复杂.套接字采用Unix域
- 共享内存 传递最快,消耗最小,传递数据过程不涉及系统调用
- 信号 数据固定且短小
其中,共享内存是IPC(进程间通信)中最快的,一旦共享内存映射到共享它的进程的地址空间中,这些进程的数据传递就不再涉及内核,因为它会以指针的方式读写内存,不涉及系统级调用
管道与共享存储映射对比¶
管道¶
请看下图,左图描述了fork()前通过pipe()开启管道的示意图,假设父进程从文件A中读取数据并通过管道传递给子进程,由子进程执行某些操作后写入文件B。 首先,进程的数据区位于0-3G的虚拟地址空间中,3G-4G为内核区,注意,文件A和文件B并不是存储在内核区,这里只是示意。并且,本次父子进程完全按照最早期Unix的实现讲解,也就是说父子进程完全独立的空间,不涉及到后来的写时复制等技术。
- 父进程通过系统调用read()从文件A读取数据的过程中,父进程的状态切换到内核态,读取数据并保存到父进程空间中的buf中,再切换回用户态。这里发生了第一次数据的拷贝。
- 父进程通过系统调用write()将读取的数据从buf中拷贝到管道的过程中,父进程状态切换到内核态,向管道写入数据,再切换回用户态。这里发生第二次数据拷贝。
- 子进程通过系统调用read()从管道读取数据的过程中,子进程状态切换到内核态,读取数据并保存到子进程空间中的buf中,再切换回用户态。这里发生第三次数据拷贝。
- 子进程通过系统调用write()将读取的数据从buf中拷贝到文件B的过程中,子进程状态切换到内核态,向文件B写入数据,再切换回用户态。这里发生第四次数据拷贝。
可以看到,这里发生了四次数据拷贝都是再内核与某个进程间进行的,这种开销往往更大(比存粹在内核中或单个进程内复制数据的开销更大)
因此,通过管道进行数据传递在编程上简单,而实际开销是作为一个追求极致效率的程序员所不允许的。接着我们来看看共享存储映射的开销是怎样的呢?
共享存储映射(存储I/O映射)¶
请看下图,该图描述了父进程使用mmap()使用共享存储映射,fork()后,fork会对内存映射文件进行特殊处理,也就是父进程在调用fork()之前创建的内存映射关系由子进程共享。该方式只有两次系统系统调用。而之前有四次调用 因此,父子进程可以通过指针对该内存区域进行读写操作,以完成数据通信。 该方法的奇特之处在于,进程间通信的I/O操作在内核的掩盖下完成,对内存的直接存取操作不涉及系统调用,避免了进程状态的频繁切换与系统调用。
- 使用mmap()建立共享存储映射区
- 父进程fork(),子进程共享该区域
- 父进程读取文件A中的数据的过程中,切换至内核态,根据mmap返回的指针ptr,将数据拷贝到共享区域,再切换回来。这里发生第一次数据拷贝。
- 子进程根据ptr指针从内存读取数据到文件B,切换到内核态,write数据到文件B,再切换回来。这里发生第二次数据拷贝。
注意:这里是父进程直接copy文件A到共享区,子进程从共享区copy数据到文件B。
共享存储映射是将磁盘上的文件映射到进程的虚拟地址空间,其物理支撑是物理内存,而进程通信时就是通过物理内存来传递数据,而不是写入磁盘再读出来。
mmap函数¶
mmap函数把一个文件或一个Posix共享内存区对象映射到调用进程的地址空间。
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
/*
若成功则返回被映射区的起始地址,若出错则返回MAP_FAILED
addr:指定被映射到进程空间内的起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址。
len:映射到调用进程地址空间中的字节数。
prot:内存映射区域的保护方式。常用 PROT_READ | PROT_WRITE
PROT_EXEC 映射区域可被执行
PROT_READ 映射区域可被读取
PROT_WRITE 映射区域可被写入
PROT_NONE 映射区域不能存取
flags:MAP_SHARED 和 MAP_PRIVATE 必须指定一个,其他可选。
MAP_SHARED 调用进程对被映射数据所作修改对于共享该对象的所有进程可见,并且改变其底层支撑(物理内存) 并不是改变内存数据就马上写回磁盘。这个取决于虚拟存储的实现。
MAP_PRIVATE 调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑(物理内存)
MAP_FIXED 用于准确解释addr参数,从移植性考虑不应指定它,如果没有指定,而addr不是空指针,那么addr如何处置取决于实现。不为空的addr值通常被当作有关该内存区应如何具体定位的线索。可移植的代码应把addr指定为空指针,并且不指定MAP_FIXED
MAP_ANON 匿名映射时用
fd:要映射到内存中的文件描述符。如果使用匿名内存映射时,即flags中设置了MAP_ANON,fd设为-1。
offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍(一般是4096的整数倍)。
*/
使用普通文件以提供内存映射I/O¶
int main(int argc, char **argv)
{
/*忽略命令行参数处理步骤*/
int fd, zero = 0;
fd = open(argv[1], O_RDWR | O_CREAT, 0644);
write(fd, &zero, sizeof(int));
ptr = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE | MAP_SHARED,
fd, 0);
close(fd);
/*
这里父子进程同步(信号量)的使用ptr进行数据交换
且退出exit(0)
*/
}
使用匿名内存映射¶
/* BSD 匿名 */
int main(int argc, char **argv)
{
/*忽略命令行参数处理步骤*/
int fd;
ptr = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE | MAP_SHARED | MAP_ANON,
-1, 0);
/*
这里父子进程同步(信号量)的使用ptr进行数据交换
且退出exit(0)
*/
}
/* SVR4 /dev/zero 特殊文件 */
int main(int argc, char **argv)
{
/*忽略命令行参数处理步骤*/
int fd;
fd = open("/dev/zero", O_RDWR);
ptr = mmap(NULL, sizeof(int),
PROT_READ | PROT_WRITE | MAP_SHARED,
fd, 0);
close(fd);
/*
这里父子进程同步(信号量)的使用ptr进行数据交换
且退出exit(0)
*/
}
mmap [文件大小与映射大小] 讨论¶
回顾第二大点讨论的mmap函数的参数,offset参数作为文件偏移,为什么要强调要4096(分页大小)的整数倍呢?mmap和页大小有关系吗? 该部分请读者一定要知道三个概念: 虚拟地址空间 —- 物理内存 —- 磁盘
首先来看,当一个进程调用mmap成功后,将一个文件映射到该进程的地址空间中,现在该进程可以用返回的指针ptr对内存进行数据操作。物理内存中数据的变化什么时候写入到磁盘取决于虚拟存储的实现,因此,并不是写入数据到内存就马上写回磁盘。当然也可以调用msync函数进行磁盘数据同步。
文件大小等于映射区大小的情况¶
当我们用普通文件作映射区时,如果文件大小时5000,并且我们也用5000的映射区时(不是页面的整数), 虽然映射区大小为5000,但仍能够在一定程度上越界访问。 这其实是因为内核的内存保护是以页面为单位的,5000大小分得的物理页面支撑实际上是2个页面(8192大小)。 在0-4999可以使用ptr进行正常的读写访问,而5000-8191这一段里,内核是允许我们读写的,但是不会写入。注意,是允许读写,但写不进去。就是说内核允许写操作,但内核又不执行这个写操作。 当超过了物理页面支撑后的任何操作都是不合规矩的,引发SIGSEGV信号。
文件大小远小于映射区大小的情况¶
这次文件大小仍然是5000,而映射区大小我们改为15000。物理页面支撑2个页面大小(8192大小)。 在访问0-4999是没有问题的,5000-8191这段允许读写但不执行写入操作。当超过物理页面支撑以后的空间分为两种情况 (1)超过物理页面但是没有超过映射区大小 –> 引发SIGBUS信号 (2)超过物理页面且超过映射区大小 —> 引发SIGSEGV信号 由此我们可以看出,mmap映射时物理页面上面并不是单纯的以我们填入的数据分配,内核仍然会对文件本身的大小进行检查。
可以总结如下:¶
(1)没超过物理页面,没超过映射区大小 —> 正常读写 (2)没超过物理页面,超过映射区大小 —> 内核允许读写但不执行写入操作 (3)超过物理页面,没有超过映射区大小 —> 引发SIGBUS信号 (4)超过物理页面, 超过映射区大小 —> 引发SIGSEGV信号
父子进程存储映射的地址分布¶
首先阐述前提条件,父进程fork后,子进程以最早期的方式讲解(不涉及写时复制等技术)。 fork()后,子进程是父进程的副本,子进程获得父进程的数据空间、堆、栈、等副本,正文部分共享,PCB进程控制块独享。 也就是说,父子进程在物理内存上是完全两个不同的进程。
考虑一个场景:父进程在fork出子进程之前调用mmap,因此父子进程依靠该共享存储映射区进行进程间通信。那么,父子进程的用户空间、物理内存、磁盘是个什么情况呢?
http://152.69.196.199/usr/uploads/2021/12/4020496715.png)
父进程fork之前,mmap成功返回一个ptr指针指向共享存储映射区的首地址。而共享存储映射区是位于进程空间的虚拟地址空间里,内核根据其实现将对应到物理内存的某个区域上,而fork之后,fork会对mmap产生的这段共享存储映射进行特殊处理,因此,当子进程复制得到这部分的副本时,ptr指针仍然指向对应的物理内存的那个区域。
这样就会产生一个疑惑,是不是子进程复制得到的这些数据的物理地址和父进程的一样呢? 答案是不同的,虽然后来在写时复制技术上不算错,但这里我们谈论的是最早的实现,也就是说,除了PCB和正文,其他部分基本上都被复制了,父子进程在物理内存上是存放在不同区域的,而共享存储映射的这部分物理区域是相同的。 综上,我们编写一个测试代码以验证我们的说法
该代码的意思是: (1)在父进程fork之前成功调用了mmap函数,我们将共享存储映射的大小设置为一个int大小的空间,将ptr指向的那块物理内存赋值为1,局部变量i的值为1。 (2)然后fork,程序先将父进程睡眠1s,尽可能的保证子进程先运行,因此子进程打印出的ptr指向的数据应该是1,i值也为1。然后将ptr指向的数据改为2,i值改为2。接着子进程睡眠 (3)父进程开始执行,如果说共享映射区的物理区域真的是共享的,那么子进程修改的数据父进程就可以打印出2。而事实确实是我们预期的。 (4)父进程打印数据: *ptr为2,i值为1。 (5)可以看到,父子进程在物理内存上的地址空间是不同的,i并没有被共享,而mmap产生的共享存储映射区则确确实实是共享的。
然而问题又出现了,发现上图的地址了吗,父子进程对同一个变量的地址是相同的,ptr的地址,ptr指向的那个地方的地址,以及i的地址,父子进程打印出来一样,代码以睡眠的方式保证了四次打印时父子进程都是没有结束的。
那么,在父子数据地址相同,并且满足局部变量不共享,共享存储映射区共享的情况下,系统是怎么实现的呢?
答案:各位读者,请记住2个概念: 虚拟地址空间 — 物理内存 父子进程所在的是用户空间,其地址可以说是逻辑地址,而逻辑地址与真实物理地址的对应关系由mmu来完成,因此,父子进程的i变量的地址一样,但是映射到物理内存上就不同了。同理,共享存储映射区的物理地址是相同的。
调用mmap之后内核做的事情¶
- 建立进程虚拟地址空间与文件内容空间之间的映射
- 而后第一次读写mmap映射的内存时,由于页表并未与物理内存映射,触发缺页异常
- 缺页异常程序先根据要访问的偏移和大小从page cache中查询是否有该文件的缓存,如果找到就更新进程页表指向page cache那段物理内存
- 没找到就将文件从磁盘加载到内核page cache,然后再令进程的mmap虚拟地址的页表指向这段page cache中文件部分的物理内存
所以结论是,内核会把文件读到page cache中。也只有这样,其它进程打开的文件会和mmap打开的文件读写结果保持一致。