谈谈虚拟内存和 mmap

在工作中偶尔会听到 mmap 这个词。首先从出处上来说,mmap()是在 <sys/mman.h> 中定义的一个函数,此函数的作用是创建一个新的 虚拟内存 区域,并将指定的对象映射到此区域。因此,一直以来在工作中聊到的 mmap 其实就是通过 内存映射 的机制来进行文件操作。

由于从定义上来说提到了虚拟内存,接下来先简单说一下 虚拟内存

一、虚拟内存

虚拟内存 是一种性能优越的内存管理技术。首先它为程序提供了看似巨大的内存空间,使得一个较大的程序能够运行在较小的内存空间中;同时又为每个进程提供了独立的虚拟地址空间,既简化了内存管理,也保护了每个进程的地址空间。

1.1 局部性

要实现虚拟内存的机制,首先不得不说其基础 局部性 。即在一个较短的时间内,执行的程序指令、访问的数据空间只会局限于某个区域;某条指令、数据在被访问后,不久后可能再次被访问,其附近的指令、数据也可能将被访问。

因此可以 将主存看作磁盘的高速缓存,只在主存中保存当前活动的指令和数据,然后根据需要在主存和磁盘之间来回传输数据

1.2 页表

为了在磁盘(虚拟内存)和主存(物理内存)之间更高效地来回传送数据,系统将虚拟内存以及物理内存按照 固定、相同 的大小分割为一个个的页,虚拟内存上的称为 虚拟页 ,而物理内存上的称为 物理页

在切分好页后,系统还在主存中用一个 页表 来记录下当前每个虚拟页的情况,包括这个虚拟页是否已被分配使用、是否已被缓存到物理内存中等。简化的页表如下图。

页表中每一项称为 页表项(Page Table Entry) ,PTE 中最重要的两个信息是 有效位地址

有效位 被设置,则表示此虚拟页当前已被缓存到物理内存中,此时 地址 将指向其缓存到的物理页起始位置;

有效位 未被设置,如果该虚拟页已分配,则此时 地址 指向其虚拟页在磁盘中的起始位置,否则地址为 null。

1.3 缺页

虚拟内存和物理内存之间的数据传送正是发生于 缺页

如上图,当 CPU 首次访问 PTE 1,发现页表中的有效位仍 未被设置 ,即判断出对应的数据(VP1)仍未被缓存到物理内存中,从而触发了 缺页异常 ,将 VP1 从 虚拟内存 拷贝到 物理内存 中。而图中可以发现此时物理内存已经满了,则会通过 LRU 等算法将物理内存中的某个物理页(假设是VP3)替换掉。因此在缺页异常发生后,上面的页表将会更新为下图的样子。

1.4 多进程

上面展示的都是单进程的情况,其实系统为 每个进程 都提供了一个 独立的页表 ,也就是独立的虚拟地址空间,从而能够很好地保护每个进程的数据。

二、mmap

了解完 虚拟内存 ,再回过头来讲一下 mmap ,也就是内存映射 。内存映射是将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容的过程。

2.1 基础概念

先讲下内存映射里的一些概念。

映射对象类型

虚拟内存区域可以映射以下两种类型的对象:

  1. 普通文件:即磁盘文件中的一块 连续 的区域。
  2. 匿名文件:一个由内核创建的全为 二进制零 的文件。当CPU首次引用此区域时,将以二进制零填充到页表中。

共享对象

在上一节 虚拟内存 可得知,系统为每个进程提供了单独的页表,从而也实现了进程间数据访问权限的管理以及数据的保护。但同时,通过内存映射的机制,将对象作为 共享对象 映射到两个进程的虚拟内存亦可实现数据的共享。

2.2 使用方式

然后先讲下如果我们应该如何通过内存映射的方式来访问文件。 mmap() 的函数定义如下:

1
void *  mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

其中参数的含义分别是:

  • start: 期望的进程虚拟内存起始位置,填 NULL 时由内核来决定起始位置
  • length: 需要映射的对象字节大小
  • fd: 文件句柄
  • offset: 距离文件开始处的偏移量
  • prot: 映射对象的访问权限,用于可指定是否可读写、执行。
  • flags: 映射对象的类型,例如指定是映射普通文件还是请求二进制零、映射共享对象还是私有的写时复制对象等。

前4项地含义可通过下图更直观地了解:

而在 iOS 开发中,当我们需要的数据类型是 NSData 时,可以更简便地通过调用以下方法

1
2
3
@interface NSData (NSDataCreation)

+ (nullable instancetype)dataWithContentsOfFile:(NSString *)path options:(NSDataReadingOptions)readOptionsMask error:(NSError **)errorPtr;

并传入 NSDataReadingMappedIfSafe 来使用内存映射方式读取文件。

2.3 读取过程

当我们通过 mmap 读取文件时,将经历以下步骤:

  1. 在当前用户虚拟内存空间中分配一片 指定映射大小 的虚拟内存区域。
  2. 将磁盘中的文件映射到这片内存区域,等待后续 按需 进行页面调度。
  3. 当CPU真正访问数据时,触发 缺页异常 将所需的数据页从磁盘拷贝到物理内存,并将物理页地址记录到页表。
  4. 进程通过页表得到的物理页地址访问文件数据。

而作为对比,当通过 标准IO 读取一个文件时,步骤为:

  1. 完整 的文件从磁盘拷贝到物理内存(内核空间)。
  2. 将完整文件数据从 内核空间 拷贝到 用户空间 以供进程访问。

2.4 优劣

通过上面 mmap标准IO 的对比,不难发现调用mmap具有以下的优势:

  1. 物理内存占用延后:数据直到真正被使用时才会发生拷贝。
  2. 物理内存占用减少:对于同一份文件无需在物理内存中存放两份,且文件区被划分成片,缺页异常时只将所需的页拷贝到物理内存。
  3. 方便实现跨进程数据交互、共享:当映射到虚拟内存的对象被设置为共享对象,则不同进程对映射对象的写操作相互可见。

然而也能发现 mmap 存在以下 劣势

  1. 无法映射变长文件:调用mmap()时需指定要映射的文件位置和需要映射的大小范围。
  2. 如果需要映射的文件过大,会导致过度占用虚拟内存:在调用mmap()后,虚拟内存空间就创建了,此时虽然不会占用物理内存,但依然会占用虚拟内存。此时可考虑只映射文件中自己需要的部分。

由此,当我们需要访问一个比较大的文件,尤其是当我们只需要访问其中的一小部分数据的时候,我们可以尝试通过 mmap 的方式来进行访问,减少由于该文件过大而对物理内存的过度占用。