操作系统 | OS
1 操作系统简介
1.1 操作系统的定义与功能
操作系统指的是从计算机加电运行后一直在内存运行的程序, 又称 “内核”. 它负责管理计算机硬件和软件资源, 同时为用户和应用程序提供一个友好的交互界面. 操作系统的主要功能包括进程管理, 内存管理, 文件系统管理, 设备管理, 用户界面和系统调用.
1.2 操作系统的分类和发展历程
- 批处理操作系统 (自动地处理一批待执行的程序, 减少人机交互, 分为单道 / 多道批处理系统)
- 分时操作系统 (多个用户通过终端同时使用计算机, 提供交互功能, 使用轮转调度, 例如 UNIX)
- 实时操作系统 (能优先处理紧急任务, 分为硬实时 / 软实时系统)
- 网络操作系统 (支持计算机之间的通信与资源共享, 如 Novell NetWare)
- 分布式操作系统 (将多台计算机资源整合在一起, 提供统一的计算环境)
1.3 操作系统的体系结构
1.3.1 单内核
单内核将操作系统的主要功能模块都在 OS 内核实现, 运行在核心态.
这种结构的优点是性能较好, 因为系统调用和上下文切换开销较小. 然而, 缺点是结构复杂, 难以扩展和维护. 此外, 一个模块出现故障可能会影响整个系统. 传统的 Unix 和 Linux 就是单内核结构的代表.
1.3.2 微内核
微内核只把最核心的 OS 功能模块保留在内核, 其余的移到用户态, 实现为用户态服务.
这种结构的优点是可扩展性和可维护性较好, 同时具有较高的安全性和可靠性. 然而, 由于系统调用和进程间通信的开销较大, 微内核结构的性能可能较差. Mach, Minix 和 QNX 等操作系统采用了微内核结构.
1.3.3 混合内核
混合内核结构试图在单内核和微内核之间找到一个平衡点. 它将一些关键的功能模块 (如文件系统, 设备驱动等) 移回内核空间, 以提高性能. 与单内核相比, 混合内核仍具有较好的可扩展性和可维护性. 混合内核结构的代表包括 Windows 系列等操作系统.
1.4 执行状态
1.4.1 用户态
用户态 (User mode): 用户态是处理器运行用户程序的模式. 在用户态下, 程序只能访问受限制的资源和功能, 无法直接访问操作系统内核和硬件资源. 用户态下的程序执行在一个受限制的环境中, 不能执行某些特权指令, 如修改内存映射表, 操作 I/O 设备等. 当用户程序需要访问受保护的资源或执行特权指令时, 必须通过系统调用 (System call) 向操作系统发出请求, 让操作系统在内核态中代为执行.
1.4.2 内核态
内核态 (Kernel mode): 内核态是处理器运行操作系统内核代码的模式. 在内核态下, 操作系统可以访问所有硬件资源和执行特权指令. 操作系统内核负责管理系统资源, 如内存, 文件, I/O 设备等, 以及处理系统调用, 中断和异常. 当操作系统在内核态中执行时, 它具有完全控制权, 可以执行任何指令和访问任何资源.
2 进程管理
进程是程序在给定数据集上的一次执行, 是操作系统资源分配和调度的基本单位. 进程就是进行中的程序, 即进程 = 程序 + 执行.
2.1 进程的内存管理
操作系统通过将内存分割为不同的区域, 实现了对进程内存的有效管理. 进程的内存空间通常分为以下几个部分:
-
代码区 (Code Segment): 代码区用于存储程序的可执行代码, 通常是只读的, 以防止程序在运行过程中意外修改其代码.
-
数据区 (Data Segment): 数据区包括全局变量和静态变量, 通常分为已初始化数据区 (初始化的全局变量和静态变量) 和未初始化数据区 (未初始化的全局变量和静态变量).
-
堆 (Heap): 堆用于存储动态分配的内存, 如使用 malloc 或 new 分配的内存. 堆的大小是可变的, 可以根据程序的需求进行扩展和收缩.
-
栈 (Stack): 栈用于存储函数调用的局部变量和控制信息 (如返回地址). 栈具有后进先出 (LIFO) 的特性, 可以实现函数调用和返回的顺序控制.
每个进程的上述空间都是独立的.
操作系统也提供了一些机制以支持进程间的通信和共享, 例如共享内存是一种允许多个进程访问同一块内存区域的进程间通信方式.
2.2 进程状态
进程的并发性指的是可同时进行的进程, 包括并行进程和交替进程.
2.2.1 进程状态
进程有五种基本状态: 新建 (New), 就绪 (Ready), 运行 (Running), 阻塞 (Waiting) 和终止 (Terminated).
就绪进程位于就绪队列, 阻塞进程位于阻塞队列.
新建状态
-
进程刚被创建好时, 处于 New 状态;
-
等待被系统接纳.
就绪状态
- 已经被成功加载进内存并初始化完毕, 等待系统分配 CPU 资源.
运行状态
- 已经从就绪状态被调度器选中, 正在利用 CPU 执行.
阻塞状态
-
进程执行受到阻碍, 必须暂停的状态;
-
阻碍进程继续执行的因素可能有: I/O, 等待某个事件发生.
终止状态
- 进程执行完毕后等待被系统清除的状态.
2.2.2 状态迁移
- 新建 -> 就绪
进程的数据结构创建完毕, 初始化好了后, 操作系统将状态标记为就绪, 将进程控制块插入进程就绪队列;
- 就绪 -> 运行
就绪进程被派遣程序安排到 CPU 上运行;
- 运行 -> 终止
进程运行结束, 或因出错被异常终止;
- 阻塞 -> 就绪
IO 完成事件, 使等待该 IO 进程被唤醒, 转入就绪状态.
2.3 进程调度
2.3.1 调度分类
- 作业调度 (磁盘 <-> 内存)
作业调度也被称为长期调度或提交调度, 是决定哪些进程进入就绪队列的过程. 这是发生在多任务操作系统的最高级别的调度. 作业调度的目标是为系统保持一定数量的就绪进程, 以便可以有效利用 CPU.
- 中程调度 (磁盘 <-> 内存)
中程调度是介于长期调度和短期调度之间的一种调度, 涉及暂停或恢复进程. 例如当系统过载或者内存不足时, 中程调度器可能会将一些进程移出内存 (换出到磁盘), 等到资源足够时再将其换回 (移回内存).
- 进程调度 (就绪队列 <-> CPU)
进程调度也称为短程调度或 CPU 调度, 是决定哪个进程将获得 CPU 的过程. 当当前执行的进程进入阻塞状态, 或当其时间片用完时, 就需要进行进程调度. 这是发生在操作系统最低级别的调度, 涉及的时间范围非常短, 通常需要优化以减少上下文切换的开销.
2.3.2 调度算法评价指标
吞吐率: 在单位时间内完成的进程数量. 吞吐率用于衡量系统处理能力, 吞吐率越高, 说明系统处理能力越强.
周转时间: 从进程创建到进程终止的总时间. 周转时间包括进程在就绪队列中等待的时间, 实际占用 CPU 运行的时间以及因等待 I/O 操作而阻塞的时间. 降低周转时间意味着进程能更快地执行完毕.
服务时间: 进程实际占用 CPU 运行的时间, 即进程在 CPU 上执行的累计时间. 服务时间是衡量进程实际运行所需时间的指标.
等待时间: 进程在就绪队列中等待 CPU 资源的总时间. 减少等待时间可以降低进程延迟并提高系统的响应能力.
响应时间: 从进程提交到进程开始执行的时间. 响应时间不等于等待时间.
响应比 (带权周转时间): 响应比是周转时间与服务时间的比值. 在一些调度算法中,响应比越高的进程将被优先调度.
2.3.3 调度算法
FCFS 调度算法
先来先服务 (First-Come-First-Serve) 采用 FIFO 队列维护就绪进程, 每次从 FIFO 队列取队首进程, 将其投入运行, 新进入就绪态的进程放在队尾.
FCFS 算法不稳定, 长进程先于短进程到达会导致平均等待时间拉长 (护航效应).
SJF 算法
短作业优先 (Shortest-Job-First) 每次进行调度时, 优先选择下一个 CPU 周期最短的进程, 以平均等待时间为指标, SJF 是最优的调度. 它是一种分时调度, 也就是非抢占式.
SJF 算法可能导致一些长进程迟迟得不到执行.
SRTF 算法
最短剩余时间优先 (Shortest-Remaining-Time-First) 可被视为 SJF 的抢占式版本. 若新就绪进程的剩余时间短于正在执行的进程, 则切换为新进程.
SRTF 同样会导致饥饿问题.
优先级调度算法
每个进程被赋予一个优先数 (Priority Number), 有的系统优先数越大优先级越高 (Linux), 有的系统反之. 每次调度时优先级最高的任务被选中执行.
它分为抢占式优先级调度和非抢占式优先级调度. 优先级调度是为了照顾紧迫型任务.
HRRN 算法
最高响应比优先调度 (Hightest Response Ratio Next) 选取最高响应比的任务执行. 它是一种动态优先权调度.
轮转调度算法
轮转调度 (Round Robin) 通过时间片轮流给每个任务分配执行时间, 具有高度的公平性. 进程结束后重新放到就绪队列队尾排队.
如果时间片结束同时有新进程到达, 默认新进程比刚结束的进程优先进入就绪队列. 时间片内进程提前完成则时间片提前结束.
MLQ 算法
多级队列调度 (MLQ) 是将任务队列划分为多个级别, 每个级别有一个独立的队列, 不同级别的队列具有不同的优先级和不同的调度策略.
一般来说, 高优先级的队列优先被调度, 低优先级的队列在高优先级队列没有任务时才会被调度. 这种调度算法适用于有明确优先级区分的任务, 如实时任务和普通任务. 它是一种静态优先权调度.
MLFQ 算法
多级反馈调度 (MLFQ) 在 MLQ 的基础上引入了反馈机制, 通过观察任务的行为来调整任务的优先级. 具体地说, 当一个任务等待了一定时间还没有得到执行时, 它的优先级会降低; 相反, 当一个任务被连续执行了一段时间时, 它的优先级会提高. 这种调度算法适用于具有不确定执行时间和可能产生饥饿的任务, 如批处理任务. 它是一种动态优先权调度.
当新进程进入内存后, 首先将它放入第一队列的末尾, 按 FCFS 原则等待调度. 当轮到该进程执行时, 如它能在该时间片 (q = 1) 内完成, 便可撤离系统. 否则, 调度程序将其转入第二队列的末尾等待调度; 如果它在第二队列中运行一个时间片 (q = 2) 后仍未完成, 再依次将它放入第三队列 (q = 4), 依此类推.
调度程序首先调度最高优先级队列中的诸进程运行, 仅当第一队列空闲时才调度第二队列中的进程运行; 换言之, 仅当第 1 ~ (i - 1) 所有队列均空时, 才会调度第 i 队列中的进程运行. 如果处理机正在第 i 队列中为某进程服务时又有新进程进入任一优先级较高的队列, 此时须立即把正在运行的进程放回到第 i 队列的末尾, 而把处理机分配给新到的高优先级进程.
2.3.4 上下文切换
上下文切换是操作系统在多任务环境下进行任务切换时的过程. 当操作系统从一个正在执行的进程切换到另一个进程时, 它需要保存当前进程的上下文信息并加载下一个进程的上下文信息, 这样可以确保进程能够从之前的状态无缝切换到新的执行环境中.
上下文切换涉及以下关键部分的保存和恢复:
-
用户打开文件表: 这是记录进程打开的文件及其相关信息的数据结构. 在上下文切换时, 操作系统需要保存当前进程的文件表信息, 并在切换到下一个进程时, 恢复其相应的文件表.
-
进程控制块 (PCB): PCB 是操作系统用于管理进程的数据结构. 它包含了与进程相关的所有信息, 如进程状态, 程序计数器, 寄存器值, 优先级, 内存分配情况等. 在上下文切换时, 操作系统需要保存当前进程的 PCB, 并加载下一个进程的PCB.
-
核心栈: 核心栈是内核为每个进程分配的栈空间. 它用于存储内核级别的函数调用和中断处理等操作. 在上下文切换时, 操作系统需要保存当前进程的核心栈的状态, 并在切换到下一个进程时, 加载其相应的核心栈状态.
关于中断向量的提及, 中断向量表是一个系统级别的数据结构, 它存储了中断处理程序的入口地址. 它确实不属于上下文切换的内容, 因为它是共享的, 并不随着进程的切换而改变.
2.4 进程同步
2.4.1 信号量
信号量 (Semaphore) 本质上是一个整数值 S, 用于控制对共享资源的访问. 信号量有两种基本操作: P (Proberen, 尝试) 操作和 V (Verhogen, 提高) 操作.
- P 操作: 当进程执行 P 操作时, 信号量 S 的值减一. 如果信号量值小于 0, 进程被阻塞, 直到信号量值变为非负数.
- V 操作: 当进程执行 V 操作时, 信号量 S 的值加一. 如果信号量值小于等于 0, 进程唤醒等待队列中的一个阻塞进程.
信号量数据结构:
typedef struct{
int value;
struct process *L;
}semaphore;
其中 L 为等待队列.
// P 操作
void wait(semaphore S){
S.value--;
if (S.value < 0){
add this process to S.L;
block();
}
}
// V 操作
void signal(semaphore S){
S.value++;
if (S.value < 0){
remove a process P from S.L;
wakeup(P);
}
}
2.4.2 进程互斥
进程互斥是指在操作系统中, 多个进程在访问共享资源时需要互斥进行, 即在同一时刻只有一个进程可以访问共享资源.
临界区
- 多个进程并发访问共享变量 (代表共享资源)
- 访问共享变量的代码区域, 称为临界区 (Critical Section)
通过保证临界区被互斥的方式访问, 从而保证并发进程计算结果的一致性.
如果多个进程间不发生碰撞, 处于临界区的进程是可以被中断的.
互斥要求
- 互斥访问: 同一时刻仅有一个进程能进入临界区
- 空闲让进: 一个进程处于空闲状态时让其他进程占用处理器资源
- 有限等待: 不能让某个进程无休止等待
互斥算法
- Peterson 两进程解法
它使用两个布尔型变量 (flag 数组) 和一个整数型变量 (turn) 来表示进程的请求和轮次.
bool flag[2] = {false, false};
int turn = 0;
// 进程 0
while (true) {
flag[0] = true;
turn = 1;
while (flag[1] && turn == 1);
// 进入临界区
flag[0] = false;
// 离开临界区
}
// 进程 1
while (true) {
flag[1] = true;
turn = 0;
while (flag[0] && turn == 0);
// 进入临界区
flag[1] = false;
// 离开临界区
}
- Lamport 多进程解法
它是一种适用于多个进程的互斥解决方案, 使用请求标记 (requesting 数组) 和时间戳 (timestamp 数组) 来决定访问顺序.
bool requesting[N] = {false};
int timestamp[N] = {0};
// 进程 i
while (true) {
requesting[i] = true;
timestamp[i] = max(timestamp) + 1;
for (int j = 0; j < N; j++) {
if (j == i) continue;
while (requesting[j]) {
while (timestamp[j] < timestamp[i] || (timestamp[j] == timestamp[i] && j < i));
break;
}
}
}
// 进入临界区
requesting[i] = false;
// 离开临界区
}
- 硬件解法
硬件解法利用特殊的硬件指令来实现进程互斥. 这些指令具有原子性, 确保在多个进程之间不会出现竞争条件.
int lock = 0;
// Test-and-Set 函数
int test_and_set(int *lock) {
int old = *lock;
*lock = 1;
return old;
}
// 进程
while (true) {
while (test_and_set(&lock));
// 进入临界区
lock = 0;
// 离开临界区
}
Test-and-Set 函数读取 lock 的旧值, 然后将 lock 的值设置为 1, 返回 lock 的旧值. 当 test_and_set 函数返回 0 时, 表示当前进程已经获取到锁, 可以进入临界区; 当 test_and_set 函数返回 1 时, 表示锁已经被其他进程占用, 当前进程需要继续等待.
2.4.3 直接协作
直接协作指的是进程之间直接进行通信和同步, 例如通过共享内存, 消息传递, 信号量等方式, 在不同的进程之间进行数据传输, 同步或协调操作. 直接协作需要遵循特定顺序.
生产者 - 消费者问题
定义: 给定一个有限大小的缓冲区, 生产者将生产出的产品放入缓冲区, 消费者从缓冲区取出产品.
步骤:
-
定义信号量的初值 empty = n, full = 0.
-
使用 P/V 操作施加并发控制.
// Producer
while (true) {
生产一个产品;
P(empty);
送产品到缓冲区;
V(full);
}
// Consumer
while (true) {
P(full);
从缓冲区取产品;
V(empty);
消费产品;
}
- 在有多个生产者或消费者时, 需要考虑添加互斥锁 mutex = 1 来保证进程同步.
// Producer
while (true) {
生产产品;
P(empty);
P(mutex);
往 Buffer[i] 放产品;
i = (i + 1) % n;
V(mutex);
V(full);
}
// Consumer
while (true) {
P(full);
P(mutex);
从 Buffer[i] 取产品;
j = (j + 1) % n;
V(mutex);
V(empty);
消费产品;
}
2.4.4 间接协作
间接协作是指进程之间不直接进行通信和同步, 而是通过一个中间的代理来实现协作, 例如通过任务队列, 事件循环等机制, 将进程需要完成的任务提交给一个任务调度器, 由调度器来负责调度和分配任务. 间接协作不需要遵循特定顺序.
读者 - 写者问题
关系:
-
有读者在读时, 不可以写; 有写者在写时, 不可以读.
-
不允许两个写者同时写共享数据.
-
允许两个读者同时读共享数据.
表示:
// Semaphore
rw_mutex = 1 // 读写锁
r_mutex = 1 // 读锁
mutex = 1 // 写锁
// Reader
while (true) {
P(rw_mutex);
P(r_mutex);
r_cnt++;
if (r_cnt == 1)
P(mutex);
V(r_mutex);
V(rw_mutex);
read();
P(r_mutex);
r_cnt--;
if (r_cnt == 0)
V(mutex);
V(r_mutex);
}
// Writer
while (true) {
P(rw_mutex);
P(mutex);
write();
V(mutex);
V(rw_mutex);
}
当一个读者正在读取共享资源时, 另一个读者也可以同时进入访问共享资源, 因为它们都只是获取了读锁, 而没有获取写锁. 但是,当一个写者正在访问共享资源时, 其他读者和写者都需要等待, 直到该写者释放写锁.
这里的 rw_mutex 即为中介结构, 用于协调读者和写者对共享资源的访问, 因此读者写者问题被视为间接协作.
2.4.5 管程
定义
管程把分散在各进程的临界区集中起来进行管理.
-
提供对共享资源的统一管理
-
提供一组对资源操作的接口
-
对并发进程对管程中资源访问的控制, 由管程统一实现
语义
-
Mesa 语义
-
Hoare 语义
-
Brinch Hanson 语义
2.5 死锁
2.5.1 死锁定义
死锁 (Deadlock) 是指在多进程或多线程环境中, 一组进程或线程互相等待彼此持有的资源, 导致所有进程或线程都无法继续执行的现象.
2.5.2 死锁原因
死锁的根本原因: 系统资源不足, 进程推进顺序不当.
导致死锁的四个必要条件:
-
互斥 (Mutual Exclusion): 资源至少有一个是不能被多个进程或线程共享的. 也就是说, 在某一时刻, 某个资源只能被一个进程或线程占用.
-
占有并等待 (Hold and Wait): 进程或线程已经占有了至少一个资源, 但又需要获取其他进程或线程持有的资源才能继续执行. 此时, 进程或线程会保持对已占有资源的占用, 并等待其他资源.
-
非抢占 (No Preemption): 资源不能被抢占. 也就是说, 一旦进程或线程占有了某个资源, 其他进程或线程不能强行将该资源从占有者手中夺走, 除非占有者自愿释放资源.
-
循环等待 (Circular Wait): 存在一个进程或线程集合, 其中每个进程或线程都在等待下一个进程或线程所持有的资源. 这导致了一个循环等待资源的链, 造成死锁.
为了避免或解决死锁, 可以采取死锁预防, 死锁避免, 死锁检测等方法.
2.5.3 死锁预防
- 进程必须获取工作所需所有资源, 才能开展工作. (破坏了 "占有并等待", 但是并行性差)
- 对资源进行编号, 约定进程按照编号大小顺序对资源进行分配. (破坏了 "循环等待")
2.5.4 死锁避免
对每次进程资源的申请, 都做严格审查:
- 确定安全, 则允许分配
- 不能确定安全, 则不允许分配, 进程等待
安全状态下不会死锁 (存在安全序列), 不安全的状态存在死锁风险.
银行家算法
数据结构:
- 可用资源向量 (Available): 表示系统当前可用的各类资源数量.
- 最大需求矩阵 (Max): 表示每个进程对各类资源的最大需求量.
- 分配矩阵 (Allocation): 表示系统已分配给每个进程的各类资源数量.
- 需求矩阵 (Need): 表示每个进程仍需要的各类资源数量, 计算公式为 Need = Max - Allocation.
安全状态和不安全状态:
- 安全状态: 存在一个安全序列, 按照这个序列, 系统可以按顺序满足所有进程的资源需求, 使它们顺利完成.
- 不安全状态: 不存在一个安全序列, 可能导致死锁.
银行家算法步骤:
- 当一个进程请求资源时, 首先检查请求的资源数量是否超过其最大需求量. 如果超过, 则拒绝分配.
- 检查请求的资源数量是否小于或等于系统当前可用资源数量. 如果大于, 则让进程等待.
- 假设系统分配了请求的资源, 更新 Available, Allocation 和 Need 矩阵.
- 检查系统是否处于安全状态. 如果是, 则分配资源; 如果不是, 则撤销分配, 让进程继续等待.
这种算法是保守的, 即使进程未来会释放资源, 不安全的状态也不能够被允许.
2.5.5 死锁检测
-
检测死锁, 并解除 (开销大)
-
不处理, 忽略 (多用于通用桌面操作系统, "鸵鸟策略")
定期启动对进程等待图的环路检测, 如果发现等待图中有环, 那么报告死锁发生.
检测时机
- 当进程阻塞时, 系统实施检测
- 定时检测
- 系统资源利用率下降时检测死锁
死锁解除手段
- 重新启动
- 撤销进程
- 剥夺进程资源
- 进程回退
2.6 进程间通信
进程间通信(IPC)分为低级通信和高级通信.
2.6.1 低级通信
低级通信包括信号量, 互斥锁等, 用于进程控制信息的传递, 传输信息量较小, 例如一个整型量.
2.6.2 高级通信
高级通信包括管道 (Pipe), 共享内存 (Shared Memory), 消息队列 (Message Queue) 等, 用于进程间信息的交换与共享, 传输信息量较大.
管道
管道是一种基本的进程间通信方式, 主要用于具有父子关系的进程之间的通信. 管道允许一个进程的输出成为另一个进程的输入, 实现了进程之间的数据传输.
管道的优点是简单易用, 但缺点是数据传输速度较慢, 且仅适用于具有亲缘关系的进程之间.
共享内存
共享内存是一种将一段内存区域映射到多个进程的地址空间的进程间通信机制. 这种方法允许多个进程访问同一块内存区域 (虚拟地址对应同一个物理地址), 从而实现快速的数据共享. 共享内存是最快的进程间通信方式, 因为数据无需在进程之间进行复制.
但共享内存的问题在于需要解决同步和互斥问题, 以避免竞争条件. 为了解决这个问题, 通常需要使用信号量, 互斥锁等同步原语.
消息传递
在消息传递通信中, 进程通过发送和接收消息来进行通信, 涉及到与系统内核的交互.
-
直接通信: 直接通信是指进程之间直接交换消息. 发送进程将消息直接发送给接收进程, 并等待接收进程的响应. 这种通信方式可以是同步的或异步的.
-
间接通信: 间接通信是通过使用共享的中间通信通道进行消息传递. 进程通过将消息发送到共享通道, 而不是直接发送给特定的接收进程. 其他进程可以从通道中接收消息, 并根据消息的标识符或其他属性来确定接收.
3 线程管理
线程 (Thread) 是操作系统中能够独立调度和执行的最小单位. 线程是独立的执行代码流.
3.1 线程与进程的关系
线程属于进程, 一个进程可以包含一个或多个线程. 与进程相比, 线程共享相同的地址空间, 代码段, 数据段和文件描述符等资源, 这使得线程之间的通信和数据共享更为高效.
线程同样具有新建, 就绪, 运行, 阻塞, 终止五种状态以及迁移过程, 也同样需要考虑同步与互斥问题.
单核 CPU 同样可以使用多线程, 因为每个子任务不同周期可以是交错的.
3.1.1 进程和线程的主要区别
- 调度
线程作为调度的基本单位.
- 并发性
线程能够支持更细粒度的并发.
- 拥有资源
进程拥有资源.
- 系统开销
线程切换开销小于进程切换开销.
3.2 线程分类
线程分为用户级线程和内核级线程.
3.2.1 用户级线程
-
在用户态以线程库的形式实现;
-
对用户级线程的操作通过调用用户态线程库 API 执行.
3.2.2 内核级线程
-
在内核态实现, 操作系统内核直接管理;
-
线程的创建由系统调用完成.
3.3 线程模型
M:1 模型
多个用户级线程绑定到一个内核级线程; 如果一个用户级线程引起阻塞, 会导致整个线程中所有的线程阻塞.
1:1 模型
1 个用户级线程绑定到 1 个内核级线程; 需要内核级线程的数量多, 消耗更多的内核资源. Linux 和 Windows 采用这种模型.
M:N 模型
多个用户级线程绑定到多个内核级线程; 管理复杂, 影响效率.
4 主存管理
主存 = 内存 = 运存 = RAM. 程序从存储空间加载到主存中才能运行.
逻辑地址和物理地址绑定分为静态重定位 (编译或加载时绑定) 和动态重定位 (运行时绑定). 现代计算机都采用动态重定位方式.
4.1 连续内存分配机制
4.1.1 固定长度
使用一个数组来记录各个连续内存块的分配情况. 内存块的状态分为已分配和空闲.
这常常会导致分区内的浪费 (内部碎片).
4.1.2 可变长度
使用一个链表或动态数组记录空闲块的状态后分配.
- 首次适配分配算法 (First-Fit) 从第一个大于请求空间的空闲区中截取所需空间, 修改调整可用表.
链表, 空闲块按起始地址升序.
- 最佳适配分配算法 (Best-Fit) 对空闲区从小到大排序, 从中搜索第一个大于请求空间的空闲区中截取所需空间, 修改调整可用表.
链表, 空闲块按大小升序.
- 最差适配分配算法 (Worst-Fit) 对空闲区从大到小排序, 选择第一个空闲区截取所需空间, 修改调整可用表.
链表, 空闲块按大小降序.
这些都会产生外部碎片.
4.2 分页机制
页表存储了虚拟地址空间到物理地址空间的映射信息. 页表存储在内存中.
4.2.1 分页
-
将进程的逻辑地址空间按页划分
-
将物理内存的地址按页划分
-
每个页代表一组连续地址
-
进程被分配的页在物理内存中不一定连续
分页可以减少内部碎片, 但是不能完全解决它.
4.2.2 地址划分
虚拟地址被按位拆分成页号 (高位) 和页内偏移 (低位) 两部分. 页号用于确定该地址位于哪个虚拟页面, 页内偏移表示在该页面中的具体位置.
当编写程序时, 实际操作的是虚拟地址, 这些地址通常会组织成连续的块, 用于存储数据结构 (如数组, 结构体等). 对于程序来说, 它的虚拟地址空间是连续的, 即使物理内存实际上可能是碎片化的.
4.2.3 建立地址映射
每一个页面对应一个页表项记录其虚拟地址到物理地址的映射关系. 32 位 CPU 的页表项大小为 4 字节, 64 位 CPU 的页表项大小为 8 字节. 页表大小等于页表项数与页表项大小的乘积.
4.2.4 地址翻译
当进程访问内存时, CPU 需要将虚拟地址转换为物理地址. 地址翻译的过程如下:
- 首先, 从虚拟地址中提取页号和页内偏移;
- 使用页号在页表中查找相应的物理页面. 如果找到了对应的映射关系, 那么从页表项中获取物理页号.
- 将物理页号与页内偏移组合, 得到物理地址.
- 访问物理地址对应的内存单元.
为了加速地址翻译过程, 通常使用硬件缓存 TLB (Translation Lookaside Buffer, 快表) 来缓存最近使用的页表项. 当虚拟地址到物理地址的转换在 TLB 中找到对应的映射关系时, 可以避免访问内存中的页表, 从而提高系统性能.
与分页机制类似, 分段机制的地址也分为段号和段内偏移两部分, 也同样具有段表. 每个段或页都有一个基地址和一个最大长度, 基地址确定了段或页在内存中的位置, 最大长度确定了段或页的大小. 段页式管理方式中, 首先会把程序的地址空间划分为多个逻辑上的段, 然后再把每个段进一步划分为多个页.
4.3 虚拟内存管理
虚拟内存管理是一种内存管理技术, 通过将物理内存与磁盘存储结合, 为进程提供一个连续的虚拟地址空间. 虚拟内存允许操作系统在物理内存不足时, 将不常用的内存页面 (Page) 换出到磁盘上的交换空间 (Swap Space). 当进程需要访问被换出的页面时, 操作系统将其从磁盘加载回物理内存. 这种机制可以实现内存的按需分配, 提高内存利用率. 虚存的核心思想是用较少的物理内存支撑较大的逻辑地址空间. 虚存的理论依据是程序的局部性原理.
4.3.1 页面置换算法
当进程访问一个尚未加载到物理内存的页面时, 会触发缺页中断. 如果物理内存有剩余空间, 那么将其从外存读取到内存中; 如果物理内存的剩余页面不足, 那么执行置换算法. 置换算法是操作系统在发生缺页中断时, 选择要换出的内存页面的方法. 这是一种常见的内存扩充方法.
当需要将一个页面从物理内存中置换出去时, 如果该页面的修改位被置为 1, 说明该页面已被修改过, 那么操作系统就需要将该页面的内容写回磁盘, 以便将修改后的数据持久化保存. 如果该页面的修改位被置为 0, 则说明该页面的内容没有被修改过, 那么操作系统可以直接将该页面从物理内存中移除, 而不必将其写回磁盘.
-
FIFO (先进先出): 选择最早被加载到内存的页面进行置换.
-
OPT (最佳页面置换): 选择未来最久未被使用的页作为牺牲页.
-
LRU (最近最少使用): 选择最近一段时间内最少被访问的页面进行置换.
LRU 近似算法: 附加引用位算法 (优先选择引用位较小的页面置换), 二次机会时钟算法 (每次调入或置换后将指针指向后继页并将引用位置 1, 引用位为 1 的页面会赢得驻留内存的第 2 次机会, Linux 常用).
4.3.2 页帧分配
进程执行过程中, 页面被频繁换入换出, 导致进程执行的大部分时间都在进行换入换出, CPU 利用率下降, 称为内存抖动. 引起抖动的主要原因: 页置换算法选择不当, 太高的多道程序度.
工作集模型
一个进程的工作集是在一段连续的执行过程中, 该进程访问的所有页面的集合. 这个模型的基本思想是: 在任何时刻, 一个进程只会访问一部分固定的页面集合, 这就是它的工作集. 通过调整分配给进程的内存量, 使其能够容纳其工作集, 可以减少缺页中断, 提高系统性能. Windows 采取的通常是这种方式.
工作集模型使用计时器和引用位近似实现, 定时器中断发生时, 如果引用位中至少有一个值为 1, 则对应的页面在工作集中.
4.3.3 内核内存分配
内核内存通常用于存储内核数据结构, 例如进程描述符, 页表, I/O 缓冲区, 文件系统缓存等.
- 伙伴系统: 一种简单但高效的内存分配算法, 它将内存划分为大小为 2 的 n 次方的块. 伙伴系统可以快速地合并和分割内存块, 以满足不同大小的分配需求.
伙伴的判定: 两个块大小相同, 两个块地址连续, 两个块必须是同一个大块分离出来的.
-
SLAB 分配器: 一种用于内核对象的内存分配器. 它将内存划分为称为 slabs 的块, 每个 slab 都包含一个或多个相同大小的对象. SLAB 分配器可以有效地重用内核对象, 以减少内存碎片和创建销毁对象的开销.
-
SLOB 分配器: SLOB 分配器是一种更简单的内存分配器, 它将内存划分为大小不等的块. SLOB 分配器比 SLAB 分配器更节省内存, 但是可能会产生更多的内存碎片.
-
SLUB 分配器: SLUB 分配器是一种改进的 SLAB 分配器, 它消除了 SLAB 分配器的一些开销, 并提高了缓存的利用率.
4.4 内存保护技术
内存保护技术可以防止一个进程意外或恶意地访问其他进程的内存空间, 保护操作系统内核和关键数据结构. 常见的内存保护技术包括:
- 地址空间隔离
操作系统为每个进程分配独立的地址空间, 以确保进程之间的内存空间不会相互干扰. 现代操作系统通常使用虚拟内存技术实现地址空间隔离.
- 访问权限控制
操作系统为内存段或页设置访问权限, 如只读, 可读写等. 当一个进程试图违反访问权限时, 操作系统将产生一个异常, 防止非法访问.
- 内核空间和用户空间隔离
操作系统将内存空间划分为内核空间和用户空间. 内核空间用于存储操作系统内核和关键数据结构, 而用户空间用于存储用户程序和数据. 内核空间和用户空间之间的隔离可以防止用户程序意外或恶意地访问内核数据和资源.
- 内存保护单元 (MPU) 和内存管理单元 (MMU)
这些硬件组件可以协助操作系统实现内存保护, 例如地址转换, 访问权限检查等功能.
5 文件系统管理
5.1 文件系统的概念与组织
每个文件代表一段连续的逻辑数据. 文件逻辑结构分为流式文件和记录式文件 (如数据库文件). 记录式文件分为顺序逻辑结构, 索引逻辑结构, 索引顺序逻辑结构. 操作系统中对文件结构的支持以流式文件为主, 数据库管理系统对数据库表的存储多采用记录式结构.
5.1.1 操作系统为文件对象提供的操作
-
open: 这个操作用于打开一个已经存在的文件. 当文件被打开时, 操作系统通常会为其创建一个文件描述符或者文件句柄, 后续的读写操作都会通过这个描述符或句柄进行.
-
close: 这个操作用于关闭一个已经打开的文件. 关闭文件会释放系统中的资源 (如文件描述符). 在关闭文件后, 任何试图使用原来的文件描述符进行读写的操作都将失败.
-
create: 这个操作用于创建一个新的文件. 通常, 新文件的内容为空, 但是文件的元数据 (如创建时间, 修改时间, 所有者等) 会被设置.
-
read: 这个操作用于从文件中读取数据. 你需要指定从文件的哪个位置开始读取, 以及读取多少数据. 读取的数据将被复制到程序的内存中.
-
write: 这个操作用于将数据写入到文件中. 你需要指定从文件的哪个位置开始写入, 以及写入多少数据. 数据从程序的内存中复制到文件.
-
seek: 这个操作用于改变文件的当前位置. 文件的当前位置用于决定下一次读写操作发生在文件的哪个位置.
-
truncate: 这个操作用于减少文件的大小. 当你截断一个文件时, 文件中超出新大小的部分将被丢弃. 如果文件的新大小大于原来的大小, 文件的行为取决于具体的文件系统, 可能会填充为零, 也可能不做任何处理.
5.1.2 文件的访问模式
-
顺序访问
顺序访问是最常见的访问模式, 特别是在处理流数据或读取文本文件时. 在顺序访问模式下, 数据按照从开始到结束的顺序访问, 读取或写入数据都从文件的当前位置开始, 然后向前移动到文件的下一个位置. 大部分编程语言的文件处理操作默认就是顺序访问, 比如 C/C++ 的 stdio, Python 的 file object 等. -
直接访问
直接访问 (也被称为随机访问) 允许应用程序直接跳转到文件的任何位置. 这在需要读取或写入文件中特定部分的数据时非常有用, 例如数据库系统就大量使用了直接访问模式. 在直接访问模式下, 操作系统通常会提供一个操作, 允许应用程序指定从文件的哪个位置开始读取或写入. -
索引访问
索引访问是一种更为高级的访问模式, 它使用一个索引结构 (如 B-Tree) 来查找数据, 可以非常快速地找到数据所在的位置. 索引通常会存储一个键和一个指向文件中数据位置的指针. 当需要查找特定的键值时, 可以使用索引来直接定位到数据, 而不需要遍历整个文件. 数据库系统通常使用索引访问模式来提高查询性能.
5.2 分区与目录结构
5.2.1 分区结构
分区是硬盘上的一个独立区域, 可以被操作系统识别并管理. 一个计算机通常只有一个硬盘. 一块硬盘可以被分成一个或多个分区, 每个分区可以有不同的文件系统, 如 NTFS, ext4, FAT32 等. 分区可以是主分区 (在一个硬盘上最多可以有四个), 扩展分区 (只能有一个, 但可以包含多个逻辑分区), 或者逻辑分区.
分区的目的是将硬盘空间划分成逻辑上的区域, 使得数据的组织和管理更加容易. 例如, 用户可以把操作系统和应用程序安装在一个分区, 把个人数据存储在另一个分区, 这样即使操作系统崩溃, 数据分区仍然是安全的.
5.2.2 目录结构
目录结构是文件系统用来组织文件的方式. 目录结构的 "按名访问" 是指通过使用文件或目录的名称来访问文件系统中的特定文件或目录. 目录结构可以是扁平的 (即所有文件都在同一级目录下), 也可以是分层的 (即目录可以包含其他目录, 形成目录树). 树状目录的优势: 同名文件更自由; 文件分组更便捷; 文件检索效率更高.
每个文件在文件系统中都有一个路径, 这个路径是从根目录开始, 通过所有中间目录, 最后到达文件本身. 例如, 在 Unix 和 Linux 系统中,路径 "/home/user/documents" 表示在 "home" 目录下的 "user" 目录中的 "documents" 目录.
5.3 文件系统加载与保护
5.3.1 文件系统加载
文件系统加载是一个必要的操作系统功能, 因为它允许操作系统与数据进行交互. 文件系统包含有关各种文件的元数据 (如名称, 大小和位置), 并定义如何在存储设备上存储和检索这些文件. 因此, 加载文件系统是在系统启动时, 或者在需要访问新挂载存储设备 (如外部硬盘, USB 驱动器, 网络文件系统等) 时的关键步骤.
操作系统在启动时, 通常会加载一个或多个文件系统. 在系统引导 (boot) 过程中, 加载的第一个文件系统通常是包含操作系统本身的文件系统. 然后, 操作系统可以加载其他文件系统, 允许对其他数据进行访问.
除了在启动时加载文件系统之外, 操作系统也可以在运行时加载文件系统. 例如, 当用户连接一个外部存储设备时, 操作系统必须加载相应的文件系统, 以便能够读取和写入设备上的数据. 此外, 网络文件系统 (如 NFS 或 SMB) 允许用户通过网络访问远程计算机上的文件, 这也需要加载相应的文件系统.
5.3.2 文件系统保护
文件系统保护是为了保障数据安全, 防止未经授权的访问和修改. 它是由操作系统提供的重要服务. 文件系统保护通常依赖于访问控制机制, 如访问控制列表 (ACL) 和 Unix 样式的权限模型.
访问控制列表: 它们为每个文件和目录定义了一个权限列表, 该列表详细列出了哪些用户或用户组可以进行什么样的操作 (如读, 写和执行).
Unix 样式的权限模型: 这是一个简化的权限系统, 为每个文件和目录分配了三组权限: 用户 (u), 组 (g) 和其他 (o). 每组权限都可以分为读 (r), 写 (w) 和执行 (x). 例如 754 表示文件所有者具有完全权限 (读, 写, 执行), 与文件所有者同组的用户具有读和执行权限, 其他用户只具有读权限.
除了访问控制外, 文件系统保护还可以包括数据加密. 数据加密可以防止未经授权的用户访问文件的内容, 即使他们能够物理访问存储设备. 此外, 某些操作系统还提供了完全加密的文件系统, 如 Linux 的 eCryptfs 和 Microsoft 的 BitLocker, 这些系统可以加密整个文件系统, 进一步增强数据保护.
5.4 文件系统实现
5.4.1 磁盘数据结构
文件控制块
文件控制块 (FCB) 的内容可以因操作系统的不同而不同, 但一般至少包括以下一些元素:
-
文件名和扩展名: 这是文件的基本标识, 可以用于定位文件.
-
文件属性: 这可以包括文件的隐藏状态, 是否只读, 是否为系统文件等.
-
文件大小: 文件的大小, 通常以字节为单位.
-
时间戳: 这可以包括文件的创建时间, 最后修改时间和最后访问时间.
-
数据块链表: 这是文件实际数据存储位置的引用列表. 在一些文件系统中, 文件可能会被分成多个部分 (或 “块”) 分布在磁盘上. 这个链表可以帮助操作系统追踪到文件的所有部分.
在早期的 DOS 系统中, FCB 是用来管理文件的重要工具. 然而,在现代的操作系统如 Windows, Unix/Linux, Mac OS 等中, 文件控制块的概念已经被更先进的文件管理方式所取代, 例如在 Unix/Linux 系统中使用 inode (index node, 索引节点) 来管理文件, 它提供了更丰富和灵活的文件属性管理方式.
启动控制块
启动控制块也称为引导扇区, 是磁盘的特定部分, 包含计算机启动时需要的信息和指令. 它是计算机启动过程中非常重要的一部分, 计算机 BIOS 会首先加载并执行启动控制块中的指令以启动操作系统.
超级块
超级块 (分区控制块) 是文件系统元数据的重要组成部分, 它存储了文件系统的全局信息. 例如, 它可能包含文件系统的大小, 空闲和已用块的数量, 块大小, 以及 inode 表的位置等信息. 超级块通常在文件系统的开始部分, 可以在文件系统挂载时被读入内存.
5.4.2 内存数据结构
文件系统加载表
文件系统加载表是一个数据结构, 用于跟踪系统中已挂载的所有文件系统. 挂载的过程可描述为将一个文件系统 (通常存储在硬盘上) 关联到操作系统的文件系统目录结构中, 创建了一个指向硬盘中文件系统的链接或引用. 文件系统加载表将分区控制块 (超级块) 中的文件系统元信息读入内存. 通过查阅文件系统加载表, 操作系统可以快速找到任何挂载的文件系统, 并查看其状态和配置.
目录缓存
目录缓存是一种内存中的数据结构, 用于存储最近访问的目录条目信息. 当用户或程序请求访问某个文件或目录时, 操作系统首先会在目录缓存中查找. 如果找到了, 就可以直接从缓存中获取信息, 而无需访问磁盘. 这可以极大地提高文件访问速度, 特别是对于频繁访问的文件.
系统打开文件表
系统打开文件表是一种全局的数据结构, 用于跟踪当前系统中所有已打开的文件. 每当一个文件被打开时, 操作系统就在这个表中创建一个新的条目, 包含了该文件的一些关键信息, 如文件位置, 访问模式 (读, 写或读写) 以及当前读 / 写位置.
进程打开文件表
进程打开文件表 (也称为文件描述符表) 是每个进程自己的数据结构, 用于跟踪该进程当前打开的所有文件. 每个条目通常包括一个指向系统打开文件表的指针, 以及这个进程特定的信息, 如当前读 /写位置。
5.4.3 文件系统分层设计
-
用户接口层: 这一层包含了用户与文件系统交互的命令和系统调用. 它可以是一个图形用户界面, 如 Windows Explorer 或 macOS Finder, 也可以是一个命令行界面, 如 Unix shell. 此层提供了打开, 关闭, 读取, 写入等文件操作的接口.
-
逻辑文件系统层: 这一层处理文件和目录的抽象逻辑, 包括文件名, 权限, 时间戳等元数据的管理, 以及目录结构的维护. 它抽象出高级的文件和目录概念, 使得上层用户接口无需关心底层的存储细节.
-
文件组织层: 这一层负责实现文件的物理组织. 它将逻辑文件系统层的文件和目录映射到磁盘块上. 例如, 它可以管理如何将文件分割成一系列块, 并确定如何在磁盘上定位这些块.
-
基础 I/O 控制层: 这一层直接与磁盘硬件交互, 处理如何在磁盘上读写数据块. 此层通常包含设备驱动程序, 这些驱动程序知道如何与特定的硬件设备进行通信.
5.4.4 目录实现
文件路径名解析
-
查找时, 会遍历路径的过程中, 会逐层将各个路径组成部分解析成目录项;
-
如果此目录项在目录项缓存中, 则直接从缓存中获取; 如果该目录项不在缓存中, 则进行一次实际的读盘操作, 从磁盘中读取该目录项, 并得到目录项对应的索引节点;
-
得到索引节点后, 建立索引节点与该目录项的联系.
如此循环, 直到找到目标文件对应的目录项即索引节点. 索引节点可以索引到对应的超级块对象, 从而得到该文件所在的文件系统类型.
路径解析实现
- 线性表
线性表可以用来表示路径名的各个组成部分, 例如目录名和文件名. 通过线性表, 可以方便地存储和遍历路径名的各个部分, 以便进行路径解析操作.
- 哈希表
哈希表可以用于缓存已解析的路径名记录, 以加快后续相同路径名的解析速度. 通过将已解析的路径名作为关键字, 可以将其映射到哈希表中的一个位置, 以便在下次解析相同路径名时可以直接从哈希表中获取缓存的解析结果, 避免重复解析.
5.4.5 文件的物理结构
- 连续分配
为文件在外存上分配物理上连续的磁盘块.
优势: 连续分配的文件在顺序读写时速度快;
劣势: 文件大小不易于文件大小拓展, 易产生磁盘空间碎片.
- 链接分配
分配离散的数据块, 并以链表的形式进行组织.
优势: 便于文件动态增长拓展, 不易产生磁盘空间碎片;
劣势: 查找效率底下, 链接的指针占据额外空间.
- 索引分配
为文件的所有数据块建立索引, 并将索引置于独立的索引磁盘块.
优势: 便于文件动态增长拓展, 支持随机访问;
劣势: 索引表占据额外空间.
在 Linux 环境中, 通常有 12 个直接块, 1 个 1 级间接索引, 1 个 2 级间接索引, 1 个 3 级间接索引.
5.4.6 磁盘空闲空间管理
- 磁盘空闲空间链表
每个空闲磁盘块需要消耗一个指针大小的管理数据结构.
- 基于位图的磁盘空闲空间组织
每个空磁盘块需要消耗一个 bit 来表示其状态. 位图通常用整数数组实现. 例如如果我们有一个 8 位的位图, 它代表 8 个块的磁盘空间. 如果第 1 位是 1, 那就意味着第一个磁盘块被占用, 如果是 0, 那就意味着它是空闲的.
Linux 的 ext4 是系统根分区的常规默认文件系统, ext4 采用位图进行空闲空间管理.
例如磁盘容量为 1 TB, 磁盘块大小为 4 KB, 则块位图占据空间大小是 1T / (4K * 8) = 32 M.
- 成组链接法
以 n 个空闲块为一组, 以组为单位对空闲空间进行管理.
Unix 常用空闲块成组链接的方法.
5.5 大容量存储
5.5.1 磁盘物理结构
机械硬盘 (HDD)
固态硬盘 (SSD)
固态硬盘是一种无移动部件的存储设备, 它使用闪存芯片来存储数据. SSD 没有机械部件, 所有操作都是电子的, 这使得 SSD 在许多方面优于 HDD.
5.5.2 磁盘调度算法
一次机械磁盘读写需要的时间包括寻道时间, 延迟时间, 传输时间. 调度算法针对的是寻道时间.
先来先服务 (FCFS)
它按照请求到达的顺序进行服务.
最短寻找时间优先 (SSTF)
这是一种贪心算法, 每次都选择离当前位置最近的 I/O 请求服务. 但它可能导致磁盘饥饿问题, 即某些请求由于远离磁头当前位置, 可能会被无限期地推迟.
扫描算法 (SCAN)
这是最常用的一种算法, 也被称为电梯算法. 磁头开始向一个方向移动, 满足在这个方向上的所有请求, 到达磁盘的一端后, 改变方向, 再满足另一个方向上的所有请求. 这种方法对各个位置的响应频率不均, 在磁盘两端的请求可能会遭受更高的等待时间.
循环扫描算法 (C-SCAN)
这是 SCAN 算法的一个变体. 当磁头到达磁盘的一端后, 它会立即返回到另一端, 然后再次开始扫描. 这种方法可以提供更均匀的等待时间.
LOOK 算法
在 SCAN 算法中, 磁头在处理请求的同时向磁盘的一个方向移动, 直到到达磁盘的末端, 然后改变方向. 但在 LOOK 算法中, 磁头在移动的过程中会查看是否还有待处理的请求. 如果没有, 它就在到达磁盘末端之前改变方向. 这意味着磁头的移动距离可能小于整个磁盘的大小, 从而可以提高效率.
C-LOOK 算法
C-LOOK 是 C-SCAN 算法的改进版. 在 C-SCAN 算法中, 磁头在到达磁盘一端后会立即跳到另一端开始处理请求. 而在 C-LOOK 算法中, 磁头在到达最后一个请求之后会立即跳到最早的请求, 而不是跳到磁盘的另一端. 这样, 磁头的移动距离可能会小于磁盘的大小, 从而提高效率.
5.5.3 磁盘交换空间管理
交换空间是磁盘上预留出来的一部分空间, 操作系统用它来存储那些被换出的内存页面. 当系统的物理内存不足以满足所有正在运行的程序的需要时, 操作系统会将一部分内存页面移动到交换空间, 以释放内存. 这一过程被称为页面交换或换出. 当这些被换出的页面再次被需要时, 操作系统会将它们从交换空间移回内存, 这一过程被称为换入.
交换文件是一种实现交换空间的方法. 在这种方法中, 操作系统在文件系统中创建一个特殊的文件, 作为交换空间. 操作系统可以像操作普通文件一样操作交换文件, 但由于交换文件通常被操作系统直接管理, 因此用户通常无法直接访问交换文件. 在 Windows 系统中, 交换文件通常被称为 "页面文件" (Pagefile).
Linux 中交换空间通常作为一个独立的磁盘分区存在, 而不是一个文件. 这种方法可以避免文件系统的开销, 从而提高性能.
6 I/O 系统
6.1 I/O 子系统功能
I/O 子系统涉及多个组件和层次:
-
应用软件: 应用软件是用户编写的程序, 通过 I/O 子系统与外部设备进行交互. 应用软件通过系统调用或 API 调用 I/O 操作, 发送请求以读取或写入数据, 控制设备状态等. 应用软件可以是各种类型的应用程序, 如文本编辑器, 游戏, 图形应用程序等.
-
设备无关的软件层: 独立于设备的 I/O 软件是位于应用软件和设备驱动程序之间的中间层. 它提供了一组标准的 I/O 接口和抽象层, 使得应用程序可以独立于具体的设备实现. 这样, 应用程序可以以一种统一的方式进行 I/O 操作, 而不需要了解底层设备的细节. 独立于设备的 I/O 软件还负责管理缓冲, 调度 I/O 操作等.
-
设备驱动程序: 设备驱动程序是连接操作系统和硬件设备的软件模块. 每个设备都有对应的设备驱动程序, 负责管理设备的初始化, 配置, 数据传输, 中断处理等任务. 设备驱动程序与 I/O 软件和操作系统进行交互, 将 I/O 请求转换为设备可理解的命令和操作.
-
中断处理程序: 中断处理程序是由设备引发的中断事件发生时执行的代码块. 中断处理程序负责检测, 响应和处理设备发出的中断信号. 当设备需要与操作系统通信, 完成特定操作或传输数据时, 它会发送中断信号, 操作系统通过中断处理程序进行相应的处理.
-
I/O 设备硬件: I/O设备硬件是指实际的物理设备, 包括各种输入和输出设备, 如键盘, 鼠标, 显示器, 打印机, 硬盘, 网卡等. I/O设备硬件与设备驱动程序配合工作, 接收和处理命令, 传输数据, 以及与计算机系统进行通信.
-
统一命名 (Uniform Naming): 统一命名是指为不同的设备, 文件或资源分配唯一且一致的标识符或名称. 这有助于操作系统和应用程序准确地识别和访问特定的设备或资源.
-
调度 (Scheduling): 调度是指操作系统对多个 I/O 请求进行管理和排序的过程. 调度算法决定了 I/O 请求的执行顺序, 以最大程度地提高系统的吞吐量和响应性能. 大容量存储是一种 I/O 调度.
-
缓冲 (Buffering): 缓冲是指在数据传输过程中使用的临时存储区域. 缓冲可以在内存中或设备之间使用, 用于暂存输入和输出数据, 以平衡数据传输速率和处理速度之间的差异.
-
缓存 (Caching): 缓存是指将最近使用的数据副本存储在高速存储器 (如内存或磁盘缓存) 中, 以提高数据访问的速度. 缓存允许系统快速访问经常使用的数据, 减少了对慢速外部设备的频繁访问.
-
错误处理 (Error Handling): 错误处理涉及到对 I/O 操作中可能出现的错误进行检测, 诊断和处理. 操作系统和应用程序需要适当地处理错误, 例如设备故障, 数据传输错误或权限问题, 以确保系统的可靠性和稳定性.
-
假脱机 (Spooling): Spooling 技术可将一台物理 I/O 设备虚拟为多台逻辑 I/O 设备, 同样允许多个用户共享一台物理 I/O 设备. 进程与这个虚拟设备交互, 而不是直接与物理设备交互. 这个虚拟设备可以立即接收进程的数据 (因此对进程来说好像是脱机的), 并在物理设备可用时将数据发送到物理设备.
-
设备预留 (Device Reservation): 设备预留是指允许进程或用户独占地使用特定的设备资源, 以避免资源冲突或竞争. 设备预留可通过锁定机制或分配机制来实现.
-
I/O 保护 (I/O Protection): I/O 保护是指操作系统对 I/O 设备进行访问控制和权限管理. 它确保只有具有适当权限的进程或用户能够执行特定的 I/O 操作, 从而保护系统的安全性和完整性.
6.2 I/O 设备分类
6.2.1 按传输速率
低速设备: 磁带, 键盘, 鼠标;
高速设备: 机械硬盘, 固态硬盘.
6.2.2 按信息交换单位
字符设备: 键盘, 鼠标, 打印机;
块设备: 光盘, 机械硬盘, 固态硬盘.
6.2.3 按使用特性
人机交互类: 鼠标;
存储类: 硬盘;
网络通信类: 网卡.
6.3 设备控制器与总线
当应用程序或操作系统发出 I/O 请求时, CPU 通过与设备控制器的交互来协调和管理 I/O 操作. CPU 发送命令和控制信息给设备控制器, 然后等待设备控制器的响应或中断. 这可以提高 CPU 的利用率.
PCIe (Peripheral Component Interconnect Express) 是一种高速串行总线, 广泛应用于现代计算机系统中. 它用于连接各种设备, 如图形卡, 存储控制器, 网卡等. PCIe 提供高带宽和可靠性, 支持热插拔和多通道通信, 并具有多个版本, 如 PCIe 3.0, PCIe 4.0 和 PCIe 5.0.
6.4 I/O 控制方式
6.4.1 程序控制 I/O
由用户进程直接控制主存或 CPU 和外围设备之间的信息传送, 又称轮询方式或忙等方式.
6.4.2 中断设备 I/O
CPU 初始化 I/O 后, 等待 I/O 完成的同时 CPU 可以执行其他任务. CPU 每次执行完一个任务会检测中断, I/O 设备在完成任务后会产生一个中断, CPU 执行中断服务程序处理中断, 如此循环.
CPU 和 I/O 设备并行工作, 显著提升了系统效率.
6.4.3 DMA
DMA 是一种在计算机的主内存和设备间直接传输数据, 而无需 CPU 参与的技术. DMA 控制器可以接管从 CPU 的地址总线, 数据总线和控制总线, 这使得数据可以直接从 I/O 设备读取或写入内存.
使用 DMA, CPU 只需要在数据传输开始和结束时做一些初始化和处理工作, 而数据传输的大部分工作都由 DMA 控制器完成.
6.4.4 通道控制 I/O
通道控制 I/O 是一种比 DMA 更进一步的技术, 主要用于大型计算机. 它不仅仅只是在设备和主内存间直接传输数据, 而且它的控制器有足够的智能来处理整个 I/O 操作, 包括设备的选择, 内存的地址选择, 命令的解释等.
这种通道控制器可以被看作是一个专用的 CPU, 它能执行一系列的 I/O 命令, 这些命令可以在通道程序中被指定. CPU 在启动一个 I/O 操作时, 只需要设置通道程序, 然后就可以开始做其他的工作. 而通道控制器会自己完成整个 I/O 操作.
6.5 I/O 缓冲机制
I/O 子系统引入 Buffering 机制的核心目的是缓解 CPU 与 I/O 速度之间速度不匹配的矛盾.
6.5.1 双缓冲
单缓冲机制提供了足够大的单个缓冲区, 可以减少访问设备的频次, 提升效率; 双缓冲机制是对单缓冲的优化, 通过增加一个 buffer 来获得数据输入与数据处理的并发.
6.5.2 环形缓冲
有限环形缓冲通过缓冲区数量的扩容, 加上有限缓冲的并发处理, 可以应对 I/O Bursts. 网卡采用了环形缓冲.
除了以上缓冲机制, 还有缓冲池机制等策略.
6.6 I/O 软件设计层次
6.6.1 中断处理程序
硬件中断
硬件中断是由硬件设备产生的, 当硬件设备需要 CPU 的注意时, 它会通过系统的中断控制器发送一个中断信号. 这通常发生在以下几种情况: 设备完成了数据传输, 设备遇到了错误, 或者设备已经准备好接收或发送数据.
例如, 当一个键盘按键被按下时, 键盘会产生一个硬件中断, 通知 CPU 有一个新的字符输入; 当一个 I/O 设备完成了一个数据传输任务时, 它也会产生一个硬件中断, 通知 CPU 数据已经准备好.
软件中断
软件中断是由软件指令产生的, 通常用于请求操作系统的服务 (如系统调用), 或者处理异常和错误. 软件中断在执行某些特殊的 CPU 指令时产生, 例如在 x86 架构中, int 指令就可以产生一个软件中断.
例如, 一个程序可能需要操作系统帮助它进行文件读取或写入, 于是它就通过产生一个软件中断来请求这项服务; 当程序发生错误, 如除以零或访问非法内存地址时, 系统也会产生一个软件中断, 用于处理这个错误.
外部中断
外部中断通常与硬件设备的输入 / 输出操作有关, 或者由用户通过输入设备 (如键盘或鼠标) 触发.
内部中断
常见的内部中断分为地址非法和页故障. 页故障的最大特点是在中断处理程序完成后仍需要执行之前触发中断的命令, 而一般的中断不需要.
6.6.2 中断控制器
当一个中断源产生一个中断请求 (IRQ) 时, 中断控制器会接收这个请求, 然后通知 CPU 有一个中断需要处理. 如果 CPU 当前正忙于处理其他的任务, 中断控制器可以将这个中断请求暂存起来, 等待 CPU 有空闲时再处理. 同时, 如果同时有多个中断请求, 中断控制器也可以根据每个中断的优先级进行排序, 以确定哪个中断应该首先被处理.
中断控制器还可以实现中断的屏蔽和非屏蔽. 屏蔽意味着暂时忽略某些中断, 这对于防止中断的过度频繁, 保护关键任务的执行, 或者在处理一个中断时避免被其他中断打断, 都是非常有用的.
6.6.3 设备驱动程序
设备驱动程序 (Device Driver) 是一种使得计算机的操作系统和硬件设备进行交互的软件. 它提供一个统一的接口, 使得操作系统无需关心硬件设备的具体实现细节, 就可以管理和控制这些设备.
-
硬件抽象: 驱动程序提供了一个硬件抽象层, 将硬件设备的复杂性隐藏在一个统一的接口之后. 这意味着, 操作系统无需关心设备的具体实现方式, 只需通过调用驱动程序提供的接口, 就可以控制硬件设备. 这大大简化了操作系统的设计和实现.
-
设备控制: 驱动程序负责实现设备的控制命令, 例如开启设备, 关闭设备, 读写设备等. 这些命令通常需要与设备的硬件接口进行交互, 因此驱动程序需要知道设备的硬件接口和工作方式.
-
数据传输: 驱动程序负责管理设备与计算机之间的数据传输. 这包括数据的输入, 输出, 以及数据的格式转换等.
-
中断处理: 驱动程序通常也需要处理设备的中断. 例如, 当设备完成了一个任务或者遇到错误时, 它会产生一个中断, 驱动程序需要能够响应这个中断, 并执行相应的处理程序.
6.6.4 设备无关软件
设备无关软件是指与具体硬件设备无关, 不依赖于硬件特性, 可以在不同硬件设备上运行的软件. 设备无关性是现代操作系统设计的一个重要原则, 它可以极大地提高软件的可移植性和复用性.
逻辑设备
逻辑设备是对物理设备的软件抽象, 它提供了一种与物理设备交互的统一接口. 通过逻辑设备, 软件可以在不知道或不关心物理设备的具体实现和特性的情况下, 进行设备的管理和控制. 例如操作系统中的文件系统就是一个逻辑设备, 它提供了一种逻辑的文件和目录结构, 用于管理和控制硬盘驱动器这种物理设备.
虚拟设备技术
独占设备是指一次只能被一个进程或用户占用的设备, 共享设备是指多个进程或用户可以同时访问的设备, 借助于磁盘缓冲区营造出多个虚拟设备的状态, SPOOLING 被称为虚拟设备技术.
用这种方法, 操作系统能够模拟出多个虚拟设备状态, 即使得多个应用程序似乎拥有自己的设备, 而实际上它们都是共享同一个物理设备.
设备预留机制
设备预留机制是一种硬件管理策略, 用于确保特定的硬件资源能够被保留给特定的任务或服务.
在计算机系统中, 某些服务或任务可能需要访问特定的硬件设备, 例如打印服务需要访问打印机, 音频服务需要访问音频设备. 为了确保这些服务能够正常工作, 系统可以使用设备预留机制, 将特定的设备资源预留给这些服务, 避免其他服务或任务占用这些资源.
6.6.5 I/O 系统调用
常见调用
-
read
-
write
-
recv/recvfrom/recvmsg
-
poll (轮询)
-
epoll
I/O 模型
- Blocking I/O
在阻塞 I/O 模型中, 进程在开始一个 I/O 操作后, 必须等待该 I/O 操作的完成, 然后才能开始下一个操作. 在 I/O 操作完成之前, 进程会被挂起, 等待 I/O 操作的完成. 这就意味着在等待 I/O 操作期间, 进程不能做其他任何事情, CPU 会处于空闲状态.
- Nonblocking I/O
与阻塞 I/O 相反, 非阻塞 I/O 模型允许进程在 I/O 操作进行中去做其他事情. 非阻塞 I/O 操作在启动后会立即返回, 进程无需等待 I/O 操作的完成, 可以继续进行其他操作. 当 I/O 操作完成时, 操作系统会通过某种机制通知进程.
- Multiplexing I/O
多路复用 I/O 是一种让单个线程能够处理多个 I/O 操作的技术. 在这种模型中可以使用一个系统调用去监视多个文件描述符, 当其中的一个或者多个文件描述符准备好进行 I/O 操作的时候, 系统调用返回., 这样程序就可以处理那些准备好的 I/O 操作.
- Asynchronous I/O
在异步 I/O 模型中, 应用程序发起一个 I/O 操作后, 不需要等待其完成, 也不需要周期性地检查其状态, 而是可以继续执行其他任务. 当 I/O 操作完成后, 操作系统会通过某种方式 (如回调函数, 事件, 信号等) 通知应用程序.

浙公网安备 33010602011771号