☰
操作系统进程切换与并发互斥:从上下文保存到管程验证
2026/10/10 1:44:30 网站建设 项目流程

简介:《吉林大学操作系统作业解析》课件是一份针对高校操作系统课程学习者的作业精讲与知识点总结资料,系统梳理进程切换、中断向量、互斥锁、读者写者问题、信号量与PV操作等核心考点。整个压缩包仅含1个演示文稿文件,大小约62KB,便于下载后直接浏览或打印复习,适配日常作业答疑与考前冲刺。内容围绕吉大操作系统作业展开,详细讲解了进程切换需要保存的地址映射寄存器、通用寄存器、SP、PSW、PC及打开文件表等现场信息,说明PSW与PC必须用一条机器指令同时恢复的原因;同时展示了Hyman提出的互斥锁软件方案中两个进程可能同时进入临界区的错误反例,剖析了读者优先与写者优先两种算法的执行流程,并补充了信号量与互斥锁在打印机资源分配等场景的应用。已有214人学习,适合正在备战操作系统考试或希望深入理解同步互斥机制的同学参考使用。

1. 操作系统作业解析:从背答案到能推演,这份 PPT 的四种打开方式

大多数第一次拿到这份操作系统作业解析的人,都会把它当成考前答案速查表:进程切换存什么,抄;PSW 和 PC 为什么不能分开恢复,背;读者写者问题怎么写,记。但真正到了期末答辩或者面试追问环节,能拉开差距的从来不是背没背过,而是能不能在纸上把反例推出来、把信号量时序画出来。这套解析把五类高频考点串在一起:进程切换、中断向量、Hyman 互斥算法、读者写者问题、打印机 PV 管理与 SCAN 管程。它适合正在复习操作系统的学生,也适合讲题讲到一半发现自己“只会结论、不会推导”的从业者。我的建议是别当答案背,当“待验证的推理题”来做。

第一章到这里,下面先把最容易被问糊的进程切换讲透。

2. 进程切换与上下文:该保存哪些现场,PSW/PC 为什么必须一条指令同时恢复

2.1 从寄存器到打开文件表:进程上下文到底包括什么

作业第一问的答案不是简单一句“保存寄存器”,而是“保存进程上下文”。进程上下文是进程在运行环境中的全部物理状态快照,包括地址映射寄存器、通用寄存器、浮点寄存器、SP、PSW、PC、打开文件表等。很多同学在这里漏掉两个点:一是浮点寄存器,二是打开文件表。

浮点寄存器容易被忽略,是因为平时写代码很少直接接触浮点状态字。但进程切换时如果只保存通用寄存器,被切走的进程下次恢复时浮点运算的中间精度、舍入模式就丢了,计算结果会出现莫名的偏差。

打开文件表这一项,不同教材的措辞不同,有的写“打开文件描述符表”,有的直接写“打开文件表”。本质都一样:进程打开的文件、当前偏移量、访问模式都属于进程的上下文,切换进程时文件读写位置不能串。算上进程控制块里的内存映射信息,才能保证恢复后的进程和切走之前“长得一模一样”。

现场信息的保存位置也要说清楚。通用寄存器、SP、PC 这类 CPU 现场,通常压入内核栈或者存入本进程的 PCB;地址映射寄存器则要等切换到目标进程后重新装载;打开文件表本来就在进程结构体里,不需要额外拷贝,切换时指向对应的表即可。

2.2 一次切换的保存顺序:关中断、内核栈与 PCB

进程切换的代码在真实内核里非常讲究顺序,但作业里问的是“保存哪些现场”,我一般会让同学用下面这段伪代码理解整个时序:

void switch_to(struct task_struct *prev, struct task_struct *next) { // 1. 关中断,防止保存现场过程中再次被打断 local_irq_disable(); // 2. 保存 prev 的通用寄存器、SP、PC、PSW 到 prev->context save_context_to(prev->context); // 3. 切换到 next 的内核栈 switch_stack(next->kernel_stack); // 4. 重新装载地址映射寄存器,指向 next 的地址空间 load_mmu_context(next->mm); // 5. 恢复现场,打开中断 restore_context_from(next->context); local_irq_enable(); }

为什么第一步要关中断?因为保存现场这个动作本身必须原子完成。如果在保存通用寄存器保存到一半时来了一个中断,中断处理程序又会触发一次现场保存,或者更糟,把已经保存一半的数据再次覆盖。关中断是“保存现场”的后悔药,先把门锁上再收拾东西。

SP 的保存有一点特殊:进程在内核态和用户态各有一个栈指针,切换时先陷入内核,保存的是内核栈指针和用户栈指针。地址映射寄存器的装载必须在 SP 切换之后、恢复现场之前完成,否则通过旧地址映射去访问新进程的栈,会直接访问到非法地址。

2.3 PSW 和 PC 的恢复顺序:两种错误时序推演

作业里第二问是个经典的“为什么不能分开做”的题目。答案核心:中断向量中的 PSW 和 PC 必须由一条机器指令同时恢复,比如中断返回指令从栈中弹出这两个值并一次性装载。

先恢复 PSW 再恢复 PC:PSW 一旦被恢复,当前状态就从管态变成了目态。此时再执行“恢复 PC”的指令,但 CPU 已经没有管态权限去完成这种特权操作了。也就是说,操作系统还没来得及把控制权交给用户进程,自己先失去了执行特权指令的能力,后面的事做不了。

先恢复 PC 再恢复 PSW:PC 被改成了用户进程的断点地址,但 PSW 仍然是管态。CPU 会拿着核心态权限直接去执行用户程序代码,等于一个带着管理员权限的进程在用户空间乱跑。此时 PSW 永远停留在管态,后续无法被正确恢复。

所以这条指令的原子性是硬要求,不是实现细节。这个结论在后续的 Hyman 反例里也会出现:凡是需要“同时满足两个条件”的临界区协议,一旦把条件判断拆成两步,就可能出问题。

3. Hyman 互斥算法:一个 counter example 的完整推演

3.1 算法再读:blocked 与 turn 的配合逻辑

Hyman 算法是经典软件互斥方案里最容易“看着对、实际错”的例子。先看它的原逻辑:

#define N 2 bool blocked[N] = {false, false}; int turn = 0; void P(int id) { int other = 1 - id; while (1) { blocked[id] = true; // 声明自己想进临界区 while (turn != id) { // 轮次不是自己就一直等 while (blocked[other]) { /* 对方也想进,先原地等待 */ } turn = id; // 对方没在等,把轮次改成自己 } /* 临界区 */ // ... blocked[id] = false; // 离开临界区,撤销声明 } }

表面逻辑:每个进程先用 blocked 数组声明意图,再用 turn 决定谁有资格。当 turn 不是自己时,先看对方是否也想进,如果对方没想进,就把 turn 改成自己。这个直觉是:你不跟我抢,那我就拿钥匙。

问题恰恰出在这个“turn = id”的位置上。

3.2 反例构造:让两个进程同时踩进临界区

要证明互斥错误,只需要找到一个合法的指令交织序列,让两个进程同时进入临界区。假设之前 P1 已经完整执行过一轮并退出临界区,此时 blocked[1] 已经从 true 改回 false,turn = 1。接下来按下面这张表推演:

步骤执行的指令blocked 状态turn说明
1P0: blocked[0] = true[T, F]1P0 声明要进
2P0: turn != 0 成立,进入内层等待[T, F]1
3P0: blocked[1] 为 false,退出内层循环[T, F]1正准备执行 turn = 0
4P0 被强占,P1 开始运行[T, F]1调度点卡在赋值前
5P1: blocked[1] = true[T, T]1P1 也声明要进
6P1: turn != 1 不成立,直接进入临界区[T, T]1P1 拿到了钥匙
7调度回 P0,执行 turn = 0[T, T]0P0 把轮次改成自己
8P0 也进入临界区[T, T]0互斥失败

关键在第 4 步:P0 已经确认“对方没在等”,但还没把 turn 改成自己,就被调度走了。P1 随后发现 turn 仍然是自己的,便直接进了临界区。等 P0 恢复执行时,它不会重新检查 blocked[1],而是直接完成 turn = 0 然后闯入临界区。

这个反例说明,Hyman 算法把“让权”动作放在了错误的位置:刚检查完对方状态、还没更新 turn 之间的空档,是整个协议最脆弱的时刻。

3.3 为什么说 Hyman 算法输在“让权之后不再查验”

对比 Peterson 算法就能一眼看出差别。Peterson 的等价写法是这样:

blocked[id] = true; turn = other; // 先把轮次让给对方 while (blocked[other] && turn == other) ; // 对方在等且轮次归对方,才继续等 // 进入临界区 blocked[id] = false;

Peterson 先让权再查验,即使让权之后被强占,由于 turn 已经指向对方,P1 在步骤 6 会发现 turn 不等于自己,不会进临界区。而 Hyman 是先查验再让权,查验后被强占的过程中,P1 依然可以拿着旧 turn 进入临界区。

从作业角度说,这道题不仅要求“找到反例”,还要求你能把一个完整执行序列画给判卷人看。只写“P0 和 P1 会同时进入临界区”是拿不稳分数的,必须落实到指令交织和 blocked、turn 的状态变化。

4. 读者写者与打印机:从 PV 计数到优先级分配的完整实现

4.1 读者写者改进算法:s 信号量补上了什么

读者写者问题基本解法里,最常见的隐患是“读者计数过程的竞争”。改进算法引入了三个信号量:s、mutex、r_w_w,以及计数器 count。先看标准实现:

semaphore r_w_w = 1; // 写者与第一个/最后一个读者之间的互斥 semaphore mutex = 1; // 保护 readcount 值 semaphore s = 1; // 读者入口与写者入口的互斥 int readcount = 0; void writer() { P(s); // 与读者入口操作互斥 P(r_w_w); // 获取写锁 // 写操作 V(r_w_w); // 释放写锁 V(s); } void reader() { P(s); // 进入读者入口 P(mutex); readcount++; if (readcount == 1) P(r_w_w); // 第一个读者负责抢占写锁 V(mutex); V(s); // 释放读者入口 // 读操作 P(mutex); readcount--; if (readcount == 0) V(r_w_w); // 最后一个读者负责释放写锁 V(mutex); }

s 信号量是为了保证“count++ 和写者抢 r_w_w 不会交错”。没有 s 时可能发生:写者已经 P(r_w_w) 进入写操作,另一个读者在这之后执行 count++,发现 count 变成 1 后去 P(r_w_w),于是这个读者被阻塞在写锁上,但它实际上是应该进入读操作的。更严重的变体是,写者完成 V(r_w_w) 后,读者被唤醒,但它的计数状态已经错乱。s 的本质是把“登记读者”和“登记写者”这两个动作串行化,避免计数检查与写锁获取互相穿插。

4.2 写者优先算法:五个信号量怎么配合

写者优先是作业里另一个难点。它用 rsem、wsem、x、y、z 五个信号量实现优先级反转,让写者在竞争中排在读者前面:

int readcount = 0, writecount = 0; semaphore rsem = 1; // 读写全局互斥 semaphore wsem = 1; // 读者读队列锁 semaphore x = 1, y = 1, z = 1; void writer() { P(y); writecount++; if (writecount == 1) P(rsem); // 第一个写者锁住读入口 V(y); P(wsem); // 写者之间也按顺序排队 // 写操作 V(wsem); P(y); writecount--; if (writecount == 0) V(rsem); // 最后一个写者打开读入口 V(y); } void reader() { P(z); // 所有读者先过 z 这道闸门 P(rsem); // 写者优先抢占后,读者在这里等待 P(x); readcount++; if (readcount == 1) P(wsem); // 第一个读者锁住写者 V(x); V(rsem); V(z); // 读操作 P(x); readcount--; if (readcount == 0) V(wsem); // 最后一个读者放行写者 V(x); }

z 是专门给读者准备的排队闸门,写者不经过 z,所以写者到达后直接去抢 rsem,而新到的读者只能被挡在 z 之后。这样“后来的读者”不可能插到“等待中的写者”前面。

rsem 是读写互斥的核心:第一个写者把它锁住,正在读的读者可以继续读完,但新读者进不来。wsem 保护的是“当前在读的读者集合”,第一个读者拿它,最后一个读者释放它,写者写之前也要拿它,确保有读者在读时写者不能进入。

4.3 打印机 PV 管理:require/return 的优先级分配与边界

作业四的打印机问题,比读者写者更偏“资源管理”,核心是:5 台打印机,n 个进程,按优先级分配。信号量实现里要用到多个数组。下面是一份补全了边界处理的参考实现:

int lp[5] = {1, 1, 1, 1, 1}; // 1 表示打印机空闲 int count = 5; // 当前空闲打印机数量 int prio[N + 1]; // 记录等待进程的优先级,-1 表示未等待 semaphore q[N + 1]; // 每个进程一个私有信号量,初值 0 semaphore mutex = 1; int require(int pid, int pri) { for (;;) { P(mutex); if (count == 0) { prio[pid] = pri; // 无空闲打印机,挂起自己 V(mutex); P(q[pid]); // 被唤醒后重新竞争 continue; } count--; for (int i = 0; i < 5; i++) { if (lp[i] == 1) { lp[i] = 0; // 占用第 i+1 号打印机 V(mutex); return i + 1; // 返回打印机编号 1..5 } } } } void return_printer(int prnt) { P(mutex); lp[prnt - 1] = 1; // 释放打印机 count++; int max_pri = -1, target = -1; for (int j = 1; j <= N; j++) { if (prio[j] > max_pri) { max_pri = prio[j]; target = j; } } if (target == -1) { V(mutex); // 没有等待进程 } else { prio[target] = -1; V(q[target]); // 唤醒优先级最高的进程 V(mutex); } }

两个细节容易踩坑:第一,函数返回的是打印机编号,按题目要求是 1 到 5,而数组下标是 0 到 4,这里要把下标换算成编号。第二,require 里进程被唤醒后必须回到入口重新检查 count,不能直接往下走,否则可能出现“明明没有空闲打印机却继续执行 for 循环找 lp[i]”的情况。有些同学的作业版本在 P(q[pid]) 之后直接跳出循环去找打印机,这就是典型的边界错误,后面章节会专门列进避坑清单。

5. 避坑排查:四类作业里最容易被扣分的点怎么自查

5.1 进程切换题只写寄存器名,不写“为什么”

现象:答“保存通用寄存器、SP、PC、PSW”,看上去和标准答案很像,但被判卷人追问“为什么打开文件表也算现场信息”就卡住。

原因:把进程上下文理解成了 CPU 寄存器的子集,忽略了上下文是“进程运行的物理环境”这一层。文件偏移、地址映射这些都是进程能继续运行的前提。

解决:答题时先写一句“进程上下文是指进程运行的物理环境”,再按 CPU 现场、内存映射、文件资源三类展开。每一类后面补一句恢复时需要做什么,这样既完整又显得有推导过程。

5.2 PSW 和 PC 恢复顺序只答“必须同时”,说不清两种错误

现象:知道要同时恢复,但被问到“为什么不能先恢复 PSW”或“为什么不能先恢复 PC”时答不出来。

原因:对管态和目态切换没有形成时序概念。本质上都是只记结论,没有在脑子里跑一遍指令序列。

解决:把两种错误顺序各写一句:先恢复 PSW,系统已转到目态,后续恢复 PC 的特权操作无法执行;先恢复 PC,PC 指向用户程序但 PSW 仍是管态,CPU 会拿核心态去执行用户代码,状态字无法再恢复。把这两句写进答案,逻辑链就闭合了。

5.3 Hyman 反例不画执行序列,只给结论

现象:写“当 P0 先推进、P1 再推进时会出现互斥失败”,但没有给出每一步的 turn 和 blocked 状态。

原因:以为反例只需要“描述场景”,不需要精确到指令。但软件互斥算法的正确性取决于任意指令交织,不把调度点标清楚,别人无法验证。

解决:按照本章第 3.2 节表格的方式,列出 5 到 8 行指令序列,把每一次赋值、判断、被强占的位置都写出来。画完这张表,反例才真正成立。

5.4 信号量初值抄错,或者让调用者带着锁返回

现象: mutex 初值写成 0,lp 数组初值写成 0,或者 require 里在 V(mutex) 之前 return,导致后续所有进程都阻塞在 mutex 上。

原因:没有把每个信号量当“资源计数器”来核对。mutex 初值必须是 1,lp[i] 为 1 表示空闲,这些初值决定了第一组进程能否正常进入。

解决:写完代码后,把“第一个进程申请”“最后一个进程释放”“无空闲打印机时挂起”这三条路径各跑一遍,检查每一次 P 是否都有对应的 V,尤其注意 return 之前要 V(mutex)。这套自查习惯比单纯看代码有效得多。

5.5 管程 release 里一次唤醒两个进程

现象:SCAN 管程的 release 直接写成 upscan、downscan 连续执行,运行结果在 Hoare 语义下会出现一个进程释放资源后唤醒了两个等待进程。

原因:没有区分 Hoare 和 Hansen 语义对 signal 的不同处理。Hoare 语义下执行 signal 的进程不立即离开管程,而是进紧急队列后还会继续执行后面的代码。

解决:在 release 里用 flag 标志控制“只执行一次有效扫描”,当前方向找不到待服务柱面时,才转向相反方向。这一步是管程实现里最容易在作业评审中被单独拿出来问的细节。

倒查完这五类问题后,你会发现大多数错误不是“不会”,而是“没把每一种错误时序和状态变化跑一遍”。这也是为什么我把这套解析当作推理题而不是背诵材料来用。

6. SCAN 管程验证:从 Hoare 与 Hansen 语义差异到一次只唤醒一个进程

最后落在一个很多人读了答案还是模糊的点:SCAN 磁盘调度管程里,为什么 Hoare 管程的 release 不能照抄 Hansen 管程的“先 upscan 再 downscan”写法。

upscan 和 downscan 的本质是一样的:从当前磁头位置出发,朝指定方向扫描,找到第一个 count[i] 非零的柱面后,把 count[i] 减一,signal(cylinder[i]) 唤醒一个正在等待该柱面的进程。关键在 signal 之后调用者去哪。

Hansen 管程里,signal 操作唤醒进程后,调用者会立即离开管程。所以 release 写成“upscan; downscan”是安全的:如果 upscan 执行了 signal,整个 release 已经结束,downscan 根本不会被继续执行;如果 upscan 没找到等待进程,说明向上方向为空,此时执行 downscan 也合理。

Hoare 管程里,signal 操作唤醒进程后,调用者进入紧急队列,等被唤醒的进程执行完再恢复。这时候问题就来了:如果 upscan 执行了 signal,调用者之后还会从紧急队列中回过神来,继续执行 release 中后面的 downscan。如果当前磁头下方恰好也有等待进程,downscan 会再次 signal,于是一次 release 唤醒了两个进程,相当于一个进程释放了两次磁盘资源,语义完全错误。

正确的 Hoare 版本是这样:

void release() { bool found = false; if (direction == UP) { upscan(&found); if (!found) downscan(&found); } else { downscan(&found); if (!found) upscan(&found); } busy = false; }

upscan 和 downscan 内部一旦找到进程并执行 signal,就把 found 置为 true。release 只在当前方向没有命中时才转向反方向,保证一次 release 至多唤醒一个等待进程。

这个验证技巧也适用于其他管程题目:拿到任何管程代码,先明确题目用的是 Hoare 语义还是 Hansen 语义,然后盯着 signal 之后的代码流。只要是 Hoare,就必须考虑“signal 执行完后调用者还会回来”的情况。从那以后,我每次拿到带管程伪代码的作业,都会先自问一遍 signal 之后谁在跑、跑完之后会做什么,再对着答案核对一次。这比背十遍管程定义都管用,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询