重拾操作系统

5,517 阅读15分钟

主要是把在重读 《现代操作系统》 中觉得有价值的东西,以 Tips 的形式记录下来。不可能面面俱到,但是如果有一定的基础应该是会回想起很多知识的。具体解释将会以链接形式补充。GitHub
相关阅读 : 重拾数据结构

1. 进程与线程

1.1 进程

主要是为了支持伪并发能力

  • 运行态 : 实际占用 CPU 资源
  • 就绪态 : 可运行,但是由于没有时间片而被暂停等待 CPU 重新调度
  • 阻塞态 : 外部某种事件导致(资源不足)不可运行

CPU 利用率 = 1 - p ^ n

p : IO 等待时间比

n : 进程数

1.2 线程

每一个进程有一个地址空间和一个控制线程,主要是使某个进程内的任务之间不被相互阻塞,实现一种进程内并行操作的假象。创建销毁更加轻量级。

共享一组资源,协同完成任务。每个线程有自己的堆栈区(因为要区分同一进程内的线程,CPU 调度要进行状态的保存)

线程模型
用户空间中实现线程
内核中实现线程
混合实现

1.3 进程间通信(IPC)

1.竞争条件

两个或者多个进程读写某些共享数据

2.临界区

将共享内存的访问代码称为临界区,确保在每个时刻两个进程不可能同时处于临界区中,这样可以避免竞争条件。核心思想为互斥。

并发程序准确高效要满足一下四个条件

  • 任何两个进程不能同时处于其临界区
  • 不应对 CPU 的速度和数量做任何假设
  • 临界区外运行的程序不得阻塞其他进程
  • 不得使进程无限期等待进入临界区
3.忙等待的互斥

互斥实现方案

屏蔽中断

每个进程进入临界区后立即屏蔽所有中断,这样 CPU 无法进行进程切换,就要离开临界区是打开中断。

锁变量

设置一个共享锁变量,初始值为 0。当一个进程想要进入临界区,必须检测锁的值是否为 0,是则置 1 进入临界区。不是则等待其他进程退出临界区时释放锁直到自己能获取到锁开始进入临界区。

锁变量还是会产生竞争条件

严格轮换法

一直循环等待直到出现允许该进程进入临界区的条件才开始运行,十分消耗 CPU 资源。

避免了竞争条件,但是临界区外运行的程序会发生阻塞

用于忙等待的锁称为自旋锁。

A:
while (TRUE) {
    while (turn != 0);
    critical_region();
    turn = 1;
    noncritical_region();
}

B:
while (TRUE) {
    while (turn != 1); 
    critical_region();
    turn = 0;
    noncritical_region();
}
Peterson 解法

一种互斥算法

#define FALSE 0
#define TRUE 1
#define N 2

int turn;
int interested[N];

void enter_region(int process) {
    int other;
    other = 1 - process;
    interested[process] = TRUE;
    turn = process;
    // 如果有另一个程序进入临界区的话则一直空循环
    while (turn == process && interested[other] == TRUE);
}

void leave_region(int process) {
    interested[process] = FALSE;
}
4.睡眠与唤醒

前面的弊端是忙等待会消耗 CPU 资源。如果在等待进入临界区时可以挂起,等到某个信号到达再唤醒就可以避免这种情况了。

生产者-消费者问题

利用资源缓冲区实现进程间的协调

#define N 100 
int count = 0;

void producer(void) {
    int item;
    while (TURE) {
        item = produce_item();
        if (count == N) {
            sleep();
        }
        insert_item(item);
        count = count + 1;
        if (count == 1) {
            wakeup(consumer);
        }
    }
}

void consumer(void) {
    int item;
    while (TURE) {
        if (count == 0) {
            sleep();
        }
        item = remove_item();
        count = count - 1;
        if (count == N - 1) {
            wakeup(producer);
        }
        consume_item(item);
    }
}
5.信号量

引入一个信号量来累计唤醒次数,可以为 0 或正数

使用 down 和 up 操作代替 sleep 和 wakeup

#define N 100
typedef int semaphore
semaphore mutex = 1;  // 控制对临界区的访问
semaphore empty = N; // 计数缓冲区的空槽数目
semaphore full = 0; // 计数缓冲区的满槽数目

void producer(void) {
    int item;
    while (TRUE) {
        utem = produce_item();
        down(&empty);
        down(&mutex);
        insert_item(item);
        up(&mutex);
        up(&full);
    }
}

void consumer(void) {
    int item;
    while (TRUE) {
        down(&full);
        down(&mutex);
        item = remove_item();
        up(&mutex);
        up(&empty);
        consume_item(item);
    }
}
  • mutex : 用于互斥,保证任一时刻只有一个进程读写缓冲区
  • full && empty : 实现同步,保证某种时间的顺序发生或者不发生
6.互斥量

仅仅适用于管理共享资源或一小段代码

7.管程
8.消息传递
9.屏障
1.4 调度

当有多个进程处于就绪态时就面临调度的选择。

内核管理线程时调度可以认为是线程级别的。

进程行为有 计算密集型I/O 密集型。而现在由于 CPU 改进速度加快,进程行为更倾向于后者,所以应该多运行该类进程保持 CPU 的利用率。

调度算法
  1. 批处理

    • 先来先服务
    • 最短作业优先
    • 最短剩余时间优先
  2. 交互式

    • 轮转调度(每个进程一个时间片,用完就轮转)
    • 优先级调度
    • 多级队列
    • 最短进程优先 (aT0 + (1 - a)T1
    • 保证优先
    • 彩票调度
    • 公平分享调度
  3. 实时

线程调度

和系统支持的线程实现方式有关(理解 : 线程表存在哪的区别)

用户级线程 : 内核并不知道,内核只是选中该进程,至于进程中跑哪个线程由用户态调度决定。

内核级线程 : 直接调度某个进程内的线程。

以上两种方式在性能上有主要差别 : 前面提及 I/O 操作其实是很耗时的,所以在进程间切换比在线程间切换更加耗时。因为线程轻量,而进程完成切换要完整的山下文切换,修改内存映像。而且同一进程内的线程 I/O 访问 cache 的局部性更高,不同进程间切换的清理缓存,这也会消耗时间。

2. 存储管理

主要思想就是内存抽象

2.1 空闲内存管理

使用位图的存储管理
使用链表的存储管理

2.2 虚拟内存

程序产生的地址为虚拟地址,在没有虚拟内存的操作系统上,直接将地址输送到内存总线上。而有虚拟内存的操作系统上,把虚拟地址输送到 MMU(Memory Management Unit),由 MMU 将虚拟地址映射为为物理地址。

分页

虚拟地址空间 : 页面 物理内存地址 : 叶框 4k大小

虚拟地址 = 虚拟页号(高位) + 偏移量(低位)

页表 : 把虚拟地址的页面映射为页框,每个进程都有自己的页表

加速分页方法 : 转换检测缓冲区(TLB)主要是优先在 TLB 中查找页面号。

大内存页表

  1. 多级页表
  2. 倒排页表 : 每个页框一个表项 + TLB 快表

2.3 页面置换算法

最优页面置换算法不可实现,因为无法确定未来。

1.最近未使用页面置换算法(NRU)

设置访问(读、写)位 R,页面修改位 M。

2.先进先出页面置换算法(FIFO)
3.第二次机会页面置换算法

设置一个检测最老页面位 R

4.时钟页面置换算法

链表实现页面选择

5.最近最少使用页面置换算法(LRU)

利用矩阵模拟 : 增加自身权重减少其他权重,行置 1,列置 0。

6.用软件模拟 LRU

老化算法

7.工作集页面置换算法
8.工作集时钟页面置换算法
算法 注释
最优算法 不可实现,但可作为基准
NRU(最近未使用)算法 LRU 的很粗糙近似
FIFO(先进先出)算法 可能抛弃重要页面
第二次机会算法 比 FIFO 有大的改善
时钟算法 现实的
LRU(最近最少使用)算法 很优秀,但很难实现
NFU(最不经常使用)算法 LRU 的相对粗略的近似
老化算法 非常近似 LRU 的有效算法
工作集算法 实现起来开销很大
工作集时钟算法 好的有效算法

2.4 内存映射文件

进程发起系统调用,把文件映射到其虚拟地址空间的一部分。一般实现是开始不加载,在程序访问时在按页加载。

// Linux 待填

2.5 实现

分页工作
  • 进程创建时 : 操作系统要确定程序和数据在初始时有多大,并为它们创建一个页表,操作系统还要在内存中为页表分配空间并对其进行初始化。
  • 进程运行时 : 页表必须在内存中(反之不需要),并且在磁盘交换区中分配空间。
  • 调度一个进程执行时 : 为新进程充值 MMU,刷新 TLB,更换页表。
  • 缺页中断发生时 : 操作系统必须通过读硬件寄存器确定是哪个虚拟地址造成了缺页中断通过该信息计算需要哪个页面,定位磁盘位置并找到合适的页框来存放新页面,必要的话要置换老页面,然后把所需页面读入页框。最后,备份程序计数器,是程序计数器指向引起缺页中断的指令,并重新执行该指令。
  • 进程退出时 : 释放页表,页面和页面在硬盘上占的空间。
缺页中断处理
  1. 硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前的指令的各种状态信息保存在特殊的 CPU 寄存器中。
  2. 启动一个汇编代码例程保存通用寄存器和其他易失信息,以免被操作系统破坏。这个例程将操作系统做为一个函数来调用。
  3. 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析这条指令,看看他在缺页中断时正在做什么。
  4. 一旦知道了发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致,如果不一致,向进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。如果没有空闲页框,执行页面置换算法寻找一个页面来淘汰。
  5. 如果选择的页框“脏”了,安排该页面写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用。
  6. 一旦页框“干净”后(无论是立刻还是在写回磁盘后),操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入。该页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行用户进程,则选择另一个用户进程运行。
  7. 当磁盘中断发生时,表明该页已被装入,页表已经更新可以反映他的位置,页框也被标记为正常状态。
  8. 恢复发生缺页中断指令以前的状态,程序计数器重新定向这条指令。
  9. 调度引发缺页中断的进程,操作系统返回调用他的汇编语言例程。
  10. 该例程恢复寄存器和其他状态信息,放回到用户空间继续执行,就好像缺页中断没有发生过一样。

2.6 分段

段是逻辑实体,大小不固定。

2.7 分段和分页结合 : MULTICS

还有 Intel Pentuium 未介绍

34 位的 MULTICS 虚拟地址

段号 页号 页内偏移
18 6 10
  1. 根据段号找到段描述符
  2. 检查该段的页表是否存在内存中。如果在,则找到他;如果不再,则产生一个段错误。如果访问违反了段的保护要求就要求发出一个越界错误(陷阱)。
  3. 检查所请求虚拟页面的页表项,如果该页面不再内存中则产生一个缺页中断,如果在内存就从页表中取出这个页面在内存中的起始地址。
  4. 把偏移量加到页面的起始地址上,得到要访问的字在内存中的地址。
  5. 最后进行读或写操作。

3. 文件系统

文件系统存放在磁盘上。多数磁盘划分为一个或多个分区,每个分区中有一个独立的文件系统。磁盘的 0 号盘扇区称为主引导记录(Master Boot Record, MBR),用来引导计算机。在 MBR 的结尾是分区表,该表给出了每个分区的其实和结束地址。表中的一个分区被标记为活动分区。在计算机被引导时,BIOS 读入并执行 MBR。MBR 做的第一件事是确定活动分区,读入它的第一个块,称为引导块,并执行。

整个分区:

MBR 分区表 磁盘分区 磁盘分区 磁盘分区...

磁盘分区:

引导块 超级块 空闲空间管理 i 节点 根目录 文件和目录

3.1 文件实现

连续分配

把每个文件作为一连串连续数据块存储在磁盘上。

链表分配

一个文件由几个磁盘块组成。

在内存中采用表的链表分配

把每个磁盘块的指针字放在内存的一个表中

i 节点

每个文件赋予一个称为 i 节点(index-node)的数据结构,列出文件属性和文件快的磁盘地址。

4. 输入/输出

4.1 I/O 硬件原理
I/O 设备

块设备 : 以块为单位传输,可寻址

字符设备 : 以字符为单位收发字符流,不可寻址

设备控制器
内存映射 I/O
直接存储器存取

DMA 工作原理:

  1. CPU 对 DMA 控制器进行编程
  2. DMA 请求磁盘传送数据到内存
  3. 磁盘传送数据到内存
  4. 磁盘给 DMA 控制器应答
  5. 完成中断

5. 死锁

5.1 资源

在进程对设备,文件等取得了排他性访问权限的时候,有可能会出现死锁。这类需要排他性使用的对象称为资源。

可抢占资源

可以从拥有它的进程中抢占而不会产生任何副作用。(存储器)

不可抢占资源

指在不引起相关的计算失败的情况下,无法把他从占有它的进程处抢占过来。( CD 刻录)

资源使用步骤:

  1. 请求资源
  2. 使用资源
  3. 释放资源

5.2 死锁概述

如果一个进程集合中的每个进程都在等待只能由该进程集合中的其他进程才能引发的事件,那么,该进程集合就是死锁的。

资源死锁条件

发生资源死锁的四个必要条件:

  1. 互斥条件 : 每个资源要么已经分配了一个进程,要么就是可用的。
  2. 占有和等待条件 : 已经得到了某个资源的进程可以再请求新的资源。
  3. 不可抢占条件 : 已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显示地释放。
  4. 环路等待条件 : 死锁发生时,系统中一定有两个或两个以上的进程组成的一条环路,该环路中的每个进程都在等待着下一个进程所占有的资源。

5.3 死锁检测与死锁恢复

死锁检测主要是判断当前空闲资源在某种合理分配下是否能使所有进程都运行完并且最终资源都能够释放。

恢复方法 :

  1. 利用抢占式恢复
  2. 利用回滚恢复
  3. 利用杀死进程恢复

5.4 死锁避免

资源轨迹图
安全状态和不安全状态
单个资源的银行家算法
多个资源的银行家算法

5.5 死锁预防

死锁避免从本质上来说是不可能的,因为他要获取未来的信息。

破坏互斥条件

如果资源不被一个进程独占死锁不会发生。(假脱机打印机)

破坏占有和等待条件

开始执行前请求所有资源就不会造成等待。另一种是请求资源时先释放自己所持有的资源,再尝试一次请求资源。

破坏不可抢占条件

针对某些资源进行虚拟化,实现可抢占。

破坏环路等待条件

保证每个进程在任何时刻只能占用一个资源如果要请求另外一个资源它必须先释放第一个资源。另一种是将所有资源统一编号,进程可以在任何时刻提出资源请求,但是请求必须按照资源编号顺序(升序)提出。

5.6 其他问题

两阶段加锁
通讯死锁
活锁
饥饿