2. 进程与线程
2.1 进程和线程
2.1.1 进程的概念和组成
-
程序: 放在磁盘里面的可执行文件,是静态的。
-
进程: 是程序的一次执行过程, 是动态的。
当一个进程被创建的时候,操作系统会为此程序创建一个唯一的,不重复的ID — PID。
UID 就是进程所属的用户。
上面所说的PID UID 以及图片中的cpu,磁盘,内存等等 都需要放在一个进程控制块中(PCB) PCB是进程存在的唯一标志。
**进程的组成: ** PCB、程序段、数据段。
PCB,是给操作系统用的,程序段和数据段是进程给自己用的。
程序段、数据段、PCB三部分组成了进程实体(进程映像)。
进程是进程实体的运行过程,是系统进行资源分配和调度的一个单位。
2.1.2 进程的特征
程序是静态的,进程是动态的,相比于程序,进程拥有一下特征:
- 动态性:进程是程序的一次执行过程,是动态产生的,变化和消亡的。
- 并发性:内存中有个进程实体,各进程可以并发执行。
- 独立性:进程是能独立运行,独立获得资源,独立接受调度的基本单位
- 异步性:各进程各自独立的,不可预知的速度向前推进
- 结构性:每个进程都配置一个PCB,都由程序段和数据段 PCB组成。
2.1.3 进程的状态与转化
状态:
- 创建态:进程正在被创建,这个阶段操作系统会为进程分配资源,初始化PCB。
- 就绪态:进程已经创建完毕了,但由于没有空闲的CPU,就暂时不运行。
- 运行态:当CPU空闲下来,操作系统就会从处于就绪态的进程中,选择一个,进行运行,就叫运行态。
- 阻塞态:在进程运行中,有可能会请求某个事件的发生,所以在这个事件发生之前,CPU会使这个进程下去,让他变位阻塞态,然后CPU再选择一个就绪态的进程。当它等待的事情发生后,这个进程就又变回就绪态。
- 终止态:一个进程执行
exit
请求操作系统终止进程,此时进程进入了终止态,然后让该进程下CPU,并回收内存空间等资源,最后回收PCB。
2.1.4 进程控制
进程控制就是 OS 用原语通过 PCB 对进程的创建、运行、阻塞、唤醒、终止进行管理,确保多进程在系统中安全、高效、有序地运行。
原语的执行必须一气呵成,不可中断。
进程的创建
- 分配进程控制块(PCB)
- 分配所需的内存和资源
- 初始化寄存器、程序计数器等运行环境
- 典型调用:
fork()
(Linux)
进程的终止
- 回收资源(内存、文件描述符等)
- 销毁 PCB
- 通知父进程进程结束状态
进程的阻塞与唤醒(成对出现)
- 阻塞:进程等待某个事件(I/O 完成、信号等)而暂停运行
- 唤醒:事件发生后让阻塞进程重新进入就绪队列
进程的切换(上下文切换)
- 保存当前进程的 CPU 环境(寄存器值、程序计数器等)
- 恢复下一个进程的运行环境
控制动作 | PCB 状态变化 | 队列变化 |
---|---|---|
创建(Create) | 新建 PCB → 初始化状态 | 加入就绪队列 |
撤销(Terminate) | 释放 PCB → 回收资源 | 从所有队列移除 |
阻塞(Block) | 状态改为阻塞 | 移入阻塞队列 |
唤醒(Wakeup) | 状态改为就绪 | 加入就绪队列 |
切换(Dispatch) | 保存当前 PCB 寄存器值 → 加载新 PCB | 运行队列切换 |
无论哪个进程控制原语,要做的无非三类事情。
- 更新PCB中信息
- 将PCB插入合适的队列
- 分配/回收资源
进程控制 = PCB + 原语 + 队列管理
2.1.5 进程通信(IPC)
进程间通信就是指两个进程或者多个进程之间产生的数据交互,
- 高级通信: 共享存储,消息传递,管道通信, 套接字(Socket)(网络通信机制)
- 低级通信: 信号量,信号。
高级通信(侧重数据传输)
共享存储:
- 基于数据结构的共享:规定的类型,规定的长度,这种方式速度慢,限制多,是一种低级通信方式
- 基于存储区的共享:操作系统在内存中划出一块共享存储区,数据形式,存放位置都由进程控制,这种方式速度快,是一种高级通信方式。
消息传递:
- 直接通信方式:消息发送进程要指明接受进程的ID
- 间接通信方式:通过"信箱"间接地通信,可以多个进程往同一个信箱send消息,也可以从多个进程receive消息。
管道通信: 管道通信的管道是一个先进先出的队列(循环队列)。
- 管道的数据流向是一个单向的,称为半双工通信
- 如果要实现双向同时通信,则需要两个管道,称为全双工通信。
- 各个进程要互斥的访问管道。
- 当管道写满时,写进程将阻塞,直到读进程将管道的数据取走,即可唤醒写进程。
- 当管道读空时,读进程将阻塞,直到写进程往管道中写入数据,就可唤醒读进程。
低级通信(侧重同步 & 事件通知)
- 信号量 - 实现进程的同步,互斥。
- 信号 - 实现进程间通信。
信号用于通知进程某个特定的事件已经发生。进程收到一个信号后,对该信号进行处理。
1️⃣ 什么是信号
- 信号是软件中断,用于通知进程发生了某种事件。
- 可以由 内核 或 其他进程 发送。
- 被发送的进程不需要提前等着,它会在执行过程中被打断去处理信号,所以叫异步。
2️⃣ 信号的来源
- 内核产生
- 硬件异常:除零错误 →
SIGFPE
- 访问非法内存:
SIGSEGV
- 子进程结束:
SIGCHLD
- 硬件异常:除零错误 →
- 用户进程产生
- 通过系统调用
kill()
给某进程发信号 - 通过终端快捷键:
Ctrl+C
→SIGINT
(中断)Ctrl+Z
→SIGTSTP
(暂停)
- 通过系统调用
3️⃣ 信号的常见类型(Linux)
信号名 | 编号 | 含义 |
---|---|---|
SIGINT | 2 | 终端中断(Ctrl+C) |
SIGKILL | 9 | 强制杀死进程(不可捕获、不可忽略) |
SIGTERM | 15 | 终止进程(可处理) |
SIGSTOP | 19 | 暂停进程(不可捕获) |
SIGSEGV | 11 | 段错误(非法内存访问) |
4️⃣ 信号的处理方式
每当从内核态要转为用户态时,就会处理这些信号,进程可以选择:
- 执行默认动作(比如终止、暂停等)
- 捕获信号(自定义信号处理函数去处理)
- 忽略信号(除
SIGKILL
和SIGSTOP
外)
5️⃣ 信号的作用
- 进程控制:杀死、暂停、恢复进程
- 事件通知:告诉进程某事件发生(如 I/O 完成)
- 错误处理:异常情况立即打断进程执行
📌 一句话记忆
信号 = 软件世界的“快递员”,异步送来事件通知,让进程立即响应,常用于控制和通知。
2.1.6 线程和多线程模型
线程是一个基本CPU执行单元,也是程序执行流的最小单位,引入线程后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发性。
线程的主要属性
- 轻量级(Lightweight)
- 线程依赖进程而存在,创建/切换开销比进程小。
- 同一进程内的线程共享资源(代码段、数据段、文件等)。
- 共享性(Sharing)
- 同一进程的线程共享进程资源:
- 地址空间
- 打开的文件
- 全局变量、堆内存
- 但每个线程有自己的栈和寄存器环境,保证独立执行。
- 同一进程的线程共享进程资源:
- 独立性(Independence)
- 尽管共享资源,每个线程都有独立的执行流(程序计数器 PC + 栈)。
- 一个线程崩溃可能影响整个进程(这是它和进程最大的区别)。
- 并发性(Concurrency)
- 同一进程内多个线程可以并发执行。
- 在多核 CPU 上,多个线程甚至可以并行运行。
- 可调度性(Schedulability)
- 线程是操作系统调度的基本单位(多数现代 OS 都是以线程为最小调度单位)。
- 每个线程可以有自己的优先级。
- 轻便性(低开销)
- 线程切换时只需保存/恢复寄存器和栈指针,不涉及整个进程的上下文,所以效率高。
📌 一句话总结
线程 = 进程里的“小兵”:
- 共享资源(吃同一锅饭),
- 有自己独立的栈和执行流(各干各的活),
- 是操作系统调度的最小单位。
线程的实现方式: 用户级线程,内核级线程,混合实现。
- 1. 用户级线程: 线程由用户态的线程库管理,操作系统内核并不知道有这些线程。
-
优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。
-
缺点: 如上图中while循环中,要是有一个线程被阻塞,其他的线程也无法进行下去,所以并发度不高,多线程不可在多核处理机上并行运行。
-
2. 内核级线程: 线程由操作系统内核管理和调度。内核为每个线程维护控制块
-
优点: 当一个线程被阻塞后,别的线程还可以继续执行,并发能力强,多线程在多核处理机上并行执行。
-
一个用户进程会占用多个内核线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。
-
3. 混合实现:
一对一模型就是内核级线程。
多对一模型就是用户级线程。
**多对多模型:**用户线程不直接一一对应内核线程,而是通过运行时系统调度映射过去。 折中方案,结合了灵活性和高效性。
线程的状态迁移和进程几乎一样:
- 新建 → 就绪 → 运行 →(阻塞 ↔ 就绪)→ 终止
✅ 进程 vs 线程区别表
对比维度 | 进程(Process) | 线程(Thread) |
---|---|---|
基本概念 | 资源分配的最小单位 | 程序执行的最小单位,依赖进程存在 |
调度单位 | 进程是早期操作系统的调度单位 | 现代操作系统以线程作为基本调度单位 |
资源拥有 | 拥有独立的地址空间、文件描述符、代码段、数据段等 | 共享进程的资源(地址空间、文件),但有独立的栈和寄存器 |
开销 | 创建/撤销开销大,切换需要保存整个进程上下文 | 创建/撤销开销小,切换只需保存少量寄存器和栈指针 |
通信方式 | 需要借助 IPC(管道、消息队列、共享内存、信号等),开销大 | 共享内存(全局变量、堆)即可直接通信,简单高效 |
健壮性 | 一个进程崩溃通常不会影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
并发性 | 进程之间可并发执行 | 一个进程内多个线程可并发执行 |
典型应用 | 系统中的独立应用(浏览器、IDE、数据库服务) | 应用内的任务单元(浏览器的标签页、IDE 的后台编译线程) |
📌 一句话记忆:
- 进程是“大房子”,线程是“房子里的工人”。
- 进程间相互独立,线程间共享资源。
- 调度和执行粒度 → 线程更轻量,更灵活。
2.2 CPU调度
2.2.1 调度的概念
当有一堆任务要处理时候,但资源有限,这些事情没法同时处理,这就需要确定某种规则来决定处理这些任务的顺序,这就是调度研究的问题。
调度的三个层次:
- 高级调度(作业调度): 按一定的原则从外存的作业后备队列中挑选一个作业调入内存,并创建进程。每个作业只调入一次,调出一次。
- 中级调度(内存调度): 按照某种策略决定将哪个处于挂起状态的进程重新调入内存。
- 低级调度(进程调度): 按照某种策略从就绪队列中选取一个进程,将处理机分配给它。
暂时调到外村等待的进程状态为挂起状态。
2.2.2 进程调度的时机切换与过程调度方式
进程调度的时机(需要调度时)
操作系统会在以下情况触发调度器,从就绪队列中选一个新进程上 CPU:
- 进程正常结束
- 进程运行完成,释放 CPU,需要选择下一个进程。
- 进程阻塞
- 因 I/O 请求、等待事件、获取不到资源等进入阻塞状态。
- 进程主动放弃 CPU
- 调用
sleep()
、yield()
等,自己让出 CPU。
- 调用
- 时间片用完(抢占式调度)
- 分时系统里,当进程的时间片耗尽,系统强制切换。
- 有更高优先级的进程就绪
- 抢占式调度策略下,高优先级进程到达,低优先级进程被剥夺 CPU。
❌ 不会发生调度的情况
有些特殊场景不允许调度,否则会破坏系统稳定性:
- 在 中断处理过程 中(防止上下文切换影响中断处理)
- 在 操作系统内核临界区 中(避免数据结构不一致)
- 在 原子操作指令执行过程中
临界区的一般概念
- 临界区 = 程序中访问 共享资源 的那段代码。
- 比如多个进程/线程访问一个共享变量、文件或缓冲区 → 就需要进入临界区,避免同时修改导致数据错乱。
操作系统内核临界区
- 指 操作系统内核自己使用的关键数据结构 所在的临界区。
- 例如:
- 进程控制块(PCB)队列
- 内存分配表
- I/O 缓冲队列
- 这些数据是内核全局共享的,如果被多个进程或中断同时修改,会导致内核数据结构损坏。
所以,操作系统会在内核临界区中:
- 禁止调度(不能切换进程)
- 禁止中断(防止中断打断内核操作)
这样可以保证内核数据结构的一致性和安全性。
进程调度的方式
进程调度的方式有两种,非剥夺调度方式和剥夺调度方式。
- 非剥夺调度方式(非抢占式): 只允许进程主动放弃处理机。
- 剥夺调度方式(抢占式): 当一个进程正在处理机上执行时,发现一个更重要的程序需要使用处理机,则立即暂停正在执行的程序,将处理机分配给更紧迫的那个进程。
进程的切换与过程
进程的切换指操作系统把 CPU 使用权 从一个进程转移到另一个进程。
本质:保存当前进程的执行现场(上下文),再恢复另一个进程的上下文。
进程切换 = 保存当前进程现场 → 更新 PCB → 调度新进程 → 恢复新进程现场。
注意,进程切换是有代价的,因此如果过于频繁的进行调度,切换,必然使系统的效率降低。
调度器,闲逛进程
调度器是 操作系统内核里的一个模块,专门负责 决定哪个进程获得 CPU 使用权。负责挑选进程 → 谁上 CPU。
闲逛进程:当“没有人可挑”的时候,调度器就让闲逛进程上 CPU。
2.2.3 调度的目标
调度的目标(调度算法的评价指标)有CPU利用率,系统吞吐量,周转时间,等待时间,响应时间。
指标 | 含义 | 目标(越好越怎样) | 适用场景 |
---|---|---|---|
CPU 利用率 | CPU 忙碌时间 ÷ 总时间 | 越高越好 | 批处理系统 |
系统吞吐量 | 单位时间内完成的作业数 | 越大越好 | 批处理系统 |
周转时间 | 作业提交 → 完成的总时间 | 越短越好 | 批处理系统 |
等待时间 | 作业在就绪队列里等待 CPU 的时间 | 越短越好 | 批处理系统、交互式系统 |
响应时间 | 从用户提交请求 → 系统首次响应的时间 | 越短越好 | 交互式系统 |
2.2.4 调度算法
调度算法大致可以分为两类:
- 适用于早期批处理系统(作业调度)的调度算法
这类算法主要关心对用户的公平性,如平均周转时间、平均等待时间等评价系统整体性能的指标,对于用户来说,交互性很差。典型算法包括:- FCFS (先来先服务)
- SJF (短作业优先)
- SRTN (最短剩余时间优先)
- HRRN (高响应比优先)
调度算法 | 算法思想 | 算法规则 | 调度类型 | 是否抢占 | 优点 | 缺点 | 是否会导致饥饿 |
---|---|---|---|---|---|---|---|
FCFS (先来先服务) | 按到达顺序执行 | 先到先服务,按到达时间排队执行 | 作业调度 / 进程调度 | 非抢占 | 实现简单、公平 | 短作业可能等待时间长 | 否 |
SJF (短作业优先) | 优先执行执行时间短的作业 | 按作业/进程长度排序,最短先执行 | 作业调度 / 进程调度 | 非抢占 | 平均周转时间最短 | 可能饿死长作业,对执行时间估计要求高 | 是 |
SRTN (最短剩余时间优先) | 抢占式 SJF | 当前作业剩余时间比新到作业长时抢占 | 作业调度 / 进程调度 | 抢占 | 平均周转时间更短,响应快 | 可能饿死长作业,切换频繁 | 是 |
HRRN (高响应比优先) | 优先执行响应比高的作业 | 响应比 = (等待时间 + 服务时间)/服务时间,选择最大者 | 作业调度 / 进程调度 | 非抢占 | 兼顾短作业和长作业,避免饥饿 | 计算响应比稍复杂 | 否 |
- 适用于交互式系统(进程调度)的调度算法
这类算法更注重系统的响应时间,公平性,平衡性等指标,典型算法包括:
- RR (时间片轮转)
- 优先级调度
- 多级反馈队列调度
调度算法 | 算法思想 | 算法规则 | 调度类型 | 是否抢占 | 优点 | 缺点 | 是否会导致饥饿 |
---|---|---|---|---|---|---|---|
RR (时间片轮转) | 轮流分配 CPU 时间 | 每个作业按时间片轮流执行,时间片用完换下一个 | 进程调度 | 抢占 | 公平,响应时间好,适合分时操作系统 | 时间片太大退化为 FCFS,太小切换开销大 | 否 |
优先级调度 | 优先级高的作业先执行 | 根据作业或进程优先级选择下一个执行 | 作业调度 / 进程调度 | 可抢占 | 可以保证重要作业先执行 | 低优先级可能长期等待 | 是 |
多级反馈队列 | 多个队列,不同优先级和时间片,动态调整 | 作业可在队列间移动,长作业逐渐降低优先级 | 进程调度 | 抢占 | 兼顾响应时间和公平性,适合混合负载 | 实现复杂 | 是 |
2.2.5 多处理机调度
在多处理机应该追求的目标: 负载均衡和处理机亲和。
- 负载均衡:尽可能让每个CPU都同等忙碌
- 处理机亲和性: 尽量让一个进程调度到同一个CPU上运行,让发挥CPU发中缓存的作用。
解决负载均衡和亲和性有两种方案。
方案一 公共有序队列
-
所有CPU共享同一个就绪进程队列(位于内核区)
-
每个CPU时运行调度程序,从公共就绪队列中选择一个进程与运行。
-
每个CPU访问公共就绪队列时需要上锁(确保互斥)
-
优点:可以天然的实现负载均衡
-
缺点:各个进程频繁切换CPU,‘亲和性’不好。
❓如何解决亲和性问题呢?
软亲和:进程尽可能在原 CPU 上运行,但可以迁移到其他 CPU
硬亲和:进程必须在指定 CPU 上运行,不允许迁移
方案二 私有就绪队列
-
每个CPU都有一个私有就绪队列
-
CPU空闲时运行调度程序,从私有就绪队列中选择一个进程运行。
-
优点:可以天然的实现亲和性
-
缺点:初始负载不均衡时,可能出现某些 CPU 队列空闲、某些 CPU 队列繁忙。
❓如何解决队列空闲或者队列繁忙呢?
推迁移(push) : 当发现别的CPU空闲,而一个很忙,就会从忙碌的CPU中推一些给空闲的CPU的就绪队列中。
拉迁移(pull) : 当发现CPU负载很低,就会从高负载的CPU的就绪队列中拉一些进程到自己的就绪队列。
2.3 进程同步与互斥
2.3.1 互斥和同步的基本概念
1. 进程互斥(Mutual Exclusion)
概念:
- 当多个进程需要访问同一共享资源(如内存数据、文件、打印机等)时,为了防止同时访问导致数据混乱,需要确保在同一时间只有一个进程访问该资源。
- 这个保证“同一时刻只有一个进程访问资源”的机制,就叫互斥。
进程互斥经典的临界区互斥模型,是由进入区,临界区,退出区,剩余区实现的,需要遵循空闲让进,忙则等待,有限等待,让权等待,这四个原则。
举例:
- 两个进程同时要修改银行账户余额,如果不加互斥,可能出现“钱多扣少加”的错误。互斥就保证了每次只有一个进程能修改账户余额。
2. 进程同步(Process Synchronization)
概念:
- 当多个进程之间存在执行顺序依赖时,需要保证进程按照某种顺序执行,这种协调机制叫同步。
- 简单来说,同步是为了顺序正确,互斥是为了防止资源冲突。
举例:
- 生产者-消费者问题:
- 生产者先生产数据,再让消费者消费;
- 消费者必须等到有数据才能消费。
- 这里就是同步:保证先后顺序正确。
特性 | 进程互斥 | 进程同步 |
---|---|---|
目的 | 防止共享资源冲突 | 保证执行顺序正确 |
关注点 | 同一时间访问冲突 | 先后执行顺序 |
常用机制 | 锁、信号量、临界区 | 信号量、条件变量、消息队列 |
举例 | 银行账户操作 | 生产者-消费者问题 |
2.3.2 互斥的实现方法
软件实现方法
- 单标志法:
算法思想:两个进程在访问完临界区后会把使用临界区的权限交给另一个进程,也就是说每个进程进入临界区的权限只能被另一个进程赋予。
但是如果此时轮到P0进程使用,但P0一直不使用(占着茅坑不拉屎),而P1要用却进不去了。
所以违背了“空闲让进”原则。
- 双标志先检查:
算法思想:设置一个布尔数组flag[]
,数组中各个元素用来标记个进程想进入临界区的意愿。flag[0] = true
表示P0进程想进入临界区,flag[1] = false
表示P1进程不想进入临界区。
但是进入区的检查和上锁两个处理不是一气呵成的,先检查后,上锁前可能会发生进程切换。此时就是同时访问临界区了。
所以违背了“忙则等待”原则。
- 双标志后检查:
算法思想: 双标志先检查的改版,因为上一个算法是先检查后上锁,无法一气呵成,所以人们又想到了先上锁后检查。
虽然解决了"忙则等待"的问题,但是很有可能会发生一直卡在2 6 代码处,两个进程都想进入临界区,但是谁也不让谁,会因各进程都长期无法访问临界资源产生"饥饿"。
所以又**违背了"空闲让进"和"有限等待"**的原则。
- Peterson 算法:
结合双标志法,单标志法的思想,如果双方都争着想进入临界区,那可以让进程尝试"孔融让梨" 做一个有礼貌的进程。
这个算法遵循了空闲让进,忙则等待,有限等待 三个原则,但是它如果进不了临界区,它会一直卡在while循环,一直在CPU上执行,占用CPU资源。
所以违背了“让权等待”的原则。
硬件实现方法
- 中断屏蔽方法:
利用"开/关中断指令"实现,强制的,不允许发生进程切换,因此不会发生两个同时访问临界区的情况。
优点:简单,高效
缺点:不适用于多处理机,只适用于操作系统内核进程,不适用于用户进程(用户随便开关中断指令,很危险)
- TestAndSet(TS指令/TSL指令)
简称TS指令,这个指令使用硬件实现的,执行的过程不允许中断,只能一气呵成。
这个和Peterson算法一样,违背了**“让权等待”**的原则。
这个和TS指令的思路几乎一致,缺点也一致。
2.3.3 互斥锁
互斥锁的目的
- 核心目标:保护 临界区(Critical Section),防止多个进程/线程同时修改共享资源而产生 竞态条件(Race Condition)。
- 实现方式:通过锁机制,让一次只有一个进程进入临界区。
自旋锁(Spinlock)
自旋锁,是互斥锁的一种实现方式,尤其适合 多处理器系统。
特点:
- 忙等待:如果锁被占用,线程 不会被挂起,而是在 CPU 上循环检查锁是否释放 → 这叫“自旋”。
- 低延迟:多核系统下,如果锁很快释放,线程能立即进入临界区,比阻塞锁(挂起等待)效率高。
- 占用 CPU:自旋等待期间,CPU 一直在检查锁,浪费 CPU,不适合长时间持锁。
使用场景:
- 多核 CPU,临界区很短 → 自旋锁很快就能获取锁
- 临界区很长或单核 CPU → 不推荐自旋锁,应该使用阻塞锁
特性 | 自旋锁 | 阻塞锁(传统互斥锁) |
---|---|---|
CPU 消耗 | 会占用 CPU(忙等待) | 不占用 CPU(挂起等待) |
延迟 | 低,锁释放立即获取 | 高,线程被挂起和唤醒有开销 |
使用场景 | 多核、临界区短 | 单核或临界区长 |
实现 | 基于原子操作(TSL/Swap) | 基于操作系统调度 |
2.3.4 信号量
信号量机制是一种由操作系统提供的 进程同步与互斥机制,用来协调多个进程对共享资源的访问。
信号量其实就是一个变量,一对原语wait(S)
和signal(S)
括号里的信号量S其实就是函数调用时传入的一个参数。 wait和signal也简称P,V操作P(S),V(S);
信号量就是一个整数 + 两个操作(P/V),用来解决进程的 互斥 和 同步 问题,是操作系统里非常经典的同步原语。
整形信号量
用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。
它当S为0的时候就会一直等待,所以不满足"让权等待"会发生忙等。
记录型信号量
整形信号量的缺陷是存在忙等,因此人们又提出了’记录型信号量’,即用记录型数据结构表示的信号量。
S.value 的初值表示系统中某种资源的数目,当发现S.value<0时表示该资源已分配完毕,因此程序调用block原语进行自我阻塞,主动放弃了处理机,并插入等待队列。所以遵循了“让权等待”原则,不会出现”忙等“现象。
利用信号量实现进程同步:
✅ 信号量的两个操作
- P 操作(wait)
- 先执行
S = S - 1
- 如果结果 < 0 → 说明没有资源,进程阻塞,进入等待队列。
- 如果结果 ≥ 0 → 说明有资源,进程继续执行。
- 先执行
👉 所以 P 是执行前 --,再检查能不能继续。
- V 操作(signal)
- 先执行
S = S + 1
- 如果结果 ≤ 0 → 说明有进程在等,唤醒一个阻塞的进程。
- 如果结果 > 0 → 说明没人在等,直接结束。
- 先执行
👉 所以 V 是执行时 ++,然后可能会唤醒别人。
2.3.5 进程同步与互斥经典问题
1. 生产者消费问题
生产者,消费者共享一个初始为空,大小为n的缓冲区。
- 只有缓冲区没满时候,生产者才能把产品放入缓冲区,否则必须等待。
- 只有缓冲区不空时候,消费者才能从中取出产品,否则必须等待。
- 缓冲区是临界资源,各进程必须互斥的访问。
这是一个 生产者-消费者问题:同步 + 互斥(要先生产后消费,同时不能多个进程一起写缓冲区)。
2. 多生产者多消费问题
本质和单个生产者/消费者 一模一样,区别是有多个进程在同时生产或消费。
多生产者-多消费者问题:也是同步 + 互斥,只是进程更多。
3. 读者写者问题
有读者和写者两组并发进程,共享一个文件。
- 允许多个读者可以同时对文件执行读操作。
- 只允许一个写者往文件中写信息
- 任一写者在完成写操作之前不允许其他读者或者写者工作
- 写者执行写操作前,应让已有的读者和写者全部退出。
但是如果源源不断的来读,一直有人读进程,可是只有conut = 0 时,也就是最后一个人读完后在可以解锁文件,写进程一直无法唤醒,就会造成’饥饿’的现象。
此时新增了一个 w 变量即可实现 “写优先”
读者-写者问题:互斥(写独占)+ 条件同步(第一个读者/最后一个读者控制写)。
4. 哲学家进餐问题
场景:
- 5 个哲学家围着桌子坐,每人面前有一只筷子。
- 吃饭要两只筷子(左+右)。
- 如果大家都先拿左手筷子,就会 死锁(没人能吃)。
信号量设计:
chopstick[i] = 1
(第 i 只筷子)- 每个哲学家必须同时申请两只筷子才能吃。
如果哲学家同时拿筷子,就会出现死锁问题
避免死锁的方法:
- 限制最多 4 个哲学家同时拿筷子。
- 或者规定奇数号哲学家先拿左,偶数号先拿右
哲学家进餐问题 = 死锁产生 + 死锁预防的经典案例。
2.3.6 管程
信号量机制实现进程同步和互斥,编写程序困难,易出错,所以引入了“管程”这种高级同步机制。
管程是一种特殊的软件模块
- 局部于管程的共享数据结构说明
- 对该数据结构进行操作的一切过程
- 对局部于管程的共享数据设置初始值的语句。
- 管程有一个名字
有点像C++中的类
管程的基本特征
- 局部于管程的数据只能被局部于管程的过程所访问
- 一个进程只有通过调用管程内的过程才能进入管程访问共享数据
- 每次仅允许一个进程在管程内执行某个内部过程。
- 管程内部自动保证互斥,不用程序员自己写
P/V
管程 = “自带锁的类” + “条件变量”,自动实现互斥与同步。
特性 | 信号量(Semaphore) | 管程(Monitor) |
---|---|---|
定位 | 低级同步原语,需要手动管理 | 高级同步工具,自动管理 |
使用方式 | 通过 P() 、V() 控制访问 | 通过调用管程内过程访问 |
互斥实现 | 需要程序员显式用互斥信号量保护 | 管程内部自动互斥 |
同步实现 | 通过信号量计数实现 | 通过条件变量 wait()/signal() |
难度 | 容易出错(P/V顺序、死锁) | 简单、安全 |
语言支持 | OS级特性,偏底层 | 高级语言直接支持(Java synchronized ,C++条件变量等) |
2.4 死锁
2.4.1 死锁的概念
死锁是各进程互相等待对方手里的资源,导致进程全部阻塞,无法向前推进。
死锁,饥饿,死循环的共同点都是进程无法顺利向前推进(故意设计的死循环除外)
名称 | 产生原因 | 是否永远无法结束 | 典型场景 |
---|---|---|---|
死锁 | 多个进程/线程相互等待资源,形成环路,无法推进。 | 是(无外力不解开) | 哲学家进餐、互斥锁环等 |
饥饿 | 调度策略不公平,某进程长期得不到资源 | 不一定(可能很久才执行) | 优先级调度低优先级任务长期不执行 |
死循环 | 程序逻辑错误,循环条件永远为真 | 是(除非人为打断) | while(1) {} |
关键点:
- 必须是多个进程/线程参与。
- 每个进程都持有部分资源,又请求其他资源,形成循环等待。
- 一旦进入死锁状态,没有外部干预无法自行解开。
经典例子:
两个进程A和B:
- A占有资源R1,请求R2;
- B占有资源R2,请求R1;
二者相互等待,导致系统“卡死”。
死锁必要条件 | 含义 | 预防方法(破坏条件) |
---|---|---|
互斥条件 | 资源一次只能被一个进程占用 | 尽量使用可共享资源(如只读文件、SPOOLing假脱机技术) |
占有且等待条件(请求和保持) | 进程持有资源的同时还申请新资源 | 一次性申请全部资源 或 先释放再申请 |
不可剥夺条件 | 已占有的资源不能强制剥夺 | 允许资源被强制剥夺(如超时回收) |
循环等待条件 | 存在资源等待环 | 资源有序分配法(按序号申请,避免环路) |
这四个条件必须同时满足,才可能发生死锁。
只要有一个条件不满足,就一定不会发生死锁。
但满足这四个条件 ≠ 一定死锁,只是具备了发生死锁的可能。
死锁的处理策略
- 预防死锁。破坏死锁产生的四个必要条件中的一个或者几个。
- 避免死锁。用某种方法防止系统进入不安全状态,从而避免死锁(银行家算法)
- 死锁的检测和解除。允许死锁的发生,不过操作系统会负责检测出死锁的发生,然后采取某种措施解除死锁。
2.4.2 预防死锁
破坏互斥条件:
互斥条件:只有对必须互斥使用的资源争抢才会导致死锁。
缺点:并不是所有的资源都可以造成可共享的资源,并且为了系统安全,很多地方还必须保护这种互斥性。因此很多时候都无法破坏互斥条件。
破坏不剥夺条件:
不剥夺条件:进程所获得的资源在未使用前,不能由其他进程强行夺走,只能主动释放。
我们可以利用时间片轮转,或者调度优先级的方式破坏不剥夺条件。
缺点:
- 实现起来比较复杂
- 释放的已获得的资源,有可能造成前一阶段的资源失效。
- 反复的申请和释放资源会增加系统开销,降低性能。
破坏请求和保持条件
请求和保持条件:进程在运行过程中已经保持了至少一个资源,需要申请新的资源,但是所需资源被其他进程占有,所以自己就会阻塞,但又对自己所占有的资源保持不放。
可以采取静态分配方法,就是进程在开始运行前,必须一次性申请完它所需的全部资源,在它的资源未满足之前,不能让它运行,一但运行起来,所占用的资源必须等待进程结束,才可以归还出去。
缺点:有些资源只需要占用很短的时间,但却一直被占用,所以会导致很严重的资源浪费,还会导致出现饥饿现象。
破坏循环等待条件
在系统中存在一个进程—资源的环形等待链,即:
- 进程 P1 等待 P2 占用的资源,
- P2 等待 P3 占用的资源,
- ……
- Pn 又等待 P1 占用的资源,
形成一个首尾相连的等待环路。
例子:
- P1 占用 R1,等待 R2
- P2 占用 R2,等待 R3
- P3 占用 R3,等待 R1
→ 三个进程互相等待,谁都无法推进,系统陷入死锁。
预防方法:
- 资源有序分配法:为资源统一编号,进程申请资源必须按编号递增(防止环路形成)。
缺点: 不方便增加新的设备,会导致资源浪费;用户编程麻烦。
2.4.3 避免死锁
银行家算法(Banker’s Algorithm)是操作系统中避免死锁的一种经典算法,由Dijkstra提出。它通过 “安全状态” 判断是否分配资源,保证系统始终不会进入死锁状态。
1. 基本思想
- 系统在每次分配资源时,都先预判此次分配是否会让系统进入不安全状态(可能死锁)。
- 如果分配后系统仍然有可能让所有进程顺利完成,则允许分配;否则拒绝本次分配,让进程等待。
- 类比“银行贷款”:银行只在保证最终能让所有客户都能还款的情况下才会放贷。
2. 算法所需数据结构
设系统有n
个进程,m
种资源:
- Available[m]:当前可用的各类资源数。
- Max
[n][m]
:每个进程对各类资源的最大需求。 - Allocation
[n][m]
:当前已分配给每个进程的资源数。 - Need
[n][m]
:每个进程还需的资源数 =Max - Allocation
。
3. 工作流程
当一个进程请求资源时(Request[i]):
- 检查合法性:
- 若
Request[i] <= Need[i]
且Request[i] <= Available
,继续;否则非法请求。
- 若
- 试分配:
- 先假设把资源分配给它:
Available = Available - Request[i]
Allocation[i] = Allocation[i] + Request[i]
Need[i] = Need[i] - Request[i]
- 先假设把资源分配给它:
- 安全性检查:
- 判断系统是否处于安全状态(能否按某个顺序让所有进程执行完释放资源)。
- 若安全 → 真正分配;
- 若不安全 → 恢复原状态,让进程等待。
4. 特点
- 优点:能避免死锁发生。
- 缺点:需要事先知道每个进程的最大资源需求,开销较大,不适用于资源动态变化频繁的系统。
如果系统处于安全状态,就一定不会发生死锁。如果系统进入不安全状态,就可能发生死锁。
处于不安全状态未必就是发生了死锁,但发生了死锁一定是在不安全状态。
4.4.4 检测和解除
死锁的检测
当系统不采取预防或避免措施时,需要定期或在资源不足时进行死锁检测。
资源分配图(RAG)法(适用于每类资源只有一个实例)
- 图中两类节点:
- 进程节点(圆形)
- 资源节点(方形)
- 两类边:
- 请求边(P → R)
- 分配边(R → P)
- 检测方法:
- 在资源分配图中找是否有环,
- 有环 ⇒ 必然死锁(单实例资源)。
我们首先将图中即不阻塞,也不是孤点(至少有一条边)的进程,然后消去它所有的请求边和分配边,使之称为孤点,然后再找下一个不阻塞也不是孤点的点,依次重复,则称改图是可完全简化,否则就会产生死锁。
死锁的解除
一旦系统检测到死锁,常用的解决方法有:
- 资源剥夺法(Resource Preemption)
- 挂起(暂时阻塞)某些死锁进程,并强制回收其占用的资源,分配给其他需要的进程。
- 缺点:可能导致饥饿,且状态恢复复杂。
- 进程撤销法(Process Termination)
- 强制终止一个或多个死锁进程,释放其资源。
- 策略:
- 一次性终止所有死锁进程(简单粗暴,代价高)。
- 按优先级、运行代价等因素逐个终止,直到死锁解除。
- 进程回退法(Rollback)
- 系统在运行过程中为进程设置检查点,一旦发生死锁,回退到某个安全状态重新运行。
- 缺点:实现复杂,需要额外存储和恢复机制。