在工作中,我们很多工程师对 I/O 的认识其实比较模糊,认为 I/O 就是应用程序执行 read()、write() 这样的一些操作,并不清楚这些操作背后的整个流程是怎样的。我们先从整体上来了解下Linux系统IO Stack有多么的复杂,下图是整个Linux系统的IO Stack:
我们一般意义上的IO,主要指磁盘和文件系统的管理,这是操作系统最核心的功能之一。磁盘为系统提供了最基本的持久化存储。文件系统则在磁盘的基础上,提供了一个用来管理文件的树状结构。那么,磁盘和文件系统是怎么工作的呢?又有哪些指标可以衡量它们的性能呢?我们先来看看,Linux 文件系统的工作原理
Linux文件系统基本概念
VFS
为了支持各种不同的文件系统,Linux 内核在用户进程和文件系统的中间,又引入了一个抽象层,也就是虚拟文件系统 VFS(Virtual File System)。VFS 定义了一组所有文件系统都支持的数据结构和标准接口。这样,用户进程和内核中的其他子系统,只需要跟 VFS 提供的统一接口进行交互就可以了,而不需要再关心底层各种文件系统的实现细节。
在 VFS 的下方,Linux 支持各种各样的文件系统,如 Ext4、XFS、NFS 等等。按照存储位置的不同,这些文件系统可以分为三类。
- 第一类是基于磁盘的文件系统,也就是把数据直接存储在计算机本地挂载的磁盘中。常见的 Ext4、F2FS等,都是这类文件系统。
- 第二类是基于内存的文件系统,也就是我们常说的虚拟文件系统。这类文件系统,不需要任何磁盘分配存储空间,但会占用内存。我们经常用到的 /proc 文件系统,其实就是一种最常见的虚拟文件系统。此外,/sys 文件系统也属于这一类,主要向用户空间导出层次化的内核对象。
- 第三类是网络文件系统,也就是用来访问其他计算机数据的文件系统,比如NFS等。
对于 Android 来说,现在普遍使用的是 Linux 常用的 ext4 文件系统。华为率先在 EMUI 5.0 以后就使用 F2FS 取代 ext4,Google 也在最新的旗舰手机 Pixel 3 使用了 F2FS 文件系统。Flash-Friendly File System 是三星是专门为 NAND 闪存芯片开发的文件系统。F2FS 文件系统在写性能方面比 ext4 更快,随机写可以优化>20%,不足之处在于可靠性方面出现过一些问题。
磁盘文件系统,要先挂载到 VFS 目录树中的某个子目录(称为挂载点),然后才能访问其中的文件。拿第一类,也就是基于磁盘的文件系统为例,在安装系统时,要先挂载一个根目录(/),在根目录下再把其他文件系统(比如其他的磁盘分区、/proc 文件系统、/sys 文件系统、NFS 等)挂载进来。
Ext4
磁盘在执行ext4文件系统格式化时,会形成如下布局:
块组
超级块: 是用于存储文件系统自身元数据的核心结构。其中的信息包括空闲与已使用块的数目、块长度、 当前文件系统状态(在启动时用于检测前一次崩溃)、各种时间戳(例如,上一次装载文件系统 的时间以及上一次写入操作的时间)。它还包括一个表示文件系统类型的魔数,这样mount例程 能够确认文件系统的类型是否正确。内核只使用第一个块组的超级块读取文件系统的元信息,即 使在几个超级块中都有超级块,也是如此。
组描述符: 包含的信息反映了文件系统中各个块组的状态,例如,块组中空闲块和inode的数目。每个块组 都包含了文件系统中所有块组的组描述符信息。
数据块位图和inode位图: 用于保存长的比特位串。这些结构中的每个比特位都对应于一个数据块或inode,用于表示对应的数据块 或inode是空闲的,还是被使用中。
inode表: 包含了块组中所有的inode,inode用于保存文件系统中与各个文件和目录相关的所有元数据。
数据块: 包含了文件系统中的文件的有用数据。
索引节点和目录项
在 Linux 中一切皆文件。不仅普通的文件和目录,就连块设备、套接字、管道等,也都要通过统一的文件系统来管理。为了方便管理,Linux 文件系统为每个文件都分配两个数据结构,索引节点(index node)和目录项(directory entry)。它们主要用来记录文件的元信息和目录结构。
索引节点,简称为 inode,用来记录文件的元数据,比如 inode 编号、文件大小、访问权限、修改日期、数据的位置等。索引节点和文件一一对应,它跟文件内容一样,都会被持久化存储到磁盘中。所以索引节点同样占用磁盘空间。
目录项,简称为 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的关联关系。多个关联的目录项,就构成了文件系统的目录结构。换句话说,索引节点是每个文件的唯一标志,而目录项维护的正是文件系统的树状结构。目录项和索引节点的关系是多对一,你可以简单理解为,一个文件可以有多个别名。不过,不同于索引节点,目录项是由内核维护的一个内存数据结构,所以通常也被叫做目录项缓存。
注意:
1.目录项不是目录,目录也是一种文件,是文件就有对应的索引节点,索引节点同样占用磁盘空间。
2.目录项与索引节点的对应关系是多对一关系(例如:通过硬链接为文件创建的别名,就会对应不同的目录项,不过这些目录项本质上还是链接同一个文件,所以,它们的索引节点相同。),但是目录的索引节点和目录项目只能是一对一。
真实案例:sdcardfs由于目录的目录项和索引节点多对一造成的系统死锁,系统Hung住。
https://github.com/novelinux/toolbox/blob/master/debug/ramdump/example1.md
索引节点和目录项纪录了文件的元数据,以及文件间的目录关系,那么具体来说,文件数据到底是怎么存储的呢?是不是直接写到磁盘中就好了呢?
实际上,磁盘读写的最小单位是扇区,然而扇区只有 512B 大小,如果每次都读写这么小的单位,效率一定很低。所以,文件系统又把连续的扇区组成了逻辑块,然后每次都以逻辑块为最小单元,来管理数据。常见的逻辑块大小为 4KB,也就是由连续的 8 个扇区组成。如下图所示:
文件系统IO
把文件系统挂载到挂载点后,就能通过挂载点,再去访问它管理的文件了。VFS 提供了一组标准的文件访问接口。这些接口以系统调用的方式,提供给应用程序使用。
文件读写方式的各种差异,导致 I/O 的分类多种多样。最常见的有,缓冲与非缓冲 I/O、直接与非直接 I/O、阻塞与非阻塞 I/O、同步与异步 I/O 等。
- 第一种,根据是否利用标准库缓存,可以把文件 I/O 分为缓冲 I/O 与非缓冲 I/O。
缓冲 I/O,是指利用标准库缓存来加速文件的访问,而标准库内部再通过系统调度访问文件。非缓冲 I/O,是指直接通过系统调用来访问文件,不再经过标准库缓存。注意,这里所说的“缓冲”,是指标准库内部实现的缓存。比方说,你可能见到过,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来。无论缓冲 I/O 还是非缓冲 I/O,它们最终还是要经过系统调用来访问文件。而根据上一节内容,我们知道,系统调用后,还会通过页缓存,来减少磁盘的 I/O 操作。
- 第二,根据是否利用操作系统的页缓存,可以把文件 I/O 分为直接 I/O 与非直接 I/O。
直接 I/O,是指跳过操作系统的页缓存,直接跟文件系统交互来访问文件。非直接 I/O 正好相反,文件读写时,先要经过系统的页缓存,然后再由内核或额外的系统调用,真正写入磁盘。想要实现直接 I/O,需要你在系统调用中,指定 O_DIRECT 标志。如果没有设置过,默认的是非直接 I/O。不过要注意,直接 I/O、非直接 I/O,本质上还是和文件系统交互。如果是在数据库等场景中,你还会看到,跳过文件系统读写磁盘的情况,也就是我们通常所说的裸 I/O。
- 第三,根据应用程序是否阻塞自身运行,可以把文件 I/O 分为阻塞 I/O 和非阻塞 I/O:
所谓阻塞 I/O,是指应用程序执行 I/O 操作后,如果没有获得响应,就会阻塞当前线程,自然就不能执行其他任务。所谓非阻塞 I/O,是指应用程序执行 I/O 操作后,不会阻塞当前的线程,可以继续执行其他的任务,随后再通过轮询或者事件通知的形式,获取调用的结果。比方说,访问管道或者网络套接字时,设置 O_NONBLOCK 标志,就表示用非阻塞方式访问;而如果不做任何设置,默认的就是阻塞访问。
- 第四,根据是否等待响应结果,可以把文件 I/O 分为同步和异步 I/O:
所谓同步 I/O,是指应用程序执行 I/O 操作后,要一直等到整个 I/O 完成后,才能获得 I/O 响应。所谓异步 I/O,是指应用程序执行 I/O 操作后
性能分析
容量
对文件系统来说,最常见的一个问题就是空间不足。用 df 命令,就能查看文件系统的磁盘空间使用情况。比如:
1 | whyred:/ # df -h /data/ |
不过有时候,碰到了空间不足的问题,可是用 df 查看磁盘空间后,却发现剩余空间还有很多。这是怎么回事呢?不知道你还记不记得,刚才我强调的一个细节。除了文件数据,索引节点也占用磁盘空间。你可以给 df 命令加上 -i 参数,查看索引节点的使用情况,如下所示:
1 | whyred:/ # df -ih /data/ |
索引节点的容量,(也就是 Inode 个数)是在格式化磁盘时设定好的,一般由格式化工具自动生成。当你发现索引节点空间不足,但磁盘空间充足时,很可能就是过多小文件导致的。所以,一般来说,删除这些小文件,或者把它们移动到索引节点充足的其他磁盘中,就可以解决这个问题。
缓存
可以用 free 或 vmstat,来观察页缓存的大小。free 输出的 Cache是页缓存和可回收 Slab 缓存的和,可以从 /proc/meminfo ,直接得到它们的大小:
1 | whyred:/ # cat /proc/meminfo |
文件系统中的目录项和索引节点缓存,又该如何观察呢?实际上,内核使用 Slab 机制,管理目录项和索引节点的缓存。/proc/meminfo 只给出了 Slab 的整体大小,具体到每一种 Slab 缓存,还要查看 /proc/slabinfo 这个文件。比如,运行下面的命令,你就可以得到,所有目录项和各种文件系统索引节点的缓存情况:
1 | cat /proc/slabinfo | grep -E '^#|dentry|inode' |
但是一般Android手机系统不会打开对应内核的配置,所以我们一般也不容易看到。