详解磁盘 IO: 第 2 部分 mmap、fadvise、AIO

85 阅读10分钟

了解 I/O 的工作原理以及理解算法和存储系统的用例和权衡可以让开发人员生活变得更好:他们将能够提前做出更好的选择(基于他们正在评估的数据库的底层内容),当数据库行为异常时排除性能问题(通过将他们的工作负载与他们选择的数据库的工作负载进行比较)并调整他们的堆栈(通过平衡负载、切换到不同的介质、文件系统、操作系统或选择不同的索引类型)。

该系列由 5 件组成:

内存映射

内存映射(映射) 允许您访问文件,就好像它已完全加载到内存中一样。它简化了文件访问并经常被数据库和应用程序开发人员使用。

内存映射将进程虚拟页面直接映射到内核页面缓存,避免像使用标准 IO 那样从用户空间缓冲区进行额外的复制。

使用mmap可以将文件以私有共享模式映射到内存段。私有映射允许从文件中读取,但任何写入都会触发相关页面的写入时复制,以保持原始页面完好无损并保持更改私有,因此任何更改都不会反映在文件本身上。在共享模式下,文件映射与其他进程共享,因此它们可以看到对映射内存段的更新。此外,更改会传递到底层文件(精确控制需要使用msync)。

除非另有说明,否则文件内容不会立即加载到内存中,而是以惰性方式加载。内存映射所需的空间已保留,但不会立即分配。第一次读取或写入操作会导致页面错误,从而触发相应页面的分配。通过传递MAP_POPULATE,可以预先对映射区域进行故障处理并强制预读文件

在上一篇文章中,我们讨论了虚拟内存和页面缓存。内存映射是通过页面缓存完成的,与标准 IO 操作()相同,并使用按需分页

在第一次访问内存时,会发出页面错误,向内核发出信号,表示请求的页面当前未加载到内存中,必须加载。内核会识别必须从哪里加载哪些数据。页面错误对开发人员来说是透明的:程序流程将继续进行,就像什么都没发生一样。有时,页面错误可能会对性能产生负面影响,我们将在本文后面讨论改善这种情况的可能方法。

还可以将文件映射到带有保护标志的内存中(例如,以只读模式)。如果对映射内存段的操作违反了请求的保护,则会发出段错误

mmap是处理 IO 的一个非常有用的工具:它避免在内存中创建缓冲区的无关副本(与标准 IO 不同,标准 IO 必须在进行系统调用之前将数据复制到用户空间缓冲区中)。此外,它还避免了触发实际 IO 操作的系统调用(以及随后的上下文切换)开销,除非发生页面错误。从开发人员的角度来看,使用mmap_ ped 文件发出随机读取看起来就像正常的指针操作,并且不涉及lseek调用。

最常提到的mmap的缺点与现代硬件关系不大:

  • mmap 会增加管理内存映射所需的内核数据结构的开销:在当今的现实和内存大小下,这个论点并不起主要作用。
  • 内存映射文件大小限制:大多数情况下,内核代码对内存更加友好,并且 64 位架构允许映射更大的文件。

当然,这并不意味着一切都必须通过内存映射文件来完成。

mmap被数据库实现者频繁使用。例如,MongoDB默认存储引擎是支持mmap的,而SQLite则广泛使用内存映射。

页面缓存优化

从我们目前讨论的内容来看,使用标准 IO 似乎简化了许多事情,并且具有一些好处,但代价是失去控制权:您只能依靠内核和页面缓存。这是事实,但仅限于一定程度。通常,内核可以使用内部统计数据更好地预测何时执行回写和预取页面。但是,有时可以帮助内核以对应用程序有利的方式管理页面缓存。

向内核告知您的意图的方法之一是使用fadvise。使用以下标志,可以向内核告知您的意图,并让其优化页面缓存的使用:

  • FADV\_SEQUENTIAL指定按顺序读取文件,从较低的偏移量到较高的偏移量,因此内核可以确保在实际读取发生之前提前获取页面。
  • FADV_RANDOM禁用预读,从页面缓存中逐出近期不太可能被访问的页面。
  • FADV_WILLNEED通知操作系统,进程在不久的将来将需要该页面。这使内核有机会提前缓存该页面,并在读取操作发生时从页面缓存中提供该页面,而不是发生页面错误。
  • FADV_DONTNEED建议内核可以释放相应页面的缓存(确保数据事先与磁盘同步)。
  • 还有一个标志(FADV_NOREUSE),但在Linux上它没有效果。

正如其名称所示,fadvise仅起到建议的作用。内核没有义务完全按照fadvise 的_建议去做。

由于数据库开发人员通常可以预测访问,因此fadvise是一个非常有用的工具。例如,RocksDB 使用它来通知内核有关访问模式,具体取决于文件类型(SSTable 或 HintFile)、模式(随机或顺序)和操作(写入或压缩)。

另一个有用的调用是mlock。它允许您强制将页面保留在内存中。这意味着一旦页面加载到内存中,所有后续操作都将从页面缓存中提供。必须谨慎使用它,因为在每个页面上调用它只会耗尽系统资源。

AIO

就 IO 类型而言,我们要讨论的最后一部分是Linux 异步 IO (AIO)。AIO 是一个接口,允许启动多个 IO 操作并注册在操作完成时触发的回调。操作将异步执行(例如,系统调用将立即返回)。使用异步 IO 可帮助应用程序在处理提交的 IO 作业时继续在主线程上工作。

负责 Linux AIO 的两个主要系统调用是io_submitio_getevents。io_submit允许传递一个或多个命令,保存缓冲区、偏移量和必须执行的操作。 可以使用io_getevents查询完成情况,该调用允许收集相应命令的结果事件。 这允许使用完全异步的接口来处理 IO、流水线化 IO 操作和释放应用程序线程,从而可能减少上下文切换和唤醒的次数。

不幸的是,Linux AIO 有几个缺点:glibc 不公开系统调用 API,需要一个库来连接它们(libaio似乎是最受欢迎的)。尽管多次尝试修复该问题,但只支持带有 O_DIRECT 标志的文件描述符,因此缓冲异步操作不起作用。此外,某些操作(例如stat、fsync、open和其他一些操作)不是完全异步的。

值得一提的是,Linux AIO 不应与Posix AIO混淆,它们是完全不同的东西。Linux 上的 Posix AIO 实现完全在用户空间中实现,根本不使用这个特定于 Linux 的 AIO 子系统。

Vectored IO

执行 IO 操作的一种可能不太流行的方法是矢量 IO(也称为分散/聚集)。之所以这样称呼,是因为它对缓冲区矢量进行操作,并允许每个系统调用使用多个缓冲区从磁盘读取和写入数据。

执行矢量读取时,首先会将字节从源读入缓冲区(最多到第一个缓冲区的长度偏移量)。然后,从源开始,从第一个缓冲区的长度开始,到第二个缓冲区的长度偏移量为止的字节将被读入第二个缓冲区,依此类推,就好像源一个接一个地填充缓冲区一样(尽管操作顺序和并行性不确定)。矢量写入的工作方式类似:缓冲区的写入方式就像在写入之前将它们连接起来一样

转存失败,建议直接上传图片文件

矢量 IO 示例:不同大小的用户空间缓冲区被映射到连续的文件区域,允许使用单个系统调用填充和刷新多个缓冲区。

这种方法有助于读取较小的块(从而避免为连续块分配较大的内存区域),同时减少用磁盘数据填充所有这些缓冲区所需的系统调用数量。另一个优点是读取和写入都是原子的:内核会阻止其他进程在读取和写入操作期间对同一描述符执行 IO,从而保证数据完整性。

从开发角度来看,如果数据在文件中以某种方式布局(例如,它被分成固定大小的头和多个固定大小的块),则可以发出一个调用来填充为这些部分分配的单独缓冲区。

这听起来很有用,但不知何故,只有少数数据库使用矢量 IO。这可能是因为通用数据库同时处理大量文件,试图保证每个正在运行的操作的活跃性并减少其延迟,因此数据是按块访问和缓存的。矢量 IO 更适用于分析工作负载和或列式数据库,其中数据连续存储在磁盘上,并且可以在稀疏块中并行处理。Apache Arrow就是一个例子。

结束语

如您所见,有很多东西可供选择,每个都有自己的优点和缺点。使用特定工具并不能保证获得积极的结果:由于它们的特殊性,这些 IO 风格很容易被误解和误用。实施、调优和最终用户使用数据库的方式仍可能发挥重要作用。

您可以看到,现有的方法似乎仍然具有某种模式:使用 O_DIRECT 可能需要您编写缓冲区缓存,使用页面缓存可能需要使用_fadvise_,而使用 AIO 可能需要您将其连接到类似 Futures 的接口。其中一些适用于更具体的用例,一些则更为通用。这些系列的主要目的是帮助人们获得基本词汇并了解数据库的底层内容,以便更轻松地查看其子系统、调整、优化并为正确的工作选择正确的工具。

在下一篇文章中,我们将讨论不可变的磁盘数据结构和 LSM 树