1

概念

系统架构

我们先来看一个典型计算机系统的架构。其中,CPU通过某种内存总线(memory bus)或互连电缆连接到系统内存。图像或者其他高性能I/O设备通过常规的I/O总线(I/O bus)连接到系统,在许多现代系统中会是PCI或它的衍生形式。更下面是外围总线(peripheral bus),比如SCSI、SATA或者USB。它们将最慢的设备连接到系统,包括磁盘、鼠标及其他类似设备。

image.png

为什么要用这样的分层架构?原因在于物理布局及造价成本。越快的总线越短,因此高性能的内存总线没有足够的空间连接太多设备。另外,在工程上高性能总线的造价非常高。所以,系统的设计采用了这种分层的方式,这样可以让要求高性能的设备离CPU更近一些,低性能的设备离CPU远一些。将磁盘和其他低速设备连到外围总线的好处很多,最重要的就是可以在外围总线上连接大量的设备。

标准设备

现在来看一个标准设备(不是真实存在的),通过它来帮助我们更好地理解设备交互的机制。从图中可以看到一个包含两个重要组件的设备。第一部分是向系统其他部分展现的硬件接口(interface),让系统软件来控制它的操作。因此,所有设备都有自己的特定接口以及典型的交互协议。

image.png

第二部分是它的内部结构(internal structure)。这部分包含设备展示给系统的抽象接口相关的特定实现,非常简单的设备通常用一个或几个芯片来实现它们的功能。更复杂的设备会包含简单的CPU、一些通用内存、设备相关的特定芯片,来完成它们的工作。

标准协议

在上图中,一个简化的设备接口包含3个寄存器:一个状态(status)寄存器,可以读取并查看设备的当前状态;一个命令(command)寄存器,用于通知设备执行某个具体任务;一个数据(data)寄存器,将数据传给设备或从设备接收数据。通过读写这些寄存器,操作系统可以控制设备的行为。

操作系统与该设备的典型交互协议如下:

While (STATUS == BUSY)
    ; // wait until device is not busy 
Write data to DATA register
Write command to COMMAND register
    (Doing so starts the device and executes the command) 
While (STATUS == BUSY)
    ; // wait until device is done with your request

该协议包含4步。

  1. 操作系统通过反复读取状态寄存器,等待设备进入可以接收命令的就绪状态。我们称之为轮询(polling)设备。
  2. 操作系统下发数据到数据寄存器。例如,你可以想象如果这是一个磁盘,需要多次写入操作,将一个磁盘块(比如4KB)传递给设备。
  3. 操作系统将命令写入命令寄存器;这样设备就知道数据已经准备好了,它应该开始执行命令。
  4. 操作系统再次通过不断轮询设备,等待并判断设备是否执行完成命令。

这个协议的好处是足够简单并且有效,但是难免会有一些低效和不方便。我们注意到轮询会导致在等待设备执行完成时浪费大量CPU时间,如果此时操作系统可以切换执行下一个就绪进程,就可以大大提高CPU的利用率。

利用中断减少CPU开销

多年前,工程师们发明了我们目前已经很常见的中断(interrupt)来减少CPU开销。有了中断后,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。当设备完成了自身操作,会抛出一个硬件中断,引发CPU跳转执行操作系统预先定义好的中断服务例程(Interrupt Service Routine,ISR),或更为简单的中断处理程序(interrupthandler)。中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待I/O的进程继续执行。

不过,使用中断并非总是最佳方案。假如有一个非常高性能的设备,它处理请求很快:通常在CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会使系统变慢:切换到其他进程,处理中断,再切换回之前的进程会有一定的代价。因此,如果设备非常快,那么最好的办法反而是轮询。如果设备比较慢,那么采用中断更好。如果设备的速度未知,或者时快时慢,可以考虑使用混合(hybrid)策略,先尝试轮询一小段时间,如果设备没有完成操作,此时再使用中断

另一个最好不要使用中断的场景是网络。网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能导致操作系统发生活锁(livelock),即不断处理中断而无法处理用户层的请求。

还有一个基于中断的优化就是合并(coalescing)。设备在抛出中断之前往往会等待一小段时间,在此期间,其他请求可能很快完成,因此多次中断可以合并为一次中断抛出,从而降低处理中断的代价。当然,等待太长会增加请求的延迟,这是系统中常见的折中。

利用DMA进行更高效的数据传送

标准协议还有一点需要我们注意。如果使用这种方式,CPU的时间会浪费在向设备传输数据或从设备传出数据的过程中。如何才能分离这项工作,从而提高CPU的利用率?

解决方案就是使用DMA(Direct Memory Access)。DMA引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要CPU介入。

DMA工作过程如下:为了能够将数据传送给设备,操作系统会通过程序告诉DMA引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以处理其他请求了。当DMA的任务完成后,DMA控制器会抛出一个中断来告诉操作系统已经完成数据传输。

与设备交互的方法

在了解了如何提升I/O的效率后,我们来讨论一下操作系统究竟如何与设备通信。

随着技术的不断发展,目前主要有两种方式来实现与设备的交互。第一种办法相对古老一些,就是用明确的I/O指令。这些指令规定了操作系统将数据发送到特定设备寄存器的方法,从而允许构造上文提到的协议。

例如在x86上,in和out指令可以用来与设备进行交互。当需要发送数据给设备时,调用者指定一个存入数据的特定寄存器及一个代表设备的特定端口。执行这个指令就可以实现期望的行为。

第二种方法是内存映射I/O(memory-mapped I/O)。通过这种方式,硬件将设备寄存器作为内存地址提供。当需要访问设备寄存器时,操作系统装载或者存入到该内存地址;然后硬件会将装载/存入转移到设备上,而不是物理内存。

两种方法都没有相对明显的优势。内存映射I/O的好处是不需要引入新指令来实现设备交互,但两种方法今天都在使用。

纳入操作系统:设备驱动程序

还有最后一个问题:每个设备都有非常具体的接口,那么如何将它们纳入操作系统,并且让操作系统尽可能通用呢?

这个问题可以通过古老的抽象技术来解决。在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。

我们来看看Linux文件系统栈,理解抽象技术如何应用于操作系统的设计和实现。下图粗略地展示了Linux软件的组织方式。可以看出,文件系统(当然也包括在其之上的应用程序)完全不清楚它使用的是什么类型的磁盘。它只需要简单地向通用块设备层发送读写请求即可,块设备层会将这些请求路由给对应的设备驱动,然后设备驱动来完成真正的底层操作

image.png

磁盘驱动器

介绍了通用的I/O设备概念后,我们将更详细地介绍一种设备:磁盘驱动器(hard disk drive)。磁盘驱动器一直是计算机系统中持久化数据存储的主要形式,文件系统技术的发展大部分都是基于它们的行为。因此,在构建管理它的文件系统软件之前,有必要先了解磁盘操作的细节。

接口

所有现代驱动器的基本接口都很简单。驱动器由大量扇区(512字节块)组成,每个扇区都可以读取或写入。在包含n个扇区的磁盘上,扇区从0到n−1编号。因此,我们可以将磁盘视为一组扇区,0到n−1是驱动器的地址空间(address space)。

多扇区操作是可能的,实际上许多文件系统一次读取或写入4KB(或更多)。但在更新磁盘时,驱动器制造商唯一保证的是单个扇区的写入是原子的。因此,如果发生不合时宜的掉电,则只能完成较大写入的一部分 (有时称为不完整写入)。

通常可以假设访问驱动器地址空间内的连续块(即顺序读取或写入)是最快的访问模式,并且通常比任何更随机的访问模式快得多。

基本构成

一个磁盘可能有一个或多个盘片(platter),每个盘片有两面,称为表面。这些盘片通常由一些硬质材料(如铝)制成,然后涂上薄薄的磁性层,即使驱动器断电,驱动器也能持久存储数据位。

所有盘片都围绕主轴(spindle)连接在一起,主轴以一个恒定的速度旋转盘片。旋转速率通常以每分钟转数(Rotations Per Minute,RPM)来测量,典型的现代数值在7200~15000 RPM范围内。

数据在扇区的同心圆中的每个表面上被编码,我们称这样的同心圆为一个磁道(track)。一个表面包含数以千计的磁道,紧密地排在一起。

磁盘的读写过程由磁头(disk head)完成,驱动器的每个表面有一个这样的磁头。磁头连接到单个磁盘臂(disk arm)上,磁盘臂在表面上移动,将磁头定位在期望的磁道上。

工作原理

让我们每次都构建一个简单的模型,来了解磁盘是如何工作的。首先假设我们有一个单一磁道的简单磁盘。该磁道只有12个扇区,每个扇区的大小为512字节,用0到11的数字表示。

image.png

单磁道延迟:旋转延迟

假设我们现在收到读取块0的请求,磁盘应如何处理该请求?

具体来说,它必须等待期望的扇区旋转到磁头下。这种等待在现代驱动器中经常发生,并且是I/O服务时间的重要组成部分,它有一个特殊的名称:旋转延迟(rotational delay,有时称为rotation delay)。在这个例子中,如果完整的旋转延迟是R,那么磁盘必然产生大约为R/2的旋转延迟,以等待0来到读/写磁头下面。

多磁道:寻道时间

磁盘只有一条磁道,这是不太现实的,现代磁盘有数以百万计的磁道。因此,我们来看看更现实一点的磁盘表面,这个表面有3条磁道。磁头当前位于最内圈的磁道上。下一个磁道包含下一组扇区,最外面的磁道包含最前面的扇区。

image.png

假设现在需要读取扇区11。为了服务这个读取请求,驱动器必须首先将磁盘臂移动到正确的磁道,这个过程也即所谓的寻道(seek)。寻道和旋转都是最昂贵的磁盘操作之一。

寻道有许多阶段:首先是磁盘臂移动时的加速阶段,随着磁盘臂全速移动而惯性滑动,然后随着磁盘臂减速而减速,最后在正确的磁道上停下来。

在寻道过程中,盘片也会跟着一起旋转,在这个例子中,大约旋转了3个扇区。因此,寻道完成时,磁头下方是扇区9。接着我们只需再等待短暂的转动延迟,当扇区11经过磁头时,数据才开始真正读写,称为传输(transfer)。因此,我们得到了完整的I/O时间图:首先寻道,然后等待转动延迟,最后传输。

一些其他细节

许多驱动器采用某种形式的磁道偏斜(track skew),以确保即使在跨越磁道边界时,也可以很方便地顺序读取。从一个磁道切换到另一个磁道时,如果没有这种偏斜,所需的下一个块已经旋转到磁头后的位置,因此驱动器将不得不等待整个旋转延迟,才能访问下一个块。

外圈磁道通常比内圈磁道具有更多扇区,这是几何结构的结果。这通常被称为多区域(multi-zoned)磁盘驱动器,其中磁盘被组织成多个区域,区域是表面上连续的一组磁道。每个区域每个磁道具有相同的扇区数量,并且外圈区域具有比内圈区域更多的扇区。

任何现代磁盘驱动器都有一个重要组成部分,即它的缓存(cache),由于历史原因有时称为磁道缓冲区(track buffer)。该缓存只是少量的内存(通常大约8MB或16MB),驱动器可以使用这些内存来保存从磁盘读取或写入磁盘的数据。例如,当从磁盘读取扇区时,驱动器可能决定读取该磁道上的所有扇区并将其缓存在其存储器中。这样做可以让驱动器快速响应所有后续对同一磁道的请求。

在写入时,驱动器面临一个选择:它应该在将数据放入其内存之后还是实际写入磁盘之后,回报写入完成?前者被称为后写(write back)缓存,后者则称为直写(write through)。后写缓存有时会使驱动器看起来“更快”,但可能有风险。如果文件系统或应用程序要求将数据按特定顺序写入磁盘以保证正确性,后写缓存可能会导致问题。

I/O时间和性能

关于磁盘驱动器的有关I/O的时间和性能问题,我们只需要记住以下几点:

  1. I/O总时间 = 寻道时间 + 旋转时间 + 传输时间,通常前两者的延迟大致相当,同时远大于传输时间。
  2. 由上一条可以得出,磁盘的随机和顺序工作负载之间的驱动性能差距很大,对于普通磁盘来说一般在几百倍左右。
  3. 高端的“性能”驱动器与低端的“容量”驱动器之间的性能差异很大。

磁盘调度

由于I/O的高成本,操作系统在决定发送给磁盘的I/O顺序方面历来发挥重要作用。给定一组I/O请求,磁盘调度程序检查请求并决定下一个要调度的请求。

与任务调度不同,对于磁盘调度,我们可以很好地猜测磁盘请求需要多长时间。通过估计请求的查找和可能的旋转延迟,磁盘调度程序可以知道每个请求将花费多长时间,因此选择先服务花费最少时间的请求。因此,磁盘调度程序将尝试在其操作中遵循SJF(最短任务优先)的原则。

SSTF:最短寻道时间优先

一种早期的磁盘调度方法被称为最短寻道时间优先(Shortest-Seek-Time-First,SSTF)。SSTF按磁道对I/O请求队列排序,选择在最近磁道上的请求先完成。例如,假设磁头当前位置在内圈磁道上,并且我们请求扇区21和2,那么我们会首先发出对21的请求,等待它完成,然后发出对2的请求。

image.png

在这个例子中,SSTF运作良好,首先寻找中间磁道,然后寻找外圈磁道。但SSTF不是万能的,原因如下。第一个问题,操作系统无法知道驱动器的几何结构,而是只会看到一系列的块。不过这个问题很容易解决。操作系统可以简单地实现最近块优先(Nearest-Block-First,NBF),然后用最近的块地址来调度请求。

第二个问题更为根本:饥饿(starvation)。在我们上面的例子中,如果有对磁头当前所在位置的内圈磁道有稳定的请求,纯粹的SSTF方法则将完全忽略对其他磁道的请求。

电梯

电梯算法也是一种比较古老的算法。该算法最初称为SCAN,简单地以跨越磁道的顺序来服务磁盘请求。我们将一次跨越磁盘称为“扫一遍”,如果请求的块所属的磁道在这次“扫一遍”中已经服务过了,它就不会立即处理,而是排队等待下次“扫一遍”。

SCAN有许多变种,C-SCAN是一种常见的变体,即循环SCAN(Circular SCAN)的缩写。该算法不是在一个方向扫过磁盘,而是从外圈扫到内圈,然后从内圈扫到外圈,如此循环往复。

然而,SCAN及其变种并不是最好的调度技术。特别是,SCAN还有SSTF实际上并没有严格遵守SJF的原则,因为它们忽视了旋转时间。

SPTF:最短定位时间优先

为了更好地理解这种算法,我们来看一个例子。

image.png

在这个例子中,磁头当前定位在内圈磁道上的扇区30上方。此时有两个分别针对扇区8和16的I/O请求因此,调度程序如何决定接下来应该服务哪个请求?

答案是“视情况而定”。这里需要考虑的情况是旋转与寻道相比的相对时间。如果在我们的例子中,寻道时间远远高于旋转延迟,那么SSTF就好了。但是,如果寻道比旋转快得多,那么在我们的例子中,寻道远一点的、在外圈磁道的服务请求8,比寻道近一点的、在中间磁道的服务请求16更好。

在现代驱动器中,查找和旋转大致相当,因此SPTF是有用的,它提高了性能。然而,它在操作系统中实现起来更加困难,操作系统通常不太清楚磁道边界在哪,也不知道磁头当前的位置(旋转到了哪里)。因此,SPTF通常在驱动器内部执行

其他调度问题

在较早的系统中,操作系统完成了所有的磁盘调度。然而在现代系统中,磁盘可以接受多个分离的请求,而且它们本身具有复杂的内部调度程序。因此,操作系统调度程序通常会选择它认为最好的几个请求,并将它们全部发送到磁盘。磁盘然后利用其磁头位置和详细的磁道布局信息等内部知识,以最佳可能(SPTF)顺序服务于这些请求

磁盘调度程序执行的另一个重要相关任务是I/O合并(I/O merging)。例如,设想一系列请求读取块33,然后是8,然后是34。在这种情况下,调度程序应该将块33和34的请求合并(merge)为单个两块请求。调度程序执行的所有请求都基于合并后的请求。合并在操作系统级别尤其重要,因为它减少了发送到磁盘的请求数量,从而降低了开销。

现代调度程序关注的最后一个问题是:在向磁盘发出I/O之前,系统应该等待多久?有人认为,即使只有一个磁盘I/O,也应立即向驱动器发出请求,这种方法被称为工作保全(work-conserving)。然而,研究表明,有时最好等待一段时间,即所谓的非工作保全(non-work-conserving)方法。通过等待,新的或“更好”的请求可能会到达磁盘,从而提高整体效率。当然,决定何时等待以及等待多久,可能会很棘手,需要大量实践和观察。


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道