上一篇文章我们讲了一下硬盘(磁盘 & SSD)在硬件上的一些限制,总结了两个优化硬盘 I/O 的方向。本篇我们就从 Linux 软件开发的角度,讲一下在软件设计中我们应该如何提高硬盘 I/O。
本文地址:https://segmentfault.com/a/1190000011830405
这里,我们会涉及一个新的 “缓存” 概念。注意,这里的 “缓存” 和前文所提及的存储架构中的 “cache” 虽然中英文用词都一样,但两者是不同的。
本文所说的缓存,指的是在 Linux 操作系统层面,在应用程序对硬盘进行读写(read
/ write
系统调用)时,对硬盘资源所做的一个预加载 / 延写入的机制。
Linux 文件缓存简介
从一个面试题说起
多年以前有一次面试,我被问了一个问题:
—— “你说一说,我们调用write()
之后,Linux 是怎么调用到底层的?”
我一脸懵逼,第一反应是这个问题太泛了,如果把我所知道的所有东西说出来的话,从顶层软件过程到底层硬件驱动编写,我可以讲一下午(参见我的工作经历)。
我只好再问了一句:“这个范畴有点大,请问您能不能具体地问一下呢?”
估计面试官也没想到我会反问,他只是重复了一下:“你就……把这个过程说一下吧。”
于是乎,我就从系统调用的实现原理讲起,然而很快就被面试官打断:“好吧可以了,你回去等消息吧。”
消息肯定是没等到,而我至今也没把握面试官希望听到的答案是什么。这次说到硬盘 I/O 的时候我忽然想到:或许面试官要的是这个吧? ————
从 read / write 到硬盘
在现代操作系统中,一个 “真正的” 文件,当调用 read
/ write
的时候,数据当然不会简单地就直达硬盘。对于 Linux 而言,这个过程的一部分是这样的:
在操作系统内核空间内,read / write 到硬件设备之间,按顺序有这么几层:
- VFS:虚拟文件系统,可以大致理解为
read
/write
/ioctl
之类的系统调用就在这一层。当调用open
之后,内核会为每一个 file descriptor 创建一个file_operations
结构体实例。这个结构体里包含了 open、write、seek 等的实例(回调函数)。这一层其实是 Linux 文件和设备体系的精华之一,很多东西都隐藏或暴露在这一层。不过本文不研究这一块 - 文件系统: 这一层是实际的文件系统实现层,向上隐藏了实现细节。当然,实际上除了文件系统之外,还包含其他的虚拟文件,包括设备节点、
/proc
文件等等 - buffer cache:这就是本文所说的 “缓存”。后文再讲。
- 设备驱动:这是具体硬件设备的设备驱动了,比如 SSD 的读写驱动、磁盘的读写驱动、字符设备的读写驱动等等。
- 硬件设备:这没什么好讲的了,就是实际的硬件设备接口。参见上一篇文章
工作机制
这里我觉得 IBM 的资料讲的特别清楚。下面是重点摘抄:
当应用程序需要读取文件中的数据时,操作系统先分配一些内存,将数据从存储设备读入到这些内存中,然后再将数据分发给应用程序;当需要往文件中写数据时,操作系统先分配内存接收用户数据,然后再将数据从内存写到磁盘上。
对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(不少于一个页面,通常是三个页面),这时的预读称为同步预读。
如果应用程序接下来是顺序读取的话,那么文件 cache 命中,OS 会加大同步预读的范围,增强缓存效率,此时的预读被称为异步预读
如果接下来 cache 没命中,那么 OS 会继续使用同步预读。
知道了原理之后,接下来就是怎么做的问题了——
高性能硬盘 I/O 优化方案
基本思路
从缓存的工作机制来看,很简单,如果要充分利用 Linux 的文件缓存机制,那么最好的方法就是:每一个文件都尽可能地采用顺序读写,避免大量的 seek
调用。
其实这一条和我前一篇文章中从硬盘工作原理的角度出发提出的两个思路是一致的。好了,现在我们可以搬出具体的 coding 思路了。这总结起来比讲原理短多了。以下就把几个设计思路讲一下吧:
尽可能顺序地读写一个文件
从文件缓存角度,如果频繁地随机读取一个文件不同的位置,很可能导致缓存命中率下降。那么 OS 就不得不频繁地往硬盘上预读,进一步导致硬盘利用率低下。所以在读写文件的时候,尽可能的只是简单写入或者简单读取文件,而不要使用 seek
。
这条原则非常适用于 log 文件的写入:当写入 log 的时候,写就好了,不要经常翻回去查看以前的内容。
单进程读写硬盘
整个系统,最好只有一个进程进行磁盘的读写。而不是多个进程进行文件存取。这个思路,一方面和上一条 “顺序写” 原则的理由其实是一致的。当多个进程进行磁盘读写的时候,随机度瞬间飙升。特别是多个进程操作多个文件的时候,磁盘的磁头很可能需要频繁大范围地移动。
如果确实有必要多个进程分别读取多个不同文件的话,可以考虑下面的替代方案:
- 这多个进程是否功能上是独立的?能不能分开放在几个不同的服务器之中?
- 如果这几个进程确实需要放在同一台服务器上,那么能不能考虑为每个频繁读写的文件,单独分配一个磁盘?
- 如果成本允许,并且文件大小不大的话,能否将磁盘更换为 SSD ?因为 SSD 没有磁头和磁盘的物理寻址动作,响应会快很多。
如果是多个进程同时写入一个文件(比如 log),那就更好办了。这种情况下,可以在这几个进程和文件中间加入一个内部文件服务器,将所有进程的存取文件需求汇总到该文件服务器中进行统一处理。
ProcessA ProcessB ProcessC
| | |
| V |
*----> The File <---*
改为
ProcessA ProcessB ProcessC
| | |
| V |
*----> ProcessD <---*
|
V
The File
顺便还可以在这个服务进程中实现一些自己的缓存机制,配合 Linux 自身的文件缓存进一步优化磁盘 I/O 效率。
以 4kB 为单位写文件
这里可以看看下面这个伪代码:
const int WRITE_BLOCK_SIZE = 4096
for (int i = 0 to 999) {
write(fd, buff, WRITE_BLOCK_SIZE)
}
其实这个问题,就是我在上一篇文章的 "硬盘文件存取速度的考量" 小节中所说的内容了。
这里有一个常量 WRITE_BLOCK_SIZE
, 这并不是可以随意取的值,比较合适的是 4096 或者其倍数,理由是文件系统往往以 4kB 为页,如果没有写够 4kB 的话,将导致文件需要多余的读出动作。虽然文件缓存在一定程度上能够帮你缓解,但总会有一部分操作会最落地到底层 I/O 的。所以实际操作中,要尽量以 4kB 为边界操作大文件。
大目录的寻址效率
有一个问题被提了出来:我们都知道,当我们面对一个大目录(目录中有很多很多文件)的时候,这个目录刷出来需要很长的时间。那么我们在开发的时候是不是要避免经常在这个大目录中读写文件呢?
实际上,当你第一次操作这个大目录的时候,可能延时确实会比较大。但是实测只要进入了这个目录之后,再后续操作的时候,却一点都不慢,和其他的普通目录相当。
这个问题的原因,我个人猜测(求权威人士指正)是这样的:
目录在文件系统中,是以一个 inode 的方式存在的,那么载入目录,实际上就是载入这个 inode。从存储的角度,inode 也只是一个普通的文件,那么载入 inode 的动作和载入其他文件一样,也会经过文件缓存策略。载入了一次之后,只要你持续地访问它,那么操作系统就会将这个 inode 保持在缓存中。因此后续的操作,就是直接读写 RAM 了,并不会受到硬盘 I/O 瓶颈的影响。
参考资料
Linux 内核的文件 Cache 管理机制介绍
磁盘I/O那些事
Linux系统结构 详解
【Linux编程基础】文件与目录--知识总结