Linux IPC之内存映射mmap()

导言:如何使用mmap()系统调用来创建内存映射,它可以用于IPC以及其他很多方面。

概述

mmap()系统调用,在调用进程的虚拟地址空间中创建一个新内存映射。映射分为两种:

  1. 文件映射(内存映射文件)
    将一个文件的一部分直接映射到调用进程的虚拟内存中。一旦一个文件被映射之后就可以通过在相应的内存区域中操作字节来访问文件内容了。映射的分页会在需要的时候从文件中自动加载。这种映射也被称为,基于文件的映射,或内存映射文件

  2. 匿名映射
    一个匿名映射没有对应的文件,相反,这种映射的分页会被初始化为0。可以把它看成是一个内容总是被初始化为0的虚拟文件映射。

一个进程的映射中的内存,可以与其他进程中的映射共享,即各个进程的页表条目指向RAM中相同分页,这种行为会在以下两种情况下发生:

情况一:当两个进程映射了一个文件的同一个区域时,它们会共享物理内存的相同分页。
情况二:通过fork()创建的子进程会继承父进程的映射的副本,并且这些映射所引用的物理内存分页与父进程中相应映射所引用的分页相同。

当两个或更多个进程共享相同分页时,每个进程都有可能会看到其他进程对分页内容做出的变更,当然这要取决于映射是私有的还是共享的

  • 私有映射(MAP_PRIVATE)
    在映射内容上发生的变更,对其他进程不可见。对于文件映射来讲,变更将不会在底层文件上进行,尽管一个私有映射的分页在上面介绍的情况中初始时是共享的,但对映射内容所做出的变更对各个进程来将则是私有的。内核使用了写时复制(copy-on-write)技术完成了这个任务。这意味着,每当一个进程试图修改一个分页的内容时,内核首先会为该进程创建一个新分页,并将需要修改的分页中的内容复制到新分页中,以及调整进程的页表。正因为这个原因,MAP_PRIVATE映射,也被称为私有写时复制映射

  • 共享映射(MAP_SHARED)
    在映射内容上发生变更,对所有共享同一个映射的其他进程都可见,对文件映射来讲,变更将会发生在底层的文件上。

以上四种不同的方式(文件、匿名、私有、共享)可以组合实现如下效果:

  1. 私有文件映射(文件 + 私有)-> 进程初始化
    映射的内容被初始化为一个文件区域中的内容,多个映射同一个文件的进程初始时会共享同样的内存物理分页,但系统使用写时复制技术,使得一个进程对映射的修改对其他进程不可见。这种映射的主要用途是,使用一个文件的内容来初始化一块内存区域。比如,根据二进制可执行文件,或共享库文件的相应部分来初始化一个进程的文本和数据段。

  2. 私有匿名映射(匿名 + 私有)-> malloc大块内存
    每次调用mmap()创建一个私有匿名映射时都会产生一个新映射,该映射与同一(或不同)进程创建的其他匿名映射是不同的,即不会共享物理分页。尽管子进程会继承父进程的映射,但写时复制语义确保在fork()之后父进程和子进程不会看到其他进程对映射所做出的修改。私有匿名映射的主要用途是,为一个进程分配新内存(用0填充),例如,在分配大块内存时,malloc()会为此而使用mmap()

  3. 共享文件映射(文件 + 共享)-> 无关进程IPC
    所有映射一个文件的同一区域的进程会共享同样的内存物理分页,这些分页的内容将被初始化为该文件区域。对映射内容的修改将直接在文件中进程。这种映射主要用于两个用途:第一,它允许内存映射I/O,这表示一个文件会被加载到进程的虚拟内存中的一个区域中,并且对该块内容的修改会自动写入到这个文件中,因此,内存映射I/O为使用read()write()来执行文件I/O这种做法提供了一种替代方案。第二,允许无关进程共享一块内容,以便以一种类似于System V共享内存段的方式来执行快速IPC。

  4. 共享匿名映射(匿名 + 共享)-> 相关进程IPC
    与私有匿名映射一样,每次调用mmap()创建一个共享匿名映射时,都会产生一个新的,与任何其他映射不共享分页的截然不同的映射。这里的差别在于,映射的分页不会被写时复制,这意味着,当一个子进程在fork()之后继承映射时,父进程和子进程共享同样的RAM分页,并且一个进程对映射内容所做出的变更会对其他进程可见。共享匿名映射允许以一种类似于System V共享内存段的方式来进行IPC,但只有相关进程之间才能这么做。

具体用法

// 创建一个映射
// 成功时,mmap()会返回新映射的起始地址;发生错误时,mmap()会返回MAP_FAILED
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • addr参数指定了映射被放置的虚拟地址。如果将addr指定为NULL,那么内核会为映射选择一个合适的地址,这是创建映射的首选做法。或者在addr中指定了一个非NULL值时,内核会在选择将映射放置在何处时将这个参数值作为一个提示信息来处理。在实践中,内核至少会将指定的地址舍入到最近的一个分页边界处。不管采用何种方式,内核会选择一个不与任何既有映射冲突的地址。
  • length参数指定了映射的字节数。尽管length无需是一个系统分页大小(sysconf(_SC_PAGESIZE)返回值)的倍数,但内核会以分页大小为单位来创建映射,因此实际上length会被向上提升为分页大小的下一个倍数。
  • prot参数是一个位掩码,它指定了施加于映射之上的保护信息,其取值要么是PROT_NONE,要么是下面列出的其他三个标记的组合(取OR):
ValueDes
PROT_NONE区域无法访问
PROT_READ区域内容可读取
PROT_WRITE区域内容可修改
PROT_EXEC区域内容可执行
  • flags参数是一个控制映射操作各个方面的选项的位掩码。这个掩码必须只包含下列值中的一个:
ValueDes
MAP_PRIVATE创建一个私有映射。区域中内容上所发生的变更对使用同一映射的其他进程是不可见的。对于文件映射来讲,所发生的变更将不会反应在底层文件上。
MAP_SHARED创建一个共享映射。区域中内容上所发生的变更对使用MAP_SHARED特性映射同一区域的进程是可见的。对于文件映射来讲,所发生的变更将直接反应在底层文件上。对文件的更新将无法确保立即生效,具体可参考对msync()系统调用。
other除了MAP_PRIVATE和MAP_SHARED之外,在flags中还可以有选择地对其他标记取OR
  • fdoffset是用于文件映射的(匿名映射将忽略它们)。fd是一个标识被映射的文件的文件描述符。offset指定了映射在文件中的起点,它必须是系统分页大小的倍数。要映射整个文件就需要将offset指定为0,并且将length指定为文件大小。

关于内存保护

mmap()prot参数指定了新内存映射上的保护信息。这个参数可以取PROT_NONE或者PROT_READPROT_WRITE以及PROT_EXEC中一个或多个标记的掩码。

如果一个进程在访问一个内存区域时违反了该区域上的保护位,那么内核会向该进程发送一个SIGSEGV信号。尽管SUSv3规定SIGSEGV应该被用来通知内存保护违背,但在一些实现上使用的规则SIGBUS

标记为PROT_NONE的分页内存的一个用途是作为一个进程分配的内存区域的起始位置或结束位置的守护分页。如果进程意外地访问了其中一个被标记为PROT_NONE的分页,那么内核会通过生成一个SIGSEGV信号来通知该进程这样一个事实。

内存保护信息驻留在进程私有的虚拟内存表中,因此,不同的进程可能会使用不同的保护位来映射同一个内存区域。使用mprotect()系统调用能够修改内存保护位。

标准中规定的对offset和addr的对齐约束

SUSv3规定mmap()的offset参数必须要与分页对齐,而addr参数在指定了MAP_FIXEDd的情况下也必须要与分页对齐。Linux遵循了这些要求。

mprotect()、msync()以及munmap()中的addr参数也存在类似的情况。SUSv3规定这个参数必须是分页对齐的,SUSv4表示一个实现可以要求这个参数是分页对齐的。

例子——使用mmap()创建一个私有文件映射

https://github.com/gerryyang/TLPI/tree/master/src/mmap

解除映射区域munmap()

munmap()系统调用执行与mmap()相反的操作,即从调用进程的虚拟地址空间中删除一个映射。

// Returns 0 on success, or -1 on error
#include <sys/mman.h>
int munmap(void *addr, size_t length);
  • addr参数是待解除映射的地址范围的起始地址,它必须与一个分页边界对齐。(SUSv3规定addr必须是分页对齐的,SUSv4表示一个实现可以要求这个参数是分页对齐的)
  • length参数是一个非负整数,它指定了待解除映射区域的大小(字节数)。范围为系统分页大小的下一个倍数的地址空间将会被解除映射。

一般来讲,通常会解除整个映射。因此可以将addr指定为上一个mmap()调用返回的地址,并且length的值与mmap()调用中使用的length的值一样。

addr = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
if (add == MAP_FAILED)
    errExit("mmap");

// code for working with mapped region

if (munmap(addr, length) == -1)
    errExit("munmap");

注意:

  1. 或者也可以解除一个映射中的部分映射,这样原来的映射要么会收缩,要么会被分成两个。这取决于在何处开始解除映射。还可以指定一个跨越多个映射的地址范围,这样的话所有在范围内的映射都会被解除。
  2. 如果在由addr和length指定的地址范围中不存在映射,那么munmap()将不起任何作用并返回0(表示成功)。
  3. 在解除映射期间,内核会删除进程持有的在指定地址范围内所有内存锁。(内存锁是通过mlock()或mlockall()来建立的)
  4. 当一个进程终止或执行了一个exec()之后,进程中所有的映射会自动被解除。
  5. 为了确保一个共享文件映射的内容会被写入到底层文件中,在使用munmap()解除一个映射之前需要调用msync()。

文件映射

要创建一个文件映射需要执行下面的步骤:
1. 获取一个文件的描述符,通常通过调用open()来完成。
2. 将文件描述符作为fd参数传入mmap()调用。

执行上述步骤后,mmap()会将打开的文件的内容映射到调用进程的地址空间中。一旦mmap()被调用之后就能够关闭文件描述符了,而不会对映射产生任何影响。但在一些情况下,将这个文件描述符保持在打开状态可能是有用的。

除了普通的磁盘文件,使用mmap()还能够映射各种真实和虚拟设备的内容,如硬盘、光盘以及/dev/mem。

在打开描述符fd引用的文件时,必须要具备与protflags参数值匹配的权限。offset参数指定了从文件区域中的哪个字节开始映射,它必须是系统分页大小的倍数。将offset指定为0会导致从文件的起始位置开始映射。length参数指定了映射的字节数。offsetlength参数一起确定了文件的哪个区域会被映射进内存。

在Linux上,一个文件映射的分页会在首次被访问时被映射进内存。这意味着如果在mmap()调用之后修改了文件区域,但映射的对应部分(即分页)还没有被访问过,那么如果相应分页还没有被加载进内存的话,变更对这个进程可能是可见的。这个行为是依赖于实现的,可移植的应用程序应该避免某个特定内核在这种场景中的行为。

私有文件映射

私有文件映射最常见的两个用途是:

  • 允许多个执行同一个程序或使用同一个共享库的进程共享同样的(只读的)文本段,它是从底层可执行文件或库文件的相应部分映射而来的。

尽管可执行文件的文本段通常是被保护成只允许读取和执行访问(PROT_READ|PROT_EXEC),但在被映射时仍然使用了MAP_PRIVATE而不是MAP_SHARED,这是因为调试器或自修改的程序能够修改程序文本(在修改了内存上的保护信息之后),而这样的变更是不应该发生在底层文件上或影响到其他进程的。

  • 映射一个可执行文件或共享库的初始化数据段。这种映射会被处理成私有,使得对映射数据段内容的变更不会发生在底层文件上。

mmap()的这两种用法通常对程序是不可见的,因为这些映射是由程序加载器和动态链接器创建的。一般可以在/proc/PID/maps的输出中发现这两种映射。

共享文件映射

当多个进程创建了同一个文件区域的共享映射时,它们会共享同样的内存物理分页。此外,对映射内容的变更将会反应到文件上。实际上,这个文件被当成了该块内存区域的分页存储。

共享文件映射存在两个用途:

内存映射I/O

由于共享文件映射中的内容是从文件初始化而来的,并且对映射内容所做出的变更都会自动反映到文件上,因此可以简单地通过访问内存中的字节来执行文件I/O,而依靠内核来确保对内存的变更会被传递到映射文件中。(一般来讲,一个程序会定义一个结构化数据类型来与磁盘文件中的内容对应起来,然后使用该数据类型来转换映射的内容),这项技术被称为内存映射I/O,它是使用read()write()来访问文件内容这种方案的替代方案

  1. 使用内存访问来取代read()和write()系统调用能够简化一些应用程序的逻辑。
  2. 在一些情况下,它能够比使用传统的I/O系统调用执行文件I/O这种做法提供更好的性能。

原因是
1. 正常的read()或write()需要两次传输:一次是在文件和内核高速缓冲区之间,另一次是在高速缓冲区和用户空间缓冲区之间。使用mmap()就不需要第二次传输了。对于输入来讲,一旦内核将相应的文件块映射进内存之后,用户进程就能够使用这些数据了;对于输出来讲,用户进程仅仅需要修改内核中的内容,然后可以依靠内核内存管理器来自动更新底层的文件。
2. 除了节省内核空间和用户空间之间的一次传输之外,mmap()还能够通过减少所需使用的内存来提升性能。当使用read()或write()时,数据将被保存在两个缓冲区中:一个位于用户空间,另个一位于内核空间。当使用mmap()时,内核空间和用户空间会共享同一个缓冲区。此外,如果多个进程正在同一个文件上执行I/O,那么它们通过使用mmap()就能够共享同一个内核缓冲区,从而又能够节省内存的消耗。

注意:
1. 内存映射I/O所带来的性能优势,在大型文件中执行重复随机访问时最有可能体现出来。如果顺序地访问一个文件,并假设执行I/O时使用的缓冲区大小足够大以至于能够避免执行大量的I/O系统调用,那么与read()和write()相比,mmap()带来的性能上的提升就非常有限,或者根本就没有带来性能上的提升。
2. 内存映射I/O也有一些缺点。对于小数据量I/O来讲,内存映射I/O的开销(映射、分页故障、解除映射、更新硬件内存管理单元的超前转换缓冲器)实际上要比简单的read()或write()大。

使用共享文件映射的IPC

由于所有使用同样文件区域的共享映射的进程,共享同样的物理分页。因此共享文件映射的第二个用途是作为一种(快速的)IPC方法。这种共享内存区域与System V共享内存对象之间的区别在于区域中内容上的变更会反应到底层的映射文件上。

这种特性,对那些需要共享内存内容在应用程序或系统重启时能够持久化的应用程序来讲是非常有用的。

例子 —— 使用mmap()创建一个共享文件映射

https://github.com/gerryyang/TLPI/blob/master/src/mmap/t_mmap.c

注意:这个简单的程序没有使用任何机制来同步多个进程对映射文件的访问。但现实世界中的应用程序通常需要同步对共享内存的访问,这可以通过多种技术来完成(信号量、文件锁等)。

匿名映射

匿名映射没有对应文件的一种映射。

MAP_ANONYMOUS和/dev/zero

在Linux上,使用mmap()创建匿名映射存在两种不同但等价的方法。
1. 在flags中指定MAP_ANONYMOUS并将fd指定为-1。
2. 打开/dev/zero设备文件并将得到的文件描述符传给mmap()。

/dev/zero是一个虚拟设备,当从中读取数据时它总是会返回0,而写入到这个设备中的数据总会被丢弃。/dev/zero的一个常见用途是使用0来组装一个文件。(如使用dd命令)

MAP_ANONYMOUS和/dev/zero技术并没有在SUSv3进行规定,尽管大多数UNIX实现都支持其中一种或两种。之所以存在两种不同的技术实现同样的语义的原因是,MAP_ANONYMOUS源自BSD,而/dev/zero源自System V。

MAP_PRIVATE匿名映射

fd = open("/dev/zero", O_RDWR);
if (fd == -1) errExit("open");
add = mmap(NULL, length, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
if (add == MAP_FAILED) errExit("mmap");

glibc中malloc()实现使用MAP_PRIVATE匿名映射来分配大小大于MMAP_THRESHOLD字节的内存块。这样在后面将这些内存块传递给free()之后就能高效地释放这些块(munmap())。它还降低了重复分配和释放大内存块而导致内存碎片的可能性。MMAP_THRESHOLD在默认情况下是128KB,但可以通过mallopt()库函数来调整这个参数。


版权声明:本文为delphiwcdj原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。