操作系统笔记:第二章 进程与线程(2.0版本)3.0版本加图得看博主的复习进度

进程与线程

进程的概念

  • 在理解进程之前,我们先要区分程序进程
  • 程序:程序是静态的,它如同存放在磁盘上的说明书,例如 QQ.exe 文件,它本身只是指令和数据的集合。
  • 进程:进程是动态的,它是程序的一次实际执行过程。同一个程序,比如 QQ,可以同时运行多次,每一次运行就对应一个独立的进程。

进程的构成要素

为了在操作系统中区分和管理不同的进程,每个进程都由三个核心部分组成:进程控制块(PCB)程序段数据段。这三部分合起来,准确地说,应该称为进程实体进程映像

  • 其中进程是一个动态的概念,代表程序的执行过程;而进程实体是静态的,它反映了进程在某个特定时间点的状态快照,比如某个变量 x 在执行 x++ 操作后的值为 2。操作系统进行资源分配和调度的基本单位就是进程,它是进程实体的运行体现。

进程控制块 PCB:进程的身份证

  • 进程控制块,英文全称为 Process Control Block,简称 PCB,是操作系统感知和管理进程的唯一凭证。当一个进程被创建时,操作系统会为其生成一个 PCB;当进程结束时,对应的 PCB 会被回收。可以把它想象成进程的身份证。
  • 操作系统创建进程时,会分配一个独一无二的进程ID,即 PID (Process ID),用于区分各个进程。PCB 中包含了操作系统管理进程所需的全部信息,主要有:
    1. 进程描述信息
      • 进程标识符 PID:系统的唯一编号。
      • 用户标识符 UID:标识该进程属于哪个用户。
    2. 资源分配清单
      • 记录进程当前使用的内存区域。
      • 记录进程打开或使用的文件列表。
      • 记录进程正在使用的 I/O 设备。
      • 这些信息帮助操作系统有效管理系统资源。
    3. 进程控制和管理信息
      • 进程当前状态:例如就绪、运行、阻塞等。
      • 进程优先级:决定进程获取 CPU 的优先顺序。
      • 程序代码的入口地址。
      • 程序在外存中的存放地址。
      • 进程进入内存的时间、累计占用 CPU 的时间等。
      • 信号量使用情况等,用于进程同步。
    4. 处理机相关信息
      • 寄存器值:如程序状态字(PSW)、程序计数器(PC)等各种寄存器的当前值。
      • 这些信息在进程切换时需要保存和恢复,确保进程能从断点处继续执行。

程序段与数据段:进程的执行内容

这两部分是进程实际运行时使用的内容:

  • 程序段:包含进程要执行的代码,也就是指令序列。
  • 数据段:包含进程在运行过程中产生的各种数据,例如程序中定义的全局变量、局部变量、动态分配的内存等。

进程的主要特征

进程具有以下五个关键特征:

  1. 动态性:进程是程序的一次执行,有创建、活动、暂停、终止等生命周期,是动态产生、变化和消亡的。这是进程最根本的特征。
  2. 并发性:在一段时间内,内存中可以同时存在多个进程实体,它们可以交替在 CPU 上执行,宏观上看起来像是同时在运行。
  3. 独立性:进程是系统进行资源分配和调度的基本单位。每个进程独立运行,拥有自己的资源,并独立接受系统的调度。
  4. 异步性:各个进程按照各自独立的、不可预测的速度向前推进。这种异步性可能导致并发执行结果的不确定性,因此操作系统需要提供进程同步机制来协调它们。
  5. 结构性:每个进程都由程序段、数据段和 PCB 三部分构成,具有明确的结构。

(注意:早期的“封闭性”概念指进程执行结果仅取决于自身,不受外界干扰。但在并发环境下,由于资源共享和相互作用,进程失去了封闭性。)


进程的五种基本状态

操作系统通过 PCB 中的一个状态变量(state)来跟踪每个进程的当前状况。为了方便管理,操作系统通常会将处于同一状态的进程 PCB 组织在一起(例如使用队列)。进程主要有以下五种基本状态:

  1. 创建态(新建态):进程正在被创建的过程中,操作系统会为其分配必要的资源(如内存空间、PID)并初始化 PCB。
  2. 就绪态:进程已经完成了创建,拥有了运行所需的所有资源,只缺少 CPU。一旦 CPU 空闲,操作系统就可以从处于就绪态的进程中选择一个来运行。系统中可能同时有多个进程处于就绪态。
  3. 运行态:进程获得了 CPU,其对应的程序代码正在 CPU 上执行。在单核 CPU 环境下,任何时刻最多只有一个进程处于运行态。
  4. 阻塞态(等待态):进程在运行过程中,因等待某个事件的发生而暂时无法继续执行,例如请求 I/O 操作完成、等待其他进程的消息、等待系统分配某种资源等。此时,操作系统会将该进程置于阻塞态,并让出 CPU 给其他就绪进程。
  5. 终止态(结束态):进程执行完毕,或者因错误、被用户或操作系统强制结束,进入终止态。此时,操作系统会回收进程占用的资源和 PCB,进程最终彻底消失。

进程状态之间的转换

进程的状态不是固定不变的,会随着事件的发生而转换:

  • 创建态 → 就绪态:进程创建完成,资源分配到位,准备运行。
  • 就绪态 → 运行态:进程被调度程序选中,获得 CPU 执行权。
  • 运行态 → 就绪态:进程的时间片用完,或被更高优先级的进程抢占 CPU。
  • 运行态 → 阻塞态:进程主动请求等待某个事件(如 I/O 请求)。这是一个主动行为。
  • 阻塞态 → 就绪态:进程等待的事件发生(如 I/O 操作完成)。这是一个被动行为,由操作系统或其他进程触发。
  • 运行态 → 终止态:进程正常执行完毕或异常终止。

重要的转换规则

  • 阻塞态进程不能直接转换运行态,必须先经过就绪态,等待调度。
  • 就绪态进程不能直接转换阻塞态,进入阻塞态的前提是进程正在运行并主动请求等待。

导致阻塞的主要事件

  • 等待 I/O 操作(如读写文件、网络通信)完成。
  • 申请资源(如内存、设备)但资源暂时不可用。
  • 等待其他进程的协作或信号(例如,对信号量执行 P 操作时资源不足)。
  • 等待用户输入等。

进程的组织方式

操作系统需要有效地管理系统中所有的进程 PCB。常用的组织方式有两种:链接和索引。

链接方式

  • 按照进程的状态(如就绪、阻塞)将 PCB 组织成不同的队列。
  • 操作系统维护指向这些队列头/尾指针。例如:
    • 执行指针:指向当前正在运行的进程的 PCB(单 CPU 系统只有一个)。
    • 就绪队列指针:指向所有处于就绪态进程的 PCB 链表。通常按优先级排序,高优先级进程排在前面。
    • 阻塞队列指针:指向所有处于阻塞态进程的 PCB 链表。有时会根据阻塞原因进一步细分为多个阻塞队列(如等待磁盘 I/O 队列、等待打印机队列等)。

索引方式

  • 根据进程状态建立多个索引表。
  • 每个索引表记录了处于该状态的所有进程的 PCB 地址。
  • 操作系统持有指向这些索引表的指针。

进程的控制

进程控制是操作系统对进程整个生命周期进行管理的核心功能,主要任务是实现进程状态的转换。这些控制操作通常是通过原语(Primitive)来实现的。

  • 原语的特性:原语是一段特殊的程序,它的执行必须是原子性的,即要么完全执行成功,要么完全不执行,执行过程中不允许被中断。这是为了保证操作系统内部数据结构的一致性。如果进程控制操作(如修改 PCB 状态、插入队列)被打断,可能导致系统状态混乱。原语通常在核心态下执行。

  • 主要的进程控制原语:进程控制原语通常涉及以下三个步骤的组合

    1. 更新目标进程 PCB 中的信息(如状态、寄存器值)。
    2. 将目标进程的 PCB 从一个队列或索引表移出,插入到另一个队列或索引表。
    3. 根据需要分配或回收进程占用的资源。

进程创建(创建原语)

  • 触发事件:用户登录、作业调度、系统提供服务请求、应用程序请求创建子进程。
  • 主要工作
    1. 申请一个空白的 PCB。
    2. 为新进程分配必要的资源(如内存、文件句柄等)。
    3. 初始化 PCB(设置 PID、UID、状态为创建态/就绪态、程序入口等)。
    4. 将 PCB 插入到就绪队列。

进程终止(撤销原语)

  • 触发事件:进程正常结束(如调用 exit)、发生不可恢复的错误(如除零)、被用户或操作系统强制终止(如任务管理器)。
  • 主要工作
    1. 从 PCB 集合中找到要终止的进程的 PCB。
    2. 如果进程正在运行,立即剥夺其 CPU,分配给其他进程。
    3. 终止该进程的所有子进程(如果存在父子关系)。
    4. 回收该进程所拥有的全部资源(内存、文件、设备),归还给父进程或操作系统。
    5. 从所在的队列(就绪、阻塞等)中移除 PCB,最后删除 PCB。

进程阻塞(阻塞原语)

  • 触发事件:进程需要等待某个事件才能继续执行(如等待 I/O、等待资源)。
  • 主要工作
    1. 找到发起阻塞请求的进程的 PCB。
    2. 保护进程的运行现场(将 CPU 寄存器等信息存入 PCB)。
    3. 将 PCB 中的状态修改为“阻塞态”。
    4. 将 PCB 插入到相应事件的等待队列(阻塞队列)。
    5. 触发调度程序,选择另一个就绪进程上 CPU 运行。

进程唤醒(唤醒原语)

  • 触发事件:进程所等待的事件发生(如 I/O 完成、资源可用)。
  • 主要工作
    1. 在相应事件的等待队列中找到目标进程的 PCB。
    2. 将 PCB 从等待队列中移除。
    3. 将 PCB 中的状态修改为“就绪态”。
    4. 将 PCB 插入到就绪队列,等待调度程序选择其运行。
    • 注意:阻塞和唤醒必须成对使用,因何事阻塞,就应由对应事件的发生来唤醒。

进程切换(切换原语)

  • 触发事件:当前进程时间片用完、有更高优先级进程到达、当前进程主动阻塞、当前进程终止。
  • 主要工作
    1. 保存当前运行进程的上下文环境(寄存器值等)到其 PCB。
    2. 更新该 PCB 的状态(如从运行态变为就绪态或阻塞态),并将其移入相应队列。
    3. 选择一个新的就绪进程。
    4. 更新新进程的 PCB 状态为“运行态”。
    5. 根据新进程 PCB 中的信息,恢复其运行所需的上下文环境到 CPU 寄存器。

进程间通信 (IPC)

进程是系统资源分配的基本单位,每个进程拥有独立的内存地址空间。为了保证系统安全,操作系统不允许一个进程直接访问另一个进程的内存。但是,进程之间常常需要协作和交换数据,这就需要进程间通信(Inter-Process Communication, IPC)机制。主要 IPC 方式有:

共享存储

  • 原理:在内存中划出一块共享存储区域,多个需要通信的进程都可以访问这块区域。通过对共享区域的读写操作,实现数据交换。
  • 实现:操作系统通过修改进程的页表或段表,将同一块物理内存映射到不同进程的逻辑地址空间中。
  • 特点
    • 速度快:数据交换无需经过内核,直接在内存中进行。属于高级通信方式
    • 需要同步互斥:各进程对共享空间的访问需要使用同步互斥工具(如后面将介绍的 P/V 操作或互斥锁)来控制,避免数据混乱。
    • 类型
      • 基于数据结构的共享:共享空间的内容和格式有预定结构(如固定大小数组),限制较多,速度相对慢,是低级通信方式
      • 基于存储区的共享:仅提供一块内存区,数据格式和管理由进程自行决定,更灵活,速度快。

消息传递

  • 原理:进程间的数据交换以格式化的消息(Message)为单位。操作系统提供发送消息(send)和接收消息(receive)两个原语。
  • 消息结构:通常包含消息头(发送/接收方 ID、消息长度等)和消息体(实际数据)。
  • 方式
    • 直接通信:发送方明确指定接收方的 ID,消息直接发送到接收方的消息缓冲队列。
    • 间接通信(信箱通信):发送方将消息发送到一个中间实体——信箱(Mailbox),接收方从信箱中获取消息。多个进程可以共享同一个信箱。
  • 特点:数据交换需要经过操作系统内核中转,速度相比共享内存较慢。

管道通信

  • 原理:“管道”(Pipe)是内存中的一个固定大小的缓冲区,行为类似一个先进先出(FIFO)队列。它本质上是一个特殊的共享文件(pipe 文件)。
  • 特点
    • 半双工:同一时间段内,数据只能单向流动。若要双向通信,需要建立两个管道。
    • 互斥访问:由操作系统保证各进程对管道的访问是互斥的。
    • 阻塞机制
      • 管道写满时,写进程阻塞,直到读进程取走数据。
      • 管道读空时,读进程阻塞,直到写进程写入数据。
    • 数据消耗:数据一旦被读出,就从管道中消失。
    • 多读进程处理:对于多个进程读同一个管道,通常有两种策略:
      • 只允许一个读进程;
      • 允许多个读进程,但系统会让它们轮流读取(如 Linux 方式)。

线程:轻量级的进程

传统的进程是顺序执行的,如果一个进程需要同时处理多个任务(例如,一个Word程序既要响应用户输入,又要进行后台打印),使用单个进程会效率低下。为了提高并发度,让一个进程内部也能并发执行多个子任务,引入了线程(Thread)的概念。可以把线程理解为“轻量级进程”。

线程与进程的比较

特性传统进程机制引入线程后
资源分配进程是资源分配和调度的基本单位进程是资源分配的基本单位
调度进程是资源分配和调度的基本单位线程是CPU调度的基本单位
并发性只能实现进程间并发可实现进程内多线程并发,以及进程间并发
系统开销进程切换开销大(需切换地址空间)同一进程内线程切换开销小;跨进程线程切换开销大

线程的独有与共享

  • 共享:同一进程内的所有线程共享该进程的地址空间(代码段、数据段)、打开的文件、I/O 设备等资源。
  • 独有:每个线程拥有自己独立的程序计数器 (PC)寄存器集合(用于函数调用和局部变量)。线程的栈对其他线程是透明的,不能共享。

线程的属性

  • 线程是处理机调度的基本单位。
  • 多 CPU 环境下,同一进程的多个线程可以并行运行在不同 CPU 上。
  • 每个线程有唯一的线程 ID (TID) 和线程控制块 (TCB)。
  • 线程也有就绪、运行、阻塞三种基本状态及相应的转换。
  • 线程本身几乎不拥有系统资源,资源是分配给进程的。
  • 同一进程内线程间通信非常方便,因为共享内存地址空间,甚至无需系统干预(但仍需注意同步互斥)。
  • 线程切换:
    • 同一进程内的线程切换,会引起进程切换,开销小。
    • 不同进程间的线程切换,引起进程切换,开销大。

线程的实现方式

根据线程的管理是由用户程序完成还是由操作系统内核完成,分为两种实现方式:

用户级线程 (User-Level Thread, ULT)

  • 管理:线程的创建、销毁、调度、切换等管理工作完全由应用程序通过线程库(如 POSIX Pthreads 库)在用户空间完成。
  • 切换:线程切换不需要进入内核态,开销小,效率高。
  • 操作系统感知:操作系统内核意识不到用户级线程的存在,它仍然只把整个进程当作一个执行单位。
  • 优缺点
    • 优点:管理开销小,切换速度快。
    • 缺点:如果一个用户级线程因系统调用(如 I/O)而被阻塞,那么整个进程下的所有用户级线程都会被阻塞,大大降低了并发度。在多核 CPU 上,同一进程的多个用户级线程无法真正并行执行。

内核级线程 (Kernel-Level Thread, KLT)

  • 管理:线程的管理工作由操作系统内核完成。内核为每个内核级线程维护一个线程控制块 (TCB)
  • 切换:线程切换需要陷入核心态,由内核进行调度,开销相对较大。
  • 操作系统感知:操作系统知道内核级线程的存在,并以它们为单位进行调度。
  • 优缺点
    • 优点:当一个线程阻塞时,同一进程的其他线程仍然可以继续执行,并发能力强。在多核 CPU 上可以实现真正的并行执行。
    • 缺点:线程的管理和切换都需要内核参与,系统开销较大。

多线程模型

在支持内核级线程的系统中,用户级线程与内核级线程之间的映射关系形成了不同的多线程模型:一对一模型,多对一模型,多对多模型。

一对一模型 (One-to-One Model)

  • 映射:一个用户级线程精确映射到一个内核级线程。
  • 特点
    • 优点:并发能力强,一个线程阻塞不影响其他线程,支持多核并行。
    • 缺点:每创建一个用户级线程都需要创建一个内核级线程,开销大,可能受限于系统内核线程的数量。

多对一模型 (Many-to-One Model)

  • 映射:多个用户级线程映射到一个内核级线程。整个进程只拥有一个内核级线程。
  • 特点
    • 优点:线程管理开销小,切换快(在用户空间完成)。
    • 缺点:并发度低,一个线程阻塞导致整个进程阻塞。无法利用多核优势。与纯用户级线程实现相似。

多对多模型 (Many-to-Many Model)

  • 映射nnn 个用户级线程映射到 mmm 个内核级线程(通常 n≥mn \ge mnm)。一个进程拥有 mmm 个内核级线程。
  • 特点
    • 结合前两种模型的优点:既有较好的并发性(一个线程阻塞不一定阻塞整个进程),又能有效利用多核(最多 mmm 个线程并行),同时创建用户级线程的开销相对较小。
    • 实现相对复杂。
    • 可以理解为:用户级线程是“代码逻辑”的载体,内核级线程是“运行机会”的载体。只有获得了内核级线程这个“运行机会”,用户级线程的代码逻辑才能被 CPU 执行。

适用多线程的场景

  • 任务可以分解为多个相对独立的子任务。
  • 计算密集型任务,需要利用多核 CPU 提高并发处理能力。
  • I/O 密集型任务,允许在等待 I/O 时执行其他子任务,提高响应速度和资源利用率。

线程的状态与控制

线程状态:线程的状态与进程非常相似,主要也包括执行(运行)状态、就绪状态、阻塞状态。状态转换的规则也基本相同。线程切换时,需要保存和恢复 PC、寄存器集合、堆栈指针等信息。

线程控制块 (TCB):操作系统(或线程库)为每个线程维护一个线程控制块(TCB),用于管理线程。TCB 通常包含:

  • 线程标识符 (TID):线程的唯一 ID。
  • 程序计数器 (PC):记录线程下一条要执行的指令地址。
  • 寄存器集合:保存线程运行时的中间结果。
  • 堆栈指针:指向线程独立的栈,用于函数调用和局部变量存储。
  • 线程运行状态:标记线程当前处于何种状态。
  • 优先级:用于线程调度。
  • 指向所属进程 PCB 的指针(如果是内核级线程)。

处理机调度

调度的基本概念与层次

调度:当系统中有多个任务需要处理,但资源(如 CPU、内存)有限,无法同时满足所有任务时,就需要按照一定的规则来决定任务的处理顺序。这个决策过程就是调度

调度的三个层次

在操作系统中,调度通常发生在三个不同的层次:

调度层次别名功能对象发生地点频率对进程状态的影响
高级调度作业调度从外存的后备作业队列中挑选作业调入内存,并为其创建进程作业外存 → 内存最低无 → 创建态 → 就绪态
中级调度内存调度/交换将暂时不能运行的进程从内存调到外存(挂起),或将具备条件的挂起进程重新调入内存。进程内存 ↔ 外存中等运行/就绪/阻塞 → 挂起态;挂起态 → 就绪/阻塞态
低级调度进程调度/CPU调度就绪队列中选择一个进程,将 CPU 分配给它。进程/线程内存 → CPU最高就绪态 → 运行态

补充:进程的挂起态与七状态模型

为了实现中级调度,引入了**挂起(Suspend)**状态。当内存不足时,可以将某些进程暂时移到外存,此时进程就处于挂起状态。挂起状态可以进一步细分为:

  • 就绪挂起:进程在就绪状态时被换出到外存。
  • 阻塞挂起:进程在阻塞状态时被换出到外存。

当内存有空闲或挂起原因消失时,中级调度会将合适的挂起进程调回内存,恢复到原来的就绪态或阻塞态。

挂起与阻塞的区别:两者都表示进程暂时不能获得 CPU,但阻塞态的进程仍在内存中,而挂起态的进程映像已被调到外存

七状态模型包括:创建态、就绪态、运行态、阻塞态、终止态、就绪挂起态、阻塞挂起态。


调度的目标(评价指标)

设计调度算法时,通常需要权衡以下目标:

  • CPU 利用率:CPU 处于“忙碌”状态(执行用户或系统进程)的时间占总时间的比例。目标是尽可能高。
    • 计算公式: CPU利用率=CPU有效工作时间总时间CPU利用率 = \frac{CPU有效工作时间}{总时间}CPU利用率=总时间CPU有效工作时间
  • 系统吞吐量:单位时间内完成的作业(或进程)数量。目标是尽可能大。
    • 计算公式: 系统吞吐量=完成的作业数量总时间系统吞吐量 = \frac{完成的作业数量}{总时间}系统吞吐量=总时间完成的作业数量
  • 周转时间:指从作业提交给系统开始,到作业完成为止的总时间。
    • 计算公式: 周转时间=作业完成时间−作业提交时间周转时间 = 作业完成时间 - 作业提交时间周转时间=作业完成时间作业提交时间
    • 它包括:作业在后备队列等待时间 + 进程在就绪队列等待时间 + 进程在 CPU 执行时间 + 进程等待 I/O 时间。
  • 带权周转时间:衡量作业周转时间相对于其真实运行时间的比率,更能体现对短作业的公平性。
    • 计算公式: 带权周转时间=周转时间作业实际运行时间带权周转时间 = \frac{周转时间}{作业实际运行时间}带权周转时间=作业实际运行时间周转时间 (实际运行时间通常指 CPU 执行时间)
    • 目标是平均带权周转时间尽可能小。
  • 等待时间:指进程/作业处于等待状态(等待 CPU、等待 I/O 完成后重新等待 CPU)的时间总和。
    • 对于进程: 等待时间=周转时间−运行时间−I/O操作时间等待时间 = 周转时间 - 运行时间 - I/O操作时间等待时间=周转时间运行时间I/O操作时间 (注意:等待 I/O 的时间不算在此等待时间内)
    • 对于作业:还需要加上在后备队列的等待时间。
    • 目标是平均等待时间尽可能短。调度算法主要影响的就是等待时间。
  • 响应时间:对于交互式系统,指从用户提交请求(如按下回车)到系统首次产生响应(如屏幕出现第一个字符)所用的时间。目标是响应时间尽可能快。

这些目标有时是相互矛盾的,例如,追求最短平均等待时间可能会饿死长作业。调度算法的设计需要在这些目标间找到平衡。


调度的实现机制

调度的时机:操作系统需要在特定时刻触发调度程序决定下个运行的进程。这些时刻包括:

  1. 当前运行进程主动放弃 CPU
    • 进程正常执行完毕而终止。
    • 进程运行中发生错误而异常终止。
    • 进程主动调用原语请求阻塞(如等待 I/O)。
  2. 当前运行进程被动放弃 CPU
    • 分配给进程的时间片用完(在分时系统中)。
    • 发生中断,需要处理更紧急的任务(如 I/O 完成中断可能唤醒高优先级进程)。
    • 有更高优先级的进程进入就绪队列(在抢占式优先级调度中)。

不能进行进程调度的时刻:特殊情况下,即使满足上述条件,也不能进行进程调度和切换:

  1. 在处理中断过程中:中断处理需快速完成,涉及硬件交互,过程复杂,不宜切换。
  2. 进程在操作系统内核程序的临界区中:为了保护内核数据结构的一致性,访问内核临界区的代码段不允许被中断和切换。
  3. 在执行原子操作(原语)的过程中:原语必须一气呵成,不可中断。

进程调度方式:根据当前运行进程是否会被强制剥夺 CPU,调度方式分为:

  1. 非剥夺调度方式(非抢占式)
    • 一旦进程获得 CPU,它会一直运行下去,直到它自己主动放弃(终止、阻塞)。即使有更高优先级的进程就绪,也不能抢占 CPU。
    • 优点:实现简单,系统开销小。
    • 缺点:无法及时响应紧急任务,可能导致短进程等待长进程时间过长。适用于早期的批处理系统。
  2. 剥夺调度方式(抢占式)
    • 当一个更高优先级进程就绪,或当前进程时间片用完时,操作系统可以强制剥夺当前进程的 CPU,分配给其他进程。
    • 优点:能优先处理紧急任务,响应速度快,可以通过时间片实现公平性。适用于分时系统和实时系统。
    • 缺点:实现复杂,调度和切换开销较大。

进程调度与切换过程

  • 狭义的进程调度:指从就绪队列中选择下一个要运行的进程。
  • 进程切换:指将 CPU 的控制权从一个进程转移给另一个进程。
  • 广义的进程调度:包含选择切换两个步骤。

进程切换涉及

  1. 保存当前进程的上下文(CPU 寄存器状态、程序计数器等)到其 PCB。
  2. 恢复被选中进程的上下文从其 PCB 到 CPU 寄存器。

进程切换有时间成本,过于频繁的调度和切换会消耗大量 CPU 时间,降低系统整体效率。

调度程序(调度器)与闲逛进程

  • 调度程序是操作系统中负责实现调度算法的代码模块。它在上述“调度的时机”被触发。
  • 闲逛进程 (Idle Process)是一个特殊的系统进程,优先级最低。当就绪队列为空,没有用户进程或系统进程可以运行时,调度程序会选择运行闲逛进程。它的作用是让 CPU 保持运转(例如,继续响应中断),并且通常设计为能耗较低。

线程调度

  • 对于只支持用户级线程的系统,调度由用户空间的线程库负责。
  • 对于支持内核级线程的系统,调度由操作系统内核负责,调度对象是内核级线程。

适用于早期批处理系统的算法(主要关注吞吐量、周转时间,不太关心响应时间)

先来先服务 (First Come First Serve, FCFS)

  • 思想:公平排队。
  • 规则:按照作业或进程到达就绪队列的先后顺序进行调度。
  • 类型:非抢占式。
  • 适用:作业调度、进程调度。
  • 优缺点
    • 优点:公平,实现简单。
    • 缺点:对短作业/进程不利。如果一个长作业先到达,后面等待的短作业需要等很久,导致平均周转时间和平均等待时间较长。对 I/O 密集型进程也不利(频繁阻塞后需重新排队)。
  • 饥饿:不会。

短作业优先 (Shortest Job First, SJF) / 短进程优先 (Shortest Process First, SPF)

  • 思想:优先处理短任务,以期获得最短的平均等待时间和周转时间。
  • 规则:选择当前已到达且估计运行时间最短的作业或进程进行调度。
    • 非抢占式 SJF/SPF:选定后一直运行到结束或阻塞。
    • 抢占式 SJF/SPF (最短剩余时间优先, Shortest Remaining Time Next, SRTN):当有新进程到达时,如果新进程的估计剩余运行时间比当前正在运行进程的剩余时间还短,则抢占 CPU。
  • 类型:可为非抢占式,也可为抢占式 (SRTN)。
  • 适用:作业调度、进程调度。
  • 优缺点
    • 优点:平均等待时间、平均周转时间理论上最优。
    • 缺点:对长作业/进程不利,可能导致饥饿(如果不断有短任务到来)。需要预估运行时间,可能不准确或被用户欺骗。
  • 饥饿:可能。

高响应比优先 (Highest Response Ratio Next, HRRN)

  • 思想:综合考虑等待时间和运行时间,平衡长短作业。
  • 规则:每次调度时,计算每个就绪进程的响应比,选择响应比最高的进程。
    • 响应比=等待时间+要求服务时间要求服务时间=1+等待时间要求服务时间响应比 = \frac{等待时间 + 要求服务时间}{要求服务时间} = 1 + \frac{等待时间}{要求服务时间}响应比=要求服务时间等待时间+要求服务时间=1+要求服务时间等待时间
  • 类型:非抢占式。
  • 适用:作业调度、进程调度。
  • 优缺点
    • 优点:兼顾了等待时间和运行时间。等待时间相同时,短作业优先;服务时间相同时,等待时间长的优先。避免了长作业饥饿问题。
    • 缺点:每次调度都需要计算响应比,开销相对较大。
  • 饥饿:不会。

适用于交互式系统的算法(更注重响应时间、公平性)

时间片轮转 (Round Robin, RR)

  • 思想:公平地为每个进程提供服务机会,保证快速响应。
  • 规则:将所有就绪进程按到达顺序排成队列。调度程序每次选择队首进程,让其运行一个固定的时间片(quantum)。
    • 若进程在时间片内完成,则主动释放 CPU。
    • 若时间片用完但进程未完成,则被剥夺 CPU,放回就绪队列末尾,等待下一轮。
  • 类型:抢占式(基于时钟中断)。
  • 适用:进程调度。
  • 优缺点
    • 优点:公平,响应快,适用于分时系统。
    • 缺点:进程切换有开销。时间片大小选择是关键:
      • 太大:退化为 FCFS,响应时间变长。
      • 太小:切换过于频繁,系统开销增大,有效计算时间减少。
  • 饥饿:不会。

优先级调度 (Priority Scheduling)

  • 思想:根据任务的紧急程度或重要性决定处理顺序。
  • 规则:为每个进程分配一个优先级,每次调度时选择优先级最高的就绪进程。
    • 非抢占式:选定后运行到结束或阻塞。
    • 抢占式:当有更高优先级进程就绪时,可抢占当前运行的低优先级进程。
  • 类型:可为非抢占式,也可为抢占式。
  • 适用:作业调度、进程调度。
  • 优缺点
    • 优点:灵活,能满足不同任务的需求(如实时系统)。
    • 缺点:可能导致低优先级进程饥饿
  • 饥饿:可能(对低优先级)。

优先级类型

  • 静态优先级:创建时确定,运行期间不变。
  • 动态优先级:初始时设定,运行期间可根据进程行为(如等待时间长短、CPU 使用情况)调整。

优先级设置策略

  • 系统进程 > 用户进程。
  • 前台交互进程 > 后台批处理进程。
  • I/O 密集型进程 > CPU 密集型进程(优先让 I/O 繁忙型进程运行,可以更快地启动 I/O 设备,提高资源利用率)。
  • 动态调整:等待时间过长则提高优先级;占用 CPU 时间过长则降低优先级。

多级反馈队列调度 (Multilevel Feedback Queue Scheduling)

  • 思想:集多种调度算法之所长,试图获得较好的综合性能。
  • 规则
    1. 设置多个就绪队列,优先级从高到低时间片从小到大
    2. 新进程首先进入最高优先级队列 (Q1),按 FCFS 等待分配该队列的小时间片。
    3. 若在时间片内完成,则离开系统。
    4. 若时间片用完仍未完成,则降级到下一个队列 (Q2) 的末尾,等待分配 Q2 较大的时间片。
    5. 以此类推,最低优先级队列通常使用 RR 或 FCFS,且时间片最长。
    6. 调度时,只有高优先级队列为空时,才调度次低优先级队列中的进程
    7. 通常采用抢占式:当一个进程在较低级队列运行时,若有新进程进入更高级队列,则抢占 CPU。
  • 类型:抢占式。
  • 适用:进程调度。
  • 优缺点
    • 优点:对短作业/进程有利(在高优先级队列快速完成),响应快(新进程首先在高优先级队列获得短时间片),通过降级惩罚 CPU 密集型长作业,相对公平。无需预估运行时间。可以调整策略偏好 I/O 型进程(如 I/O 阻塞后返回原队列)。
    • 缺点:实现复杂。可能导致低优先级队列中的进程饥饿(如果高优先级队列一直有新进程进入)。
  • 饥饿:可能(对长期在低优先级队列的进程)。

多级队列调度 (Multilevel Queue Scheduling)

  • 思想:将进程按类型(如系统进程、交互进程、批处理进程)分成不同组,放入不同的就绪队列,每个队列采用适合其特性的调度算法。
  • 规则
    1. 系统设置多个就绪队列。
    2. 进程创建后被固定地分配到某个队列。
    3. 队列之间的调度:
      • 固定优先级:高优先级队列空闲时才调度低优先级队列(可能导致低优先级饥饿)。
      • 时间片划分:为每个队列分配一定的 CPU 时间比例(如系统队列 50%,交互队列 30%,批处理队列 20%)。
    4. 每个队列内部可以采用不同的调度算法(如系统队列用优先级,交互队列用 RR,批处理队列用 FCFS)。
  • 类型:取决于具体实现。
  • 适用:进程调度。
  • 优缺点
    • 优点:针对性强,可以为不同类型进程提供合适的调度策略。
    • 缺点:不够灵活,进程不能在队列间移动。固定优先级可能导致饥饿。
  • 饥饿:可能(取决于队列间调度策略)。

调度算法对比总结表

算法类型抢占性优点缺点饥饿风险
FCFS进程/作业非抢占公平,简单对短作业/IO密集型不利,平均等待时间长不会
SJF/SPF (非抢占)进程/作业非抢占平均等待/周转时间短对长作业不利,需预估时间,可能饥饿可能
SRTN (抢占SJF)进程/作业抢占比非抢占SJF更优的平均等待/周转时间对长作业不利,需预估时间,可能饥饿,切换开销可能
HRRN进程/作业非抢占平衡长短作业,无饥饿需计算响应比,开销大不会
RR进程抢占公平,响应快切换开销,时间片选择敏感不会
优先级进程/作业可抢占/非抢占灵活,满足紧急任务需求可能导致低优先级饥饿可能
多级反馈队列进程抢占综合性能好,响应快,兼顾短作业,无需预估时间实现复杂,可能饥饿可能
多级队列进程取决于实现针对性强不灵活,固定优先级可能饥饿可能

同步与互斥

基本概念

  • 进程同步:并发进程具有异步性,即它们以各自独立的、不可预测的速度推进。但在许多场景下,这些并发进程需要相互协作,按照一定的次序来完成任务。例如,进程 A 必须等待进程 B 产生数据后才能进行处理。这种为了完成共同任务而需要协调执行次序所产生的制约关系,称为进程同步。同步是一种直接制约关系,源于进程间的合作。

  • 进程互斥:并发执行的进程往往需要共享某些系统资源,如打印机、共享变量、内存缓冲区等。其中,有些资源在同一时间段内只允许一个进程访问,这类资源称为临界资源。对临界资源的访问必须是互斥的,即当一个进程正在访问某临界资源时,其他试图访问该资源的进程必须等待,直到前者访问结束并释放资源。进程互斥是一种间接制约关系,源于对共享资源的竞争。

  • 临界区:在每个进程中,访问临界资源的那段代码被称为临界区(Critical Section)。为了实现互斥访问,对临界区的访问逻辑上可以分为四个部分:

    1. 进入区 (Entry Section):在进入临界区之前,检查是否可以进入。如果可以,则设置“正在访问”的标志(如同“上锁”),阻止其他进程进入。
    2. 临界区 (Critical Section):实际访问临界资源的代码。
    3. 退出区 (Exit Section):访问完成后,清除“正在访问”的标志(如同“解锁”),允许其他等待的进程进入。
    4. 剩余区 (Remainder Section):进程中除了上述三部分之外的其他代码。

实现互斥应遵循的原则

设计互斥机制时,应遵循以下原则,以保证正确性和效率:

  1. 空闲让进:当临界区没有进程访问时,如果一个进程请求进入,应立即允许。
  2. 忙则等待:当已有进程在临界区内时,其他试图进入的进程必须等待。
  3. 有限等待:任何请求进入临界区的进程,应保证在有限的时间内能够进入,避免饥饿(即永远等待下去)。
  4. 让权等待(非必须):当进程不能进入临界区时,应立即释放 CPU(从运行态变为阻塞态),而不是持续占用 CPU 进行无效的循环等待(称为“忙等”)。这条原则主要为了提高 CPU 利用率,但并非所有互斥实现都满足。

实现临界区互斥的方法

软件实现方法(主要讨论两个进程 P0, P1 的情况)

  1. 单标志法
    • 思想:设置一个整型变量 turn,表示当前允许哪个进程进入临界区。进程访问完临界区后,将权限交给对方。
    • 逻辑turn 初始为 0。P0 进程的进入区是 while (turn != 0);,退出区是 turn = 1;。P1 进程的进入区是 while (turn != 1);,退出区是 turn = 0;
    • 问题:违背“空闲让进”。如果当前轮到 P0 进入,但 P0 一直不进入,即使临界区空闲,P1 也无法进入。必须严格轮流访问。
  2. 双标志先检查法
    • 思想:设置一个布尔数组 flag[2]flag[i] 表示进程 Pi 是否进入临界区。进程先检查对方是否想进,如果不想,则标记自己想进,然后进入。
    • 逻辑flag 初始均为 false。P0 进程进入区是 while (flag[1]); flag[0] = true;,退出区是 flag[0] = false;。P1 类似。
    • 问题:违背“忙则等待”。可能发生这种情况:P0 检查 flag[1]false,但在执行 flag[0] = true; 之前被切换,此时 P1 也检查 flag[0]false,然后 P1 设置 flag[1] = true; 进入临界区。之后 P0 恢复执行,也设置 flag[0] = true; 进入临界区。导致两个进程同时进入。原因是“检查”和“上锁”非原子操作。
  3. 双标志后检查法
    • 思想:为了解决先检查法的问题,改为先标记自己想进,再检查对方是否也想进。如果对方也想进,则自己等待。
    • 逻辑flag 初始均为 false。P0 进程进入区是 flag[0] = true; while (flag[1]);,退出区是 flag[0] = false;。P1 类似。
    • 问题:违背“空闲让进”和“有限等待”。可能发生这种情况:P0 设置 flag[0]=true,切换到 P1;P1 设置 flag[1]=true。此时两者都在 while 循环中检查对方的 flag,发现都为 true,于是相互等待,谁也进不了临界区,导致死锁/饥饿
  4. Peterson 算法
    • 思想:结合单标志法和双标志法。进程先表明自己想进入 (flag[i]=true),然后主动表示“你先请” (turn=j)。只有当对方不想进入 (flag[j]==false) 或者对方虽然想进入但当前轮到自己 (turn==i) 时,自己才能进入。
    • 逻辑:需要 flag[2] (初始 false) 和 turn (初始 0或1)。P0 进程进入区是 flag[0]=true; turn=1; while (flag[1] && turn==1);,退出区是 flag[0]=false;。P1 进程进入区是 flag[1]=true; turn=0; while (flag[0] && turn==0);,退出区是 flag[1]=false;
    • 特点:遵循了空闲让进、忙则等待、有限等待原则,是较好的软件解决方案。但仍然存在“忙等”,即在 while 循环中空耗 CPU,未实现“让权等待”。

硬件实现方法:通常能保证某些操作的原子性,从而更简单有效地解决互斥问题。

  1. 中断屏蔽方法
    • 思想:在进入临界区前执行关中断指令,在退出临界区后执行开中断指令。关中断期间,CPU 不会响应中断,就不会发生进程切换,保证了临界区代码的原子执行。
    • 优缺点
      • 优点:简单高效。
      • 缺点:代价高,中断被屏蔽时间过长会影响系统响应。不适用于多核 CPU(关中断只对执行该指令的核有效)。滥用危险,通常只允许在操作系统内核中使用,不提供给用户进程。
  2. TestAndSet 指令 (TS 或 TSL)
    • 思想:这是一条原子硬件指令。它读取一个内存位置(比如一个布尔锁变量 lock)的当前值,将其无条件设置为 true,并返回读取到的旧值
    • 优缺点
      • 优点:实现简单,适用于多核环境。
      • 缺点:存在“忙等”,不满足“让权等待”。
    • 逻辑
// 硬件实现的原子操作
bool TestAndSet(bool *lock_ptr) {
	bool old_value = *lock_ptr;
	*lock_ptr = true;
	return old_value;
}

// 使用 TSL 实现互斥
bool lock = false; // 共享锁变量,false表示未锁
// 进入区
while (TestAndSet(&lock)) {
	// 当 lock 原来为 true 时,TSL 返回 true,循环忙等
	// 当 lock 原来为 false 时,TSL 返回 false,跳出循环,进入临界区 (此时 lock 已被设为 true)
}
// 临界区代码 ...
// 退出区
lock = false; // 解锁
// 剩余区代码 ...
  1. Swap 指令 (或 Exchange, XCHG)
    • 思想:这也是一条原子硬件指令。它原子地交换两个内存位置(通常是一个寄存器和一个内存变量,或两个内存变量)的内容。
    • 优缺点:与 TSL 类似,实现简单,适用于多核,但存在“忙等”,不满足“让权等待”。
    • 逻辑
// 硬件实现的原子操作
void Swap(bool *a, bool *b) {
	bool temp = *a;
	*a = *b;
	*b = temp;
}

// 使用 Swap 实现互斥
bool lock = false; // 共享锁变量,false表示未锁
// 每个进程有一个局部变量 key
bool key = true; // 进程想获取锁
// 进入区
do {
	Swap(&lock, &key); // 原子地交换 lock 和 key 的值
	// 如果 lock 原来为 false (未锁),交换后 lock=true, key=false,循环结束,进入临界区
	// 如果 lock 原来为 true (已锁),交换后 lock=true, key=true,继续循环忙等
} while (key == true);
// 临界区代码 ...
// 退出区
lock = false; // 解锁
// 剩余区代码 ...

互斥锁 (Mutex Lock)

互斥锁是解决临界区问题的一种常用且简单的抽象工具。它提供两个基本操作:

  • acquire(): 获取锁。如果锁可用,则获取锁并将其标记为不可用;如果锁已被其他进程持有,则调用进程被阻塞(或忙等),直到锁被释放。
  • release(): 释放锁。将锁标记为可用。
  • acquire()release() 操作本身必须是原子的,通常借助上述硬件指令(如 TSL, Swap)或中断屏蔽来实现。

自旋锁 (Spin Lock)

如果互斥锁在 acquire() 无法获得锁时采用忙等(即循环检查锁状态)的方式,那么这种锁就称为自旋锁。上面基于 TSL 和 Swap 的硬件实现就是自旋锁的例子。

  • 特点
    • 优点:等待期间无需进行进程上下文切换,如果锁被持有的时间很短,忙等的代价可能低于上下文切换的代价。
    • 缺点:忙等消耗 CPU 时间,违反“让权等待”。
  • 适用场景多处理器系统中,当锁的持有时间预期很短时较优(一个核在忙等,其他核可能很快释放锁)。不适合单处理器系统(忙等时不可能有其他进程运行来释放锁)。

信号量机制 (Semaphore)

提出背景:软件互斥方法逻辑复杂易错,硬件互斥方法存在忙等问题。1965年,[[迪杰斯特拉]](Dijkstra)提出了信号量机制,提供了一种更强大、更易用的同步互斥工具。

信号量:是一个特殊的变量,可以用来表示系统中某种资源的数量。对信号量的操作只有三种:初始化、P 操作(wait)、V 操作(signal)。这些操作都是原语,保证原子性。
P 操作 (wait(S) 或 P(S))

  • 荷兰语 Proberen (尝试)。
  • 意图:申请一个资源。
  • 动作:
    1. 将信号量 S 的值减 1 (S--)。
    2. 如果 S 的值变为负数 (S < 0),表示资源不足,则调用 P 操作的进程被阻塞,并放入该信号量的等待队列中。
    3. 如果 S 的值大于等于 0 (S >= 0),表示资源充足(或够用),进程继续执行。
      V 操作 (signal(S) 或 V(S))
  • 荷兰语 Verhogen (增加)。
  • 意图:释放一个资源。
  • 动作:
    1. 将信号量 S 的值加 1 (S++)。
    2. 如果 S 的值在加 1 后小于等于 0 (S <= 0),表示之前有进程在等待该资源,则从该信号量的等待队列中唤醒一个进程(将其从阻塞态变为就绪态)。
    3. 如果 S 的值大于 0 (S > 0),表示没有进程在等待,V 操作完成。

信号量的两种类型

整型信号量

  • 就是一个整数变量 S
  • wait(S) 操作:while (S <= 0); S--;
  • signal(S) 操作:S++;
  • 缺点:当 S <= 0 时,wait 操作会进行忙等,不满足“让权等待”。

记录型信号量(常用)

  • 使用一个结构体表示,包含一个整数值 value(资源数量)和一个进程等待队列 L
  • wait(S) 操作:
    S.value--;
    if (S.value < 0) {
    	// 将当前进程添加到 S.L 队列
    	block(S.L); // 阻塞当前进程
    }
    
  • signal(S) 操作:
    S.value++;
    if (S.value <= 0) {
    	// 从 S.L 队列中移除一个进程 P
    	wakeup(P); // 唤醒进程 P
    }
    
  • 优点:S.value < 0 时,进程调用 block 原语主动放弃 CPU 进入阻塞态,实现了“让权等待”。S.value 的绝对值表示正在等待该资源的进程数量。

使用信号量实现互斥

  1. 定义互斥信号量 mutex初值为 1。表示允许进入临界区的“名额”为 1 个。
  2. 进入区执行 P(mutex) 操作。申请进入名额,若 mutex 变为 0,成功进入;若变为负数,则阻塞等待。
  3. 退出区执行 V(mutex) 操作。释放名额,若有等待进程,则唤醒一个。
semaphore mutex = 1; // 初始化互斥信号量

Process_i() {
    ...
    P(mutex); // 进入临界区前 P 操作 (加锁)
    // --- 临界区代码 ---
    访问临界资源;
    // --- 临界区代码 ---
    V(mutex); // 退出临界区后 V 操作 (解锁)
    ...
}

注意

  • P 和 V 操作必须成对出现。
  • 对不同的临界资源应使用不同的互斥信号量。

使用信号量实现同步

同步用于协调进程间的执行次序,确保某个操作(后操作)必须在另一个操作(前操作)完成后才能执行。

  1. 定义同步信号量 S初值为 0。表示“前操作”尚未完成,或者说,后操作所需的“条件”或“消息”尚未产生。
  2. 前操作完成之后,执行 V(S)。表示条件已满足或消息已产生。
  3. 后操作开始之前,执行 P(S)。检查条件是否满足,若 S <= 0(即 V(S) 未执行),则阻塞等待。
semaphore S = 0; // 初始化同步信号量

// 进程 P1 (执行前操作)
P1() {
    ...
    执行代码1;
    执行代码2; // 前操作
    V(S);     // 通知 P2 可以开始了
    执行代码3;
    ...
}

// 进程 P2 (执行后操作)
P2() {
    ...
    P(S);     // 等待 P1 的通知
    执行代码4; // 后操作 (必须在代码2之后)
    执行代码5;
    ...
}

使用信号量实现前驱关系

一个复杂任务可能分解为多个具有前驱关系的子任务(如 S1->S2, S1->S3, S2->S4 等)。这可以看作是多个同步问题的组合。

  1. 每一对直接前驱关系(如 S1 是 S2 的前驱)设置一个同步信号量a初值为 0
  2. 在每个前驱操作(如 S1)完成之后,对其所有后继操作对应的信号量执行 V 操作(如 V(a), V(b))。
  3. 在每个后继操作(如 S2)开始之前,对其所有前驱操作对应的信号量执行 P 操作(如 P(a))。如果一个操作有多个前驱(如 S6 的前驱是 S3, S4, S5),则需要执行多个 P 操作(P(e), P(f), P(g))。

管程机制 (Monitor)

提出背景:虽然信号量机制很强大,但使用 P、V 操作编程容易出错(如 P/V 顺序错误、遗漏 P/V 等),导致死锁或同步失败。管程是一种更高级别的同步机制,旨在简化并发编程,将同步和互斥的细节封装起来。

管程定义:管程是一个软件模块,它包含:

  1. 共享数据结构:表示需要被并发访问的资源(如缓冲区)。这些数据只能被管程内部的过程访问。
  2. 一组过程(函数):定义了对共享数据结构的操作。是唯一能访问共享数据的入口。
  3. 初始化代码:用于设置共享数据的初始状态。
  4. 管程名称

管程的基本特征

  1. 封装性:管程内的共享数据只能通过管程提供的过程来访问,外部无法直接触及。
  2. 互斥性最重要的特性!管程保证在任何时刻最多只有一个进程(或线程)能在管程内部执行代码。这种互斥是由编译器在编译时自动添加的,程序员无需关心。
  3. 同步机制:管程内部通常使用条件变量 (Condition Variable) 来实现进程同步(等待某个条件满足)。

条件变量:不是信号量,它没有值,只提供两个操作:

  • x.wait(): 当调用进程发现条件 x 不满足时,执行 x.wait()。该进程会阻塞,并被放入与条件 x 关联的等待队列中。同时,该进程会自动释放管程的互斥权,允许其他进程进入管程。
  • x.signal(): 当一个进程在管程中改变了使得条件 x 可能满足的状态时,可以执行 x.signal()。这会唤醒正在 x 条件等待队列中等待的一个进程。被唤醒的进程需要重新竞争进入管程的权利。

管程与信号量的比较

  • 相似点:都能实现进程阻塞和唤醒。
  • 不同点:
    • 信号量有值,代表资源数量;条件变量无值,仅用于排队等待。
    • 信号量的 P/V 操作由程序员显式调用;管程的互斥由编译器隐式保证,同步通过条件变量实现。
    • 管程将数据和操作封装在一起,结构更清晰,不易出错。

用管程解决生产者-消费者问题 (伪代码)

monitor ProducerConsumer {
    // 共享数据
    Item buffer[N];
    int count = 0; // 缓冲区中的产品数
    int in = 0, out = 0; // 缓冲区指针

    // 条件变量
    condition full;  // 缓冲区满时,生产者等待
    condition empty; // 缓冲区空时,消费者等待

    // 过程:向缓冲区放入产品
    procedure insert(Item item) {
        if (count == N) {
            full.wait(); // 缓冲区满,生产者等待
        }
        buffer[in] = item;
        in = (in + 1) % N; // 有意思,索引超不过N,很像哈希表
        count++;
        empty.signal(); // 唤醒可能在等待的消费者
    }

    // 过程:从缓冲区取出产品
    procedure remove() returns Item {
        Item item;
        if (count == 0) {
            empty.wait(); // 缓冲区空,消费者等待
        }
        item = buffer[out];
        out = (out + 1) % N;
        count--;
        full.signal(); // 唤醒可能在等待的生产者
        return item;
    }
} // end monitor

// 生产者进程
Producer() {
    while (true) {
        item = produce_item();
        ProducerConsumer.insert(item); // 调用管程过程
    }
}

// 消费者进程
Consumer() {
    while (true) {
        item = ProducerConsumer.remove(); // 调用管程过程
        consume_item(item);
    }
}

使用管程,程序员只需关注何时需要等待 (wait) 和何时需要通知 (signal),互斥访问缓冲区的逻辑由管程自动保证。


经典同步问题及其信号量解法

解决 PV 操作问题的通用思路:

  1. 关系分析:明确有哪些进程(或角色),它们之间存在哪些互斥关系(争用哪些临界资源)和同步关系(谁必须等谁)。
  2. 整理流程:梳理每个进程的基本操作流程,确定 P、V 操作的大致位置。
  3. 设置信号量
    • 为每个互斥关系设置一个互斥信号量,初值通常为 1
    • 为每个同步关系设置一个同步信号量,初值根据初始状态(如初始资源数量)确定,通常为 0 或资源的初始数量。

生产者-消费者问题

  • 描述:一组生产者生产产品放入缓冲区,一组消费者从缓冲区取出产品消费。缓冲区大小为 n
  • 关系
    • 互斥:对缓冲区的访问必须互斥。
    • 同步:缓冲区满时生产者需等待;缓冲区空时消费者需等待。
  • 信号量
    • mutex = 1:互斥访问缓冲区。
    • empty = n:表示空闲缓冲区的数量 (同步)。
    • full = 0:表示已用缓冲区(产品)的数量 (同步)。
  • 解法
    producer() {
        while(1) {
            生产一个产品;
            P(empty);   // 申请一个空缓冲区 (若无则等待)
            P(mutex);   // 申请访问缓冲区的权限 (加锁)
            将产品放入缓冲区;
            V(mutex);   // 释放访问缓冲区的权限 (解锁)
            V(full);    // 增加一个产品 (通知消费者)
        }
    }
    
    consumer() {
        while(1) {
            P(full);    // 申请一个产品 (若无则等待)
            P(mutex);   // 申请访问缓冲区的权限 (加锁)
            从缓冲区取出一个产品;
            V(mutex);   // 释放访问缓冲区的权限 (解锁)
            V(empty);   // 增加一个空缓冲区 (通知生产者)
            消费产品;
        }
    }
    
    重要:实现互斥的 P 操作 (P(mutex)) 必须在实现同步的 P 操作 (P(empty)P(full)) 之后,否则可能导致死锁。两个 V 操作的顺序可以互换。

多生产者-多消费者问题(特殊版:苹果橘子问题)

  • 描述:爸爸放苹果,妈妈放橘子,女儿吃苹果,儿子吃橘子。盘子(缓冲区)只能放一个水果。
  • 关系
    • 互斥:对盘子的访问互斥 (缓冲区大小为1,可能隐式互斥,但显式加锁更安全)。
    • 同步:盘空才能放;有苹果女儿才能吃;有橘子儿子才能吃。
  • 信号量
    • plate = 1:表示盘子是否为空位 (同步)。
    • apple = 0:表示盘中是否有苹果 (同步)。
    • orange = 0:表示盘中是否有橘子 (同步)。
    • mutex = 1:(可选,但建议加上) 互斥访问盘子。
  • 解法
    dad() { // 爸爸放苹果
        while(1) {
            准备苹果;
            P(plate);   // 等待盘子空
            P(mutex);
            放入苹果;
            V(mutex);
            V(apple);   // 通知女儿有苹果了
        }
    }
    
    mom() { 
        /* 类似爸爸,放橘子,V(orange) */
    }
    
    daughter() { // 女儿吃苹果
        while(1) {
            P(apple);   // 等待有苹果
            P(mutex);
            取出苹果;
            V(mutex);
            V(plate);   // 腾出盘子
            吃苹果;
        }
    }
    
    son() { 
        /* 类似女儿,吃橘子,P(orange) */
    }
    
    • 如果缓冲区大小为 1,plate, apple, orange 三个信号量在任何时刻最多只有一个为 1,这天然地实现了对盘子的互斥。此时 mutex 可以省略。但若缓冲区大于 1,则必须加 mutex

吸烟者问题

  • 描述:一个供应者提供三种组合(烟草+纸,烟草+胶水,纸+胶水),三个吸烟者分别拥有第三种材料(胶水,纸,烟草)。供应者放上两种材料,对应的吸烟者取走,卷烟,抽掉,然后通知供应者完成。需要轮流供应。
  • 关系
    • 互斥:桌子(缓冲区)同时只能放一种组合。
    • 同步:① 供应者放组合1 -> 吸烟者1取;② 供应者放组合2 -> 吸烟者2取;③ 供应者放组合3 -> 吸烟者3取;④ 吸烟者完成 -> 供应者放下一组。
  • 信号量
    • offer1 = 0, offer2 = 0, offer3 = 0:表示桌上是否有对应组合 (同步)。
    • finish = 0:表示吸烟是否完成,用于通知供应者 (同步)。
    • (不需要显式互斥信号量,因为缓冲区为1,且同一时间 offer1/2/3 最多一个为1)
  • 解法
    int i = 0; // 控制轮流供应
    
    provider() {
        while(1) {
            if (i == 0) { 准备组合1; V(offer1); }
            else if (i == 1) { 准备组合2; V(offer2); }
            else { 准备组合3; V(offer3); }
            i = (i + 1) % 3;
            P(finish); // 等待吸烟者完成
        }
    }
    smoker1() { // 拥有胶水,需要组合1 (烟草+纸)
        while(1) {
            P(offer1); // 等待组合1
            取走组合1,卷烟,抽掉;
            V(finish); // 通知供应者完成
        }
    }
    smoker2() { /* 类似,P(offer2) */ }
    smoker3() { /* 类似,P(offer3) */ }
    

读者-写者问题

  • 描述:多个读者可以同时读文件,但写者必须互斥访问(不能有其他读者或写者)。
  • 核心要求:允许多读;写-写互斥;读-写互斥。
  • 读者优先解法 (可能饿死写者):
    • 关系:写者与写者互斥,写者与读者互斥。
    • 信号量
      • rw = 1:用于实现写者互斥,以及第一个读者和写者之间的互斥。
      • mutex = 1:用于互斥访问读者计数器 count
      • count = 0:记录当前正在读的读者数量。
    • 解法
int count = 0;
semaphore mutex = 1;
semaphore rw = 1;

writer() {
	while(1) {
		P(rw);      // P操作实现写者互斥及与读者互斥
		写文件;
		V(rw);      // V操作释放锁
	}
}
reader() {
	while(1) {
		P(mutex);   // 互斥访问 count
		if (count == 0) {
			P(rw);  // 第一个读者需要获取读写锁,阻止写者
		}
		count++;
		V(mutex);

		读文件; // 多个读者可同时读

		P(mutex);   // 互斥访问 count
		count--;
		if (count == 0) {
			V(rw);  // 最后一个读者释放读写锁,允许写者
		}
		V(mutex);
		// 可选:做其他事情
	}
}
  • 读写公平解法 (使用一个额外的信号量 w 实现排队效果):
    • 信号量
      • w = 1:控制读写操作的排队。
      • mutex = 1:互斥访问 count
      • rw = 1:控制写者互斥以及写者与读者组的互斥。
      • count = 0:读者计数。
    • 解法
int count = 0;
semaphore mutex = 1, rw = 1, w = 1;

writer() {
	while(1) {
		P(w);       // 申请写权限(排队)
		P(rw);      // 申请对文件的写锁
		写文件;
		V(rw);      // 释放写锁
		V(w);       // 释放写权限
	}
}

// 读者-写者问题(公平解法)
reader() {
	while(1) {
		P(w);       // 申请读权限(排队),确保写者优先或先到先服务
		P(mutex);   // 互斥访问 count
		if (count == 0) {
			P(rw);  // 第一个读者需要获取读写锁,阻止写者
		}
		count++;
		V(mutex);
		V(w);       // **关键:读者获取排队资格后立即释放w,允许其他读者或下一个写者排队**

		读文件; // 多个读者可同时读

		P(mutex);   // 互斥访问 count
		count--;
		if (count == 0) {
			V(rw);  // 最后一个读者释放读写锁,允许写者
		}
		V(mutex);
		// 可选:做其他事情
	}
}
  • 公平性分析
    • 信号量 w 起到了一个“门卫”的作用。无论是读者还是写者,在真正尝试获取读写锁 rw 或修改读者计数 count 之前,都必须先通过 P(w)
    • 如果一个写者先执行了 P(w) 并等待 P(rw),那么后续到达的读者会被阻塞在 P(w),直到该写者完成并执行 V(w)
    • 如果读者先执行了 P(w),它会立刻 V(w),允许其他读者也尝试 P(w)。但如果此时有一个写者在等待 P(w),那么等当前这批读者全部离开(最后一个读者 V(rw)) 后,该写者有机会通过 P(w) 进而获取 P(rw)
    • 这种机制使得读者和写者大致按照到达的顺序排队,避免了某一方被无限期饿死。

哲学家进餐问题 (Dining Philosophers Problem)

  • 描述:五个哲学家围坐在一张圆桌旁,每人面前有一盘意面,每两位哲学家之间放着一根筷子。哲学家只有两种状态:思考或吃饭。吃饭时,哲学家必须同时拿起左右两边的筷子。如何设计一个算法,让哲学家们能正常吃饭,同时避免死锁(所有人都拿着左手筷子等右手筷子)和饥饿(某个哲学家一直拿不到筷子)?
  • 资源:5 根筷子,是临界资源,必须互斥使用。
  • 进程:5 个哲学家进程。
  • 关系
    • 互斥:每个哲学家需要互斥地使用其左右两边的筷子。
    • 隐含同步:需要同时拿到两根筷子才能吃饭。
  • 直接思路(导致死锁)
    1. 每个哲学家先尝试拿起左手边的筷子。
    2. 再尝试拿起右手边的筷子。
    3. 吃饭。
    4. 放下两根筷子。
    • 如果所有哲学家同时拿起左手筷子,那么他们都会在等待右手筷子时阻塞,形成循环等待,导致死锁。
  • 信号量设置
    • chopstick[5] = {1, 1, 1, 1, 1}:用一个信号量数组代表 5 根筷子,初值均为 1,表示初始都可用。chopstick[i] 代表哲学家 i 左手边(或编号为 i)的筷子。哲学家 i 需要的筷子是 chopstick[i]chopstick[(i+1) % 5]
  • 避免死锁的方法一:最多允许四位哲学家同时尝试拿筷子
    • 思想:只要有一个哲学家不参与竞争,就至少有一根筷子是空闲的,可以打破循环等待。
    • 增加信号量room = 4,表示餐厅最多容纳 4 人同时“准备”进餐。
    • 优点:简单易懂,有效避免死锁。
    • 缺点:限制了并发度。
    • 解法
semaphore chopstick[5] = {1, 1, 1, 1, 1};
semaphore room = 4; // 限制同时进餐的哲学家数量

philosopher(int i) { // i 为哲学家编号 (0 到 4)
	while(1) {
		思考;
		P(room);              // 进入餐厅 (获取尝试拿筷子的资格)
		P(chopstick[i]);      // 拿起左手边的筷子
		P(chopstick[(i+1)%5]);// 拿起右手边的筷子
		吃饭;
		V(chopstick[(i+1)%5]);// 放下右手边的筷子 (V操作顺序不关键)
		V(chopstick[i]);      // 放下左手边的筷子
		V(room);              // 离开餐厅 (释放资格)
	}
}
  • 避免死锁的方法二:要求同时拿起两根筷子(使用全局互斥锁保护拿筷子动作)
    • 思想:将“拿起左右两根筷子”这个复合动作变成一个原子操作,要么一次性拿到两根,要么一根也拿不到(需要等待)。
    • 增加信号量mutex = 1,用于保护拿筷子的临界区。
    • 优点**:避免死锁。
    • 缺点:严重降低了并发性,同一时间最多只有一个哲学家在尝试拿筷子。
    • 解法
semaphore chopstick[5] = {1, 1, 1, 1, 1};
semaphore mutex = 1; // 保护拿筷子动作的互斥锁

philosopher(int i) {
	while(1) {
		思考;
		P(mutex);             // 进入拿筷子的临界区
		P(chopstick[i]);      // 尝试拿左筷
		P(chopstick[(i+1)%5]);// 尝试拿右筷
		V(mutex);             // 退出拿筷子的临界区 (拿到两根后才释放)
		吃饭;
		V(chopstick[(i+1)%5]);// 放下右筷
		V(chopstick[i]);      // 放下左筷
	}
}
  • 避免死锁的方法三:奇数哲学家先拿左筷再拿右筷,偶数哲学家先拿右筷再拿左筷
    • 思想:打破所有人都按同一顺序拿筷子造成的循环等待。
    • 优点:避免死锁,实现简单,并发性较好。
    • 缺点:实现逻辑略复杂一点。
    • 饥饿问题:上述方案主要解决了死锁问题。但在非常不巧的调度下,某个哲学家可能总是无法同时获得两根筷子(例如,每次他尝试拿时,总有一根被邻居拿着),导致饥饿。现代操作系统中信号量的实现通常会包含某种排队机制(如 FIFO),可以很大程度上缓解饥饿问题,但理论上饥饿仍可能发生。更复杂的算法(如 Chandy/Misra 解决方案)可以完全避免饥饿,但实现更复杂。
    • 解法
semaphore chopstick[5] = {1, 1, 1, 1, 1};

philosopher(int i) {
	while(1) {
		思考;
		if (i % 2 == 0) { // 偶数编号哲学家
			P(chopstick[(i+1)%5]); // 先拿右手边
			P(chopstick[i]);       // 再拿左手边
		} else { // 奇数编号哲学家
			P(chopstick[i]);       // 先拿左手边
			P(chopstick[(i+1)%5]); // 再拿右手边
		}
		吃饭;
		V(chopstick[(i+1)%5]); // 放下筷子 (顺序不重要)
		V(chopstick[i]);
	}
}

死锁 (Deadlock)

死锁的基本概念

死锁:在多道程序环境下,多个进程并发执行,它们可能会竞争系统资源(如打印机、内存、文件等)或相互协作(如通过消息队列通信)。当一组进程中的每一个进程都在等待仅由该组中其他进程才能引发的事件(通常是释放资源)时,这组进程就陷入了死锁状态。通俗地说,死锁就像交通堵塞中的环形僵局:每辆车都在等待前面的车移动,但前面的车又在等待它前面的车移动,最终形成一个循环,谁也动不了。

死锁的特征

  • 死锁涉及两个或多个进程。
  • 死锁中的进程处于阻塞态(等待状态)。
  • 如果不借助外力干预,死锁状态下的进程将永远无法向前推进

死锁与饥饿的区别

  • 死锁 (Deadlock):一组进程互相等待对方持有的资源,导致所有进程都无法继续执行,处于永久阻塞状态。死锁是“一组”进程的问题。
  • 饥饿 (Starvation):某个进程由于资源分配策略(如优先级低)或其他原因,长时间甚至永远得不到所需的资源(如 CPU 时间片、I/O 设备),导致无法向前推进。饥饿可以是单个进程的问题,且该进程可能在就绪态或阻塞态之间转换,但始终无法完成任务。

关键区别:死锁进程一定处于阻塞态,且是循环等待;饥饿进程可能处于就绪态(等待 CPU),也可能处于阻塞态(等待资源),没有形成循环依赖,只是运气不好或优先级太低。


死锁产生的必要条件

死锁的发生需要同时满足以下四个必要条件。其中任何一个条件不成立,死锁就不会发生。

  1. 互斥条件 (Mutual Exclusion)
    • 描述:进程对所分配到的资源进行排他性使用。即在一段时间内,某资源只能被一个进程占用。如果其他进程请求该资源,则必须等待,直到资源被释放。
    • 说明:这是很多资源(如打印机、物理内存块)本身的属性所决定的,无法避免。如果资源可以共享(如只读文件),则不会因争用该资源产生死锁。
  2. 占有并等待条件 (Hold and Wait)
    • 描述:进程至少已经保持了一个资源,并且正在请求其他进程当前所占用的新资源。即进程持有部分资源的同时,还在等待其他资源。
    • 说明:这是死锁发生的关键之一。进程不是一次性获取所有所需资源。
  3. 非剥夺条件 (No Preemption)
    • 描述:进程已获得的资源,在未使用完之前,不能被系统强行剥夺。只有持有资源的进程自己主动释放资源,其他进程才能使用。
    • 说明:保证了进程对已分配资源的控制权,但也可能导致资源被无效占用。
  4. 循环等待条件 (Circular Wait)
    • 描述:存在一个进程资源的循环等待链。即有一组等待进程 {P0, P1, …, Pn},其中 P0 正在等待 P1 所占用的资源,P1 正在等待 P2 所占用的资源,…,Pn 正在等待 P0 所占用的资源。
    • 说明:这是前三个条件共同作用的结果。循环等待并不意味着死锁,但死锁必然包含循环等待。
      注意:这四个条件是死锁发生的必要条件,不是充分条件。也就是说,即使这四个条件都满足,系统也不一定会发生死锁(例如,循环等待链中的某个进程在死锁形成前释放了资源)。但只要发生死锁,这四个条件必定同时成立。

死锁的处理策略

针对死锁问题,主要有以下几种处理策略:

  1. 死锁预防 (Deadlock Prevention)
    • 策略:通过设置某些限制条件,破坏死锁产生的四个必要条件中的至少一个,从而在设计上保证死锁永远不会发生。
    • 实现:在系统设计阶段或进程编程时加入规则。
  2. 死锁避免 (Deadlock Avoidance)
    • 策略:不限制必要条件的存在,而是在资源动态分配过程中,使用某种算法(如银行家算法)来预测每一次资源分配请求是否会导致系统进入不安全状态(可能导致死锁的状态)。如果分配会导致不安全状态,则暂时拒绝该请求。
    • 实现:需要进程预先声明所需的最大资源量。
  3. 死锁检测与解除 (Deadlock Detection and Recovery)
    • 策略:允许系统进入死锁状态。通过一个检测算法定期检查系统中是否存在死锁。如果检测到死锁,则采取解除措施(如剥夺资源、终止进程)来打破死锁。
    • 实现:需要维护资源分配信息,并运行检测算法,以及实现解除机制。
  4. 死锁忽略 (Deadlock Ignorance) / 鸵鸟策略
    • 策略:假定死锁在系统中发生的概率很低,或者即使发生,其处理成本也高于预防、避免或检测恢复的成本。因此,干脆不对死锁做任何处理,寄希望于它不会发生,或者由用户(或系统管理员)手动重启系统来解决。
    • 实现:无特殊实现。
    • 适用:许多通用操作系统(如 Windows, UNIX)实际上采用这种策略处理大部分死锁问题,因为完全的死锁预防或避免代价过高,且某些死锁(如用户程序间的死锁)可以通过重启应用解决。

死锁预防 (Deadlock Prevention)

死锁预防是通过破坏死锁产生的四个必要条件之一或多个来实现的。

1. 破坏互斥条件

  • 方法:尽量使资源可共享使用,而不是独占。例如,使用假脱机 (SPOOLing) 技术将独占设备(如打印机)改造成共享设备。多个进程可以将打印请求发送到打印队列(磁盘上的缓冲区),由一个专门的后台进程(打印守护进程)依次从队列中取出并实际打印。这样,进程看起来像是共享了打印机。
  • 缺点:并非所有资源都能改造成可共享的。对于必须互斥使用的资源(如修改某个共享变量),此方法无效。而且改造本身也有成本。

2. 破坏占有并等待条件

  • 方法一:静态分配(一次性申请)
    • 要求进程在开始运行前,一次性申请它在整个运行过程中所需的全部资源。如果系统能够满足其所有请求,则分配给它,进程开始运行;否则,进程等待,期间不占用任何资源。
    • 优点:简单易行,确实破坏了占有并等待条件。
    • 缺点
      • 资源利用率低:进程可能在运行早期就申请了后期才需要的资源,导致这些资源长时间闲置。
      • 可能导致饥饿:如果一个进程需要多种稀缺资源,可能长时间等待。
      • 编程困难:很难精确预知进程所需全部资源。
  • 方法二:允许临时释放
    • 允许进程先获取部分资源,但在请求新资源而得不到满足时,必须释放它当前已经占有的所有资源,然后再重新尝试一次性获取所有新旧资源。
    • 优点:相对于方法一,提高了资源利用率。
    • 缺点:实现复杂,反复申请和释放资源会增加系统开销,可能导致进程多次重复相同的工作,甚至饥饿。

3. 破坏非剥夺条件

  • 方法一:隐式剥夺
    • 当一个进程请求新的资源得不到满足时,它必须释放所有已经占有的资源。这些被释放的资源可以分配给其他等待的进程。该进程需要时再重新申请。
    • 缺点:类似破坏“占有并等待”的方法二,代价高,实现复杂,可能导致前功尽弃。
  • 方法二:显式剥夺(通常用于特定资源,如 CPU 和内存)
    • 当一个进程请求的资源被另一个优先级较低的进程占有时,系统可以强行剥夺低优先级进程占有的该资源,分配给高优先级进程。
    • 或者,当一个进程请求的资源被另一个进程占有,而另一个进程又在等待其他资源时,系统可以剥夺另一个进程占有的资源。
    • 缺点
      • 仅适用于状态易于保存和恢复的资源(如 CPU 寄存器)。
      • 可能导致被剥夺进程反复执行相同操作。
      • 实现复杂,需要考虑剥夺策略和代价。通常只用于特定资源管理。

4. 破坏循环等待条件

  • 方法:资源顺序分配法(资源分级)
    • 将系统中的所有资源统一编号(例如,R1, R2, …, Rn)。
    • 规定每个进程必须按照资源编号递增的顺序来申请资源。即,一个进程在持有编号为 i 的资源后,只能再申请编号大于 i 的资源。
    • 原理:如果所有进程都遵循这个规则,就不可能形成 P0 等 P1 的 Rj,P1 等 P0 的 Rk,且 j > k 同时 k > j 的情况。从而打破了循环等待链。
    • 优点:实现相对简单,资源利用率和并发性优于静态分配。
    • 缺点
      • 资源编号困难:确定一个合理的、满足所有需求的资源编号顺序很困难。
      • 限制用户编程:用户必须按规定顺序申请资源,可能与实际逻辑不符,增加编程难度。
      • 可能浪费资源:即使某个低编号资源暂时不用,但为了以后申请高编号资源,也必须先持有它。

总结:死锁预防策略通常会降低资源利用率、增加系统开销或限制用户编程,因此在实际系统中应用较少,或者只针对特定方面(如 SPOOLing)。


死锁避免 (Deadlock Avoidance)与银行家算法

死锁避免策略允许前三个必要条件存在,但在资源分配时动态判断,确保永远不会进入可能导致死锁的不安全状态

安全状态与不安全状态

  • 安全状态 (Safe State):指系统存在一个安全序列 <P1, P2, …, Pn>。所谓安全序列,是指系统可以按照这个序列的顺序,为每个进程 Pi 分配它未来仍然需要的全部资源(即使这些资源当前被其他进程持有),使得 Pi 能够顺利执行完毕并释放其占有的所有资源。如果存在这样一个序列,那么系统就处于安全状态。安全状态一定不是死锁状态。
  • 不安全状态 (Unsafe State):指系统中不存在任何一个安全序列。系统处于不安全状态不一定是死锁状态,但有可能会演变成死锁状态。系统进入不安全状态是死锁发生的必要非充分条件。

死锁避免的目标:就是在进行资源分配决策时,始终确保系统停留在安全状态。如果一个分配请求会导致系统从安全状态变为不安全状态,则拒绝该请求,让请求进程等待。

银行家算法 (Banker’s Algorithm):银行家算法是死锁避免策略中最著名的算法。它要求进程在运行前声明其可能需要的各类资源的最大数量。算法通过模拟银行家发放贷款的策略来管理资源分配:只有当银行家确信即使借出这笔钱,仍然有足够的备用资金满足其他客户未来的最大需求,从而保证所有贷款都能收回时,才会批准贷款。

银行家算法所需的数据结构:假设系统中有 m 类资源,n 个进程。
1. 可利用资源向量 Available[m]:表示系统中当前每类资源可用的实例数量。Available[j] = k 表示第 j 类资源当前有 k 个可用实例。
2. 最大需求矩阵 Max[n][m]:表示每个进程 i 对每类资源 j 的最大需求量。Max[i][j] = k 表示进程 i 在整个运行过程中最多需要 kj 类资源实例。
3. 分配矩阵 Allocation[n][m]:表示当前每个进程 i 已经分配到的每类资源 j 的实例数量。Allocation[i][j] = k 表示进程 i 当前持有 kj 类资源实例。
4. 需求矩阵 Need[n][m]:表示每个进程 i 未来仍需的每类资源 j 的实例数量。
5. 计算公式:Need[i][j] = Max[i][j] - Allocation[i][j]

银行家算法步骤
进程 Pi 发出资源请求向量 Request_i[m]Request_i[j] 表示请求 j 类资源数量):

  1. 合法性检查
    • 检查请求量是否超过其最大需求:Request_i[j] <= Need[i][j] 对所有 j 都成立?若否,则出错(请求超过声明的最大值)。
    • 检查请求量是否超过当前可用资源:Request_i[j] <= Available[j] 对所有 j 都成立?若否,则 Pi 必须等待(资源不足)。
  2. 尝试分配(模拟):如果上述检查都通过,系统假定将资源分配给 Pi,并修改以下数据结构(仅在内存中模拟,不实际分配):
    • Available[j] = Available[j] - Request_i[j]
    • Allocation[i][j] = Allocation[i][j] + Request_i[j]
    • Need[i][j] = Need[i][j] - Request_i[j]
  3. 安全性检查(核心):调用安全性算法,检查模拟分配后的系统状态是否为安全状态
    • 安全性算法步骤
      • a. 初始化工作向量 Work[m] = Available (模拟分配后的可用资源) 和布尔向量 Finish[n] (所有元素初始为 false)。
      • b. 寻找一个满足以下条件的进程 Pi: * Finish[i] == false (该进程尚未完成) * Need[i][j] <= Work[j] 对所有 j 都成立 (该进程未来所需资源 <= 当前模拟可用资源)
      • c. 如果找到这样的进程 Pi: * 假定 Pi 执行完成并释放资源:Work[j] = Work[j] + Allocation[i][j] 对所有 j。 * 标记 Pi 已完成:Finish[i] = true。 * 回到步骤 b,继续寻找下一个满足条件的进程。
      • d. 如果找不到满足条件的进程 Pi: * 检查是否所有进程都已标记为 Finish[true]。如果是,则系统处于安全状态。 * 如果存在 Finish[k] == false 的进程,则系统处于不安全状态
  4. 决策
    • 如果安全性算法检查结果为安全状态,则正式将资源分配给 Pi(即确认2的修改)。
    • 如果检查结果为不安全状态,则撤销第 2 步的模拟修改,恢复到分配前的状态,并让进程 Pi 等待

银行家算法优缺点

  • 优点:可以避免死锁,允许比死锁预防更高的资源利用率和并发度。
  • 缺点
    • 需要预知最大资源需求:这在实践中往往很难做到。
    • 进程数量固定,资源种类固定:不适用于动态变化的系统。
    • 计算开销大:每次资源请求都需要运行安全性算法,对于进程多、资源种类多的系统,开销可能很大。
    • 资源分配保守:为了保证安全,可能会拒绝一些实际上不会导致死锁的请求。

死锁检测与解除 (Deadlock Detection and Recovery)

这种策略允许死锁发生,通过周期性地运行检测算法发现死锁,并在发现后采取措施解除。

死锁检测:通常基于资源分配图 (Resource Allocation Graph) 或类似的数据结构。

  • 资源分配图:一个有向图,包含两类节点:
    • 进程节点 P (圆圈表示)
    • 资源节点 R (方框表示,内部的点表示该类资源的实例数量)
      • 请求边 (Request Edge):从进程 Pi 指向资源 Rj (Pi -> Rj),表示 Pi 正在请求 Rj 的一个实例。
      • 分配边 (Assignment Edge):从资源 Rj 的一个实例指向进程 Pi (Rj -> Pi),表示 Rj 的一个实例已经分配给了 Pi。
  • 死锁定理
    • 如果资源分配图中没有环路,则系统一定没有发生死锁。
    • 如果资源分配图中存在环路,则系统可能发生了死锁。
      • 每类资源只有一个实例:如果图中存在环路,则必定发生了死锁。环路是死锁的充要条件
      • 每类资源有多个实例:如果图中存在环路,不一定发生死锁。环路是死锁的必要不充分条件。需要更复杂的算法来确认。
  • 基于资源分配图简化的死锁检测算法(适用于每类资源有多个实例):
    • 类似银行家算法中的安全性检查,但检查的是当前状态是否死锁,并非是否安全。
    • 数据结构Available[m], Allocation[n][m], Request[n][m] (表示当前每个进程正在请求的资源量)。
    • 算法步骤
      • a. 初始化 Work[m] = Available, Finish[n] (所有为 false)。
      • b. 寻找一个满足以下条件的进程 Pi: * Finish[i] == false * Request[i][j] <= Work[j] 对所有 j (该进程的当前请求能被满足)
      • c. 如果找到这样的 Pi: * 假定满足其请求,让其运行完毕并释放资源:Work[j] = Work[j] + Allocation[i][j] * 标记 Finish[i] = true * 回到步骤 b。
      • d. 如果找不到这样的 Pi: * 检查是否存在 Finish[k] == false 的进程。如果存在,则这些 Finishfalse 的进程就处于死锁状态。 * 如果所有进程的 Finish 都为 true,则系统没有死锁。
  • 检测时机
    • 每次有资源请求不能满足时检测。
    • 定时检测(如每隔一段时间)。
    • 系统资源利用率下降到某个阈值时检测。

死锁解除:一旦检测到死锁,就需要采取措施来解除它。常用方法有:

  1. 资源剥夺 (Resource Preemption)
    • 方法:从一个或多个死锁进程中强制收回某些资源,将这些资源分配给其他死锁进程,以打破循环等待。
    • 选择牺牲者(被剥夺资源的进程):需要考虑代价最小原则,如剥夺哪个进程的资源代价最低(考虑进程优先级、已执行时间、已占用资源、还需资源等)。
    • 回滚 (Rollback):被剥夺资源的进程需要回滚到之前的某个安全状态,并重新执行。这可能很困难或代价高昂。
    • 防止饥饿:要确保同一个进程不会总是被选为牺牲者。
  2. 进程终止 (Process Termination)
    • 方法一:终止所有死锁进程
      • 简单粗暴,一定能打破死锁。
      • 代价很大,之前完成的计算全部丢失。
    • 方法二:逐个终止死锁进程
      • 每次终止一个死锁进程,释放其资源,然后重新运行死锁检测算法,看死锁是否解除。重复此过程直到死锁消失。
      • 选择终止哪个进程:同样需要考虑代价最小原则(优先级、已运行时间、剩余时间、占用资源多少、是交互进程还是批处理进程等)。

代价:死锁解除的代价通常很高,尤其是进程终止,可能导致数据丢失或不一致。因此,是否进行死锁检测与解除,以及选择哪种解除方式,都需要权衡。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值