跳转至

mmap共享存储映射(存储IO映射)系列详解

原文地址

https://blog.csdn.net/qq_36359022/article/details/79992287

概要

mmap共享存储映射又称为存储I/O映射,是Unix共享内存概念中的一种。 在Unix进程间通信中,大致有:

  1. 管道 pipe(),用于父子进程间通信(不考虑传递描述符)
  2. FIFO(有名管道) 非父子进程也能使用,以文件打通
  3. 文件 文件操作,效率可想而知
  4. 本地套接字 最稳定,也最复杂.套接字采用Unix域
  5. 共享内存 传递最快,消耗最小,传递数据过程不涉及系统调用
  6. 信号 数据固定且短小

其中,共享内存是IPC(进程间通信)中最快的,一旦共享内存映射到共享它的进程的地址空间中,这些进程的数据传递就不再涉及内核,因为它会以指针的方式读写内存,不涉及系统级调用

管道与共享存储映射对比

管道

请看下图,左图描述了fork()前通过pipe()开启管道的示意图,假设父进程从文件A中读取数据并通过管道传递给子进程,由子进程执行某些操作后写入文件B。 首先,进程的数据区位于0-3G的虚拟地址空间中,3G-4G为内核区,注意,文件A和文件B并不是存储在内核区,这里只是示意。并且,本次父子进程完全按照最早期Unix的实现讲解,也就是说父子进程完全独立的空间,不涉及到后来的写时复制等技术。

  1. 父进程通过系统调用read()从文件A读取数据的过程中,父进程的状态切换到内核态,读取数据并保存到父进程空间中的buf中,再切换回用户态。这里发生了第一次数据的拷贝。
  2. 父进程通过系统调用write()将读取的数据从buf中拷贝到管道的过程中,父进程状态切换到内核态,向管道写入数据,再切换回用户态。这里发生第二次数据拷贝。
  3. 子进程通过系统调用read()从管道读取数据的过程中,子进程状态切换到内核态,读取数据并保存到子进程空间中的buf中,再切换回用户态。这里发生第三次数据拷贝。
  4. 子进程通过系统调用write()将读取的数据从buf中拷贝到文件B的过程中,子进程状态切换到内核态,向文件B写入数据,再切换回用户态。这里发生第四次数据拷贝。

66384-be4ltmb0cl5.png

可以看到,这里发生了四次数据拷贝都是再内核与某个进程间进行的,这种开销往往更大(比存粹在内核中或单个进程内复制数据的开销更大)

因此,通过管道进行数据传递在编程上简单,而实际开销是作为一个追求极致效率的程序员所不允许的。接着我们来看看共享存储映射的开销是怎样的呢?

共享存储映射(存储I/O映射)

请看下图,该图描述了父进程使用mmap()使用共享存储映射,fork()后,fork会对内存映射文件进行特殊处理,也就是父进程在调用fork()之前创建的内存映射关系由子进程共享。该方式只有两次系统系统调用。而之前有四次调用 因此,父子进程可以通过指针对该内存区域进行读写操作,以完成数据通信。 该方法的奇特之处在于,进程间通信的I/O操作在内核的掩盖下完成,对内存的直接存取操作不涉及系统调用,避免了进程状态的频繁切换与系统调用。

  1. 使用mmap()建立共享存储映射区
  2. 父进程fork(),子进程共享该区域
  3. 父进程读取文件A中的数据的过程中,切换至内核态,根据mmap返回的指针ptr,将数据拷贝到共享区域,再切换回来。这里发生第一次数据拷贝。
  4. 子进程根据ptr指针从内存读取数据到文件B,切换到内核态,write数据到文件B,再切换回来。这里发生第二次数据拷贝。

注意:这里是父进程直接copy文件A到共享区,子进程从共享区copy数据到文件B。

83186-5o47zltuss.png

共享存储映射是将磁盘上的文件映射到进程的虚拟地址空间,其物理支撑是物理内存,而进程通信时就是通过物理内存来传递数据,而不是写入磁盘再读出来。

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函数进行磁盘数据同步。

80580-d3r489arlg9.png

文件大小等于映射区大小的情况

当我们用普通文件作映射区时,如果文件大小时5000,并且我们也用5000的映射区时(不是页面的整数), 虽然映射区大小为5000,但仍能够在一定程度上越界访问。 这其实是因为内核的内存保护是以页面为单位的,5000大小分得的物理页面支撑实际上是2个页面(8192大小)。 在0-4999可以使用ptr进行正常的读写访问,而5000-8191这一段里,内核是允许我们读写的,但是不会写入。注意,是允许读写,但写不进去。就是说内核允许写操作,但内核又不执行这个写操作。 当超过了物理页面支撑后的任何操作都是不合规矩的,引发SIGSEGV信号。

92530-jhfaryo9deb.png

文件大小远小于映射区大小的情况

这次文件大小仍然是5000,而映射区大小我们改为15000。物理页面支撑2个页面大小(8192大小)。 在访问0-4999是没有问题的,5000-8191这段允许读写但不执行写入操作。当超过物理页面支撑以后的空间分为两种情况 (1)超过物理页面但是没有超过映射区大小 –> 引发SIGBUS信号 (2)超过物理页面且超过映射区大小 —> 引发SIGSEGV信号 由此我们可以看出,mmap映射时物理页面上面并不是单纯的以我们填入的数据分配,内核仍然会对文件本身的大小进行检查。

32746-bmqqgeydlzg.png

可以总结如下:

(1)没超过物理页面,没超过映射区大小 —> 正常读写 (2)没超过物理页面,超过映射区大小 —> 内核允许读写但不执行写入操作 (3)超过物理页面,没有超过映射区大小 —> 引发SIGBUS信号 (4)超过物理页面, 超过映射区大小 —> 引发SIGSEGV信号

父子进程存储映射的地址分布

首先阐述前提条件,父进程fork后,子进程以最早期的方式讲解(不涉及写时复制等技术)。 fork()后,子进程是父进程的副本,子进程获得父进程的数据空间、堆、栈、等副本,正文部分共享,PCB进程控制块独享。 也就是说,父子进程在物理内存上是完全两个不同的进程。

考虑一个场景:父进程在fork出子进程之前调用mmap,因此父子进程依靠该共享存储映射区进行进程间通信。那么,父子进程的用户空间、物理内存、磁盘是个什么情况呢?

66570-x1bb2oajy4q.pnghttp://152.69.196.199/usr/uploads/2021/12/4020496715.png)

父进程fork之前,mmap成功返回一个ptr指针指向共享存储映射区的首地址。而共享存储映射区是位于进程空间的虚拟地址空间里,内核根据其实现将对应到物理内存的某个区域上,而fork之后,fork会对mmap产生的这段共享存储映射进行特殊处理,因此,当子进程复制得到这部分的副本时,ptr指针仍然指向对应的物理内存的那个区域。

这样就会产生一个疑惑,是不是子进程复制得到的这些数据的物理地址和父进程的一样呢? 答案是不同的,虽然后来在写时复制技术上不算错,但这里我们谈论的是最早的实现,也就是说,除了PCB和正文,其他部分基本上都被复制了,父子进程在物理内存上是存放在不同区域的,而共享存储映射的这部分物理区域是相同的。 综上,我们编写一个测试代码以验证我们的说法

08687-m89ovwzd9mi.png

该代码的意思是: (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产生的共享存储映射区则确确实实是共享的。

07473-a38ptv43o2.png

然而问题又出现了,发现上图的地址了吗,父子进程对同一个变量的地址是相同的,ptr的地址,ptr指向的那个地方的地址,以及i的地址,父子进程打印出来一样,代码以睡眠的方式保证了四次打印时父子进程都是没有结束的。

那么,在父子数据地址相同,并且满足局部变量不共享,共享存储映射区共享的情况下,系统是怎么实现的呢?

答案:各位读者,请记住2个概念: 虚拟地址空间 — 物理内存 父子进程所在的是用户空间,其地址可以说是逻辑地址,而逻辑地址与真实物理地址的对应关系由mmu来完成,因此,父子进程的i变量的地址一样,但是映射到物理内存上就不同了。同理,共享存储映射区的物理地址是相同的。

19302-bobdzfi9jh9.png

调用mmap之后内核做的事情

  1. 建立进程虚拟地址空间与文件内容空间之间的映射
  2. 而后第一次读写mmap映射的内存时,由于页表并未与物理内存映射,触发缺页异常
  3. 缺页异常程序先根据要访问的偏移和大小从page cache中查询是否有该文件的缓存,如果找到就更新进程页表指向page cache那段物理内存
  4. 没找到就将文件从磁盘加载到内核page cache,然后再令进程的mmap虚拟地址的页表指向这段page cache中文件部分的物理内存

所以结论是,内核会把文件读到page cache中。也只有这样,其它进程打开的文件会和mmap打开的文件读写结果保持一致。