L2:多进程产生的请求队列
- 第二层抽象
多个磁盘读写请求
- 现在我们可以处理一个磁盘块读写请求了,但操作系统中有多个进程,每个进程都会提出磁盘块访问请求,所以在实际操作系统中是多个进程产生多个磁盘块读写请求的情形。
- 经过第一层抽象以后,只要告诉操作系统要读写的盘块号就可以完成磁盘读写。
- 多个磁盘读写请求,需要用队列来组织这些请求,这就是操作系统对磁盘管理的第二层抽象。
- 经过第二层抽象以后,想进行磁盘读写的进程首先建立一个磁盘请求数据结构,并在这个数据结构中填上要读写的盘块号,然后将这个数据结构放入磁盘请求队列中就完成了“磁盘读写”。
操作系统要完成的工作
- (1)从队列中选择一个磁盘请求;
- (2)取出请求读写的盘块号;
- (3)根据盘块号计算出 C、H、S;
- (4)用 out 语句向磁盘控制器发出具体指令。
- 在这些步骤中,(2)-(4)是前面已经论述过的,此处主要论述(1)该怎么处理
- 从多个请求中选择出一个合适的请求来分配资源,这是典型的调度算法,所以第二层抽象的核心就是磁盘调度算法。
请求示例
- 给定一个磁盘请求队列 98, 183, 37, 122, 14, 124, 65, 67 (这些数字是柱面号),且已知当前柱面位置为 53,那么现在该选择哪个磁盘读写请求来处理呢?
- 首先需要思考另一个问题,为什么这里是柱面号呢?进程提出的磁盘读写请求中给出的是盘块号,盘块号中包含有柱面号、磁头号和扇区号三个部分的信息,此处为什么只关注其中的柱面号信息呢?
- 仍然从磁盘读写角度出发来分析,影响磁盘读写时间的主要因素是寻道(寻找柱面),所以在设计调度算法时应该优先考虑柱面号这个参数。
- 算法指标:寻道(主要是寻找柱面)是磁盘读写时间的主要因素,所以总寻道距离就成为评价磁盘调度算法的一个基本准则。
FCFS 调度
- 磁臂总共移动了 640 个柱面
- 磁臂在不断地长途奔袭。具体的说,磁臂从柱面 53 移动到柱面 98 的过程中,完全可以将柱面 65 和 67 处的磁盘请求处理了,这样将来就不用再移动磁臂了,总寻到距离一定会减小。
短寻道优先(Shortest-seek-timeFirst,简称 SSTF)
- 思想很简单,每次选择离当前磁头最近的柱面请求进行处理。
- 针对上面给出的磁盘请求队列,SSTF 的磁臂移动总距离(就是总寻道距离)要小得多了,总距离是 236 个柱面,比 640 小了很多。
- SSTF 是让总寻道距离最小的离线算法吗?
- 离线算法是指算法输入在算法执行之前已经全部出现,与之相对应的在线算法,即输入在执行过程中仍不断进入。
- 仔细想一想,磁头先向右移动去处理柱面 65、67 所产生的寻道是没有必要的,因为在接下来向左移动处理柱面 14 以后会向右移动一直处理到柱面 183,在这个移动过程中必然会经过柱面 65、67
- 所以 SSTF 不是导致最短寻道距离的离线算法,其核心原因是因为 SSTF 是一个贪心算法,该算法只考虑眼前利益,柱面 65、67 离当前柱面 53 最近,而没有考虑到当前的寻道会对未来寻道产生什么影响。因此,有比 SSTF 寻道时间更短的磁盘调度算法。
- 另一方面,SSTF 还会导致对用户磁盘请求的服务机会不均等,公平性问题:
- 当磁盘读写请求很多时,处于两端柱面的磁盘请求可能会被无限期延迟,这是因为 SSTF 很可能会在中间柱面来回摆动
磁盘调度扫描算法(SCAN 算法)
- 基本原理很简单:首先向一个方向进行扫描,处理经过的所有磁盘请求,直到这个方向不再磁盘请求时,磁头开始向另一个方向扫描,并处理经过的所有磁盘请求。
- 针对上面给出的磁盘请求队列,SCAN 算法导致的磁臂移动总距离是 208 个磁道,比 SSTF 更少
- 同时SCAN 算法也解决了位于两端柱面的磁盘请求饥饿问题。
- 但是 SCAN 还有一个小缺点,那就是位于中间的柱面请求还是占了便宜,因为在磁臂摆动的过程中,中间的柱面请求要比位于两端的柱面请求得到更快的处理。
循环扫描算法(CSCAN 算法)
- 解决 SCAN 算法对中间柱面请求优先处理的公平性问题。
- 办法很简单,不进行摆动扫描,采用复位扫描。
- 首先向某个方向进行扫描,比如沿着柱面号小的方向扫描,处理经过的所有磁盘请求,直到这个方向不再磁盘请求时,磁头迅速复位到另一个方向的最大请求位置,然后再沿着同样方向(柱面号小的方向)进行扫描,再处理经过的所有磁盘请求,如此反复。
- 这个算法也有一个更加有名的名称—电梯算法。
- 电梯算法是磁盘调度问题的一个基本解法,被应用到很多实际操作系统中。
现在可以开始具体实现多进程对磁盘的访问了
- (1)进程提出磁盘读写请求就是新建一个磁盘请求数据结构 req,可以用函数 make_request() 来产生req,并在 req 中填好请求盘块号等信息;
- (2)将请求 req 加入到操作系统中的磁盘请求电梯队列中,当然加入时应该进行临界区保护,因为多个进程都要往这个全局电梯队列中放入请求,所以该电梯队列是一个共享结构,修改时需要进行临界区保护;
- (3)在磁盘中断处理时,通常就是上一个磁盘请求处理完成时发出的中断,从电梯队列中取出一个磁盘请求进行处理。由于向队列中添加请求 req 时已经形保证了磁盘请求队列具有电梯队列的形状,所以磁盘中断处理程序从电梯队列取磁盘请求时,只要取出位于队列首部的请求即可;
- (4)取出磁盘读写请求以后要做的工作当然是取出请求数据结构中的盘块号,算出扇区号,算出 C、H、S 信息,用 out 指令发出去,和前面的第一层抽象拼合在一起即可。
static void make_request()
{
req->sector = bh->b_blocknr«1;
add_request(major+blk_dev,req);
}
// add_request 在电梯队列中插入磁盘请求 req
static void add_request(struct blk_dev_struct *dev, struct request *req)
{
struct requset *tmp = dev->current_request;
req->next=NULL;
cli(); //关中断(进入临界区)
for(; tmp->next; tmp = tmp->next)
// 用来寻找req在电梯队列中的插入位置
// 如果是条件IN_ORDER(tmp,req) && IN_ORDER(req,tmp->next) 成立,则 req 的柱面号位于 tmp 的柱面号
// 和 tmp->next 的柱面号之间。
// 如果是条件!IN_ORDER(tmp,tmp->next) && IN_ORDER(req,tmp->next) 成立,则 req的柱面号要比 tmp 的柱面号小,
// 也比 tmp->next 的柱面号小。
if((IN_ORDER(tmp,req)||!IN_ORDER(tmp,tmp->next))&&IN_ORDER(req,tmp->next)) break;
req->next = tmp->next;
tmp->next = req;
sti(); //开中断(离开临界区)
}
#define IN_ORDER(s1, s2) ((s1)->sector < (s2)->sector)
read(write)_intr
- 在磁盘控制器已经处理完成一个磁盘请求时,会产生磁盘中断。
static void read(write)_intr(void)
{
// 会唤醒一个进程,该进程就是正在等待那个磁盘请求的进程
end_request(1);
// 处理下一个磁盘请求,即电梯队列中位于队首的那个请求
do_hd_request();
}
do_hd_request()
{
// CURRENT 是一个全局指针,用来指向电梯队列中的队首元素
扇区号 = CURRENT->sector;
根据扇区号算出 C、H、S;
// 再回到磁盘使用的上一层抽象 计算 C、H、S 并发出 out 指令
hd_out(C,H,S,···);
}
L3:磁盘请求到高速缓存
- 在用户态调用读磁盘函数的时候,不用指定读到哪个地址,而是内核在执行的时候会返回 bh(该结构体里面包含了缓存的内存地址 b_data 还有对应的块号 block。当读写请求执行到内核里时,先查找 bh 的缓存表,如果有直接返回,没有再去访问磁盘。同时如果是写入,那么不用阻塞,直接写入 bh 里缓存的地址,然后等待时机刷新)
- 正是因为高速缓存在内核,所以才有每次 IO 都在内核空间(内核程序的地址空间)和用户空间(用户程序的地址空间)来回拷贝,而不是从 IO 设备直接写入到用户空间
到目前为止的磁盘处理过程
- 操作系统处理各个进程提出的磁盘请求,
- 根据请求中的磁盘块号在磁盘上找到相应的扇区位置,将这些扇区读入到内核态内存中,
- 然后再由系统调用(如 sys_read)将存放于内核态内存中的磁盘数据拷贝到用户态内存中,
- 用户态程序操作用户态内存中的数据。
磁盘高速缓存
- 磁盘读写时的数据要经过用户态内存、内核态内存以及磁盘扇区三个地方
- 根据这一基本结构,可以问这样一个问题:每次磁盘读都要真的去读磁盘扇区吗?要读的信息不会在内核态内存中吗?
- 高速缓存读写会大幅减少磁盘读写次数,从而大幅提高磁盘使用的效率
- 从用户角度出发,磁盘读写变成了高速缓存读写,用户向高速缓存发出读写请求,如果用户请求的数据在高速缓存中,操作系统会直接将信息返回,如果不在,才发出磁盘请求去读写磁盘。
高速缓存的关键是要提供一种机制来快速查找一个盘块数据是否在高速缓存中
-
要根据一个关键字(盘块号)来快速查找这个关键字是否在一个表中,最通常使用的数据结构就是散列表。
- 所以高速缓存要以盘块号为关键字形成一个散列表来组织那些已载入的磁盘块数据—缓存块。
-
如果发现高速缓存中没有用户请求的磁盘块,此时应该去读写物理磁盘,这就要求在高速缓存中取出一个空闲缓存块,用来缓存从磁盘块中读出的数据。
-
组织空闲缓存块通常使用的数据结构就是空闲链表。
-
设计磁盘高速缓存的核心就是要建立两个数据结构:
- 用一个散列表中组织有内容的缓存块,
- 在用一个空闲链接组织那些空闲的缓存块
bread 函数
- bread 函数中字母 b 的含义不是 block,虽然磁盘的确是块设备。此处字母b 的更为准确的含义是 buffer,这样的名字正好印证了这一层抽象的核心思想:在用户眼里,读磁盘实际上是读缓存区,所以该函数的名字是 buffer read。
// 参数是要读写的设备号 dev 以及具体的盘块号 block
struct buffer_head * bread(int dev, int block)
{
struct buffer_head * bh;
// 首先要查找高速缓存散列表,看其中是否已经存有块号为 block 的磁盘块数据
// 如果没有找到 block 号对应的缓存块,说明该磁盘块还没有从物理磁盘上读入。
// 此时 getblk 会从空闲链表中分配一个空闲缓存块bh,并将这个空闲缓存块 bh 交给后面的函数进行处理
bh = getblk(dev,block);
// 如果找到了该缓存块,则 if (bh->b_uptodate) 的判断条件就为真,此时直接返回
if (bh->b_uptodate) return bh;
// make_request 利用 bh 中的信息找到用户要读写的磁盘块号、磁盘读写的目标内存地址(高速缓存地址)等,
// 根据这些信息制造一个磁盘请求数据结构 req,然后将 req 加入到操作系统中的电梯队列中即可,
make_request(READ, bh);
// 用户进程在将磁盘读写请求放入电梯队列以后就可以睡眠等待了(当前进程等待在缓存块 bh 上)。
// 将来磁盘中断处理时会执行 end_request,那时候就会唤醒等待在 bh 上进程。
wait_on_buffer(bh);
return bh;
}
struct buffer_head * getblk(int dev,int block)
{
struct buffer_head * bh;
// 由于可能存在散列冲突,所以要沿着外散列表进行顺序查找,逐个比对缓存头中的磁盘块号是否和用户请求的盘块号 block 一致,
for (bh = hash(dev,block) ; bh != NULL ; bh = bh->b_next)
// 即 tmp->b_blocknr == block,如果相等,说明该磁盘块在散列表中,直接返回。
if (bh->b_dev == dev && bh->b_blocknr == block) return bh;
// 如果 getblk 在散列表中找不到block 对应的缓存块,就需要去空闲链表 free_list 中去分配空闲缓存块,
// 可以直接使用 free_list 中的第一个缓存块即可。
bh = free_list;
remove_from_queues(bh);
bh->b_uptodate=0;
bh->b_dev=dev;
bh->b_blocknr=block;
insert_into_queues(bh);
return bh;
}
#define NR_HASH 307 //一个素数
struct buffer_head * hash_table[NR_HASH];
#define hash(dev,block) hash_table[(((unsigned)(dev b̂ lock)) % )]