软考(软件设计师)进程管理—进程基本概念,信号量与PV操作
一、什么是进程?
- 定义: 程序的一次执行实例。程序是静态的指令集合(存储在磁盘上的文件),进程是动态的、活动的实体(加载到内存中运行)。
- 关键组成:
- 代码段 (Text Segment): 要执行的机器指令。
- 数据段 (Data Segment): 全局变量、静态变量。
- 堆 (Heap): 动态分配的内存(如
malloc/new
)。 - 栈 (Stack): 函数调用时的局部变量、返回地址、参数等。
- 进程控制块 (PCB): 操作系统管理进程的核心数据结构,存储进程的所有关键信息(状态、寄存器值、PID、资源列表、调度信息等)。每个进程都有唯一的PCB。
二、进程状态与转换
进程在其生命周期中会经历不同的状态。经典的五状态模型如下:
图例2:进程状态转换图
- 新建 (New): 进程刚被创建(如用户运行命令
./my_program
),操作系统分配PCB和初始资源。 - 就绪 (Ready): 进程已获得除CPU外的所有必要资源,等待被CPU调度执行。就绪队列中有多个进程排队。
- 运行 (Running): 进程正在CPU上执行指令。单核CPU任一时刻只有一个进程处于此状态。
- 阻塞/等待 (Blocked/Waiting): 进程因等待某个事件(如I/O操作完成、信号量、子进程结束)而主动放弃CPU。即使CPU空闲也不会运行。
- 终止 (Terminated): 进程执行完毕或被强制终止。操作系统回收其资源(内存、文件描述符等),PCB可能保留一段时间供父进程查询。
挂起(Suspend)
-
目的: 当系统物理内存不足时,操作系统需要将暂时不需要运行的进程的整个内存映像(代码、数据、堆、栈)移出物理内存,保存到外存(如磁盘的交换区/Swap Space) 中,以腾出空间给其他急需内存的进程使用。这个过程称为换出 (Swap Out) 或被挂起 (Suspend)。
-
效果: 被挂起的进程不在物理内存中。因此,即使它处于“逻辑上”就绪或阻塞的状态,也无法被立即调度运行(因为CPU无法直接访问外存中的指令和数据)。它必须先被换入 (Swap In) 回物理内存。
-
触发条件: 通常由操作系统内核的中程调度器 (Medium-Term Scheduler) 负责决定何时挂起哪些进程。常见触发点包括:
-
系统整体内存紧张。
-
需要为高优先级进程腾出内存。
-
用户或系统管理员显式挂起一个进程(如
Ctrl+Z
发送SIGTSTP
信号给前台进程)。 -
进程等待一个预计会非常长时间才能发生的事件(如等待用户输入但用户长时间无操作)。
-
理解了“挂起”后,我们就能定义这四种状态了:
-
活跃就绪 (Ready / Active Ready)
-
定义: 进程在物理内存中,并且具备运行的所有条件(除了CPU时间片)。它位于内存的就绪队列中,等待调度器选择它运行。
-
状态转换来源:
-
新建进程初始化完成。
-
运行进程时间片用完或被更高优先级进程抢占。
-
阻塞进程等待的事件发生。
-
静止就绪进程被换入内存。
-
-
状态转换去向:
-
被调度器选中 -> 运行。
-
被操作系统挂起(换出内存) -> 静止就绪。
-
-
特点: 这是标准五状态模型中的“就绪”状态。进程随时可以被调度运行。
-
-
静止就绪 (Ready, Suspended / Suspended Ready)
-
定义: 进程在外存(交换区)中,但其PCB信息(包括状态)表明它逻辑上是就绪的。也就是说,如果它被换入内存,它就能立即进入活跃就绪队列等待CPU。
-
状态转换来源:
-
活跃就绪进程被挂起(换出内存)。
-
静止阻塞进程等待的事件发生(事件发生时,它还在外存)。
-
-
状态转换去向:
-
被操作系统换入内存 -> 活跃就绪。
-
(理论上也可能被终止,但通常挂起是为了恢复)
-
-
特点:
-
进程不在内存,无法被调度运行。
-
当内存空闲或该进程优先级高时,会被换入变成活跃就绪。
-
是“逻辑就绪,物理缺席”的状态。
-
-
-
活跃阻塞 (Blocked / Active Blocked / Waiting)
-
定义: 进程在物理内存中,但正在等待某个事件的发生(如I/O完成、信号量、子进程结束等)。它位于内存的阻塞队列(按等待事件分组)中。
-
状态转换来源:
- 运行进程主动请求等待资源/事件(如发起I/O调用)。
-
状态转换去向:
-
等待的事件发生 -> 活跃就绪。
-
被操作系统挂起(换出内存) -> 静止阻塞。
-
-
特点: 这是标准五状态模型中的“阻塞”状态。进程在内存中,但CPU不会选择它运行,直到事件发生。
-
-
静止阻塞 (Blocked, Suspended / Suspended Blocked)
-
定义: 进程在外存(交换区)中,并且其PCB信息表明它逻辑上仍在等待某个事件。即使事件发生了,它也无法立即响应,必须先被换入内存。
-
状态转换来源:
- 活跃阻塞进程被挂起(换出内存)。
-
状态转换去向:
-
被操作系统换入内存 -> 活跃阻塞(等待的事件可能仍未发生,也可能在换入过程中/后发生)。
-
等待的事件发生 -> 静止就绪(注意:事件发生时,进程还在外存,所以状态变为静止就绪。当它被换入后,就会进入活跃就绪)。
-
-
特点:
-
进程不在内存,无法运行且仍在等待事件。
-
这是“双重等待”状态:既等待事件,又等待被换入内存。
-
操作系统通常优先挂起阻塞进程,因为它们反正暂时无法运行,换出它们对系统性能影响相对较小。
-
-
三、进程间通信
一、进程间通信(IPC)与同步互斥的必要性
-
进程间通信(IPC)是什么?
-
指运行在不同进程(有时甚至是同一进程的不同线程)之间的任务交换数据或信息。
-
进程通常拥有独立的地址空间,一个进程无法直接访问另一个进程的内存。IPC 提供了打破这种隔离的机制。
-
常见 IPC 方式: 管道、命名管道(FIFO)、消息队列、共享内存、信号量、套接字、RPC 等。
-
-
为什么需要同步互斥?
-
竞态条件: 当多个进程/线程并发访问和操作共享资源(如共享内存区、文件、打印机、数据库记录)时,如果访问的最终结果取决于进程执行的特定时序,就发生了竞态条件。
-
临界区: 访问共享资源的代码段称为临界区。
-
互斥的必要性: 为了避免竞态条件,必须确保一次只有一个进程/线程可以进入其临界区执行。这就是互斥。
-
同步的必要性: 除了互斥访问资源,进程间有时还需要协调执行顺序。例如,进程 A 必须在进程 B 生产完数据后才能消费该数据。这种在时间上协调进程执行顺序的机制称为同步。
-
-
互斥需要满足的条件
-
忙则等待: 当临界区已有进程时,其他试图进入的进程必须在循环中等待(忙等)或阻塞(睡眠)。
-
空闲让进: 当临界区空闲时,应允许一个等待进入的进程立即进入。
-
有限等待: 一个进程等待进入临界区的时间应该是有限的,不能无限期等待(避免饥饿)。
-
让权等待(可选但高效): 等待进入临界区的进程应主动释放 CPU 给其他进程运行(避免忙等浪费 CPU)。
-
二、信号量机制(Semaphore)
信号量是荷兰计算机科学家 Edsger Dijkstra 在 1965 年提出的一种经典的、强大的进程同步与互斥工具。它本质上是一个计数器,结合了等待队列和两个原子操作(P/V 操作,或 Wait/Signal, Down/Up)。
-
信号量的结构
-
整数值S: 表示可用资源的数量或信号量的状态。
-
等待队列: 当进程因执行 P 操作而阻塞时,会被放入这个队列中休眠。
-
两个原子操作:
-
P 操作:
-
将信号量的值减 1。
-
如果减 1 后信号量值 >= 0,则该进程继续执行。
-
如果减 1 后信号量值 < 0,则该进程被阻塞(睡眠),并放入与该信号量关联的等待队列中。
-
-
V 操作 (Signal / Up / Verhogen(荷兰语“增加”)):
-
将信号量的值加 1。
-
如果加 1 后信号量值 <= 0,则说明等待队列中有进程在等待,此时唤醒等待队列中的第一个进程(将其移入就绪队列)。
-
如果加 1 后信号量值 > 0,则没有进程在等待,直接返回。
-
-
-
关键点: P 和 V 操作是原子的!这意味着在执行 P 或 V 操作期间,不会被中断(如被时钟中断或切换到另一个进程),从而保证了操作的完整性,避免了在检查信号量值和修改变量之间发生竞态条件。
三、PV操作实现进程同步互斥
一、核心概念回顾
-
信号量 (Semaphore)
S
:- 一个整型变量
S.value
:表示资源可用数量或状态。 - 一个等待队列
S.queue
:存储因执行 P 操作而阻塞的进程。
- 一个整型变量
-
P 操作 (Wait / Down):
void P(Semaphore S) {S.value--; // 原子地减1if (S.value < 0) { // 资源不足block(); // 当前进程阻塞,加入S.queue} }
-
V 操作 (Signal / Up):
void V(Semaphore S) {S.value++; // 原子地加1if (S.value <= 0) { // 有进程在等待wakeup(); // 从S.queue唤醒一个进程} }
关键特性:P/V 操作是 原子的(执行过程不可中断),由操作系统内核或硬件指令(如 Test-and-Set)保证。
二、实现互斥 (Mutual Exclusion)
目标:确保任一时刻只有一个进程进入临界区(Critical Section)。
工具:二元信号量 (Mutex),初始值 S = 1
。
伪代码
Semaphore mutex = 1; // 初始化为1 (表示临界区空闲)// 进程Pi
Process Pi() {P(mutex); // 申请锁// 临界区代码 (访问共享资源)V(mutex); // 释放锁// 剩余区代码
}
工作流程
- 进程A 进入临界区:
P(mutex)
:
mutex.value = 1-1 = 0 ≥ 0
→ 成功进入。
- 进程B 尝试进入:
P(mutex)
:
mutex.value = 0-1 = -1 < 0
→ 阻塞,加入等待队列。
- 进程A 退出临界区:
V(mutex)
:
mutex.value = -1+1 = 0 ≥ 0
→ 唤醒进程B。
- 进程B 被唤醒:
- 从
P(mutex)
后继续执行 → 进入临界区。
- 从
示意图
进程A 进程B┌─────────┐ ┌─────────┐│ P(mutex)│ │ P(mutex)││ (S=1→0) │ │ (S=0→-1)│ ← 阻塞!├─────────┤ └────┬────┘│ 临界区 │ │├─────────┤ ▼│ V(mutex)│ ┌─────────┐│ (S=0→-1)│ ← 唤醒B │ (阻塞中)│└─────────┘ └─────────┘
互斥锁的本质:通过
S=1
表示“钥匙”,P 操作是拿钥匙,V 操作是还钥匙。
三、实现同步 (Synchronization)
目标:协调进程间的执行顺序(如:进程B 必须在 进程A 完成后执行)。
工具:同步信号量,初始值 S = 0
。
场景:进程A 先执行任务X,进程B 后执行任务Y。
Semaphore sync = 0; // 初始化为0 (表示“事件未发生”)// 进程A
Process A() {// 执行任务XV(sync); // 通知B:"X已完成"
}// 进程B
Process B() {P(sync); // 等待A的通知// 执行任务Y
}
工作流程
- 若 B 先执行:
P(sync)
:
sync.value = 0-1 = -1 < 0
→ 阻塞等待。
- A 执行任务X:
- 完成后执行
V(sync)
:
sync.value = -1+1 = 0 ≥ 0
→ 唤醒 B。
- 完成后执行
- B 被唤醒:
- 继续执行任务Y。
示意图
进程A 进程B┌─────────┐ ┌─────────┐│ 执行任务X│ │ P(sync) │├─────────┤ │ (S=0→-1)│ ← 阻塞!│ V(sync) │ └────┬────┘│ (S=0→-1)│ ← 唤醒B ▼└─────────┘ ┌─────────┐│ 唤醒后 ││ 执行任务Y│└─────────┘
同步信号量的意义:
S=0
表示“条件不满足”,P 是等待条件,V 是发出信号。
四、综合案例:生产者-消费者问题
问题描述:
- 生产者向**有限缓冲区(Buffer Size=N)**写入数据。
- 消费者从缓冲区读取数据。
需满足:
- 互斥:任一时刻仅一个进程访问缓冲区。
- 同步:
- 缓冲区满时,生产者等待 (
empty=0
); - 缓冲区空时,消费者等待 (
full=0
)。
- 缓冲区满时,生产者等待 (
信号量设计
信号量 | 含义 | 初始值 |
---|---|---|
mutex | 缓冲区互斥锁 | 1 |
empty | 空闲缓冲区数量 | N |
full | 已用缓冲区数量 | 0 |
伪代码
// 全局定义
Semaphore mutex = 1, empty = N, full = 0;// 生产者
Producer() {while (true) {item = produce_item(); // 生产数据P(empty); // 等待空位 (empty--)P(mutex); // 申请缓冲区锁buffer.add(item); // 写入缓冲区V(mutex); // 释放锁V(full); // 增加数据项 (full++)}
}// 消费者
Consumer() {while (true) {P(full); // 等待数据 (full--)P(mutex); // 申请缓冲区锁item = buffer.remove(); // 取出数据V(mutex); // 释放锁V(empty); // 释放空位 (empty++)consume_item(item); // 消费数据}
}
关键点图解
为什么P操作顺序必须是 empty→mutex
和 full→mutex
?
若颠倒顺序(如生产者先 P(mutex)
再 P(empty)
):
- 生产者获锁后,若发现缓冲区满 (
empty=0
) → 执行P(empty)
阻塞。 - 但此时锁未被释放!消费者无法进入临界区取数据 → 死锁。
黄金规则:
- 先申请资源信号量 (
empty
/full
),再申请互斥锁 (mutex
)。- 释放时顺序相反:先放锁,再放资源。
五、经典同步问题模式
问题类型 | 信号量设计 | 关键点 |
---|---|---|
读者-写者 | rw_mutex=1 (写互斥) | 读者计数 + 写者优先/读者优先 |
read_count=0 (读者计数) | ||
哲学家就餐 | chopstick[5]={1,1,1,1,1} | 解决死锁:限人数/按序拿/异步放 |
六、总结:P/V 操作的核心逻辑
- 互斥实现:
- 用
mutex=1
的二元信号量包裹临界区。
- 用
- 同步实现:
- 前驱关系:用
S=0
的信号量,前驱V后驱P。 - 资源限制:用 计数信号量(如
empty
/full
)管理资源数量。
- 前驱关系:用
- 避免死锁:
- 按固定顺序申请多个信号量(如先资源后锁)。
- 设置等待超时或使用
tryP
操作。
- 原子性保证:
- P/V 操作必须由操作系统内核实现(关中断/硬件指令)。
一句话精髓:
P 是等待资源(可能阻塞),V 是释放资源(可能唤醒),信号量是协调进程的“交通灯”。