最近看到了一篇不错的对 Linux 下磁盘 I/O 进行概述的文章,作者是 ScyllaDB 公司的 CTO,好像还是 KVM(Kernal-based Virtual Machine)最初的开发者,也是一位大牛了。
原文地址:https://www.scylladb.com/2017/10/05/io-access-methods-scylla/
当大多数后台开发者说起 I/O 时,他们往往会想到的是网络 I/O。因为现今大多数的资源都是通过网络来获取的:数据库,对象存储和其他的一些微服务。然而,数据库的开发者就必须要考虑文件 I/O 了。本文将会讲解文件 I/O 的几种分类和他们的优缺点,以及为什么 ScyllaDB 要使用异步直接 I/O(AIO/DIO)。
读写文件的几种方式
一般来说,在 Linux 下共有四种读写文件的方式:传统读写——read/write,mmap,直接 I/O 读写——direct I/O (DIO) read/write 和异步直接 I/O 读写——asynchronous direct I/O (AIO/DIO)。
传统读写
从一开始就有的传统读写实际上就是调用 read(2)1和 write(2)的系统调用。在更现代化的实现中,read 系统调用(或者 read 的变体——pread,readv,preadv 等等)让内核读取一部分文件并复制这部分数据到调用线程的内存地址空间中。如果所有请求的数据都在 page cache 中,那么内存就会直接复制并返回。否则,就要安排磁盘读取请求的数据到 page cache 中,阻塞住调用的线程,直到数据可用时才会继续线程并复制数据。对于 write 来说,通常2只会将数据复制到 page cache 中,内核之后会负责将 page cache 中的数据写回到磁盘。
mmap
另一个更流行的方式就是使用 mmap(2)系统调用,将文件内存映射到应用的地址空间。这会导致一部分地址直接引用到包含文件数据的 page cache,之后应用就可以通过内存读写指令来对文件数据进行操作了。如果 cache 中命中了请求的数据,内核就可以被完全绕过,可以使读写达到内存的速度。如果未命中,一个缺页中断会发生,内核就会阻塞线程直到从磁盘中读取完数据。当数据被完全读取到 page cache 中后,内存管理单元(Memroy Management Unit, MMU)会通知需要读取的用户线程新的数据已经可用了。3
直接 I/O(DIO)
传统读写和 mmap 都会涉及到内核的 page cache,并会将 I/O 的调度交给内核控制。当应用想要自己控制 I/O 时(原因我们之后会解释),就应该使用直接 I/O。只需要使用 O_DIRECT 标志位打开文件,接下来使用正常的 read/write 系统调用就好。与前面说的需要读写 page cache 的传统读写不同,直接 I/O 会直接操作磁盘,这会造成用户线程无条件地阻塞。之后磁盘控制器会绕过内核,将数据直接复制到用户空间中去。
异步直接 I/O(AIO/DIO)
作为直接 I/O 的优化,异步直接 I/O 变化不大,只是会避免调用线程的阻塞。实际上,调用线程会使用 iosubmit(2)系统调用来调度直接 I/O 操作来避免线程阻塞,I/O 操作和调用线程是并行的。另一个系统调用 io_getevents(2)被用作等待和收集已完成的 I/O 操作的结果。与直接 I/O 一样,内核的 page cache 也被绕过了,磁盘控制器负责将数据复制到用户空间中。
不同 I/O 方式的优缺点
不同的 I/O 方式既有相同点也有不同点,下表基于各自的特征做了总结:
特点 | 传统 I/O | mmap | 直接 I/O | 异步直接 I/O |
---|---|---|---|---|
缓存控制 | 内核 | 内核 | 用户 | 用户 |
是否有复制 | 是 | 否 | 否 | 否 |
MMU 活动频率 | 低 | 高 | 无 | 无 |
I/O 调度 | 内核 | 内核 | 混合 | 用户 |
线程调度 | 内核 | 内核 | 内核 | 用户 |
I/O 对齐 | 自动 | 自动 | 手动 | 手动 |
应用复杂度 | 低 | 低 | 中等 | 高 |
缓存控制
对于传统读写和 mmap,缓存是内核的责任。系统的大部分内存都分配给了 page cache。内核来决定当可用内存低时哪个页面被淘汰,是内核来决定哪个页面要写回磁盘,也是内核来控制预读。应用可以通过使用 madvise(2)和 fadvise(2)系统调用来提供一些指导给内核。
让内核来控制缓存最大的好处是可以使用过去好几十年以来经过内核开发者反复优化的控制算法。这些算法已经被数千个不同的应用所使用并且被普遍证明很有效率。那么坏处则是这些算法是通用的,并不能针对应用来调整。换句话说,内核必须猜测应用接下来会做什么,并且即使应用知道这一点,它也没有办法帮内核做出更合理的猜测。这会导致——错误的页面被淘汰,错误顺序的 I/O 调度,或者当数据未来不会使用的情况下进行错误的预读。
复制和 MMU 活动
mmap 方法的好处之一是,如果数据在 page cache 中,就会完全绕过内核,内核不需要将数据从内核复制到用户空间再复制回来。这样的话可以节省一部分 CPU 周期。这样会有利于数据在缓存中的读取负载(例如,如果存储大小与内存大小的比率接近 1:1)。
然而,当数据不在缓存中时,mmap 的缺点就显现了。当存储大小与 RAM 大小的比率明显高于 1:1 时,通常会发生这种情况。进入缓存的每个页面都会导致另一个页面被逐出:这些页面必须插入到页表中或从页表中删除;内核必须扫描页表以隔离不活动的页,使它们成为驱逐的候选者,等等。此外,mmap 需要占用更多内存,来给页表使用。在 x86 处理器上,这需要占用 mmap 文件的 0.2%大小的内存。这听起来很低,但是如果应用程序的存储与内存的比率为 100:1,结果是 20% 的内存 (0.2% * 100) 专门用于页表。
I/O 调度
让内核控制缓存(使用 mmap 和传统读写)的问题之一是应用程序失去了对 I/O 调度的控制。内核负责选择它认为合适的任何数据块并安排相应的写入和读取操作。这可能会导致以下问题:
- 写风暴:当内核调度大量写操作时,磁盘会长时间忙碌并影响读延迟。
- 内核会无法区分“重要”和“不重要”的 I/O 操作。可能后台任务的 I/O 会影响前台任务的 I/O,从而影响它们的延迟4。
通过绕过内核页面缓存,应用程序自己承担了调度 I/O 的负担。 这并不意味着问题已经解决; 但这确实意味着只要有足够的注意力和努力,问题是可以解决的。
使用直接 I/O 时,每个线程控制何时发出 I/O。然而,内核控制线程何时运行,因此发出 I/O 的责任被内核和应用程序共享。而使用 AIO/DIO,应用程序可以完全控制何时发出 I/O。
线程调度
使用 mmap 或传统 I/O 的 I/O 密集型程序无法预测缓存命中率。因此这种情况下就必须运行大量的线程(明显大于运行它的机器的核心数)。使用太少的线程可能会导致这些线程都在等待磁盘,无法充分利用 CPU。由于每个线程通常最多有一个磁盘 I/O 未完成,因此运行线程的数量必须是存储子系统的并发数乘以某个小因子,以保持磁盘被完全占用。但是,如果缓存命中率足够高,那么大量线程将相互竞争有限数量的 CPU 核心。
当使用直接 I/O 时,这个问题会有所缓解,因为应用程序确切地知道线程何时在 I/O 上被阻塞以及何时可以运行,因此应用程序可以根据运行时的条件调整正在运行的线程数。
使用 AIO/DIO,应用程序可以完全控制正在运行的线程和等待的 I/O(两者完全分离);因此它可以轻松适应纯内存,纯磁盘或者它们之间的任意状况。
I/O 对齐
存储设备有对应的块大小,因此所有的 I/O 必须以该块大小的倍数执行,通常为 512 或 4096 字节。使用传统 I/O 或 mmap,内核自动执行对齐,一个小的磁盘读或写操作在发生之前被内核扩展到正确的块边界。
使用直接 I/O,将由应用程序来执行块对齐。这会带来一些复杂性,但也提供了一个优势:即使是 512 字节的对齐足够,内核通常也会过度对齐到 4096 字节,但使用直接 I/O 的应用程序可以发出 512 字节对齐的读取,从而节省小规模读取的带宽。
应用复杂度
虽然之前的讨论倾向于将 AIO/DIO 用于 I/O 密集型应用程序,但这种方法带来了巨大的成本:复杂性。将缓存管理的责任放在应用程序上意味着它可以做出比内核更好的选择,并以更少的开销做出这些选择。但是,这些算法需要编写和测试。使用异步 I/O 需要使用回调、协程或类似方法编写应用程序,并且通常会降低许多可用库的可重用性。
Scylla 和 AIO/DIO
对于 Scylla,我们选择了性能最高的 AIO/DIO。为了分离出这种 I/O 方式的复杂性,我们编写了 Seastar,这是一个用于 I/O 密集型应用程序的高性能框架。Seastar 抽象了执行 AIO 的细节,并为网络、磁盘和多核通信提供了通用 API。它还提供了适用于不同用例的回调和协程状态管理风格。
Scylla 的不同部分突出显示了可以使用 I/O 的不同方式:
- 压缩使用应用程序级的预读和后写来确保高吞吐量,但由于低命中率而不使用应用程序级缓存(这也能避免冷数据把缓存淹没)。
- 查询(读取)使用应用程序控制的预读和应用程序级缓存。应用程序控制的预读可以防止预读溢出,因为我们提前知道磁盘上数据的边界。此外,应用程序级缓存允许我们不仅缓存从磁盘读取的数据,还缓存将多个文件中的数据合并到单个缓存项中的工作。
- 小读取与 512 字节边界对齐,以减少总线数据传输和延迟。
- Seastar I/O 调度器允许我们动态控制压缩和查询(以及其他操作类)的 I/O 速率以满足用户服务级别协议 (SLA)。
- 单独的 I/O 调度类保证 commitlog 的写入能使用到足够的带宽,不会被读取影响也不会影响读取。
AIO/DIO 是直接从应用程序驱动 NVMe 驱动器以进一步绕过内核的良好起点。这可能会成为未来的 Seastar 功能。
总结
我们已经展示了在 Linux 上执行磁盘 I/O 的四种不同方法以及其中涉及的不同权衡。从传统的读写操作开始或使用 mmap 获得良好的内存性能很容易,但为了获得最佳性能和控制,我们为 Scylla 选择了异步 I/O。