文件I/O的内核缓冲

本文转载自文件 I/O 的内核缓冲

导语

从最粗略的角度理解 Linux 文件 I/O 内核缓冲(buffer cache),啰嗦且不严谨。只为了直观理解。

当我们说一个程序读写磁盘上的文件时,通常指的是把磁盘设备上的数据块存储到用户空间内存中(或把用户空间内存的数据存储到磁盘设备上)。
read model without kernel
然而,程序与硬件的交互是交由操作系统内核来处理的,这样做的好处是,一方面可以为应用程序提供简单统一的接口,降低用户与硬件交互的复杂度;另一方面也提高了与硬件交互的安全性。
因此,上图的模型在用户空间与磁盘之间多了一层:内核空间

于是,一个读文件操作变成了:
read model with kernel

操作系统(内核)先从磁盘读取数据到内核空间的内存(read①),再把数据从内核空间内存拷贝到用户空间内存(read②)。此后,用户应用程序才可以操作此数据。

在这个过程中有两次数据读取操作:
read① 从磁盘上读取;
read② 从内存中读取。

我们知道,访问磁盘的速度要远远低于访问内存的速度,这是不同存储介质的物理特性和访问方式决定的,两者是毫秒和纳秒的区别,所以理论上 read① 的速度要远远慢于 read②。那么整个文件读取过程的时间瓶颈就出现在了对磁盘的读取上。要解决这个问题,就用上了 缓冲(buffer)

什么是缓冲

由于中文翻译的问题,我们有时候会把字面上相似,但实际差别较大的两个东西混为一谈,比如缓冲缓存,再比如伪类伪元素

缓冲(buffer),简单来说是为了解决速度不均匀的问题,而在生产和消费者之间设立的一个缓和区、平衡区。
比如我们经常在看在线视频时,开始会提示一小段时间的「缓冲」,这是因为视频播放要求均匀、持续的数据,而网络传输是时快时慢的,此时缓冲的作用就是等待生产者(网络)积累一定的数据后才给消费者去消费。这是生产者速度追不上消费者的情况。

video buffer

再比如汽车安全气囊,当汽车速度骤降时,人体会受到方向盘冲击,而安全气囊就是一个对人体向前速度的缓冲,以减少身体接触方向盘时的速度。这是生产者速度大于消费者的情况。

airbag buffer

由于我们将要关注的「内核缓冲区告诉缓存」既是一种缓冲机制,又发挥缓存的作用,所以这里特别容易搞糊涂 T T

内核缓冲

回到刚才的读文件场景。因为内核从磁盘读取数据的速度太慢,跟不上程序从内存中读数据的速度,所以它也设立了一个缓冲区,用来中和两者的速度差异。这就是文件 I/O 的内核缓冲,原名叫 Kernel Buffer Cache,一般翻译成「缓冲区高速缓存」(又是缓冲又是缓存的……我们先按缓冲去理解它的作用)。
它本质上就是图二中内核空间的一块内存,是读取过程中绕不开的中转站,只不过为了读写效率做了特别的工作,所以起了个特殊的名字。

那么内核缓冲做了什么事情呢?

  1. 读:数据预读
  2. 写:延时回写

数据预读(read_ahead)

数据预读指的是,当程序发起 read() 系统调用时,内核会比请求更多地读取磁盘上的数据,保存在缓冲区,以备程序后续使用。这种数据的预取基于一种预设:程序会重复地访问最近访问过的数据,且这种访问往往是顺序访问(比如对文件从前到后的顺序访问)。

因此当我们向内核请求读取数据时,内核会先从自己的 buffer cache 去寻找,如果命中数据,则不需要进行真正的磁盘 I/O,直接从内存中返回数据;如果缓存未命中,则内核会从磁盘中读取请求的 page,并同时读取紧随其后的几个 page(比如三个),如果文件是顺序访问的,那么下一个读取请求就会命中之前预读的缓存。
当然,预读算法非常复杂,这里只是一个简化的逻辑。当内核判断文件并非顺序读取时,也可能会放弃预读。

因此,预读提供了以下好处:

  1. 减少了 I/O 时间对进程的影响。 因为进程的读取操作和真正的 I/O 可能发生在不同的时空,数据是预取的,当进程需要它的时候早已经在内存中准备好了,对于这个进程来说,I/O 时间是不存在的,但是对于整个系统来说,I/O 时间是一个必要成本,因为总要从磁盘读数据,只是发生的时间早晚罢了;
  2. 提供了缓存。 当进程对文件重复访问时,buffer cache 提供了缓存,把本来应该发生的 I/O 省掉了,这个和第一点不同,是结结实实得省掉了一次 I/O 时间;
  3. 减少了磁盘处理器的命令数,因为每个命令多读了几个相邻扇区,或者说,把小块的 I/O 变成了大块的 I/O,提升了磁盘性能;

回写

回写指的是,当程序发起 write() 系统调用时,内核并不会直接把数据写入到磁盘文件中,而仅仅是写入到缓冲区中,几秒后才会真正将数据刷新到磁盘中。对于系统调用来说,数据写入缓冲区后,就返回了,因此一个 read() / write() 并非真正执行 I/O 操作,它只代表数据在用户空间 / 内核空间传递的完成。
延迟往磁盘写入数据的一个最大的好处就是,可以合并更多的数据一次性写入磁盘。也就是上面说的,把小块的 I/O 变成大块 I/O,减少磁盘处理命令次数,提高提盘性能。
另一个好处是,当其它进程紧接着访问该文件时,内核可以从直接从缓冲区中提供更新的文件数据。

因此,Linux 内核以 buffer cache 为介质,通过预读和回写的机制,提高了文件 I/O 速度,和磁盘访问效率。

内核缓冲区有多大?

Linux 内核对 buffer cache 并没有设定固定的大小上限,内核会分配尽可能多的内存给 buffer cache,它只受到可用内存总量限制。当可用内存不足,内核会将脏页(修改过的缓存)回写入磁盘,预读算法也有相应的策略去回收不必要的缓存。

示例

我们通过一个简单程序的过程图来对内核缓冲做一个粗略的演示:
下图是一个复制程序,把文件 A 的内容拷贝到文件 B,过程简化了很多,只列出了关注点。
buffer-cache-graph

用户的缓冲:C 库 I/O 缓冲

由于缓冲可以将小块 I/O 合并成大块 I/O 操作这一特性,C 库的 I/O 函数(比如 stdio 库中的 fpringf()、fgets()、fputs()等)也提供了缓冲机制。
我们知道,这些库函数是用户程序和 read()、write() 这些 system call 之间的桥梁,它的 buffer 并不会对磁盘读写产生什么影响,而在于可以减少系统调用的开销。

系统调用的开销大吗?可以看下面一张图:
sys_call
这是《The Linux Programming Interface》展示的一个系统调用 execve() 的执行过程,它还是需要蛮多的步骤,内核必须捕获调用、检查调用参数的有效性、在内核态和用户态之间传输数据。

虽然单个系统调用开销看起来并不巨大,但是试想一个场景:向磁盘写入 1000 字节,是写入 1000 次,每次写入 1 字节呢,还是一次性写入 1000 字节比较好呢。这两者的一个最大差别就是前者需要调用 write() 系统调用 1000 次,而后者只需要调用一次。那么累积起来,系统调用的消耗也是可观的。

上面书中有一个对比,复制 100MB 的数据,设立一个缓冲区,缓冲区大小为 1 字节的用时为 107.43 秒(total cpu 107.32 / user cpu 8.20 / sys cpu 99.12),而当缓冲区大小为 4096 字节时,这个成绩是 2.05 秒(total cpu 0.38 / user cpu 0.01 / sys cpu 0.38)(缓冲区再大性能提升就不显著了,因为 anyway 读写 I/O 的时间和用户空间、内核空间拷贝数据的时间开销是大头,少量的系统调用的开销对比起来就微乎其微了)。

因此,在用户这边,对 I/O 操作也设置一个缓冲区以减少系统调用,也是一个不错的选择。

当然,缓冲是一种策略,一种解决问题的方式,只是 Linux kernel、glibc 为解决特定的问题实现了它。我们用户在编写程序解决问题的时候,也可以将这种思想作为一种选择。

posted @ 2020-06-05 19:33  Yungyu  阅读(1575)  评论(0编辑  收藏  举报