Linux系统第十二章学习笔记
块设备I/O和缓冲管理
前言
本章涵盖了块设备I/O和缓冲管理。它阐述了块设备I/O的原理以及I/O缓冲的优势。讨论了Unix的缓冲管理算法并指出其缺点。接着,使用信号量设计新的缓冲管理算法,以提高I/O缓冲区的效率和性能。演示了简单的PV算法易于实现,具有良好的缓存效果,并且不会发生死锁和饥饿现象。最后,提出了一个编程项目,比较了Unix缓冲管理算法和PV算法的性能,有助于读者更好地理解文件系统中的I/O操作。
12.1 块设备I/O缓冲
在第11章,我们展示了读/写常规文件的算法。这些算法依赖于两个关键操作:get_block
和put_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 的主要优点是:
- 计数信号量可以用来表示可用资源的数量,例如空闲缓冲区的数量。
以下是关于新的缓冲管理算法的具体内容。
12.3.1 使用信号量的缓冲管理算法
假设存在一个单处理器内核(一次只运行一个进程)。使用计数信号量上的 P/V 操作设计新的缓冲管理算法,满足以下要求:
- 保证数据一致性。
- 具有良好的缓存效果。
- 高效:无重试循环和不必要的进程“唤醒”。
- 不会发生死锁和饥饿。
值得注意的是,仅仅通过在 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 算法中的重试循环,并且信号量机制更加精确和可控。