靠近年初的时候,Linux内核的软盘驱动上进行了一些久违的工作,而且半年后人们发现,今年早些时候的工作反倒使Linux内核的软盘处理退步了。现在,Linux5.15即将推出一个修复方案。
自从今年早些时候对软盘驱动程序进行修改后,该驱动程序继续发挥作用,但它无意地改变了一些软盘处理代码:之前带有O_NDELAY标志的内核将允许打开一个软盘设备,即使没有插入磁盘的情况下。更新后的Linux内核如果在没有插入介质/磁盘的情况下打开软盘设备,将产生一个错误。此外,它还破坏了打开有写保护的软盘的功能。
如果Linux5.14的发布顺利的话,Linux5.15的合并窗口很可能在今晚开启,排队的软盘驱动的修复,作为一个迟来的添加块应该会被加入。对于这些回归的修复没有什么值得关注的地方,只是恢复了软盘代码中围绕O_NDELAY的早期补丁。
今年早些时候的软盘补丁最初是在Linux5.12时期合并的,但也被回传到之前的稳定版系列,包括Linux5.10 LTS,所以5.15的这个新补丁最终也可能被回传到稳定/LTS内核中。
IO是Linux内核里面除了进程管理、内存管理之外另一个比较重要的概念。IO涉及的知识覆盖面会非常宽泛,从用户态编程框架模型、到内核系统调用、文件系统、io调度、磁盘驱动、网络驱动等等。IO对于系统的影响,个人理解其实更多的体现在性能方面,并且其中涉及纯软件编程方面的内容会比较多一些。
本文着重记录IO相关的知识点和脉络,其中部分内容是根据宋宝华老师《IO微课》并加入个人理解整理。
模型分为:阻塞IO、非阻塞IO、多路复用IO、SignalIO、异步IO、Libevent等等,这些不同的IO模型有不同的适用场景,IO模型的选择会深刻影响到系统性能。
比如,阻塞/非阻塞/异步IO适合块设备(磁盘),其它更多的是适合字符设备/socket等(因为主要是监听事件)。
标记CPU与IO占用时间分布情况的图形,蓝色的标识CPU耗时阶段,红色的标识IO耗时阶段,如下所示
CPU与IO需要并行问题源于:CPU与IO是分属于不同硬件的,两者互不干扰。 将二者并行操作,能最大程度的充分利用硬件资源、节省时间。做软件的比较忌讳的是CPU等IO、IO等CPU这类互等的场景,从红蓝图上直观来看,比较好的系统状态是红/蓝并行,让“红的更红、蓝的更蓝”。
最简单的IO模型,进程调用此类API时会被阻塞住等待IO完成才能继续执行。
纯粹的非阻塞基本是不用的,它的原理是:当应用发起一个IO时,如果暂时无法完成,Linux内核返回一个EAGIN,也即需要用户态频繁去尝试。
解决了在一个线程中如何操作才能搞定多个阻塞IO。
它的代码框架实现经历了两个阶段,目前均参在于代码中:
引入问题:返回后再次select监控时,就需要重新将所有要监控的fd加入set中(“健忘症”),对于大量并发场景这里会有不小开销。
引入问题:循环遍历操作(“后处理”)对于大量并发也会有不小开销。
Tips: ① 由于epoll将告之监控和等待IO拆分成2个API,相比select前一阶段CPU时间消耗在while(1)循环中变得没有了,“健忘症”被治愈了 ② 由于epoll_wait()出参中已经包含了所有满足读写事件的fd,省去了扫描所有fd的时间,这里只需要遍历满足的fd即可,“后处理”时间变短了 因此,对于大量并发场景下epoll消耗的CPU要远低于select的,epoll目前几乎是大规模网络程序设计里面处理并发阻塞读写的代名词
很多网络并发服务的编程框架都是用epoll来做的:
SignalIO目前基本很少有人再用了,不过也有一些工具比如perf/strace等还在使用它。
纯粹的异步IO在Linux中也是存在的,是指发起一个IO后立即返回完全不需要等待,后台线程会负责IO动作,当在同步等待点上需要同步等待IO完成时再利用某个函数去等待。异步IO基本思想同样也是不让IO去阻塞CPU消耗型的task,将CPU与IO做并行处理,让CPU的归CPU、IO的归IO。
目前Linux中异步IO有如下两种实现,一是在glibc中,一是在kernel中
对于在glibc中aio: 一般就是需要发起IO时调用aio_read等IO函数,这些函数会立即返回,然后在IO的同步等待点上调用aio_suspend来等待。
在kernel中的aio版本: linux提供的一套系统调用,一般是配合硬盘的O_DIRECT形式去访问的。用一系列的系统调用,如io_setup/io_sunmit准备IO,利用io_getevents来在同步等待点等待。
现在用户态开发流行基于异步事件的编程模型,libevent也是类似的IO模型,在做大规模网络编程时比较流行的。
所谓“基于事件”,指的是当某一个事件发生时会触发某些动作,比如向某个节点echo一个值之类的,它类似于早期在微软的MFC编程IDE中添加一个button点击button会触发一个事件等等。
在Linux上libevent的底层实现原理是基于epoll的,在其它平台上封装了对应平台的系统调用。 良好的跨平台性,它提供如下一些统一的接口:
//设置监听处理函数句柄一个连接 一个进程/线程 | 进程/线程会占用大量系统资源,切换开销大,可扩展性差 |
多个连接 一个进程/线程 select | fd上限+重复初始化+逐个排查所有fd状态(O(n)效率) |
多个连接 一个进程/线程 epoll | |
当一个fd的特定事件发生(如可读/可写/出错),libevent会自动调用用户设定的callback来处理该事件 |
① bootchart 用于linux与android启动加速,能将CPU和IO消耗阶段转换成图片形式显示、哪一部分用时多少。 它的原理描述起来很简单,就是在代码特定函数中打一些统计点,去记录某一段时间内的各个进程的时间消耗分布情况,抓取log数据并解析成图表形式。
文件系统的架构,文件系统与VFS的hook,磁盘上目录与文件的组织,用户态文件系统。
Linux系统的一个基本设计思想就是“一切皆文件”,它实现这一设计的方法即它的VFS层,通过VFS层把内核“各种类型”的访问转换成对用户态统一的API接口,让用户态看到的就是一切皆文件、一切皆文件操作。
下图显示了vfs对下层不同类型操作的封装
VFS与文件系统的hook: VFS非常类似于CPP中的基类,提供了很多“虚函数”,即面向对象中所谓的“接口”,这个类比是非常形象的,这些接口都需要“实例化”时提供具体实现。
之于VFS,它对上一层提供的“接口”就是read/write/ioctl/open/...这些函数,而实例化,就是对下一层“各种不同操作”所继承的统一struct file_operations
结构体进行填充、并且对其内部的函数加以实现。
字符设备: 实现字符设备驱动,就是实现file_operations结构体成员函数。
块设备: 块设备访问有两种方式,一是直接访问裸分区(如/dev/sda),另一种是访问里面文件系统(如ext4/f2fs)。
字符设备与块设备区别:
文件转化为对磁盘的访问: 有一个百万格子的网站实践,很类似于磁盘上文件系统的组织,假设每个格子对应磁盘上的一个block块,那么一个file在这些格子上该怎么映射、格子怎么查找、怎么记录空闲格子等等,这些就是文件系统要解决的问题。
① 对于文件本身数据,下面的每种颜色标识一个文件的存储情况,显然一个文件可能占用不止1个格子(多个block块):
② 在文件系统里面,除了放文件本身数据以外,还需要放文件管理的数据:super block、inode bitmap、block bitmap、inode table等等,他们组织结构如下图:
① 目录或者实体文件,是"唯一"描述并映射到一个特定文件的数据结构 |
① 目录是一类特殊的文件,它的内容就是一张“名字inode”对照表 |
① 硬盘一个真实文件的一次打开引用,比如一个文件如果被打开100次,那么就有100个file结构产生。(但是注意磁盘文件对应的inode只有一个) |
关键数据结构:Inode inode映射目录或者实体文件,是"唯一"描述并映射到一个特定磁盘文件/目录的数据结构。 inode是文件系统的核心中的核心数据结构,它才是硬盘的真实的存储,其它都是对inode的引用。
① inode diagram: 在inode结构体中,它是由一些指针组成的,用于记录这个文件在磁盘占用哪些block块,并且对于大文件而言,这个diagram可能需要进行分级(可能因为文件太大而分成多级indirect指向)以满足空间要求。 下图显示了inode结构中block块指针这部分的分级,包含了直接指向和间接指向:
② Ext4对inode diagram的改进: 用Extents
数据结构替代之前对block块的记录形式,用以减少间接映射表的层级数量,它的记录方式是将文件在磁盘上某段儿“连续的block”用一个extent记录,这样就免去了一个block记录一次,节省了存储空间、提升了效率。
下图显示了ext4文件系统中extents在inode中的组织形式:
③ inode cache缓存: 这里的inode cache是各个文件系统在做现实时对“table of inodes”这部分数据的缓存,这部分数据在文章前面有提到,记录了每个inode的数据结构。 在读写文件时inode数据结构的访问会非常频繁,因此为了提高效率用kmem_cache_create
创建了专用的slab
cache去做了缓存,并且标记该部分slab是可以回收的。 下图显示了ext3文件系统里面创建inode cache的代码:
文件在磁盘的存放 下面这个图是将以上各个部分图形综合在一起,显示的文件如何通过文件系统在磁盘存放的
=》找到对应的下级目录inode =》根据它找到对应下级目录 =》同样重复操作:在该级目录的文件中用file name做字符串匹配 =》找到对应的file文件inode =》通过file文件的inode找到对应data块,做读/写操作
假设去查找并读取/usr/bin/emacs文件,这个过程大概如下图:
该过程描述: ○ 因为根"/"对应的dentry->d_inode是知道的,所以读取根的inode表,找到对应的inode结构体,读取其中datablock指针这部分,找到磁盘上对应的文件,读取内容,去做字符串匹配"usr",找到它对应inode2; ○ 读取“inode
cache缓存inode表,找到inode2的结构体,读取inode2里面的datablock指针部分,找到磁盘上文件,读取内容,去做字符串匹配"bin",找到它对应的inode11; ○
重复的过程,找到"emacs"文件对应的inode119,查找inode119的datablock指针部分,找到磁盘上文件,读取/写入内容。
以上过程中会读取很多dentry和inode结构体,linux内核都会将之保存在slab cache中。 文件系统中涉及的slab cache有两级:
说起symlink和hardlink这里还需要再提及一下目录,文章前面提到过所谓目录是一个“特殊的目录文件”,它里面内容是file<==>inode对应关系表。
Hardlink 硬链接 对于硬链接而言,它没有单独的inode结构体,只是在目录文件中增加一行,让硬链接文件指向对应的inode。 硬链接:
Symlink 符号链接 符号链接在linux中是一个真实存在的实体文件,它有单独的inode结构体,只不过符号链接文件“内容”是指向源文件。 符号链接(软链接):
rm -rf
删除时,是不能删除掉源目录中内容的,只是删除了软链接文件自身(rm/unlink)
cd ..
时进入的是软链接自身所在的父目录
比如下图显示一个符号链接cbw_file的创建和它单独的inode
在文件系统中对比Hardlink硬链接和Symlink符号链接
② 创建目录a的软链接b:
③ 创建软链接b的硬链接c:
⑦ 创建目录a的硬链接b:
而占据在VFS生成的这些object上的具体inode结构体中都包含有一个i_private成员,这个成员指向了下一级具体FS文件系统的inode信息,这样VFS与具体FS联系在一起了。
而在kernel中有的地方在创建“专用的slab cache”时也会标记reclaim,但是却没有给这些专用slab cache写shrinker函数,就造成了这部分slab object虽然被统计在可回收内存部分,但实际上内存紧张时无法被回收。因此,在写专用slab cache时写shrinker函数这一点是需要特别注意的。
各个数据结构之间的关系:
所有的userspace文件系统,在linux kernel看起来都是不存在的,因为在linux kernel注册的文件系统都需要hookup进VFS,而用户态文件系统是在VFS之上的。但是,在内核中有一个模块fuse,对userspace它可以提供API注册接口,对VFS它可以hookup进去。
用户态文件系统注册进FUSE 如下面代码,这里需要实现fuse_operations{}
这个结构体成员,同时将它用fuse_main()
函数注册进FUSE
用户态文件系统访问流程 当用户态进程想透过VFS去访问一个userspace文件系统下的文件时,VFS先通过fuse转化成消息传递给userspace,然后userspace操作完毕之后再将消息传递给fuse,然后fuse再传递给VFS,VFS再反馈给用户态进程。
这个过程非常繁琐,涉及到内存频繁在用户态和内核态之间互拷、消息在用户态和内核态之间互传,因此效率肯定是不高的,但是实现成本很低,因此对于性能要求不高的场景会有应用优势。而且,通过fuse可以快速实现文件系统原型,因为是用户态的东西调试起来也很方便。
Tips: zfs是写时拷贝(copy-on-write)文件系统的鼻祖,后来的btrfs等都是学习它的。 在Linux中有一个通过fuse实现的zfs文件系统,但是效率很低,相比其它内核态文件系统,它基本没有使用价值。
预备知识:数据库里的transaction(事物)有什么特性?
磁盘在被具体fs组织起来的时候,是以group为单位进行管理的,这样做的好处是:能尽量将同一个目录下的file摆放在同一个group中,这样会避免在连续访问同一目录下的file时在磁盘上跳转的很远,能加速查找的过程。 ① 除了第一个group中的主superblock以外,其它会有备份的superblock曾强整个fs的鲁棒性,相应地也增加访问时间和浪费存储空间。 ② 每个group中都有自己的group描述符,来记录该group中的inode bitmap、block bitmap、inode表等等位置信息。 文件系统区分group如下
将一个文件变大的三点修改:
修改操作涉及不同数据区,因此不可能是原子的:
fs的这种非原子性操作造成结果是:当有掉电等场景时,就可能造成文件的丢失、损坏等等。
① 假设先修改了元数据:如果元数据都被修改完了,但是datablock的数据只写了一半,这时掉电了,那么造成的结果是一上电后打开该文件,可能就偷到了之前在磁盘上这个datablock保存的文件内容了;(安全性问题) ② 假设先修改了datablock:如果数据已经写完了,但是元数据还没有写完,会造成元数据之间不一致,文件系统状态错误了;(文件损坏、截断、文件系统损坏)
bitmap中没有标记该inode被使用,但是在其它地方(比如目录文件、inode、datablock、databitmap等地方)这个file都是有记录的。
⑧ 此时文件系统已经存在不一致的情况了,尝试用fsck.ext4这个工具去修复fsck.ext4 image
,发现它并没有检测出来fs不一致的情况。它并没有删除fs中记录的nihao这个文件的其余部分、或者也没有补齐inode
bitmap中记录的比特位。(当然,这里可以用fsck.ext4
Tips: 从以上例子可以看出,文件系统不一致时,文件系统读访问也可能是正常的,但是写入一个文件时就会报莫名其妙的错误,后续再无法写入了。
基于以上描述,突然掉电对于fs的一致性损害是非常大的,很可能造成fs不能使用了、重要数据丢失了等等。 在Linux Kernel早期,当出现异常掉电时,下次开机会首先自动运行一个fsck程序,该程序对fs进行检查,并尝试修复fs不一致的情况,它会询问用户意见如何修复等等。
下面描述了fsck的基本功能:
针对上面模拟文件系统掉电的小实验,使用fsck尝试修复:
Tips: 之前直接修改完时,用fsck是没有发现错误的,但是这里在建立新文件报错后,再次用fsck是可以检查到错误的 另外,可以使用fsck.ext4 -f参数来强制修复,也是可以修复之前错误的
这里需要再次提及的是,无论软件技术有多么牛逼,也是无法保证掉电不丢数据的,只有硬件上UPS电源、大电容才能彻底确保不丢数据。而软件只能提供fs的一致性保护(恢复/修复)
fsck是可以修复fs不一致问题,但也有自身缺陷:fsck检查和修复fs的速度是非常慢的。因此,目前文件系统的一致性保护,都是使用fs的日志系统。
fs的日志系统借用了数据库的transaction的概念,数据库里的transaction(事物)的特性:
日志借用了以上特性,当需要修改fs中的内容时,如下操作: ① 首先去写一个日志区,然后将日志commit了,表示日志已经写完了; ② 真正去修改fs中内容,写完后checkpoint该日志,表示fs中内容刚已经写完了该日志没有用处了; ③ 最后日志系统将该日志free掉;
对于掉电情况: 以上fs操作中每个环节都可能发生突然掉电,因此日志对于fs一致性保护的操作是: ① 日志未commit:日志损坏,下次上电直接放弃该日志,当作修改fs动作从未发生过 ② 日志已commit未checkpoint:下次上电时回放日志,写入fs中即可
日志性能改进: 从日志操作的流程上看出,以上操作的开销是很大的,意味着每次通过fs写磁盘都会做2次写操作(元数据+data数据),这对IO性能是一个极大的挑战。
因为对于写fs时显然datablock数据块是更大的部分,因此是否可以只将元数据部分做日志、即只确保fs的一致性,而不是去保证元数据与datablock数据的一致性??即:掉电后fs通过日志还是可以正常工作的,而datablock数据可能错了。在这种情况下,fs的元数据和data数据可能就对不上了,但是也只能如此来确保fs的读写性能了。如果只做元数据的一致性的话,datablock部分可以单独来搞了。
如下显示元数据日志(Metadata)采用这种只对元数据做日志的方式:
其实,日志无非有两个特性:一是备份、二是锁
fs相关工具集,这些debug工具和方法对于快速查看和定位问题还是很重要的
① fdisk 查看硬盘分区情况:
8)。通dd到文件中的结果见该文件末尾还是一堆@@@符号、还并没有占满整个sector:
queue的三进三出,如果进行bio层面的分析该工具几乎是必须的;它的原理是在内核中的一些关键函数点上增加了一些记录信息,然后抓下来这些记录并且解析。
btrfs是一种通过“读-拷贝-更新”来完成fs修改的文件系统,这个文件系统是没有日志的(也不需要日志),它是通过copy-on-write来实现fs一致性的。
所谓cow,即当写入fs的操作时,先去读取需要写入的block并对它拷贝一个副本,然后更新副本中的内容,最后再把它上一级节点做拷贝更新,如此一级一级向上修改,一直到修改superblock节点指向(这个修改是原子的),完成整个树状结构修改:
这个修改过程如下图显示:
btrfs的transaction完全是靠树是否更新完来决定的,对btrfs的cow流程更加详细的描述如下:
因此,这个文件系统以cow替代日志,一是效率很高,二是很适合做快照这种场景(只新建一个快照的root即可);
btrfs的一些命令示例:
Tips: 这里引申出一个有cow的fs的一个功能:当升级fs时,可以先做快照,然后再升级原fs,如果升级失败可以回滚使用快照即可,不会影响到分区加载、或者系统启动
BIO与IO调度是介于vfs/fs与磁盘之间部分,通过pagecache、电梯算法queue等实现了对磁盘的统一读写,这里也是IO的性能瓶颈之一。
应用程序对于磁盘的访问流程,是能够以pagecache为界分成两个较为明显的层次的:
① 左半个部分:是第一阶段“文件系统部分”从file到address_space_operations的图示,之前章节已经介绍过了
② 右半个部分:灰色框体内是address_space的软件架构,左侧通过inode->i_mmaping指针嵌入inode结构体中与fs交互,右边依靠块驱动部分与磁盘交互
Tips: 这里有几个概念需要澄清一下,它们的大小关并系不固定 ○ page - 内存管理的最小数据单元,Linux内核通常4KB ○ block - 文件系统管理的最小数据单元,通常按照page页大小来格式化成4KB读写性能好,另外,因为一次会读写磁盘的一个block,当文件小时block越大会越浪费磁盘空间 ○ sector - 硬件读写磁盘的最小数据单元,跟硬件有关
需要注意的是: ① 对于pagecache而言,在做内存统计时是区分buffers和cached,它们都是通过RadixTree来组织的
一般情况下,O_DIRECT参数是基本上没有人使用,因为会引入2个问题:
基于以上,因此Linux内核一般不建议用O_DIRECT,而如果非要O_DIRECT就所有磁盘访问都采用O_DIRECT,这样会比较安全。
① Block IO流程 BIO的作用: 处理磁盘的哪些block(fs单位)最终需要被读到哪些page上(memory单位),而且进一步,最好是磁盘的哪些blocks最终读到哪些pages。
Tips: 当fs被格式化成的(block大小 < page大小)的时候,这时候可能一个page就对应着几个bio(也即对应几个不连续的datablock) 这时会有大量的bio产生,会对性能产生影响。 因此,当格式化成的block较大就会造成空间浪费,当格式化成的block较小就会造成性能降低。这里也恰恰体现了“时间空间互换”的编程思维。
“蓄水”:bio会首先被发送到本TASK的“闸门”——plug队列,在插入plug queue之前需要将每个bio转换成request(转换方法是先找bio是否能够match到某一个request,如果不能的话就创建一个新的request,不同的bio可能会被组合到同一个request内,因为request会被对应到磁盘连续空间上); “防水”:当本TASK的plug队列积蓄到一定程度或者task认为足够了,就“开闸”放水,将plug queue内的所有request都发到“IO电梯调度队列”中,这样汇总起来,所有TASK的request最终都进入到IO电梯调度queue中; “QoS”:IO电梯调度queue将根据不同的电梯调度算法(Deadline,cfq等等,这里算法更新很快)不同策略,将这些request进行排序,然后将排好序的request放入dispatch队列; “收发”:这个dispatch queue被块设备驱动使用,块设备驱动将会从dispatch
② Block IO流程示例 下面是用ftrace去跟踪一个IO读写流程函数级别的操作:
从ftrace的执行结果上看,这里不单单是有函数调用关系图,还有每个函数的执行时间,因此可以用于在验证性能时抓热点函数。
IO电梯调度算法有很多种,目前比较流行的调度算法有NOOP、Deadline、CFQ三个。 虽然算法不同但是中心目的都是做QoS的(优先级、流量控制、资源预留等等)。比如:
修改某个硬盘的IO调度算法:
① blktrace 操作级别分析工具,跟踪每个IO从上到下流程 见上面:“⑦ blktrace”
② ftrace 函数级别分析工具,分析函数调用关系和函数执行时间。
③ ionice 设置某个进程对IO访问的调度类、优先级等:
TASK的IO调度优先级不一样,会影响到TASK访问磁盘的速率。如下iotop查看的3546优先级=0,3547优先级=7,访问磁盘速率还是蛮大的。
④ iotop 查看磁盘IO性能,每个TASK访问磁盘优先级、访问速度等等:
⑤ iostat 用于监控每个硬盘上的流量情况,有多少IO请求、每秒钟读写多少等等,是看整个硬盘宏观情况。
⑥ cgroup 正如进程调度、内存管理里面的cgroup一样,在IO部分也可以通过区分不同的cgroup来控制IO资源的使用(基于权重的优先级、绝对的优先级、调度类等等等等)。
版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。