理解 Linux 之文件 I/O——知其然,知其所以然
在操作系统中, 最为复杂同时也最为重要的功能就是文件 I/O。 一台 PC 可以不连接互联网, 但是一定需要程序的载入、文件的打开, 而这些操作与 I/O 均密不可分。 包括软件开发中, 数据库与 I/O 的关系密切相关, 有时衡量一个 DB 的效率, 其实就是在衡量其 I/O 效率。 理解文件 I/O, 就是在理解我们常用应用软件, 如 MySQL、Redis、Nginx、ES、Prometheus 等的核心。
文章相关视频讲解:
PS:视频相关学习文档,点击获取
1. 处于内核态的系统调用
操作系统的本质就是帮助用户更加高效的管理硬件, 向上提供统一的接口, 向下兼容不同的硬件, 使得用户并不需要关心硬件, 如硬盘的细节, 只需要关心操作系统为我们提供的抽象: 文件系统。 然而引入操作系统的代价就是用户对硬件的所有操作, 例如打开一个文件, 运行一个程序, 均需经由操作系统来完成, 如此以来, 就有了系统调用。
系统调用存在的原因就在于操作系统不允许用户直接访问硬件, 如果用户有此需求, 则需将想要访问的地址与内容告诉操作系统, 由操作系统进行硬件的访问, 最后由操作系统将结果返回给用户。
操作系统也是软件, 也是由一行行的代码所组成, 所以必定运行在内存中, 只不过操作系统所运行的内存受到保护, 用户无法直接对其进行操作而已。 当用户想要打开一个文件时, 将文件路径告知操作系统, 此时操作系统将会接管 CPU 的执行, 并将 CPU 的某标识位标记为内核态, 执行一系列的 I/O 操作, 取出结果并将结果发送给用户内存空间后, 再将 CPU 的执行权交给用户。 从本质上来看, 系统调用其实就是一次进程切换, 只不过所花费的时间要比普通的进程间切换大得多而已。
接下来将会看到, 为了”对抗”系统调用所带来的巨大代价, 先贤们实现了各种各样增加 I/O 效率的方式。 但是, 没有哪一种方式能够”一招吃遍天下鲜”, 不同的应用场景会有不同的最佳解决方式。
2. Linux 通用 I/O 模型
Linux 为系统用户提供了一些通用的 IO 函数, 包括 open、read、write 等方法, 当用户每次调用这些方法时, 都将产生一次系统调用, 此时程序运行由用户态切换至内核态, 内核做完自己应该完成的事情之后, 将结果保存至用户指定的位置中, 并再由内核态切换至用户态, 使用户继续执行下面的代码。
open 方法既能打开一个已经存在的文件, 也能创建并打开一个新的文件。 其原型如下:
具体的方法使用请参见 Linux manual page。open 方法在成功时将返回该文件的文件描述符, 用于在后续函数调用中指代该文件, 该文件描述符在进程中唯一, 即使打开的是同一个文件, 两者的文件描述符也不相同。
对于用户自定义的文件, 文件描述符通常都是从 3 开始, 0、1、2 这三个描述符分别代表标准输入、标准输出以及标准错误, 定义于 unistd.h 头文件中。
read 系统调用此报告文件描述符 fd 所指代的打开文件中读取数据, 其定义为:
count 参数指定最多能读取的字节数, buffer 参数提供用来存放数据的内存缓冲区地址(由用户所提供), 缓冲区至少应有 count 字节。
从标准输入中读取数据和从文件中读取数据会有些许差异, 因为在默认情况下, 从终端读取字符会在遇到换行符(\n)时 read 调用就会结束, 而对于普通文件, 则不会这样。
现在来进一步地了解 read 系统调用背后所发生的事情。 当程序调用 read 方法时, 产生系统调用, 则当前程序执行的状态由用户态切换至内核态, 操作系统将所需要的文件内容读取至内核某缓冲区中。 同时, 由于 I/O 是一个相对来说代价较大的操作, 为了减少读取磁盘的数据, 操作系统还会额外的读取更多的内容进入内核缓冲区, 下次读取这些内容时, 直接从缓冲区中读取, 不再从磁盘中读取, 从而提升整体效率。 数据进入内核缓冲区后, 内核需要将数据复制到用户缓冲区中, 也就是 read 方法所传递的 void *buffer 中。 传输完毕后由内核态切换至用户态, read 系统调用完成。
在执行 read 系统调用时, 总计发生了 2 次用户态切换, 额外的一次数据复制(kernel to User)。 并且, kernel buffer 中保存了多于用户当前所需的数据, 用于加快下一次的 read 调用。
write 系统调用将数据写入一个打开的文件中, 当调用成功时, 返回实际写入文件的字节数。 与 read 系统调用相同, write 调用在返回成功时, 仅是将数据写入到内核缓冲区中, 再由内核寻找适当的时机将该部分数据真正地写入到磁盘中。
采用这一设计可以有效的减少内核必须执行的磁盘传输次数, 因为可能调用 10 次 write 系统调用, 内核仅进行一次磁盘写入, 不仅减少了单个 write 系统调用所需时间, 并且提高了操作系统整体的运作效率。
这一机制所带来的唯一问题就是由于数据在某一时刻仅暂存于内核缓冲区中, 当系统发生断电或者是意外宕机时, 该部分数据就会丢失。 对于数据库等对数据要求非常严格的系统, 这种数据丢失是无法接受的。 所以, 内核额外的提供了 fsync 等强制刷新数据至磁盘的系统调用。
fsync 系统调用将使缓冲区数据和与打开文件描述符 fd 相关的所有元数据都刷新到磁盘上, 调用 fsync 会强制使文件处于 Synchronized I/O file integrity completion 状态。 所以, 当程序想要确保数据完全写入磁盘时, 可在 write 调用后执行 fsync 调用, 进行强制刷盘。
这份是基于 Linux 内核 4.0 版本的内核学习路线思维导图,下面有 Linux 内核相关视频学习资料:
Linux 内核相关学习视频,清晰版导图可以点击:学习资料 获取
3. C 标准 I/O 函数库
上面所提到的 open, read 等系统调用均有 Linux/Unix 系统所提供, 如 Windows 等操作系统并不支持此类调用。 为了解决不同操作系统底层提供的通用 I/O 函数不同的问题, ANSI C 制定了一系列的标准 I/O 函数, 其目的就是为了解决代码的可移植性问题以及屏蔽 I/O 细节(缓冲区大小的选择, 文件锁实现等)。
标准 I/O 函数库中最常用的方法为 fopen, fgets, fputs 以及 printf, fprintf。 fopen 和 open 的作用类似, 以某种模式(只读、只写等)打开一个文件, 唯一不同的是 open 返回 int 类型的文件描述符, 而 fopen 返回 FILE 类型的指针。
为了更好的理解 FILE 文件对象, 首先需要了解 C 标准 I/O 库的过程。 C 标准 I/O 库的底层实现, 同样是基于 Linux 提供的通用 I/O 函数, 只不过标准库对其进行了封装而已。
C 标准库除了帮助用户处理平台可移植性问题以外, 还会帮助用户减少系统调用的次数, 但是会额外的增加数据在内存间的复制次数。
如上图所示,与 Linux 通用 I/O 相比, C 标准 I/O 库自身也维护一个类似于内核缓冲的缓冲池, 内核缓冲区的数据并不会直接被复制到用户缓冲区中, 而是复制到标准库缓冲区中。 并且, 所复制的字节数也远大于用户所需要的字节数(count), 当下次进行内容读取时, 直接从标准缓冲区中读取, 而不需要进行系统调用从内核缓冲区中读取。
该方式从整体上减少了系统调用的次数, 额外的增加了一次用户空间的数据复制, 由于标准 I/O 函数对系统调用进行了二次封装, 所以解决了可移植性问题。
fputc 以及 fputs 分别向所关联的文件流中写入单个字符或者是一串字符。 由于标准 IO 缓冲区的存在, 调用该方法时仅是将数据写入到 C 标准 IO 缓冲区中, 而后 C 标准库根据相应的条件决定何时执行 write 系统调用, 将数据写入内核缓冲区。 此后, 内核将决定在适当的时机将数据真正地写入磁盘。
3.1 C 标准 I/O 缓冲
标准 IO 库提供了 3 种类型的缓冲: 全缓冲、行缓冲以及不带缓冲。
在全缓冲类型下, C 函数库只有在完全填满标准 IO 缓冲区后才进行实际的 IO 操作, 磁盘文件通常是全缓冲的。 也就是说, 当程序使用 fopen 打开一个磁盘文件并调用 fputs 进行数据写入时, 数据可能仅写入了标准 IO 缓冲区中。 在随后的 fputs 调用中, 若 C 标准 IO 函数发现缓冲区已满, 则进行一次系统调用, 将数据写入至内核缓冲区中。
在行缓冲区类型下, 在输入和输出中遇到换行符(\n)时, 标准 IO 库执行实际的 IO 操作。 当一个流涉及到终端时(如标准输出), 通常时行缓冲的, 例如 printf 函数。
不带缓冲则表示只要向标准缓冲区中写入数据, 标准库就会立即进行系统调用, 将数据写入内核缓冲。 标准错误通常是不带缓冲的, 原因在于期望能够尽快的看到错误的产生。
编译并运行上述代码, 将会得到:
当标准 IO 函数与系统调用混合使用时, 将会看到与代码期望完全不同的结果。 尽管 printf 函数在 fprintf 之前执行, 但由于 printf 为行缓冲, 而标准错误为不带缓冲, 所以标准错误信息将在标准输出信息打印之前打印。 write 函数为系统调用, 输出的时机要优先于不带换行符的标准输出。
标准 IO 函数库同时也提供了 fflush 函数, 用于将标准缓冲区的数据强制刷新至内核, 如果我们在 printf 函数调用后调用 fflush, 则会在标准错误输出之前看到输出。
其结果为:
使用过 Docker 部署 Python 项目的小伙伴儿可能对环境变量 PYTHONUNBUFFERED 感到很熟悉, 官方文档解释如下:
Force stdin, stdout and stderr to be totally unbuffered. On systems where it matters, also put stdin, stdout and stderr in binary mode.
简单来说, 在 Docker 中使用该变量, 能够更快的使日志输出, 并且在容器 crash 的情况下, 也能看到必要的日志信息。
更详细的解释:
Setting PYTHONUNBUFFERED=TRUE or PYTHONUNBUFFERED=1 (they are equivalent) allows for log messages to be immediately dumped to the stream instead of being buffered. This is useful for receiving timely log messages and avoiding situations where the application crashes without emitting a relevant message due to the message being “stuck” in a buffer. As for performance, there can be some (minor) loss that comes with using unbuffered I/O. To mitigate this, I would recommend limiting the number of log messages. If it is a significant concern, one can always leave buffered I/O on and manually flush the buffer when necessary.
有关 Linux 通用 IO 以及 C 标准 IO 库的缓冲区, 可用下图清晰总结。
原图来源于 Linux/Unix 系统编程手册, P200。
4. 内存映射 I/O
现代操作系统大多数均采用分段+分页的方式来管理内存空间, 其目的就在于使得每一个进程的地址空间独立, 并且使系统能够运行超过其内存空间总数的各种进程。
分页内存管理的基本思想就是映射, 思想和哈希表基本类似: 将一个大范围的空间映射至一个小范围空间内。
当程序想要访问的虚拟地址没有在页表项建立映射时, 系统将发起一个”缺页异常”, 由操作系统建立页表项并建立虚拟地址页与物理地址页的映射关系。 如此一来, 能够使得不常用的数据或者是内存片段被细粒度地换置至磁盘中, 内存中保留常用的数据。
mmap 方法的原理与虚拟内存映射基本相同, 将进程的一部分地址空间与磁盘文件建立映射关系, 将文件当做是内存中的一个数组使用, 减少 read, write 以及 lseek 的调用。
需要注意的是, 内存映射一个文件并不会导致整个文件被读取到内存中, 就如同虚拟内存空间不会都在物理地址空间一样, 而是仅仅为需要的文件数据保留映射关系。
此外,使用内存映射 I/O 的读写并不一定会比 C 标准 I/O 库或者是 Linux 通用 I/O 更加高效, 其原因在于虽然 mmap 减少了用户态的切换以及减少了数据的复制, 但是增加了处理缺页错误、建立页表项的时间, 并且各个平台对于 mmap 的实现也各有不同, 其优点就在于更加简洁的随机读取以及数据写入。
5. 异步 I/O
异步 I/O 的实现通常会有两种: 当文件可读/可写时, 内核向进程发送一个信号, 或者是内核调用进程提供的回调函数。 在 Linux 下, AIO 有 2 种实现: 基于线程模拟异步 I/O 的 glibc AIO, 以及由内核实现的 Kernel AIO。
对于 glibc AIO 而言, 是在用户空间使用多线程来模拟实现的, 并不能真正的称之为异步 I/O, 但是能够在任意的文件系统、任意的操作系统上运行。
而 Kernel AIO 采用信号通知的方式实现异步 I/O, 只能在 Linux 操作系统上运行, 基本没有可移植性。 此外, 一个最重要的问题就是 Kernel AIO 要求用户必须使用 O_DIRECT 模式打开文件, 即绕过内核高速缓冲区, 直接将数据传递至文件或者是磁盘设备, 这种方式又称为直接 I/O(direct I/O)。
对于大多数应用而言, 使用直接 I/O 可能会大大的降低性能, 并且会有诸多不便之处。 其原因在于内核针对缓冲区高速缓存做了不少优化, 包括按顺序读取, 在成簇磁盘块上执行 I/O, 允许访问同一文件的多个进程共享内核缓冲区。 并且, 直接 I/O 由于直接将数据传输至磁盘, 所以必须遵守磁盘的一些限制, 包括但不限于: 用于传递数据的缓冲区其内存边界必须对齐为块大小的整数倍, 待传输的数据长度必须是块大小的整数倍…
正是因为诸多限制, 不管是 glibc AIO, 还是 Kernel AIO, 在绝大部分的应用中都未曾使用, 看起来 AIO 就是专门为数据库应用所提供的实现。
6. Zero Copy(sendfile)
sendfile 系统调用用于在两个文件描述符之间传输数据, 在 Linux Kernel 2.6.33 以前, sendfile 只能将数据从一个具体的文件发送到一个 socket 中。 而在此版本之后, sendfile 的接收方可以是任意的文件, 但是输入端只能是存在于虚拟内存空间的文件描述符, socket 则不在此列。
方法原型为:
一个简单的文件复制示例:
另外需要注意的是, sendfile 在文件之间传输数据时, 并不支持 O_APPEND flags, 只能覆盖写入。
sendfile 系统调用完全在内核空间进行, 数据不会从内核空间拷贝至用户空间, 并且能够得到 DMA 的硬件支持, 因而速度很快。
7. 总结
对于非数据库类型的应用而言, 文件 I/O 的可选范围并不广, 即与其花费大量时间调试异步 I/O, 不如直接选择 C 标准库 I/O, 因为同时监听成百上千的文件读写并不常见。 对于频繁随机读取和写入的文件而言, 可以使用内存映射 I/O 来减少 lseek 的调用, 而 sendfile 系统调用更多地应用于文件至 socket 的数据传输。
评论