《Unix/Linux系统编程》教材学习笔记第十二章

发布时间 2023-11-12 21:07:52作者: シバ鳥

chapter12

块设备I/O缓冲区

读写普通文件的算法依赖于两个关键操作,即get_block和put_block,这两个操作将磁盘块读写到内存缓冲区中。由于与内存访问相比,磁盘I/O速度较慢,所以不希望在每次执行读写文件操作时都执行磁盘I/O。因此,大多数文件系统使用I/O缓冲来减少进出存储设备的物理I/O数量。合理设计的I/O缓冲方案可显著提高文件I/O效率并增加系统吞吐量。

I/O缓冲的基本原理非常简单。文件系统使用一系列IO缓冲区作为块设备的缓存内存。当进程试图读取(dev,blk)标识的磁盘块时,它首先在缓冲区缓存中搜索分配给磁盘块的缓冲区。如果该缓冲区存在并且包含有效数据,那么它只需从缓冲区中读取数据,而无须再次从磁盘中读取数据块。如果该缓冲区不存在,它会为磁盘块分配一个缓冲区,将数据从磁盘读入缓冲区,然后从缓冲区读取数据。当某个块被读入时,该缓冲区将被保存在缓冲区缓存中,以供任意进程对同一个块的下一次读/写请求使用。同样,当进程写入磁盘块时,它首先会获取一个分配给该块的缓冲区。然后,它将数据写入缓冲区,将缓冲区标记为脏,以延迟写入,并将其释放到缓冲区缓存中。由于脏缓冲区包含有效的数据,因此可以使用它来满足对同一块的后续读/写请求,而不会引起实际磁盘I/O。脏缓冲区只有在被重新分配到不同的块时才会写人磁盘。

在read_file/write_file中,假设它们从内存中的一个专用缓冲区进行读/写。对于I/O缓冲,将从缓冲区缓存中动态分配缓冲区。假设BUFFER是缓冲区的结构类型,而且getblk(dev, blk)从缓冲区缓存中分配一个指定给(dev, blk)的缓冲区。定义一个bread(dev, blk)函数,它会返回一个包含有效数据的缓冲区(指针)。

BUFFER *bread(dev,blk)  //return a buffer containing valid data
{
    BUFFER *bp = getblk(dev,blk);   //get a buffer for (dev,blk)
    if(bp data valid)
        return bp;
    bp->opcode = READ;              //issue READ operation
    start_io(bp);                   //start I/O on device
    wait for I/O completion;
    return bp;
}

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

write_block(dev,blk,data)       //write data from U space
{
    BUFFER *bp = bread(dev,blk) //read in the disk block first
    write data to bp;
    (synchronous write)?bwrite(bp):dwrite(bp);
}

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

同步写入操作等待写操作完成。它用于顺序块或可移动块设备,如USB驱动器。对于随机访问设备,例如硬盘,所有的写操作都是延迟写操作。在延迟写操作中,dwrite(bp)将缓冲区标记为脏,并将其释放到缓冲区缓存中。由于脏缓冲区包含有效数据,因此可用来满足同一个块的后续读/写请求。这不仅减少了物理磁盘I/O的数量,而且提高了缓冲区缓存的效果。脏缓冲区只有在被重新分配到不同的磁盘块时才会被写入磁盘,此时缓冲区将被以下代码写入:

awrite(BUFFER *bp)
{
    bp->opcode = ASYNC;     //for ASYNC write;
    start_io(bp);
}

awrite()会调用start_io()在缓冲区开始I/O操作,但是不会等待操作完成。当异步(ASYNC)写操作完成后,磁盘中断处理程序将释放缓冲区。

物理块设备I/O:每个设备都有一个I/O队列,其中包含等待I/O操作的缓冲区。缓冲区上的start_io()操作如下:

start_io(BUFFER *bp)
{
    enter bp into device I/O queue;
    if(bp is first buffer in I/O queue)
        issue I/O command for bp to device;
}

当I/O操作完成后,设备中断处理程序会完成当前缓冲区上的I/O操作,并启动I/O队列中的下一个缓冲区的I/O(如果队列不为空)。设备中断处理程序的算法如下:

InterruptHandler()
{
    bp = dequeue(device I/O queue); //bp = remove head of I/O queue
    (bp->opcode == ASYNC)?brelse(bp):unblock process on bp;
    if(!empty(device I/O queue))
        issue I/O command for first bp in I/O queue;
}

Unix I/O 缓冲区管理算法

Unix I/O缓冲区管理算法最早出现在第6版Unix中(Ritchie和Thompson 1978;Lion1996)。Bach著作的第3章对该算法进行了详细论述(Bach 1990)。Unix缓冲区管理子系统由以下几部分组成。

(1)I/O缓冲区:内核中的一系列NBUF缓冲区用作缓冲区缓存。每个缓冲区用一个结构体表示。

typdef struct buf{
struct buf *next_free;      //freelist pointer
struct buf *next_dev;       //dev_list pointer
int dev, bIk;               //assigned disk block;
int opcode;                 //READ|WRITE
int dirty;                  //buffer data modified
int async;                  //ASYNC write flag
int valid;                  //buffer data valid
int busy;                   //buffer is in use
int wanted;                 //some process needs this buffer
struct semaphore lock=1;    //buffer locking semaphore; value=1
struct semaphore iodone=0;  //for process to wait for I/O completion;
char buf[BLKSIZE];          //block data area
}BUFFER;
BUFFER buf[NBUF],*freelist; //NBUF buffers and free buffer list

缓冲区结构体由两部分组成:用于缓冲区管理的缓冲头部分和用于数据块的数据部分。为了保护内核内存,状态字段可以定义为一个位向量,其中每个位表示一个唯一的状态条件。为了便于讨论,这里将它们定义为int。

(2)设备表:每个块设备用一个设备表结构表示。

struct devtab{
u16 dev;            //major device number
BUFFER *dev_list;   //device buffer list
BUFFER *io_queue;   //device I/O queue
}devtab[NDEV];

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

(3)缓冲区初始化:当系统启动时,所有I/O缓冲区都在空闲列表中,所有设备列表和I/O队列均为空。

(4)缓冲区列表:当缓冲区分配给(dev, blk)时,它会被插入设备表的dev_list中。如果缓冲区当前正在使用,则会将其标记为BUSY(繁忙)并从空闲列表中删除。繁忙缓冲区也可能会在设备表的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算法(Lion 1996;(Bach 1990)第3章)。

/* getblk:return a buffer=(dev,blk) for exclusive use */
BUFFER *getblk(dev,b1k){
    while(1){
    (1). search dev_list for a bp=(dev,blk);
    (2). if(bp in dev_lst){
            if(bp BUSY){
                set bp WANTED flag;
                sleep(bp);  //wait for bp to be released
                continue;   //retry the algorithm
            }
            /* bp not BUSY */
            take bp out of freelist;
            mark bp BUSY;
            return bp;
        }
    (3). /* bp not in cache; try to get a free buf from freelist */
        if(freelist empty){
            set freelist WANTED flag;
            sleep(freelist);    //wait for any free buffer
            continue;           //retry the algorithm
        }
    (4). /* freelist not empty */
        bp = first bp taken out of freelist;
        mark bp BUSY;
        if(bp DIRTY){       //bp is for delayed write
            awrite(bp);     //write bp out ASYNC;
            continue;       //from (1) but not retry
        }
    (5). reassign bp to (dev,blk);  //set bp data invalid,etc.
        return bp;
    }
}

/** brelse:releases a buffer as FREE to freelist **/
brelse(BUFFER *bp){
    if(bp WANTED)
        wakeup(bp);         //wakeup ALL proc's sleeping on bp;
    if(freelist WANTED)
        wakeup(freelist);   //wakeup ALL proc's sleeping on freelist;
    clear bp and freelist WANTED flags;
    insert bp to (tail of) freelist;
}

缓冲区存放在散列队列中。当缓冲区的数量很大时,散列可以减少搜索时间。当缓冲区的数量很少时,由于额外系统开销,散列实际上可能会增加执行时间。此外,散列对缓冲区缓存性能几乎没有影响。实际上,可以通过简单的散列函数hash(dev, blk)=dev将设备列表视为散列队列。这样,将设备列表用作散列队列不会损失通用性。下面是有关Unix算法的一些具体说明。

(1)数据一致性:为了确保数据一致性,getblk一定不能给同一个(dev, blk)分配多个缓冲区。这可以通过让进程从休眠状态唤醒后再次执行“重试循环”来实现。其次,脏缓冲区在重新分配之前被写出来,这保证了数据的一致性。

(2)缓存效果:缓存效果可通过以下方法实现。释放的缓冲区保留在设备列表中,以便可能重用。标记为延迟写入的缓冲区不会立即产生I/O,并且可以重用。缓冲区会被释放到空闲列表的末尾,但分配是从空闲列表的前面开始的。这是基于LRU(最近最少使用)原则,它有助于延长所分配缓冲区的使用期,从而提高它们的缓存效果。

(3)临界区:设备中断处理程序可操作缓冲区列表,例如从设备表的I/O队列中删除bp,更改其状态并调用brelse(bp)。所以,在getblk和brelse中,设备中断在这些临界区中会被屏蔽。这些都是隐含的,但没有在算法中表现出来。

Unix算法的缺点

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

(2)缓存效果不可预知:在Unix算法中,每个释放的缓冲区都可被获取。如果缓冲区由需要空闲缓冲区的进程获取,那么将会重新分配缓冲区,即使有些进程仍然需要当前的缓冲区。

(3)可能会出现饥饿:Unix算法基于“自由经济”原则,即每个进程都有尝试的机会,但不能保证成功。因此,可能会出现进程饥饿。

(4)该算法使用只适用于单处理器系统的休眠/唤醒操作。

新I/O缓冲区管理算法

下面将展示一种用于I/O缓冲区管理的新算法。该算法将在信号量上使用P/V来实现进程同步,而不是使用休眠/唤醒。与休眠/唤醒相比,信号量的主要优点是:

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

(2)当多个进程等待一个资源时,信号量上的V操作只会释放一个等待进程,该进程不必重试,因为它保证拥有资源。

这些信号量属性可用于设计更有效的缓冲区管理算法。

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

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

(1)保证数据一致性。

(2)良好的缓存效果。

(3)高效率:没有重试循环,没有不必要的进程“唤醒”。

(4)无死锁和饥饿。

注意,仅通过信号量上的P/V来替换Unix算法中的休眠/唤醒并不可取,因为这样会保留所有的重试循环。必须重新设计算法来满足所有上述要求,并证明新算法的确优于Unix算法。首先,定义以下信号量。

BUFFER buf[NBUF];            //NBUF I/O buffers
SEMAPHORE free = NBUF;       //counting semaphore for FREE buffers
SEMAPHORE buf[i].sem = 1;    //each buffer has a lock sem=1;

为了简化符号,用缓冲区本身来表示每个缓冲区的信号量。与Unix算法一样,最开始,所有缓冲区都在空闲列表中,所有设备列表和I/O队列均为空。下面将展示一个使用信号量的简单缓冲区管理算法。

PV算法

BUFFER *getb1k(dev,b1k)
{
    while(1){
(1).    P(free);            //get a free buffer first
(2).    if(bp in dev_list){
(3).        if(bp not BUSY){
                remove bp from freelist;
                P(bp);      //lock bp but does not wait
                return bp;
            }
            //bp in cache but BUSY
            V(free);        //give up the free buffer
    (4).    P(bp);          //wait in bp queue
            return bp;
        }
        //bp not in cache,try to create a bp=(dev,blk)
    (5). bp = frist buffer taken out of freelist;
        P(bp);              //lock bp, no wait
    (6). if(bp dirty){
            awrite(bp);     //write bp out ASYNC, no wait
            continue;       //continue from (1)
        }
    (7). reassign bp to (dev,blk);  //mark bp data invalid, not dirty
        return bp;
    }                       //end of while(1)
}

brelse(BUFFER *bp)
{
    (8). if(bp queue has waiter){V(bp); return;}
    (9). if(bp dirty && free queue has waiter){awrite(bp); return;}
    (10). enter bp into (tail of) freelist; V(bp); V(free);
}

下面将说明PV算法是正确的并满足要求。

(1)缓冲区唯一性:在getblk()中,如果有空闲缓冲区,则进程不会在(1)处等待,而是会搜索dev_list。如果所需的缓冲区已经存在,则进程不会重新创建同一个缓冲区。如果所需的缓冲区不存在,则进程会使用一个空闲缓冲区来创建所需的缓冲区,而这个空闲缓冲区保证是存在的。如果没有空闲缓冲区,则需要同一个缓冲区的几个进程可能在(1)处阻塞。当在(10)处释放出一个空闲缓冲区时,它仅释放一个进程来创建所需的缓冲区。一旦创建了缓冲区,它就会存在于dev_list中,这将防止其他进程再次创建同一个缓冲区。因此,分配的每个缓冲区都是唯一的。

(2)无重试循环:进程重新执行while(1)循环的唯一位置是在(6)处,但这不是重试,因为进程正在不断地执行。

(3)无不必要唤醒:在getblk()中,进程可以在(1)处等待空闲缓冲区,也可以在(4)处等待所需的缓冲区。在任意一种情况下,在有缓冲区之前,都不会唤醒进程重新运行。此外,当在(9)处有一个脏缓冲区即将被释放并且在(1)处有多个进程等待空闲缓冲区时,该缓冲区不会被释放而是直接被写入。这样可以避免不必要的进程唤醒。

(4)缓存效果:在Unix算法中,每个释放的缓冲区都可被获取。而在新的算法中,始终保留含等待程序的缓冲区以供重用。只有缓冲区不含等待程序时,才会被释放为空闲。这样可以提高缓冲区的缓存效果。

(5)无死锁和饥饿:在getblk()中,信号量锁定顺序始终是单向的,即P(free),然后是P(bp),但决不会反过来,因此不会发生死锁。如果没有空闲缓冲区,所有请求进程都将在(1)处阻塞。这意味着,虽然有进程在等待空闲缓冲区,但所有正在使用的缓冲区都不能接纳任何新用户。这保证了繁忙缓冲区最终将被释放为空闲缓冲区。因此,不会发生空闲缓冲区饥饿的情况。

I/O缓冲区管理算法比较

系统组织

下图是模拟系统的组织结构。

Box#1:用户界面

这是模拟系统的用户界面部分。它会提示输人命令、显示命令执行、显示系统状态和执行结果等。在开发过程中可以手动输入命令来执行任务。在最后测试过程中,任务应该有自己的输入命令序列。例如,各任务可以读取包含命令的输入文件。完成任务切换之后,将从另一个文件读取任务输入。

多任务处理系统

Box#2

这是多任务处理系统的CPU端,模拟单处理器(单CPU)文件系统的内核模式。实际上,它与用于用户级线程的多任务系统相同,只是以下修改除外。当系统启动时,它会创建并运行一个优先级最低的主任务,但它会创建ntask工作任务,所有任务的优先级都是1,并将它们输入readyQueue。然后,主任务执行以下代码,该代码将任务切换为从readyQueue运行工作任务。

/****** Main Task Code ******/
//after system initialization
while(1){
    while(task && readyQ == 0); //loop if no task runnable
    if(readyQ)                  //if readyQueue nonempty
        kswitch();              //switch to run a working task
    else
        end_task();             //all working tasks have ended
}

由于主任务的优先级最低,所以如果没有可运行的任务或所有任务都已结束,它将再次运行。在后一种情况下,主任务执行end_task(),在其中收集并显示模拟结果,然后终止,从而结束模拟运行。

所有工作任务都执行同一个body()函数,其中每个任务从输入文件中读取命令来执行读或写磁盘块操作,直到命令文件结束。

下面是body()函数代码。

#define CMDLEN 10
int cmdfile[NTASK];     //opened command file descriptors
int task = ntask;       //number of active tasks
int body()
{
    int dev,blk;
    char opcode,cmd[CMDLEN];
    while(1){
        if(read(cmdfile[running->pid],cmd,CMDLEN)==0){
            running->status = DEAD;     //task ends
            task--;                     //dec task count by 1
            tswtich( );
    }
    sscanf(cmd,"%c%4d%5d",&opcode,&dev,&blk);
    if(opcode=='r')             //read(dev,blk)
        readBlk(dev,blk);
    if(opcode=='w")
        writeBlk(dev, blk);     //write(dev,blk)
    }
}

各任务的命令由rand()函数取模极限值来随机生成。每个命令都是三重形式

[r xxx yyyy] or [w xxx yyyy], where xxx=dev, yyyy=blkno;

对于[r dev blk]命令,任务调用

BUFFER *bread(dev,blk)

来获取包含有效数据的缓冲区(指针)。从缓冲区读取数据后,它会将缓冲区释放回缓冲区缓存以便重用。在bread()中,任务可以等待getblk()中的缓冲区,或者等待缓冲区数据生效。如果是这样,它会休眠(在Unix算法中)或者被阻塞(在PV算法中),将任务切换到运行readyQueue中的下一个任务。

对于[w dev blk]命令,任务试图获取用于(dev, blk)的缓冲区。如果必须要等待getblk()中的缓冲区,它将会休眠或被阻塞,并切换任务以运行下一个任务。在将数据写入缓冲区之后,它会将延迟写入的缓冲区标记为脏,并将该缓冲区释放到缓冲区缓存中。然后,它会试图执行下一个命令等。

缓冲区管理器

缓冲区管理器实现各缓冲区管理函数,包括:

BUFFER *bread(dev, blk)
        dwrite(BUFFER *bp)
        awrite(BUFFER *bp)
BUFFER *getblk(dev, blk)
        brelse(BUFFER *bp)

这些函数可能会调用磁盘驱动程序来发出物理磁盘I/O。

磁盘驱动程序

磁盘驱动程序由两部分组成。

(1)start_io():维护设备I/O队列,并对I/O队列中的缓冲区执行I/O操作。

(2)中断处理程序:在每次I/O操作结束时,磁盘控制器会中断CPU。当接收到中断后,中断处理程序首先从IntStatus中读取中断状态,IntStatus包含:

|dev  | R|W |  StatusCode|
|<----- e.g.10 chars---->|

其中dev会标识设备。中断处理程序从设备I/O队列的头部删除缓冲区,并为缓冲区处理中断。对于磁盘读取中断,中断处理程序会先将数据块从DataIn移动到缓冲区的数据区域。然后,它将缓冲区数据标记为有效,并唤醒或解除对正在缓冲区上等待有效数据的任务的阻塞。被唤醒的进程将会从缓冲区读取数据并将缓冲区释放回缓冲区缓存。对于磁盘写入中断,没有进程在缓冲区中等待I/O完成。此类异步写入缓冲区由中断处理程序处理。它会关闭缓冲区的异步写入标记并将缓冲区释放到缓冲区缓存中。然后,它会检查设备I/O队列。如果I/O队列为非空,它会对I/O队列中的第一个缓冲区发出I/O命令。在发出磁盘写入操作之前,它会将缓冲区的数据复制到DataOut以便磁盘控制器获取。最后,它会写一个ACK到IntAck来确认当前中断,允许磁盘控制器继续控制。当中断处理程序处理完当前中断后,会从最后一个中断点开始恢复正常任务执行。

磁盘控制器

(3)Box#3:这是磁盘控制器,它是主进程的一个子进程。因此,它与CPU端独立运行,除了它们之间的通信通道,通信通道是CPU和磁盘控制器之间的接口。通信通道由主进程和子进程之间的管道实现。

  • 命令:从CPU到磁盘控制器的I/O命令。
  • DataOut:在写操作中从CPU到磁盘控制器的数据输出。
  • DataIn:在读操作中从磁盘控制器到CPU的数据。
  • IntStatus:从磁盘控制器到CPU的中断状态。
  • IntAck:从CPU到磁盘控制器的中断确认。

磁盘中断

从磁盘控制器到CPU的中断由SIGUSR1(#10)信号实现。在每次I/O操作结束时,磁盘控制器会发出kill(ppid, SIGUSR1)系统调用,向父进程发送SIGUSR1信号,充当虚拟CPU中断。通常,虚拟CPU会在临界区屏蔽出/入磁盘中断(信号)。为防止竞态条件,磁盘控制器必须要从CPU接收一个中断确认,才能再次中断。

虚拟磁盘

(4)Box#4:这些是Linux文件模拟的虚拟磁盘。使用Linux系统调用lseek()、read()和write(),可以支持虚拟磁盘上的任何块I/O操作。为了简单起见,将磁盘块大小设置为16字节。由于数据内容无关紧要,所以可以将它们设置为16个字符的固定序列。

模拟系统代码

模拟系统代码见教材代码实践。

模拟系统的改进

可通过多种方式改进模拟系统,使其成为更好的真实文件系统模型。

(1)模拟系统可以扩展为支持多个磁盘控制器,而不是单独一个磁盘控制器,这样可通过一个数据信号来缓解I/O堵塞。

(2)可用非均匀分布生成输入命令,以改善实际系统中模型文件操作。例如,可以生成更多的读命令而不是写命令,以及一些设备上有更多的I/O需求等。

PV算法的改进

PV算法非常简单,易于实现,但是它有以下两个缺点。

首先,它的缓存效果可能并非最佳。这是因为一旦没有空闲缓冲区,所有请求进程都将被阻塞在getblk()中的(1)处,即使它们所需的缓冲区可能已经存在于缓冲区缓存中了。

其次,当进程从空闲列表信号量队列中唤醒时,它可能会发现所需的缓冲区已经存在,但处于繁忙状态,在这种情况下,它将在(4)处再次被阻塞。严格地说,进程被不必要地唤醒了,因为它被阻塞了两次。可以通过参阅文献(Wang 2015)了解改进算法,改进算法在进程切换方面表现最佳,而且缓存性能也更好。

GPT提问环节

块设备I/O



缓冲区管理



在学习中遇到的一些问题

问题:在编译教材上的示例代码时出现了“未引用的定义”的报错,如下图。

询问GPT后,给出了如下的解释。

根据它的回答,我发现自己在编译时没有引用库,所以才会导致编译报错,按照GPT所给的方法就能正确编译了。

代码实践

1.PV算法实现

2.Unix I/O缓冲区管理算法实现

3.使用信号量的缓冲区管理算法实现