MMAP and PMDK
从前面的系列文章中可以看到,以 Intel AEP 为代表的新硬件既可以被当做内存使用也可以被当做持久化 设备使用。后一种使用模式中又包含了多种模式,例如 FSDAX、DEVDAX、RAW 等。FSDAX 中最快速的用法 就是将 NVM 直接映射到内存来访问,绕过文件系统的 PAGE CACHE。这就将 NVM 和 MMAP 联系起来了。
MMAP
mmap 如果 flags 取值是 MAP_ANONYMOUS,则类似于 malloc 分配内存。实际上,在分配较大块的 内存时,glibc 的 malloc 调用的就是 mmap1。如果 fd 是一个有效的文件句柄,mmap 会把一个文件的指定范围映射到虚拟内存,从而可以像访问内存一样读写文件了。当然,如果需要将文件 内容写回到文件系统,需要主动调用 msync。
很多系统中会用 mmap 来分配较大的内存块,或用它来处理用于进程间通讯的共享内存,或者用来简化 文件的访问。在 PMDK 2 中,也有类似的 API:pmem_map_file,实现了对 mmap 等的封 装。
MMAP 处理变长文件
在操作文件时,mmap 需要提供文件的句柄和长度,最合适的用法就是操作一个已知长度的文件了。可是, 更多的情况是预先并不知道文件的长度。这就需要用到一些技巧。例如,我们需要处理一个已知长度为 1GiB 的文件,但是它可能会增长,假设最多会到 10GiB。
最简单直接的做法是先 mmap 最初的 1GiB 文件,在发现文件长度增加后,重新去做 mmap。但是每次 mmap 返回的地址可能会不一样(MAP_ANONYMOUS),导致代码不好处理。如果每次用 MAP_FIXED 指定地址,可以解决这个问题。
更好一点的办法是预先按照最大的文件长度 mmap,但是要确保不访问超过实际文件大小的区域。否则, 容易触发 SIGBUS 错误。在文件增长后,重新 mmap 增长出来的区域,例如最后的 128MiB。此时, 可以用 MAP_FIXED,指定此前获得的 addr + 1GiB 的地址作为 mmap 的地址。这样还可以就保持 之前的地址可用。
MMAP 的性能问题
对于文件操作,如果不是特别频繁的调用 mmap,额外的开销不算大。毕竟文件的 IO 相对可能更为 耗时。但是对于 NVM 设备,由于其操作速度接近内存,mmap 本身带来的开销就不容忽视了3。
mmap 的主要额外开销为:
- mmap 等调用的开销。
- 缺页中断的开销。
- 页面首次使用触发的分配和清零开销。
缺页中断的处理时间相对较长,而且对于可能重复访问的页面,缺页和不缺页的处理时间可能差别较大。 处理这种情况可以采用 prefault 的处理机制,例如先在内存里访问一遍映射出来的虚拟地址空间。 mmap 提供了 MAP_POPULATE 选项来实现 prefault 的处理,预先加载页表。
页面首次使用的分配和清零也很耗时间,对于此类代价的经典做法是提前将文件的空间都分配好,并且 写零。文件空间提前分配好,而不是随用随分配,也可以更大概率的获得联系的物理空间。这些操作也 是数据库系统几十年来处理文件的习惯性操作。
上面这两种处理方法的本质都是提前批处理,避免了后续每次操作的不可控代价。
实际上,缺页也会带来另外一个较大的开销:也就是对页表的处理4。如果我们启用大页支持, 显然可以大大减少 PMD 的开销3。PMDK 也支持大页面,但是启用大页面支持要有 一些前提条件4,主要是分配的尺寸和对齐要按照 2MiB 计算。对齐是个很有意思的细节, 我遇到过很多次因为文件系统没有对齐导致的性能损失,有时对性能的影响会达到 15% 或更多。
MMAP 示例
mmap 的 man page 里头有一个简单的示例程序,可以看到最基本的用法。
这里是一个处理文件增长的示例:https://github.com/zedware/notebook/blob/master/samples/pmdk/mmap.cc
Footnotes
1 https://github.molgen.mpg.de/git-mirror/glibc
3 https://www.usenix.org/system/files/conference/hotstorage17/hotstorage17-paper-choi.pdf
4 https://pmem.io/2018/05/15/using_persistent_memory_devices_with_the_linux_device_mapper.html