Linux系统第十二章学习笔记

块设备I/O和缓冲管理

前言

本章涵盖了块设备I/O和缓冲管理。它阐述了块设备I/O的原理以及I/O缓冲的优势。讨论了Unix的缓冲管理算法并指出其缺点。接着,使用信号量设计新的缓冲管理算法,以提高I/O缓冲区的效率和性能。演示了简单的PV算法易于实现,具有良好的缓存效果,并且不会发生死锁和饥饿现象。最后,提出了一个编程项目,比较了Unix缓冲管理算法和PV算法的性能,有助于读者更好地理解文件系统中的I/O操作。

12.1 块设备I/O缓冲

在第11章,我们展示了读/写常规文件的算法。这些算法依赖于两个关键操作:get_blockput_block,它们在内存中读/写磁盘块。由于与内存访问相比,磁盘I/O速度较慢,因此在每次读/写文件操作时进行磁盘I/O是不可取的。因此,大多数文件系统使用I/O缓冲来减少与存储设备之间的物理I/O次数。一个设计良好的I/O缓冲方案可以显著提高文件I/O效率并增加系统吞吐量。

I/O缓冲的基本原理非常简单。文件系统使用一组I/O缓冲作为块设备的缓存内存。当进程尝试读取由(dev, blk)标识的磁盘块时,它首先在缓冲缓存中搜索已分配给磁盘块的缓冲区。如果这样的缓冲区存在并包含有效数据,则它直接从缓冲区读取,而无需再次从磁盘读取块。如果这样的缓冲区不存在,它会为磁盘块分配一个缓冲区,将数据从磁盘读入缓冲区,然后从缓冲区读取数据。一旦块被读入,缓冲区将保留在缓冲缓存中,以满足任何进程对相同块的下一次读/写请求。类似地,当进程写入磁盘块时,它首先获取分配给该块的缓冲区。然后,它将数据写入缓冲区,标记缓冲区为延迟写入并释放到缓冲缓存中。由于脏缓冲区包含有效数据,它可以用于满足对相同块的后续读/写请求,而不会产生实际的磁盘I/O。只有当脏缓冲区要重新分配给不同的块时,才会将其写入磁盘。

在讨论缓冲管理算法之前,我们首先介绍以下术语。在read_file/write_file中,我们假设它们从/向内存中的专用缓冲区读取/写入。使用I/O缓冲,缓冲区将动态分配在缓冲缓存中。假设BUFFER是缓冲区的结构类型(在下面定义),并且getblk(dev, blk)从缓冲缓存中分配给(dev, blk)的缓冲区。定义一个bread(dev, blk)函数,该函数返回包含有效数据的缓冲区(指针)。

BUFFER *bread(dev, blk) // 返回包含有效数据的缓冲区
{
  BUFFER *bp = getblk(dev, blk); // 为(dev, blk)获取一个缓冲区
  if (bp 数据有效)
    return bp;
  bp->opcode = READ; // 发出读操作
  start_io(bp); // 在设备上启动I/O
  等待I/O完成;
  return bp;
}

读取数据后,进程通过brelse(bp)将缓冲区释放回缓冲缓存。类似地,定义一个write_block(dev, blk, data)函数如下:

write_block(dev, blk, data) // 从U空间写入数据
{
  BUFFER *bp = bread(dev, blk); // 先读取磁盘块
  将数据写入bp;
  (同步写入)? bwrite(bp) : dwrite(bp);
}

其中,bwrite(bp)用于同步写入,dwrite(bp)用于延迟写入,如下所示。

------------------------------------------------------------------
bwrite(BUFFER *bp){ | dwrite(BUFFER *bp){
  bp->opcode = WRITE; | 将bp标记为延迟写入;
  start_io(bp); | brelse(bp); // 释放bp
  等待I/O完成; | }
  brelse(bp); // 释放bp |
} |
------------------------------------------------------------------

同步写入等待写入操作完成,适用于顺序或可移动块设备,例如USB驱动器。对于随机访问设备,例如硬盘,所有写入都可以是延迟写入。在延迟写入中,dwrite(bp)将缓冲区标记为脏并释放到缓冲缓存中。由于脏缓冲区包含有效

数据,它们可以用于满足相同块的后续读/写请求。这不仅减少了物理磁盘I/O的次数,还提高了缓冲缓存效果。只有当脏缓冲区要重新分配给不同的磁盘块时,才会通过以下方式将其写入磁盘。

awrite(BUFFER *bp)
{
  bp->opcode = ASYNC; // 用于异步写入;
  start_io(bp);
}

awrite()调用start_io()以在缓冲区上启动I/O操作,但不等待操作完成。当异步写入操作完成时,磁盘中断处理程序将释放缓冲区。

物理块设备I/O: 每个设备都有一个I/O队列,其中包含待处理I/O的缓冲区。对于缓冲区上的start_io()操作,它是这样的:

start_io(BUFFER *bp)
{
  将bp插入设备I/O队列;
  如果(bp是I/O队列中的第一个缓冲区)
    为bp向设备发出I/O命令;
}

当I/O操作完成时,设备中断处理程序完成当前缓冲区的I/O操作,并开始对I/O队列中的下一个缓冲区进行I/O,如果队列非空的话。设备中断处理程序的算法如下:

InterruptHandler()
{
  bp = 出队列(设备I/O队列); // bp = 移除I/O队列头部
  (bp->opcode == ASYNC)? brelse(bp) : 在bp上取消阻塞的进程;
  如果(设备I/O队列非空)
    为队列中的第一个bp发出I/O命令;
}

12.2 Unix I/O 缓冲管理算法

Unix I/O 缓冲管理算法首次出现在V6 Unix(Ritchie 和 Thompson 1978; Lion 1996)中。在 Bach 的第 3 章(Bach 1990)中详细讨论了该算法。Unix 缓冲管理子系统包括以下组件。

(1) I/O 缓冲区

在内核中使用一组 NBUF 缓冲区作为缓冲缓存。每个缓冲区由一个结构表示。

typedef struct buf {
    struct buf *next_free;  // freelist 指针
    struct buf *next_dev;   // dev_list 指针
    int dev, blk;           // 分配的磁盘块
    int opcode;             // READ|WRITE
    int dirty;              // 缓冲区数据已修改
    int async;              // ASYNC 写标志
    int valid;              // 缓冲区数据有效
    int busy;               // 缓冲区正在使用
    int wanted;             // 一些进程需要此缓冲区
    struct semaphore lock=1;    // 缓冲区锁定信号量;值=1
    struct semaphore iodone=0;  // 用于进程等待 I/O 完成的信号量
    char buf[BLKSIZE];       // 块数据区
} BUFFER;

BUFFER buf[NBUF], *freelist;  // NBUF 缓冲区和空闲缓冲区列表

缓冲区结构分为两部分:缓冲管理的头部部分和数据的块部分。为了节省内核内存,状态字段可能被定义为位向量,其中每个位表示一个唯一的状态条件。这里为了清晰和讨论的方便,它们被定义为 int。

(2) 设备表

每个块设备由一个设备表结构表示。

struct devtab {
    u16 dev;         // 主设备号
    BUFFER *dev_list; // 设备缓冲区列表
    BUFFER *io_queue; // 设备 I/O 队列
} devtab[NDEV];

每个 devtab 都有一个 dev_list,其中包含当前分配给该设备的 I/O 缓冲区,以及一个 io_queue,其中包含在该设备上挂起的 I/O 操作的缓冲区。I/O 队列可以按照最佳 I/O 操作的方式进行组织。例如,它可以实现各种磁盘调度算法,如电梯算法或线性扫描算法等。为简单起见,Unix 使用 FIFO I/O 队列。

(3) 缓冲区初始化

当系统启动时,所有 I/O 缓冲区都在空闲列表中,所有设备列表和 I/O 队列都为空。

(4) 缓冲区列表

当一个缓冲区分配给 (dev, blk) 时,它被插入到 devtab 的 dev_list 中。如果缓冲区当前正在使用,则将其标记为 BUSY 并从空闲列表中移除。一个 BUSY 缓冲区也可能在 devtab 的 I/O 队列中。由于缓冲区不能同时处于空闲和繁忙状态,因此通过使用相同的 next_free 指针来维护设备 I/O 队列。当一个缓冲区不再繁忙时,它被释放回到空闲列表,但仍然保留在 dev_list 中以供可能的重用。缓冲区只有在重新分配时才能从一个 dev_list 更改为另一个 dev_list。如前所述,读/写磁盘块可以用 bread、bwrite 和 dwrite 表示,所有这些都依赖于 getblk 和 brelse。因此,getblk 和 brelse 构成了 Unix 缓冲管理方案的核心。getblk 和 brelse 的算法如下。

(5) Unix getblk/brelse 算法

/* getblk: 返回一个独占使用的缓冲区=(dev, blk) */
BUFFER *getblk(dev, blk){
    while(1){
        // (1). 在 dev_list 中搜索一个 bp=(dev, blk)
        // (2). 如果 (bp 在 dev_list) {
        if (bp BUSY){
            设置 bp WANTED 标志;
            等待(bp);  // 等待 bp 被释放
            继续;      // 重试算法
        }
        /* bp 不繁忙 */
        从 freelist 中取出 bp;
        标记 bp 为 BUSY;
        返回 bp;
        // (3). 如果 (bp 不在缓存中; 尝试从 freelist 中获取一个空闲缓冲区
        // (4). 如果 (freelist 为空) {
        设置 freelist WANTED 标志;
        等待(freelist);  // 等待任何空闲缓冲区
        继续;  // 重试算法
        // (5). 如果 (freelist 不为空) {
        从 freelist 中取出第一个 bp;
        标记 bp 为 BUSY;
        如果 (bp DIRTY) {  // bp 用于延迟写入
            awrite(bp);  // 异步写入 bp;
            继续;  // 从 (1) 但不重试
        }
        // (5). 重新分配 bp 给 (dev,blk); 设置 bp 数据无效等。
        返回 bp;
    }
}

/** brelse: 将一个缓冲区释放为 freelist 中的 FREE **/
brelse(BUFFER *bp){
    if (bp WANTED)
        唤醒(bp);  // 唤醒所有在 bp 上睡眠的进程
    if (freelist WANTED)
        唤醒(freelist);  // 唤醒所有在 freelist 上睡眠

的进程
    清除 bp 和 freelist 的 WANTED 标志;
    插入 bp 到 (freelist 的末尾);
}

在 Bach(1990)中指出,缓冲区是在哈希队列中维护的。当缓冲区的数量很大时,哈希可能会减少搜索时间。如果缓冲区的数量较小,则由于额外的开销,哈希实际上可能会增加执行时间。此外,研究(Wang 2002)表明哈希对缓冲区性能几乎没有影响。实际上,我们可以将设备列表视为哈希队列,哈希函数为 hash(dev, blk) = dev。因此,使用设备列表作为哈希队列是没有损失泛化性的。Unix 算法非常简单易懂。也许正因为其极端简单性,人们乍一看可能并不是很感兴趣。有些人甚至可能认为它因为重复的重试循环而显得天真。然而,你越看越觉得它是有道理的。这个惊人简单但有效的算法证明了最初的 Unix 设计者的聪明才智。以下是关于 Unix 算法的一些具体评论。

  • (1) 数据一致性

为了确保数据一致性,getblk 绝不能为相同的 (dev, blk) 分配多个缓冲区。这是通过使进程在从睡眠中唤醒后重新执行“重试循环”来实现的。读者可以验证每个分配的缓冲区是唯一的。其次,在重新分配之前,脏缓冲区将被写出,从而确保数据一致性。

  • (2) 缓存效果

缓存效果通过以下手段实现。释放的缓冲区仍保留在设备列表中以供可能的重用。标记为延迟写入的缓冲区不会立即进行 I/O,可供重用。缓冲区被释放到 freelist 的尾部,但从 freelist 的前部分配。这是基于 LRU(Least-Recent-Used)原则的,有助于延长分配缓冲区的寿命,从而增加其缓存效果。

  • (3) 临界区

设备中断处理程序可能操作缓冲区列表,例如从 devtab 的 I/O 队列中删除 bp、更改其状态并调用 brelse(bp)。因此,在 getblk 和 brelse 中,在这些关键区域中屏蔽设备中断。这些是暗示的但未在算法中显示的。

12.2.1 Unix 算法的缺点

Unix 算法非常简单而优雅,但它也有以下缺点。

(1) 效率低

该算法依赖于重试循环。例如,释放一个缓冲区可能唤醒两组进程:那些需要释放的缓冲区的进程,以及只需要一个空闲缓冲区的进程。由于只有一个进程可以获取释放的缓冲区,所有其他唤醒的进程必须再次进入睡眠状态。从睡眠中唤醒后,每个唤醒的进程必须从头开始重新执行算法,因为所需的缓冲区可能已经存在。这将导致过多的进程切换。

(2) 预测不可知的缓存效果

在 Unix 算法中,每个释放的缓冲区都可以使用。如果缓冲区是由需要空闲缓冲区的进程获取的,那么即使仍然需要该缓冲区的进程存在,缓冲区也会被重新分配。

(3) 可能出现饥饿

Unix 算法基于“自由经济”的原则,其中每个进程都有机会尝试,但不能保证成功。因此,可能发生进程饥饿。

(4) 该算法使用 sleep/wakeup,仅适用于单处理器系统。

12.3 新的 I/O 缓冲管理算法

在这一节中,我们将展示一种新的 I/O 缓冲管理算法。与使用 sleep/wakeup 不同,我们将使用信号量上的 P/V 进行进程同步。信号量相对于 sleep/wakeup 的主要优点是:

  1. 计数信号量可以用来表示可用资源的数量,例如空闲缓冲区的数量。

以下是关于新的缓冲管理算法的具体内容。

12.3.1 使用信号量的缓冲管理算法

假设存在一个单处理器内核(一次只运行一个进程)。使用计数信号量上的 P/V 操作设计新的缓冲管理算法,满足以下要求:

  1. 保证数据一致性。
  2. 具有良好的缓存效果。
  3. 高效:无重试循环和不必要的进程“唤醒”。
  4. 不会发生死锁和饥饿。

值得注意的是,仅仅通过在 Unix 算法中使用 P/V 信号量替代 sleep/wakeup 不是一个可接受的解决方案,因为这样做将保留所有的重试循环。我们必须重新设计算法以满足上述所有要求,并证明新的算法确实优于 Unix 算法。首先,我们定义以下信号量。

BUFFER buf[NBUF];        // NBUF 个 I/O 缓冲区
SEMAPHORE free = NBUF;   // 用于 FREE 缓冲区的计数信号量
SEMAPHORE buf[i].sem = 1; // 每个缓冲区都有一个锁信号量 sem=1

为了简化表示法,我们将每个缓冲区的信号量称为缓冲区本身。与 Unix 算法一样,最初所有缓冲区都在空闲列表中,所有设备列表和 I/O 队列都为空。以下是使用信号量的简单缓冲管理算法的示例。

/* 获取一个独占使用的缓冲区 */
BUFFER *getblk(dev, blk) {
    P(free);  // 申请一个 FREE 缓冲区
    P(buf[blk].sem);  // 锁定相应缓冲区的信号量
    if (buf[blk].valid) {
        V(buf[blk].sem);  // 如果缓冲区有效,释放信号量
        return &buf[blk];  // 直接返回缓冲区
    }
    V(buf[blk].sem);  // 否则释放信号量
    // 读取磁盘块到缓冲区
    read_disk(dev, blk, &buf[blk]);
    return &buf[blk];  // 返回缓冲区
}

/* 释放缓冲区 */
void brelse(BUFFER *bp) {
    P(bp->sem);  // 锁定缓冲区的信号量
    bp->valid = 1;  // 设置缓冲区有效
    V(bp->sem);  // 释放信号量
    V(free);  // 释放一个 FREE 缓冲区
}

在这个新算法中,我们使用了 P/V 操作来控制对缓冲区的访问。P(free) 用于获取一个空闲缓冲区,P(buf[blk].sem) 用于锁定特定的缓冲区。V(free) 用于释放一个空闲缓冲区,而 V(buf[blk].sem) 用于释放特定缓冲区的锁。这种方法避免了 Unix 算法中的重试循环,并且信号量机制更加精确和可控。

苏格拉底挑战

Buffer Management in OS

posted @ 2023-11-18 18:43  20211120  阅读(15)  评论(0编辑  收藏  举报