49 内存映射 mmap()
内存映射分类
内存映射可以通过 mmap()
函数来完成。
- 内存映射从可见性来讲可以分成私有(
MAP_PRIVATE
)和共享(MAP_SHARED
)。- 私有内存映射有写时复制语义。
- 共享映射页面上的内容变化对所有共享者都可见。共享文件映射的页面变化还会同步回文件。
- 内存映射从映射类型上可以分成文件映射和匿名映射。
- 文件映射页面被初始化为文件对应位置的内容。
- 匿名映射页面被初始化为 0。
因此,它们可以有以下 4 种组合方式:
- 私有文件映射:映射的内容会被初始化为相同的内容。多个映射同一个文件的进程一开始会共享内存的物理分页,但是在修改时会触发写时复制。在私有文件映射上面的变更不会同步回文件。
- 私有匿名映射:
malloc()
申请大块内存时就会使用mmap()
的私有匿名映射。这样的映射虽然会在fork()
后由子进程继承,但是在写入页面的时候会触发写时复制。 - 共享文件映射:主要功能有两个,即文件映射 I/O 和无关进程的 IPC。
- 共享匿名映射:可以通过
fork()
由相关进程共享。
Note
进程的文本段就是私有文件映射。尽管文本段保护位一般是 PROT_EXEC | PROT_READ
,而且程序本身也一般不会尝试去修改代码本身,但是调试器等可能会修改程序代码。我们不希望这样的修改操作同步回可执行文件,所以使用 MAP_PRIVATE
而不是 MAP_SHARED
来映射文本段。
进程的初始化数据段也是私有文件映射。(书上没说,但是未初始化数据段大概是私有匿名映射吧?)
共享内存不能跨越 exec()
(会替换整个地址空间),但是能够跨越 fork()
,可以参考 Stack Overflow 的 这个回答。内存映射也不像 System V 共享内存段一样是一种需要手动释放的资源,在进程终止的时候就会解除。
创建内存映射
#include <sys/mman.h>
void *mmap(void addr[.length], size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void addr[.length], size_t length);
其中 addr
填写 NULL
就好(固定地址我感觉很少用)。如果固定地址,则内核一般会将其对齐到页大小的整数倍。 length
也无需是页的整数倍,但同理,内核在实际进行映射前会将其提升到页面大小的下一个整数倍。
prot
是内存映射页面的保护位(prot
可能表示“protect”),可以使用 PROT_NONE
或者 PROT_READ
/ PROT_WRITE
/ PROT_EXEC
中一个或多个值的或。允许使用 PROT_NONE
,是因为我们可以用它来映射一块内存区域的开始位置或者结束位置作为守护分页。除了在用 mmap()
创建映射时指定保护位之外,也可以用 mprotect()
来修改内存保护位。
受到硬件限制,指定的有些保护位可能和最终的效果不一致。比如有些硬件上可读或者可写会隐含可执行的权限。x86 架构上页表是支持 NX(no execute)的,因而 Linux/x86-32 上
mmap()
能实现精确的权限控制。
flags
先在 MAP_PRIVATE
和 MAP_SHARED
中二选一,然后再或上其他标志(略)。
fd
和 offset
是只有文件映射才需要。
创建文件映射如果失败,会返回 MAP_FAILED
,也就是 (void *)-1
。
解除内存映射
如上面的 API 所示,munmap()
的参数为 addr
和 length
。一般来说会使用相同的 addr
和 length
来解除整个映射。但是也可以解除部分映射。SUSv3 要求 addr
必须对齐到页,SUSv4 规定“一个实现可以要求该地址对齐到页”。
部分解除映射的结果是:一个映射可能缩短,也可能从中间分开成为两个内存映射记录。
🔵 文件映射
私有文件映射:进程的文本段、初始化数据段。
共享文件映射:可以用于文件映射 I/O 和无关进程的 IPC。
文件映射 I/O
文件映射 I/O 可以提供比一般 I/O 更好的性能。如果多个进程在同一个文件上进行文件映射 I/O,那么可以共享内存页面从而减少内存消耗;文件映射 I/O 也避免了写者将数据拷贝到内核高速缓冲区、读者从内核高速缓冲区将数据读入用户缓冲区的两步过程,更新文件的操作可以由内核自动管理。内存映射 I/O 的性能在于大型文件随机访问(而不是顺序访存)。
文件映射 I/O 的边界情况
假设文件长度大于请求映射的长度:首先,如果请求的长度不是页的整数倍,会被对齐到下一个整数倍,因此下图虽然申请了 6000 字节,实际相当于申请了 8192 字节。0~8191 字节被映射到了文件上,访问 8192 及以后的字节会产生段错误(其实就是访存越界)。
假设文件长度小于请求映射的长度:1. 文件的真实长度范围内可以访问,改动会同步回文件;2. 文件的真实长度到下一个页的整数倍的区域被初始化为 0,可以访问,但是改动不会同步到文件。3. 超过文件真实长度的下一个整数倍的位置就算作越界访问。如果小于申请长度算总线错误(SIGBUS),超过申请长度就是段错误。
也就是说,申请映射超过文件大小的空间是没有意义的。不过文件大小可以后期调整:在映射之后再通过 ftruncate()
或者 write()
等系统调用改变(实际上是增大)文件的长度,也可以使得往映射的区域写入数据时不会造成非法内存访问。
内存保护和文件访问模式
创建文件映射时需要先打开文件,而文件打开模式会影响内存保护位的设置。
- 如果是私有文件映射,那么只要对文件可读,任何内存保护方式都行,因为页面不会被写入文件。
- 如果是共享文件映射,那么对于只读的文件,不能以可写的方式来映射。
- 对于只写(
O_WRONLY
)打开的文件,无法进行映射。因为内存映射需要将文件内容载入进来(实际上载入的时机是第一次访问触发缺页错误时,除非指定了MAP_POPULATE
选项)。
msync()
我的理解是:虽然共享文件映射的内存页面变化在不同进程之间实时可见,但是还是需要有一种机制保证数据能真正写入到磁盘,类似于 fsync()
。
SYNOPSIS
#include <sys/mman.h>
int msync(void addr[.length], size_t length, int flags);
MS_ASYNC
:异步写入,也就是让内存映射同步到内核高速缓冲区(有不少系统上这两个缓冲区是同一个)。MS_SYNC
:同步写入,阻塞到写入到磁盘。MS_INVALIDATE
:让同一文件的其他映射失效(这样进程就能读到其他进程写入的最新数据)。
由于大多数 UNIX 系统有全局统一缓冲区(意味着文件读写的内核高速缓冲区和文件映射物理页面是同一个),MS_ASYNC
和 MS_INVALIDATE
基本上是 no-op。可以参考 https://stackoverflow.com/a/60593175/ 对 MS_INVALIDATE
的解释, https://stackoverflow.com/a/48623358/ 也提到了 MS_ASYNC
在 Linux 上是 no-op。
除了私有 / 共享之外的 mmap()
标志
MAP_ANONYMOUS
:创建匿名映射。
MAP_NORESERVE
:影响 overcommit 的工作方式。如果每个应用的虚拟内存总量已经超过了系统内存,这就是 overcommit。/proc/sys/vm/overcommit_memory 文件记录了 overcommit 的策略。默认为 0,表示在没有 MAP_NORESERVE
的情况下只拒绝太大的 overcommit;1 表示允许 overcommit;2 表示严格控制系统中 overcommit 的内存小于等于 $\mathit{swapsize} + {(\mathit{ramsize} \times \mathit{overcommit\_ratio})}/{100}$。其中 overcommit_ratio 对应文件 /proc/sys/vm/overcommit_ratio,默认值为 50。在 overcommit 策略为 2 时,只有私有可写文件映射和共享匿名映射会受到监控(因为私有只读映射不需要写回,而共享文件映射可以由文件本身充当交换)。
如果内存不足,OOM killer 会寻找进程并发送 SIGKILL 信号。/proc/PID/oom_score 记录了每个进程的分数(越高越容易被杀,/proc/PID/oom_score_adj 记录了对策略的调整,详见 proc(5)。
MAP_FIXED
:这个标记表示在固定的位置创建映射。在一些场景下有用,但是建议还是让系统来选择地址。
其他见 man mmap
。
🔵 匿名映射
创建匿名映射有两种方式:
- 给定
MAP_ANONYMOUS
标志,然后在 fd 处填写 -1(这是为了可移植性考虑,有些实现要求 fd 为 -1,有些实现看到MAP_ANONYMOUS
标志就会忽略 fd),offset 会被忽略,可以填写 0。 - 打开 /dev/zero 设备,然后将其用文件映射的方式打开。/dev/zero 这个设备读出的内容都是 0,写入的内容会被丢弃。在有些 UNIX 实现中(不包括 Linux),由于没有
MAP_ANONYMOUS
标志,只能用这一种方法。
抄书:
Glibc 中的
malloc()
实现使用MAP_PRIVATE
匿名映射来分配大小大于MMAP_THRESHOLD
字节的内存块。这样在后面将这些内存块传递给free()
之后就能高效地释放这 些块(通过munmap()
)。(它还降低了重复分配和释放大内存块而导致内存分片的可能性。)MMAP_THRESHOLD
在默认情况下是 128 kB,但可以通过mallopt()
库函数来调整这 个参数。
Glibc 中 malloc()
的行为在 man malloc
的 NOTES 中有清楚的说明,包括对 sbrk()
和 mmap()
的使用。
mremap()
Linux 提供了(不可移植的)mremap()
系统调用来改变一个映射的大小。如果一个内存块是 glibc 从 mmap()
创建匿名映射得到的,那么在使用 realloc()
进行扩充时,就会调用 mremap()
函数。
文件的非线性映射
方法一:先用 mmap()
创建匿名映射,获得一片可用内存区域。然后用带有 MAP_FIXED
标志的 mmap()
来将文件的部分页面映射到内存区域中(带有 MAP_FIXED
标志的 mmap()
如果和原来的映射重叠,则会将之前的映射解除),可以实现文件的非线性映射。这种方法是可移植的。
方法二:也可以用 remap_file_pages()
来调整文件映射为非线性映射:
- 需要先用
mmap()
创建文件映射。 - 然后用
remap_file_pages()
调整文件页面的映射顺序。
和多次使用 mmap()
相比,先 mmap()
再 remap_file_pages()
的好处是内核无需创建额外的 VMA(Virtual Memory Area)数据结构。
Note
根据 remap_file_pages(2) 的说法,现在这个函数已经标记为 deprecated,因为其实现过于复杂。唯一可能用到的地方是 32 位系统上的数据库应用,但是应用程序应该寻求替代方法。