磁盘IO系列(一):IO的多种类型

1,106 阅读9分钟

了解IO的工作原理并理解各种算法和存储系统的使用场景和权衡取舍,可以极大地改善开发人员和运维人员的工作生活:他们可以在一开始就做出更好的选择(基于他们正在评估的数据库的内部原理),在数据库出现异常时排查性能问题(通过将他们的工作负载与该数据库的预期使用情况进行比较),并调优他们的技术栈(通过平衡负载、切换到不同的介质、文件系统或操作系统,或者选择不同的索引类型)。

image.png

虽然网络IO经常被讨论,但文件系统IO却少有提及。部分原因是网络IO在特性和实现细节上因操作系统不同而多样化,而文件系统IO的工具集相对较小。此外,在现代系统中,人们主要使用数据库作为存储手段,所以应用程序通过驱动程序通过网络与数据库通信,而文件系统IO则由数据库开发人员来理解和处理。然而,我仍然认为了解数据如何写入和读取磁盘是很重要的。

IO有几种不同的“类型”(为了简洁,部分函数略去):

  • 系统调用:open、write、read、fsync、sync、close
  • 标准IO:fopen、fwrite、fread、fflush、fclose
  • 向量IO:writev、readv
  • 内存映射IO:open、mmap、msync、munmap

让我们先从结合了一些“用户态”优化的标准IO开始讨论,因为这是应用程序开发人员最常使用的。

缓冲IO

在谈论stdio.h函数时,“缓冲”这个术语有些混乱。使用标准IO时,可以选择完全缓冲、行缓冲或完全不缓冲。这种“用户态”缓冲与我们将在本文后面讨论的内核缓冲(页缓存)无关。你也可以将其视为“缓冲”和“缓存”之间的区别,这可能有助于澄清这些概念。

扇区/块/页面

块设备是一种特殊的文件类型,提供对硬件设备(如HDD或SSD)的缓冲访问。块设备按扇区(相邻字节组)操作。大多数磁盘设备的扇区大小为512字节。扇区是块设备数据传输的最小单位,不能传输小于一个扇区的数据。然而,通常可以一次获取多个相邻的段。文件系统的最小可寻址单位是块。块是由设备驱动程序请求的多个相邻扇区组成的。典型的块大小为512、1024、2048和4096字节。通常,IO通过虚拟内存进行,虚拟内存将请求的文件系统块缓存到内存中,并作为中间操作的缓冲区。虚拟内存使用页面,它们映射到文件系统块。典型的页面大小为4096字节。

总结来说,虚拟内存页面映射到文件系统块,文件系统块映射到块设备扇区。

标准IO

标准IO使用read()和write()系统调用执行IO操作。读取数据时,首先访问页缓存。如果数据不存在,则触发缺页错误并调入内容。这意味着对当前未映射区域执行的读取将花费更长时间,因为缓存层对用户是透明的。

写入时,缓冲区内容首先写入页缓存。这意味着数据不会立即到达磁盘。实际的硬件写入是在内核决定进行脏页回写时完成的。

image.png

页缓存

页缓存存储最近访问过的文件片段,这些片段更有可能在近期再次被访问。当处理磁盘文件时,read()和write()调用不会直接访问磁盘,而是通过页缓存进行。

image.png

在执行读操作时,首先会查询页缓存。如果数据已经加载到页缓存中,则只需将其复制给用户:不会进行磁盘访问,读取操作完全由内存提供。否则,文件内容会被加载到页缓存中,然后返回给用户。如果页缓存已满,最近最少使用的页面将被刷新到磁盘并从缓存中驱逐,以腾出空间供新页面使用。

write()调用只是将用户空间缓冲区复制到内核页缓存,标记写入的页面为脏页。随后,内核会在一个称为刷新或写回的过程中将修改写入磁盘。实际的IO通常不会立即发生。同时,read()将从页缓存中提供数据,而不是读取(现已过时的)磁盘内容。正如你所看到的,页缓存在读取和写入时都会加载。

标记为脏的页面将被刷新到磁盘,因为它们的缓存表示现在与磁盘上的不同。这个过程称为写回。写回可能有潜在的缺点,例如排队IO请求,因此了解写回时使用的阈值和比率并检查队列深度以确保避免限流和高延迟是值得的。你可以在Linux内核文档中找到有关调整虚拟内存的更多信息。

页缓存的逻辑由时间局部性原理解释,该原理指出,最近访问过的页面将在近期的某个时刻再次被访问。

另一个原理,空间局部性,意味着物理上相邻的元素很有可能彼此接近。这个原理在预取过程中被使用,该过程提前加载文件内容,预测它们的访问并摊销一些IO成本。

页缓存还通过延迟写入和合并相邻读取来提高IO性能。

释义:缓冲缓存和页缓存

缓冲缓存和页缓存:以前是完全独立的概念,在Linux 2.4内核中被统一。现在大多数人称其为页缓存,但有些人仍使用缓冲缓存这个术语,它们变得同义。

根据访问模式,页缓存持有最近访问或可能很快被访问的文件块(预取或标记为fadvise)。由于所有IO操作都通过页缓存进行,像读-写-读这样的操作序列可以从内存中提供,而无需后续的磁盘访问。

延迟错误

当执行由内核和/或库缓冲支持的写入时,确保数据实际到达磁盘很重要,因为它可能在某处被缓冲或缓存。错误将在数据刷新到磁盘时出现,这可能发生在fsyncing或关闭文件时。如果你想了解更多,可以查看LWN文章《Ensuring Data Reaches the Disk》。

直接IO

在某些情况下,不使用内核页缓存进行IO是不可取的。在这种情况下,可以在打开文件时使用O_DIRECT标志。它指示操作系统绕过页缓存,避免存储数据的额外副本,并直接对块设备执行IO操作。这意味着缓冲区直接刷新到磁盘,而不将其内容复制到相应的缓存页面并等待内核触发写回。

对于“传统”应用程序,使用直接IO可能会导致性能下降而不是加速,但在适当的情况下,它可以帮助获得对IO操作的精细控制并提高性能。通常,使用这种类型IO的应用程序实现其特定于应用程序的缓存层。

image.png

使用直接IO通常不被内核开发人员所看好。Linux手册页甚至引用了Linus Torvalds的话:“O_DIRECT这个接口一直让我感到不安,因为整个接口设计得很愚蠢。”

然而,像PostgreSQL和MySQL这样的数据库使用直接IO是有原因的。开发人员可以确保对数据访问的精细控制,可能使用自定义IO调度器和特定于应用程序的缓冲缓存。例如,PostgreSQL对WAL(预写日志)使用直接IO,因为他们必须尽可能快地执行写操作,同时确保其持久性,并且可以使用这种优化,因为他们确定数据不会立即被重用,因此绕过页缓存进行写入不会导致性能下降。

不鼓励同时使用直接IO和页缓存打开同一个文件,因为即使数据在页缓存中,直接操作也会针对磁盘设备执行,这可能导致不期望的结果。

块对齐

由于直接IO涉及对后备存储的直接访问,绕过页缓存中的中间缓冲区,因此要求所有操作都对齐到扇区边界。

image.png

换句话说,每次操作的起始偏移量必须是512的倍数,缓冲区大小也必须是512的倍数。当使用页缓存时,由于写操作首先进入内存,对齐并不重要:当实际的块设备写操作执行时,内核会确保将页面分割成合适大小的部分,并向硬件执行对齐的写操作。

image.png

例如,RocksDB通过预先检查操作是否块对齐来确保对齐(旧版本通过在后台对齐允许未对齐访问)。

无论是否使用O_DIRECT标志,确保你的读写操作块对齐总是一个好主意。跨越段边界将导致从磁盘加载或写回多个扇区,如上图所示。使用块大小或适合块内的值可以保证块对齐的I/O请求,防止内核内部的额外工作。

非阻塞文件系统IO

我在这里添加这一部分,因为我经常听到在文件系统IO上下文中提到“非阻塞”一词。这很正常,因为网络和文件系统IO的大多数编程接口是相同的。但值得一提的是,没有真正意义上的“非阻塞”文件系统IO。

对于常规文件,O_NONBLOCK通常被忽略,因为块设备操作被认为是非阻塞的(不像套接字操作,例如)。文件系统IO的延迟不被系统考虑。可能这个决定是因为操作完成时间有一个或多或少的硬性限制。

出于同样的原因,通常在网络上下文中使用的select和epoll,无法监视和/或检查常规文件的状态。

结束语

今天我们讨论了页缓存、标准IO以及数据库开发人员常用的O_DIRECT标志优化,以便控制标准IO委托给内核的缓冲缓存,并讨论了其使用情况、工作原理、适用场景及其缺点。

下一篇文章将介绍内存映射、向量IO和页缓存优化。

如果你有任何补充或发现我的文章中有错误,请随时联系我,我会乐意进行相应的更新。