Linux内核的文件预读

来源:互联网 发布:淘宝上买沙发可靠吗 编辑:程序博客网 时间:2024/05/21 06:20

编者按:Linux文件预读算法磁盘I/O性能的发展远远滞后于CPU和内存,因而成为现代计算机系统的一个主要瓶颈。预读可以有效的减少磁盘的寻道次数和应用程序的I/O等待时间,是改进磁盘读I/O性能的重要优化手段之一。本文作者是中国科学技术大学自动化系的博士生,他在1998年开始学习Linux,为了优化服务器的性能,他开始尝试改进Linux kernel,并最终重写了内核的文件预读部分,这些改进被收录到Linux Kernel 2.6.23及其后续版本中。

从寄存器、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看看,或许会有惊喜:

# blockdev –setra 2048 /dev/sda

当然预读大小不是越大越好,在很多情况下,也需要同时考虑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所提供的状态信息来支持多个流的交织读。
预读建议

 

相关链接:

 

 

-------------------------------


琢磨ULK2时的一些个人理解。参考了WFG的这篇文章: 
http://os.51cto.com/art/200711/60574.htm 
如果觉得有必要,以后会写写其他版本预读算法的实现分析及改进逻辑。 

一 为什么需要预读 

1 I/O合并 2 延迟隐藏 
参见上述链接。 

二 预读算法初步设计 

设read系统调用的内核实现函数为do_generic_read。如果不考虑预读,直观上讲,其实现用伪码表示,应该是如下形式: 


do_genric_read()

    for(read系统调用需读取的所有页){
        if(页不在pagecache){
            分配page加入pagecache;
            锁住page;
            启动对page的I/O传送;
         }
        等待当前页I/O传送完成; 
        copy当前页数据到用户空间;
    }
}

在上述表示中,我们将核心流程进行简化:只在读取新页时启动I/O;如果page已经在pagecache里时,说明它要么包含有效数据,要么在page新加入到pagecache时,已经启动I/O。在不考虑I/O错误时,这种简化是可行的。 

如果考虑预读,有两种形式的预读:同步预读与异步预读。 
每个read系统调用需要读取的所有页面是连续的,它们应被一次性同步预读。称为“同步”,是因为read需同步等待这些页面I/O完成。当read与前一个read在文件内的位置是顺序的,说明它正在顺序读取文件,此时应进行异步预读:启动对read“最后一个页面之后”的一组连续页面的预读,为下一个顺序read提前准备数据,从而形成流水线预读。称为“异步”,是因为后续read的I/O启动与数据处理是异步进行的。 

特别友情提醒:本文以后出现的“read”都是指read系统调用。 

检测顺序读还有一个特例。当文件被打开后的第一次读,并且读的是文件首部时,我们善意推定,后续的read会是顺序的,因此需进行异步预读。如果不满足上述顺序性条件,就判定为随机读。任何一个随机读都将终止当前的顺序序列,从而终止预读行为。 

利用这两种预读,尽最大可能隐藏I/O延迟。其实现用伪码表示,变成如下形式: 

do_genric_read()
{
    generic_read_ahead(read需读取的所有页)/* 启动同步预读 */
    if(顺序读)
        /* 启动异步预读*/
        generic_read_ahead(read最后一页之后的一组连续页面); 
     /* 等待同步预读完成 */
    for(read需读取的所有页){
        等待当前页I/O传送完成; 
        copy当前页数据到用户空间;
    }
}

注意:do_genric_read并不需要等待异步预读I/O传送完成。 

generic_read_ahead(预读的所有页)
{
    for(预读的所有页){
      if(页不在pagecache){
      分配page加入pagecache;
      锁住page;
      启动对page数据的I/O传送;
     }
  }
}

注意:generic_read_ahead只是启动页I/O传送,但并不等待I/O完成。 
异步预读的“一组连续页面”在读文件开始时可设置一个初值,如“read需读取的所有页”的2倍。在顺序读时,其额度可不断加大(例如加倍),直至某一个上限,后面还会讲到这一点。 

上述实现的特点是: 
1 同步预读的页面肯定会被用到,不会浪费;异步预读的页面不一定被用到,如果后续读不是顺序的,就可能部分或全部被浪费掉。 
2 同步预读是为当前read服务的,因此有较小的预读组;而异步预读是为顺序读形成流水线服务的,应该有较大的预读组。 
3 我们一开始就启动所有可能的预读,这是一种有“确定预读时机”的方案。 

三 上述实现中的问题 

我们使用预读,是希望程序在处理一批数据时,硬盘能在后台把下一批数据给CPU事先准备好,以便CPU和硬盘能流水线作业。流水线预读的所达到的理想状态是:当read向“同步预读”的页面发出请求时,页面已经由前一个read的“异步预读”读入内存,因此read无须等待I/O传送。 

考虑这样的情形: 

假设read“同步预读”的第一页被锁。如果它是由前一个read(或更之前的read)的“异步预读”启动I/O传送的, 说明I/O传送尚未完成。显然,对比理想状态,I/O传送速度要慢于进程运行速度。这可能是I/O系统负担太重,也可能是进程处理太快。无论哪种情形,此时再启动其他预读I/O(它与前一个read发出的I/O请求不太可能合并),不但于事无补,很可能加重I/O负担,使得I/O传送速度更慢。但在实现中,read的同步预读是无条件地启动所有页的I/O传送(如果它不是被前一个read异步预读启动的话),因此可能导致系统性能问题。 

再考虑这样的情形: 

假设read是顺序的,其“异步预读”的第一页未锁,说明I/O传送已经完成。如果此页是由“前一个read”异步预读启动I/O,为达到流水线预读的理想状态,此时启动下一轮预读是可行的。但如果此页是由“前一个read之前的read”启动I/O,显然,I/O传送速度要快于进程运行(可能是进程计算量太大),预读的页面数量相对很充足了,此时,启动其他预读I/O,会使得预读页面过早过多占据pagecache空间,而且一旦后续读并非顺序读,会导致I/O处理时间与pagecache空间的浪费。但在实现中,只要read为顺序读,其“异步预读”也是无条件的,同样会导致系统性能问题。 

四 提出新的设计 

结合上面的分析,要使预读发挥最大的效益,必须对要预读的页考虑两个因素:其I/O传送是否已经由前一个read或更前的read启动;它是否仍处于I/O传送中。前者需要记录“预读历史”,后者需要跟踪页面加锁状态。记录“预读历史”后,还能明确知道新的预读究竟从何处开始。read需根据两个因素的组合特性,决定是否启动预读I/O。我们考虑在预读中记录预读历史,然后将“确定预读时机”转化为“动态预读时机”:在处理每个页的读操作时,执行“与此页状态相关”的预读:由这个预读根据页加锁状态及预读历史,动态决定是否启动预读I/O。其基本原则是:当页是由“以前的预读”启动I/O并且加锁时,不要启动新的预读;当页是由“最近一次”预读启动I/O,并且页未锁时,应该启动新的预读。需要强调的是:“以前的预读”不但包括“最近一次预读”,但还包括更早的预读。 

我们还应注意到一点,当预读与页绑定在一起时,我们无法象原实现一样,独立启动针对顺序读的异步预读,而预读函数无法自己判断顺序读。所以,应当给预读函数传递一个表示顺序读的标志。伪码如下: 

do_genric_read()

    int reada_ok=0;        /* 表示是否顺序读的标志 */
    if(顺序读)reada_ok=1;
    for(read需读取的所有页){
        if(页不在pagecache){
          分配page加入pagecache;
          锁住page;
          启动对page的I/O传送;
       }
       generic_read_ahead(page,reada_ok,file);/*与此页状态相关的预读,page指当前页 */
       等待当前页I/O传送完成;
       copy当前页数据到用户空间;
   }
}

generic_read_ahead(page,reada_ok,file)
{
    根据reada_ok标志,page加锁状态及预读历史,确定是否启动预读I/O;
}

五 详细分析generic_read_ahead的设计 

从直观上看,我们希望上述实现在循环读取页的过程中,某一次读取会触发同步预读,另一次读取会触发异步预读,其他读取不会触发预读。这样,就与原实现在语义上等价。(真实的情形不一定是这样,后面会看到例子) 

1 记录预读历史 

先考虑如何记录预读历史。我们称最近一次预读的页面集合为一个预读组,显然,记录预读组是必须的,我们将预读组记录在file对象中。按照前面的分析,当顺序读命中预读组时,如果页未锁,正是推进下一次预读的最佳时机:需要启动新一轮预读I/O,在紧跟预读组尾部的位置设置新的预读组,以包含新的预读页面。如果页被锁,则不推进预读。因此,每一个后续的顺序读都可能产生一个新的预读组,那么,是否需要记录所有这些预读组呢?我们注意到一点,触发建立新预读组的读操作,其读取的页包含在前一个预读组中,因此,后续的顺序读可能仍访问前一个预读组,但不可能访问前一个预读组之前的页。因此,对顺序读,只需记录最近两个预读组即可。我们将最近两个预读组合并成预读窗口,作为总的预读历史记录在file对象中。当然,如果前面的reads只预读过一次,仅有一个预读组时,则预读窗口即为预读组。显然,当预读往前推进,设置新的预读组时,预读窗口也在同步推进。需要强调的是,因为需要区分最近一次预读和以前的预读,所以预读窗口不能完全替代预读组。预读组和预读窗口的设置,与每次预读的大小息息相关,我们将表示“下一个预读的大小”的字段f_ramax也记录在file对象中。 

2 启动预读时机 

有了预读组与预读窗口的定义,关于预读启动时机的基本原则可初步描述为:当读取的页被锁并命中预读窗口时,不要启动新的预读;当读取的页未锁并命中预读组时,应该启动新的预读。在这个原则的约束下,在read循环读取页面时,究竟何时启动一个预读,使它能对应“同步预读”?当读是顺序时,也即reada_ok为1时,何时启动另一个预读,使它能对应“异步预读”? 

考虑这样的情形。在read读取的页面中,前一部分页面在预读窗口中,正等待I/O传送完成。考察在预读窗口外的第一页,因为它没有被前面的预读启动I/O,很可能不在pagecache中,直到本次读取页面的操作,将页加入pagecache并启动I/O,此时页面被锁,需要等待I/O操作完成。而其后需读取的页面,当然也很可能都不在pagechache中。因此,此时是启动“同步预读”的最佳时机。需要注意的是,当页在预读窗口外,而页未锁时,说明页早已在pagecache中(例如被其他进程装入内存),此时,无须启动同步预读,因为不用等待I/O操作完成,“启动同步预读”的时间不会被隐藏。 

启动了同步预读后,会设置新的预读组,包含其后需读取的所有页面。在read继续往前读取页面时,发现某页未锁(此页必然在预读组中),如果read是顺序读,此时,应触发新的预读,即“异步预读”。当然,如果read不是顺序读,就不应触发异步预读。 

此,预读启动时机的基本原则可总结为: 
a 当页被锁并且不在预读窗口中时,启动同步预读。 
b 当读是顺序的(reada_ok为1),页未锁,且页在预读组中时,启动异步预读。 
c 当读不是顺序时,禁止异步预读。 
d 其他情形下,不能启动预读。 

需要提到的是,为了在等待同步预读完成的过程中,异步预读能尽快启动并完成,使得顺序读之间达到最大程度的流水,异步预读需要激活低级I/O设备驱动程序,确保新页被尽快读取。但需要注意的是,过于频繁激活低级I/O设备驱动程序,会影响I/O请求的合并,对系统性能是不利的。 

3 预读过程 

现在来讨论预读组与预读窗口的设置与推进过程。当read第一次打开文件来读时,预读组和预读窗口初始化为空,表示“下一个预读的大小”的f_ramax初值为0。我们注意到一点,read是第一次读取文件,预读窗口为空,它读取的页面很可能不在pagecache里,因此在加入到pagecache并启动I/O前会加锁。为了支持后面将触发的同步预读,设置f _ramax为read需读取的页数。一旦同步预读被触发,会启动后续f_ramax页的的I/O传送,并设置预读组包括所有预读页面,预读窗口与预读组保持一致。 

然后,使f_ramax加倍,为以后的顺序读作准备。因为对顺序读,我们需要考虑合适的预读粒度:预读粒度太小的话,达不到应有的性能提升效果;预读太多,又有可能载入太多程序不需要的页面,启动大量无效I/O,造成资源浪费。为此,采用一个2倍的扩张过程:f_ramax 在每次预读后加倍,下一个顺序读就会预读加倍的页面。当读是顺序的,这一扩张过程将不断重复,f_ramax将逐次倍增,直到达到系统设定的块设备的上限值。 

当某个read是顺序的,在循环读取页面之前,判断f_ramax是否小于需读取的页面数,若是,将f_ramax设置为后者。这里的逻辑是:只要能预读,不管是同步还是异步的,就应该至少先预读read所需求的所有页面。假设读取某页时,触发预读,启动后续f_ramax页的的I/O传送,其中包括需读取的所有页面。我们设置预读组包括所有预读页面,预读窗口为最近两个预读组,并使f_ramax加倍。 

当某个read是随机的。因为预读是为顺序读服务的,对一个随机读保留预读历史是毫无意义的。我们清除预读历史:将预读组和预读窗口置为空,f_ramax初值清0。此时,文件状态与打开文件第一次来读的状态是类似的。我们同样设置f_ramax为需读取的页数,为后面的预读作准备。 

4 放松顺序性标准 

顺序性要求当前read与前一个read在文件内的位置是顺序的,其严格定义应该是两个位置紧密相连。有了预读窗口,我们或许可以放松一下顺序性标准:只要read读取的第一页命中预读窗口(前一个read设置),就认为read是顺序的。这样会使得预读算法更加激进,预读效果可能更好。 

六 同步预读与异步预读相互配合的实际情形 

为了加深对预读算法的理解,我们结合实际的情景描述同步预读与异步预读。 

1 随机读 

一个read如果是随机的,会禁止异步预读。一般而言,在它读取所有页面的过程中,只会触发一次同步预读操作。在极端情形下,例如read读取的所有页面都已经被“其他进程”装入内存,因而都未锁时,一次预读都不会触发。不过,这种情形也无须预读了。 

2 顺序读 

当本次read是顺序的,需要启动异步预读。 

顺序读的几种组合情形 
a 没有预读 
读预读窗口内的页时,页都被锁,没有同步预读。读预读窗口之外的页时,页都未锁,没有异步预读。结果,一次预读都未发生。这应该是一种非常极端的巧合情形。 

b 一次异步预读 
假设read所需读取的页未超过预读窗口的范围。 
首先读在预读窗口中但不在预读组中的页,不管是否加锁,不会进行预读。接着读到预读组里的页,其中已锁的页不会引发同步预读,此时发现某页未锁,引发异步预读:启动相关页的I/O数据传送,包含read需要的所有页,并设置新的预读窗口及预读组。在新的预读组中不包含read所需要的页面,read读“后续页”时不管页是否加锁,都不会引发预读。 

c 两次异步预读 
假设read所需读取的页超过了预读窗口的范围。 

第一次异步预读的情形同上,但在新的预读组中包含了read所需要的所有页面。于是开始与第一次异步预读类似的情形:先读到在新的预读窗口中但不在新预读组中的页,不会触发预读。再往后读到新的预读组中的页,其中加锁的页不会引发同步预读。此时,发现某一页未锁,于是再次触发新的异步预读,并再次设置新的预读窗口及预读组。 此时,新的预读组中不可能包含read所需要的页面。因此,read后续的读操作不会再次引发新的预读。 

d 一次同步预读 
假设read所需读取的页超过了预读窗口的范围。 
读预读组中的页时,页都被锁。读到预读窗口外页时,其中未锁的页不会引发异步预读。此时,发现某页锁住,因此进行同步预读:启动相关页的I/O数据传送,包含read需要的所有页,并设置新的预读窗口及预读组。接着读到新的预读窗口(与预读组一致)中的页,所有的页都加锁,因而不会有任何预读操作。 

e 一次同步预读加上一次异步预读 
假设read所需要的页超过了预读窗口的范围。 
第一次同步预读的情形同上。在读到新的预读窗口(与预读组一致)中的页时,发现某一页未锁,于是再次进行新的异步预读,并再次设置新的预读窗口及预读组。此时,新的预读组中不可能包含read所需要的页面,read后续的读操作不会再次引发新的预读。 


因此,一个read如果为顺序读,在其读取所有页面的过程中,至多引发两次预读。还要注意到,经过一次异步预读后,read所需求的所有页都会在新的预读窗口内,因而其后不会有同步预读发生。也就是说,不会有“一次异步预读加上一次同步预读”的情形出现。

原创粉丝点击