Linux文件预读算法磁盘I/O性能的发展远远滞后于CPU和内存,因而成为现代计算机系统的一个主要瓶颈。预读可以有效的减少磁盘的寻道次数和应用程序的I/O等待时间,是改进磁盘读I/O性能的重要优化手段之一…
从寄存器、L1/L2高速缓存、内存、闪存,到磁盘/光盘/磁带/存储网络,计算机的各级存储器硬件组成了一个金字塔结构。越是底层存储容量越大。然而访问速度也越慢,具体表现为更小的带宽和更大的延迟。因而这很自然的便成为一个金字塔形的逐层缓存结构。由此产生了三类基本的缓存管理和优化问题:
◆预取(prefetching)算法,从慢速存储中加载数据到缓存;
◆替换(replacement)算法,从缓存中丢弃无用数据;
◆写回(writeback)算法,把脏数据从缓存中保存到慢速存储。
其中的预取算法,在磁盘这一层次尤为重要。磁盘的机械臂+旋转盘片的数据定位与读取方式,决定了它最突出的性能特点:擅长顺序读写,不善于随机I/O,I/O延迟非常大。由此而产生了两个方面的预读需求。
来自磁盘的需求
简单的说,磁盘的一个典型I/O操作由两个阶段组成:
1.数据定位
平均定位时间主要由两部分组成:平均寻道时间和平均转动延迟。寻道时间的典型值是4.6ms。转动延迟则取决于磁盘的转速:普通7200RPM桌面硬盘的转动延迟是4.2ms,而高端10000RPM的是3ms。这些数字多年来一直徘徊不前,大概今后也无法有大的改善了。在下文中,我们不妨使用8ms作为典型定位时间。
2.数据传输
持续传输率主要取决于盘片的转速(线速度)和存储密度,最新的典型值为80MB/s。虽然磁盘转速难以提高,但是存储密度却在逐年改善。巨磁阻、垂直磁记录等一系列新技术的采用,不但大大提高了磁盘容量,也同时带来了更高的持续传输率。
显然,I/O的粒度越大,传输时间在总时间中的比重就会越大,因而磁盘利用率和吞吐量就会越大。简单的估算结果如表1所示。如果进行大量4KB的随机I/O,那么磁盘在99%以上的时间内都在忙着定位,单个磁盘的吞吐量不到500KB/s。但是当I/O大小达到1MB的时候,吞吐量可接近50MB/s。由此可见,采用更大的I/O粒度,可以把磁盘的利用效率和吞吐量提高整整100倍。因而必须尽一切可能避免小尺寸I/O,这正是预读算法所要做的。
表1 随机读大小与磁盘性能的关系
来自程序的需求
应用程序处理数据的一个典型流程是这样的:while(!done) { read(); compute(); }。假设这个循环要重复5次,总共处理5批数据,则程序运行的时序图可能如图1所示。
图1 典型的I/O时序图
不难看出,磁盘和CPU是在交替忙碌:当进行磁盘I/O的时候,CPU在等待;当CPU在计算和处理数据时,磁盘是空闲的。那么是不是可以让两者流水线作业,以便加快程序的执行速度?预读可以帮助达成这一目标。基本的方法是,当CPU开始处理第1批数据的时候,由内核的预读机制预加载下一批数据。这时候的预读是在后台异步进行的,如图2所示。
图2 预读的流水线作业
注意,在这里我们并没有改变应用程序的行为:程序的下一个读请求仍然是在处理完当前的数据之后才发出的。只是这时候的被请求的数据可能已经在内核缓存中了,无须等待,直接就能复制过来用。在这里,异步预读的功能是对上层应用程序“隐藏”磁盘I/O的大延迟。虽然延迟事实上仍然存在,但是应用程序看不到了,因而运行的更流畅。
预读的概念
预取算法的涵义和应用非常广泛。它存在于CPU、硬盘、内核、应用程序以及网络的各个层次。预取有两种方案:启发性的(heuristic prefetching)和知情的(informed prefetching)。前者自动自发的进行预读决策,对上层应用是透明的,但是对算法的要求较高,存在命中率的问题;后者则简单的提供API接口,而由上层程序给予明确的预读指示。在磁盘这个层次,Linux为我们提供了三个API接口:posix_fadvise(2), readahead(2), madvise(2)。
不过真正使用上述预读API的应用程序并不多见:因为一般情况下,内核中的启发式算法工作的很好。预读(readahead)算法预测即将访问的页面,并提前把它们批量的读入缓存。
它的主要功能和任务可以用三个关键词来概括:
◆批量,也就是把小I/O聚集为大I/O,以改善磁盘的利用率,提升系统的吞吐量。
◆提前,也就是对应用程序隐藏磁盘的I/O延迟,以加快程序运行。
◆预测,这是预读算法的核心任务。前两个功能的达成都有赖于准确的预测能力。当前包括Linux、FreeBSD和Solaris等主流操作系统都遵循了一个简单有效的原则:把读模式分为随机读和顺序读两大类,并只对顺序读进行预读。这一原则相对保守,但是可以保证很高的预读命中率,同时有效率/覆盖率也很好。因为顺序读是最简单而普遍的,而随机读在内核来说也确实是难以预测的。
Linux的预读架构
Linux内核的一大特色就是支持最多的文件系统,并拥有一个虚拟文件系统(VFS)层。早在2002年,也就是2.5内核的开发过程中,Andrew Morton在VFS层引入了文件预读的基本框架,以统一支持各个文件系统。如图所示,Linux内核会将它最近访问过的文件页面缓存在内存中一段时间,这个文件缓存被称为pagecache。如图3所示。一般的read()操作发生在应用程序提供的缓冲区与pagecache之间。而预读算法则负责填充这个pagecache。应用程序的读缓存一般都比较小,比如文件拷贝命令cp的读写粒度就是4KB;内核的预读算法则会以它认为更合适的大小进行预读I/O,比比如16-128KB。
图3 以pagecache为中心的读和预读
大约一年之后,Linus Torvalds把mmap缺页I/O的预取算法单独列出,从而形成了read-around/read-ahead两个独立算法(图4)。read-around算法适用于那些以mmap方式访问的程序代码和数据,它们具有很强的局域性(locality of reference)特征。当有缺页事件发生时,它以当前页面为中心,往前往后预取共计128KB页面。而readahead算法主要针对read()系统调用,它们一般都具有很好的顺序特性。但是随机和非典型的读取模式也大量存在,因而readahead算法必须具有很好的智能和适应性。
图4 Linux中的read-around, read-ahead和direct read
又过了一年,通过Steven Pratt、Ram Pai等人的大量工作,readahead算法进一步完善。其中最重要的一点是实现了对随机读的完好支持。随机读在数据库应用中处于非常突出的地位。在此之前,预读算法以离散的读页面位置作为输入,一个多页面的随机读会触发“顺序预读”。这导致了预读I/O数的增加和命中率的下降。改进后的算法通过监控所有完整的read()调用,同时得到读请求的页面偏移量和数量,因而能够更好的区分顺序读和随机读。
预读算法概要
这一节以linux 2.6.22为例,来剖析预读算法的几个要点。
1.顺序性检测
为了保证预读命中率,Linux只对顺序读(sequential read)进行预读。内核通过验证如下两个条件来判定一个read()是否顺序读:
◆这是文件被打开后的第一次读,并且读的是文件首部;
◆当前的读请求与前一(记录的)读请求在文件内的位置是连续的。
如果不满足上述顺序性条件,就判定为随机读。任何一个随机读都将终止当前的顺序序列,从而终止预读行为(而不是缩减预读大小)。注意这里的空间顺序性说的是文件内的偏移量,而不是指物理磁盘扇区的连续性。在这里Linux作了一种简化,它行之有效的基本前提是文件在磁盘上是基本连续存储的,没有严重的碎片化。
2.流水线预读
当程序在处理一批数据时,我们希望内核能在后台把下一批数据事先准备好,以便CPU和硬盘能流水线作业。Linux用两个预读窗口来跟踪当前顺序流的预读状态:current窗口和ahead窗口。其中的ahead窗口便是为流水线准备的:当应用程序工作在current窗口时,内核可能正在ahead窗口进行异步预读;一旦程序进入当前的ahead窗口,内核就会立即往前推进两个窗口,并在新的ahead窗口中启动预读I/O。
3.预读的大小
当确定了要进行顺序预读(sequential readahead)时,就需要决定合适的预读大小。预读粒度太小的话,达不到应有的性能提升效果;预读太多,又有可能载入太多程序不需要的页面,造成资源浪费。为此,Linux采用了一个快速的窗口扩张过程:
◆首次预读: readahead_size = read_size * 2; // or *4
预读窗口的初始值是读大小的二到四倍。这意味着在您的程序中使用较大的读粒度(比如32KB)可以稍稍提升I/O效率。
◆后续预读: readahead_size *= 2;
后续的预读窗口将逐次倍增,直到达到系统设定的最大预读大小,其缺省值是128KB。这个缺省值已经沿用至少五年了,在当前更快的硬盘和大容量内存面前,显得太过保守。比如西部数据公司近年推出的WD Raptor 猛禽 10000RPM SATA 硬盘,在进行128KB随机读的时候,只能达到16%的磁盘利用率(图5)。所以如果您运行着Linux服务器或者桌面系统,不妨试着用如下命令把最大预读值提升到1MB看看,或许会有惊喜:
当然预读大小不是越大越好,在很多情况下,也需要同时考虑I/O延迟问题。
图5 128KB I/O的数据定位时间和传输时间比重
重新发现顺序读
上一节我们解决了是否/何时进行预读,以及读多少的基本问题。由于现实的复杂性,上述算法并不总能奏效,即使是对于顺序读的情况。例如最近发现的重试读(retried read)的问题。
重试读在异步I/O和非阻塞I/O中比较常见。它们允许内核中断一个读请求。这样一来,程序提交的后续读请求看起来会与前面被中断的读请求相重叠。如图6所示。
图6 重试读(retried reads)
Linux 2.6.22无法理解这种情况,于是把它误判为随机读。这里的问题在于“读请求”并不代表读取操作实实在在的发生了。预读的决策依据应为后者而非前者。最新发布的2.6.23对此作了改进。新的算法以当前读取的页面状态为主要决策依据,并为此新增了一个页面标志位:PG_readahead,它是“请作异步预读”的一个提示。在每次进行新预读时,算法都会选择其中的一个新页面并标记之。预读规则相应的改为:
◆当读到缺失页面(missing page),进行同步预读;
◆当读到预读页面(PG_readahead page),进行异步预读。
这样一来,ahead预读窗口就不需要了:它实际上是把预读大小和提前量两者作了不必要的绑定。新的标记机制允许我们灵活而精确地控制预读的提前量,这有助于将来引入对笔记本省电模式的支持。
图7 Linux 2.6.23预读算法的工作动态
另一个越来越突出的问题来自于交织读(interleaved read)。这一读模式常见于多媒体/多线程应用。当在一个打开的文件中同时进行多个流(stream)的读取时,它们的读取请求会相互交织在一起,在内核看来好像是很多的随机读。更严重的是,目前的内核只能在一个打开的文件描述符中跟踪一个流的预读状态。因而即使内核对两个流进行预读,它们会相互覆盖和破坏对方的预读状态信息。对此,我们将在即将发布的2.6.24中作一定改进,利用页面和pagecache所提供的状态信息来支持多个流的交织读
这里总结几个Linux文件缓存(page cache)使用情况、命中率查看的工具。
perf-tools里面的cachestat
来自于大名鼎鼎的Brendan Gregg的cachestat,已经被加到他的perf-tools
http://www.brendangregg.com/blog/2014-12-31/linux-page-cache-hit-ratio.html
pcstat(page cache stat)
来自https://github.com/tobert/pcstat,这里的pc不是电脑的意思,是page cache的意思,可以查看文件里面有多少在内存命中:
vmtouch(Virtual Memory Toucher)
这个是page cache的查看和操控工具,来自https://hoytech.com/vmtouch/
例子
它还可以touch文件,例如:访问big-dataset.txt并且把它的内容带入memory:
在linux服务器使用过程中,由于linux对内存的使用原则是能cache就尽量cache,所以会出现pagecache占用很多的情况。
suse的版本有一个pagecachelimit的功能,centos中没有看到。即便是将这个功能合入到centos中,也会发现设置了没效果的情况。
cat /proc/sys/vm/pagecache_limit_mb
0
1.将0改为对应的值,比如说12000,限制为120G,我们来看对应的内核中算法为什么没有生效,
page cache总数 > 8*free_pages + pagecache_limit_mb
算法的关键是判断是否需要进行pagecache限制触发的回收,当pagecache设置某个值之后,系统中并不是直接以这个值作为判断标志,而是加上了free的限制,
从这个意义上说,因为设置的pagecache_limit_mb比较少,但是当free很多的时候,是比较难触发这个条件的。那么要加大回收还有哪些手段?下面针对读和写两个方面来描述。
2.是通过修改/proc/sys/vm/dirty_background_ration以及/proc/sys/vm/dirty_ratio两个参数的大小来实现回收。
这种场景针对的是很多写的情况:vm.dirty_background_ratio:这个参数指定了当文件系统缓存脏页数量达到系统内存百分之多少时(如5%)就会触发pdflush/flush/kdmflush等后台回写进程运行,将一定缓存的脏页异步地刷入外设;
vm.dirty_ratio:而这个参数则指定了当文件系统缓存脏页数量达到系统内存百分之多少时(如10%),系统不得不开始处理缓存脏页(因为此时脏页数量已经比较多,为了避免数据丢失需要将一定脏页刷入外存);在此过程中很多应用进程可能会因为系统转而处理文件IO而阻塞。
为什么需要两个比例?之前一直错误的认为dirty_ratio的触发条件不可能达到,因为每次肯定会先达到vm.dirty_background_ratio的条件,后来根据业务现象,发现不是如此。确实是先达到vm.dirty_background_ratio的条件然后触发flush进程进行异步的回写操作,但是这一过程中应用进程仍然可以进行写操作,如果多个应用进程写入的量大于flush进程刷出的量那自然有可能达到vm.dirty_ratio这个参数所设定的坎,此时操作系统会转入同步地处理脏页的过程,可能阻塞应用进程。
3.针对流媒体使用的时候,写脏页不多,大多数是读的场景,大多是sendfile或者read引起了很多文件进入cache,但是这些文件被继续访问的概率很低,所以相当于是一次性使用,之后相当长的时间之内是不会再使用的,所以我们对于pagecache限制的需求很强烈,由于内核本身就有根据水线来动态回收page cache的功能,因此问题的根源可能还在于默认情况下min和low水线间隔太近,导致当free内存低于low水线后,发起的kswapd回收太慢,无法跟上临时大量内存申请的节奏,从而free内存很快突破min水线,造成内存申请过程中进行cache回收或是申请失败,从而引起性能问题
因此解决问题的办法之一,可以通过加大min和low水线之间的间隔来实现
内核刚好提供了这样一个参数,即/proc/sys/vm/extra_free_kbytes,设置该参数后将拉大min和low水线的间隔,从而保证有足够的空闲内存来应对临时大量的内存动态申请。
4.加大水线,提高水线有助于提前触发回收,这样相当于后面的内存需求,能从时间上更快触发回收,但是当你的内存消耗速度大于内存回收的速度,还是会cahce很多,kswap很高。
5.如果还是回收不及时的话,那么需要将kswap的nice级别调低,也有一些帮助,renice设置为-20,虽然从调度上来说它还是低于实时进程,但是比调整前级别高了些。
6.使用posix_madvise,madvise,fadvise,利用POSIX_MADV_DONTNEED特性,但也不能调用太多,容易引起sys冲高,大家在测试的时候,可以分别对访问几M之后做清理,找到最佳的设置点。
7.修改系统调用,带入一个特性值进入,使得系统得知不放入cache,这个和directio的区别是,尽量不要拷贝到用户态,要区分应用场景,因为directio目前还不支持sendfile这种调用,需要多一次拷贝,也就是虽然节省了查找address_space中的radix树的流程,但是多了一次内核到用户态的内存拷贝。当然,我曾经尝试将directio变成支持sendfile,因为sendfile是基于splice的调用,而原来的directio的流程中,是将用户的页面作为写入地址的,所以需要将directio中对页面的检查部分进行定制修改,主要修改pmd的映射。
8.如果是内存化的存储介质,也就是可以按字节寻址的存储介质,则可以开启DAX特性,mount挂载的时候,带上该特性,这样就绕过了pagecache。
9.这个是对6的改进,有一定的特殊性,在加入radix树之前,主动调用 invalidate_mapping_pages ,这个比在用户态释放麻烦些,但是效果更好。
如果大家有更好的办法,希望能提醒一下我。
另外,cache里面保持了哪些内容,这个问题经常会被问到。
这里要提到两个工具,一个是vmtouch,另外一个是hcache,源码我就不贴了,有兴趣的可以去网上下载。
lsof | grep REG | grep mnt | sort -u | awk ‘{print $9}’ | xargs vmtouch |
这个会将我们应用场景下cache占用的文件累计起来,大家要使用的话需要根据文件名称过滤一下。前提是这个文件的句柄还在被pid所持有,有的文件fd已经关闭,但是还是在系统中存放,那么这个方法是获取不到的。
除了这两个工具查看,还有slab的占用要算在里面,
SlabInfo=cat /proc/slabinfo |awk 'BEGIN{sum=0;}{sum=sum+$3*$4;}END{print sum/1024/1024}'
常见的还有另外一个问题,就是热点文件的访问,当时我的处理思路是,每隔一段时间,取一下lsof,然后对比前后两个文件。
复制代码
#!/bin/bash
sum=0;
while read line
do
if [ $(grep $line $2|wc -l) -eq 1 ]
then
echo $line »$12$2.txt
sum=$(($sum+1))
fi
done < $1
echo “sum=$sum”
复制代码
sum的个数就是前后相同的文件个数,不太精确,因为文件有打开就有关闭,但是多次取样的话,效果还可以。
有的应用场景会尽量不要pagecache,比如视频点播,但是又不能用dio读,因为dio读有对齐要求,而且没法预读,常用的办法是在用户态取dropcache,但这个dropcache不分青红皂白,
所以我们在内核态改造成了对某些特定格式,特定大小,特定访问时间的进行drop,效果比较好。
Linux下,I/O处理的层次可分为4层:
系统调用层,应用程序使用系统调用指定读写哪个文件,文件偏移是多少
文件系统层,写文件时将用户态中的buffer拷贝到内核态下,并由cache缓存该部分数据
块层,管理块设备I/O队列,对I/O请求进行合并、排序
设备层,通过DMA与内存直接交互,将数据写到磁盘
下图清晰地说明了Linux I/O层次结构:
写文件过程
写文件的过程包含了读的过程,文件先从磁盘载入内存,存到cache中,磁盘内容与物理内存页间建立起映射关系。用于写文件的write函数的声明如下:
ssize_t write(int fd, const void *buf, size_t count);
其中fd对应进程的file结构, buf指向写入的数据。内核从cache中找出与被写文件相应的物理页,write决定写内存的第几个页面,例如”echo 1 > a.out”(底层调用write)写入的是a.out文件的第0个位置,write将写相应内存的第一页。
write函数修改内存内容之后,相应的内存页、inode被标记为dirty,此时write函数返回。注意至此尚未往磁盘写数据,只是cache中的内容被修改。
那什么时候内存中的内容会刷到磁盘中呢?
把脏数据刷到磁盘的工作由内核线程flush完成,flush搜寻内存中的脏数据,按设定将脏数据写到磁盘,我们可以通过sysctl命令查看、设定flush刷脏数据的策略:
linux # sysctl -a | grep centi
vm.dirty_writeback_centisecs = 500
vm.dirty_expire_centisecs = 3000
linux # sysctl -a | grep background_ratio
vm.dirty_background_ratio = 10
以上数值单位为1/100秒,“dirty_writeback_centisecs = 500”指示flush每隔5秒执行一次,“dirty_expire_centisecs = 3000” 指示内存中驻留30秒以上的脏数据将由flush在下一次执行时写入磁盘,“dirty_background_ratio = 10”指示若脏页占总物理内存10%以上,则触发flush把脏数据写回磁盘。
flush找出了需要写回磁盘的脏数据,那存储脏数据的物理页又与磁盘的哪些扇区对应呢?
物理页与扇区的对应关系由文件系统定义,文件系统定义了一个内存页(4KB)与多少个块对应,对应关系在格式化磁盘时设定,运行时由buffer_head保存对应关系:
linux # cat /proc/slabinfo | grep buffer_head
buffer_head 12253 12284 104 37 1 : tunables 120 60 8 : slabdata 332 332 0
文件系统层告知块I/O层写哪个设备,具体哪个块,执行以下命令后,我们可以在/var/log/messages中看到文件系统层下发到块层的读写请求:
linux # echo 1 > /proc/sys/vm/block_dump
linux # tail -n 3 /var/log/messages
Aug 7 00:50:31 linux-q62c kernel: [ 7523.602144] bash(5466): READ block 1095792 on sda1
Aug 7 00:50:31 linux-q62c kernel: [ 7523.622857] bash(5466): dirtied inode 27874 (tail) on sda1
Aug 7 00:50:31 linux-q62c kernel: [ 7523.623213] tail(5466): READ block 1095824 on sda1
块I/O层使用struct bio记录文件系统层下发的I/O请求,bio中主要保存了需要往磁盘刷数据的物理页信息,以及对应磁盘上的扇区信息。
块I/O层为每一个磁盘设备维护了一条I/O请求队列,请求队列在内核中由struct request_queue表示。每一个读或写请求都需经过submit_bio函数处理,submit_bio将读写请求放入相应I/O请求队列中。该层起到最主要的作用就是对I/O请求进行合并和排序,这样减少了实际的磁盘读写次数和寻道时间,达到优化磁盘读写性能的目的。
使用crash解析vmcore文件,执行”dev -d”命令,可以看到块设备请求队列的相关信息:
crash > dev -d
MAJOR GENDISK NAME REQUEST QUEUE TOTAL ASYNC SYNC DRV
8 0xffff880119e85800 sda 0xffff88011a6a6948 10 0 0 10
8 0xffff880119474800 sdb 0xffff8801195632d0 0 0 0 0
执行”struct request_queue 0xffff88011a6a6948”,可对以上sda设备相应的request_queue请求队列结构进行解析。
执行以下命令,可以查看sda设备的请求队列大小:
linux # cat /sys/block/sda/queue/nr_requests
128
如何对I/O请求进行合并、排序,那就是I/O调度算法完成的工作,Linux支持多种I/O调度算法,通过以下命令可以查看:
linux # cat /sys/block/sda/queue/scheduler
noop anticipatory deadline [cfq]
块I/O层的另一个作用就是对I/O读写情况进行统计,执行iostat命令,看到的就是该层提供的统计信息:
linux # iostat -x -k -d 1
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %util
sda 0.00 9915.00 1.00 90.00 4.00 34360.00 755.25 11.79 120.57 6.33 57.60
其中rrqm/s、wrqm/s分别指示了每秒写请求、读请求的合并次数。
task_io_account_read函数用于统计各个进程发起的读请求量, 由该函数得到的是进程读请求量的准确值。而对于写请求,由于数据写入cache后write调用就返回,因而在内核的层面无法统计到一个进程发起的准确写请求量,读时进程会等buff可用,而写则写入cache后返回,读是同步的,写却不一定同步,这是读写实现上的最大区别。
再往下就是设备层,设备从队列中取出I/O请求,scsi的scsi_request_fn函数就是完成取请求并处理的任务。scsi层最终将处理请求转化为指令,指令下发后进行DMA(direct memory access)映射,将内存的部分cache映射到DMA,这样设备绕过cpu直接操作主存。
设备层完成内存数据到磁盘拷贝后,该消息将一层层上报,最后内核去除原脏页的dirty位标志。
以上为写磁盘的大致实现过程,对于读磁盘,内核首先在缓存中查找对应内容,若命中则不会进行磁盘操作。若进程读取一个字节的数据,内核不会仅仅返回一个字节,其以页面为单位(4KB),最少返回一个页面的数据。另外,内核会预读磁盘数据,执行以下命令可以看到能够预读的最大数据量(以KB为单位):
linux # cat /sys/block/sda/queue/read_ahead_kb
512
下面我们通过一段systemtap代码,了解内核的预读机制:
//test.stp
probe kernel.function(“submit_bio”) {
if(execname() == “dd” && __bio_ino($bio) == 5234)
{
printf(“inode %d %s on %s %d bytes start %d\n”,
__bio_ino($bio),
bio_rw_str($bio),
__bio_devname($bio),
$bio->bi_size,
$bio->bi_sector)
}
}
以上代码指示当dd命令读写inode号为5234的文件、经过内核函数submit_bio时,输出inode号、操作方式(读或写)、文件所在设备名、读写大小、扇区号信息。执行以下代码安装探测模块:
stap test.stp &
之后我们使用dd命令读取inode号为5234的文件(可通过stat命令取得文件inode号):
dd if=airport.txt of=/dev/null bs=1 count=10000000
以上命令故意将bs设为1,即每次读取一个字节,以此观察内核预读机制。执行该命令的过程中,我们在终端中可以看到以下输出:
inode 5234 R on sda2 16384 bytes start 70474248
inode 5234 R on sda2 32768 bytes start 70474280
inode 5234 R on sda2 32768 bytes start 70474352
inode 5234 R on sda2 131072 bytes start 70474416
inode 5234 R on sda2 262144 bytes start 70474672
inode 5234 R on sda2 524288 bytes start 70475184
由以上输出可知,预读从16384字节(16KB)逐渐增大,最后变为524288字节(512KB),可见内核会根据读的情况动态地调整预读的数据量。
由于读、写磁盘均要经过submit_bio函数处理,submit_bio之后读、写的底层实现大致相同。
直接I/O
当我们以O_DIRECT标志调用open函数打开文件时,后续针对该文件的read、write操作都将以直接I/O(direct I/O)的方式完成;对于裸设备,I/O方式也为直接I/O。
直接I/O跳过了文件系统这一层,但块层仍发挥作用,其将内存页与磁盘扇区对应上,这时不再是建立cache到DMA映射,而是进程的buffer映射到DMA。进行直接I/O时要求读写一个扇区(512bytes)的整数倍,否则对于非整数倍的部分,将以带cache的方式进行读写。
使用直接I/O,写磁盘少了用户态到内核态的拷贝过程,这提升了写磁盘的效率,也是直接I/O的作用所在。而对于读操作,第一次直接I/O将比带cache的方式快,但因带cache方式后续再读时将从cache中读,因而后续的读将比直接I/O快。有些数据库使用直接I/O,同时实现了自己的cache方式。
异步I/O
Linux下有两种异步I/O(asynchronous I/O)方式,一种是aio_read/aio_write库函数调用,其实现方式为纯用户态的实现,依靠多线程,主线程将I/O下发到专门处理I/O的线程,以此达到主线程异步的目的。
另一种是io_submit,该函数是内核提供的系统调用,使用io_submit也需要指定文件的打开方式为O_DIRECT,并且读写需按扇区对齐。
Reference: Chapter 14 - The Block I/O Layer, Linux kernel development.3rd.Edition