Lecture 4:知识 - 同步与互斥
408 学习笔记:Lecture 4:知识 - 同步与互斥。保留原有知识内容、公式与图示;有对应原件时提供查看和下载入口。
本页目录
并发题先分清“谁不能同时访问同一资源”(互斥)和“谁必须在谁之后发生”(同步),再为每个资源或前驱关系设置语义清晰的锁/信号量。不能只背 PV 模板而不分析资源和事件。
4.1 同步、异步与互斥
4.1.1 基本关系
- 同步(直接制约关系):为完成共同任务,进程/线程须在某些事件点相互等待、配合。例如产品放入缓冲区后,消费者才能取出。
- 异步性:并发进程以独立、不可预知的速度推进;同步机制用于建立必要的先后约束。
- 互斥(间接制约关系):多个进程竞争访问同一数据或资源,执行结果会受交错次序影响,故同一时刻只允许一个访问者。
| 资源类型 | 特点 | 例子 |
|---|---|---|
| 临界资源(互斥共享) | 一段时间内只允许一个进程使用 | 打印机、可修改共享变量、缓冲区、文件写入区 |
| 同时共享资源 | 允许多个进程同时访问 | 只读程序、只读数据 |
临界资源是被保护的对象;临界区是访问临界资源的那段代码,二者不能混淆。
4.1.2 临界区结构与四准则
do {
entry section; // 进入区:申请许可、上锁或等待
critical section; // 临界区:访问临界资源
exit section; // 退出区:解锁、释放资源
remainder section; // 剩余区:其他代码
} while (true);
临界区问题的四项原则:
- 空闲让进:临界区空闲时,应尽快允许一个请求者进入。
- 忙则等待:已有进程在临界区时,其他请求者必须等待,保证互斥。
- 有限等待:一个进程申请后,必须能在有限时间内进入,避免饥饿。
- 让权等待:不能进入时应让出 CPU,避免持续占用 CPU 自旋。
前 3 项侧重正确性;让权等待侧重效率。自旋锁可保证互斥,但不满足让权等待。
4.2 软件与硬件互斥方法
4.2.1 软件算法的比较
| 算法 | 思路 | 主要缺陷 |
|---|---|---|
| 单标志法 | turn 指定当前只允许某一方进入 |
可能轮到不想进入的进程,违反空闲让进 |
| 双标志先检查 | 先检查对方标志,再声明自己要进入 | “检查”和“置标志”不是原子整体,可能同时进入,违反互斥 |
| 双标志后检查 | 先声明意图,再检查对方意图 | 双方都声明后可相互等待,违反空闲让进/有限等待 |
| Peterson | flag[i] 表示进入意图,turn 表示两者竞争时的让步 |
两进程情形可满足前三项,但仍忙等,且依赖内存访问顺序 |
Peterson 的核心不是 turn 的“所有权”,而是双方都申请时的谦让:进程 $i$ 先设置 flag[i]=true,再令 turn=j,在“对方也想进入且轮到对方”时等待。
4.2.2 中断屏蔽
单处理机内核可在进入临界区前关中断、退出后开中断,使临界区执行期间不会因中断被切换。它简单高效,但:
- 不适合多处理器:关闭本 CPU 中断不能阻止其他 CPU 进入临界区。
- 只能由内核使用:关/开中断是特权指令,用户程序不能也不应执行。
- 只能保护很短的内核临界区,否则会损害中断响应。
4.2.3 TestAndSet、Swap 与自旋锁
TestAndSet(TSL)和 Swap/Exchange 是不可中断的硬件原子指令,可将“检查锁”和“置锁”合成为一个原子动作,适用于多处理器:
// 返回 lock 的旧值,同时把 lock 置为 true;整个调用原子完成
while (TestAndSet(&lock)) {
; // busy waiting
}
/* critical section */
lock = false;
- 优点:实现简单,不会出现软件算法中检查/置锁被拆开的漏洞;适合锁持有时间短的多核临界区。
- 缺点:等待者一直占用 CPU,违反让权等待;单核或临界区较长时浪费 CPU。
- 普通函数调用不天然原子;必须由硬件原子指令实现,才可作为互斥基础。
互斥锁可抽象为 acquire()/release()。获取不到锁时,自旋锁循环检查;可阻塞 mutex 则将等待线程挂入队列、让出 CPU。
4.3 信号量与 PV 原语
4.3.1 定义与操作
信号量是表示资源数量或事件状态的整型/记录型变量,只能通过初始化、wait 和 signal 原子访问:
| 名称 | 别名 | 作用 |
|---|---|---|
wait(S) |
P(S)、proberen |
申请资源,或等待前驱事件 |
signal(S) |
V(S)、verhogen |
释放资源,或通知后继事件 |
PV 操作都是原语,必须一气呵成。整型信号量的简化形式为:
wait(S) { while (S <= 0) ; S--; } // 资源不可用时忙等
signal(S) { S++; }
它仍然忙等。记录型信号量以等待队列实现让权等待:
typedef struct { int value; queue L; } semaphore;
wait(S) {
S.value--;
if (S.value < 0) { add_this_process_to(S.L); block(); }
}
signal(S) {
S.value++;
if (S.value <= 0) { remove_a_process_from(S.L); wakeup(); }
}
若 S.value<0,其绝对值常可理解为等待该资源的进程数。队列可按 FIFO 等策略组织;多处理机上,PV 的原子性仍要借助硬件原子操作或锁保证。
4.3.2 用 PV 实现互斥和同步
互斥:每个临界资源设置一个互斥信号量,初值通常为 1。
semaphore mutex = 1;
P(mutex);
/* critical section */
V(mutex);
同步:每一对前驱/后继关系设置一个同步信号量,初值通常为 0;前操作完成后 V(S),后操作开始前 P(S)。
semaphore S = 0;
P1: 前操作; V(S);
P2: P(S); 后操作;
P 可能阻塞,V 不会使执行它的进程因资源不足而阻塞。因此多个 P 的顺序尤其重要:若先 P(mutex)、后因同步 P(S) 阻塞,就会持锁等待,能产生事件的进程可能无法进入临界区,导致死锁。相邻且独立的 V 操作通常可交换,但仍须核对题目所要求的事件顺序。
4.4 PV 题的通用解法
- 列出并发进程/线程和每个进程是否反复执行;未说明只执行一次时通常要写
while (true)。 - 找临界资源:同一可写对象的读-写、写-读、写-写通常要互斥;读-读通常可并行。
- 找每个前驱关系:前操作之后放
V,后操作之前放P。 - 为每个资源或关系定义语义明确的信号量,并按资源数或事件是否已发生设置初值。
- 检查所有
P:是否有对应V;共享计数器是否被保护;是否出现拿着一把锁再等待另一条件的环。
一个资源存在多组互斥关系时,可设置多个更细粒度的锁来提高并发度,不能因“都是全局变量”就把无冲突的读操作也串行化。
4.5 经典同步问题
4.5.1 生产者-消费者
大小为 $N$ 的有界缓冲区:满时生产者等待,空时消费者等待;放入/取出缓冲区还要互斥。
semaphore mutex = 1, empty = N, full = 0;
producer() {
while (true) {
item x = produce();
P(empty); P(mutex);
put(x);
V(mutex); V(full);
}
}
consumer() {
while (true) {
P(full); P(mutex);
item x = get();
V(mutex); V(empty);
consume(x);
}
}
| 信号量 | 初值 | 语义 |
|---|---|---|
mutex |
1 | 保护缓冲区及其访问操作的互斥 |
empty |
$N$ | 空缓冲单元数,控制生产者 |
full |
0 | 已有产品数,控制消费者 |
必须先 P(empty/full) 再 P(mutex)。否则进程可能拿着 mutex 在条件信号量上阻塞,另一类进程无法进入缓冲区改变条件,从而死锁。多生产者/多消费者、一次取/放多个产品时,仍按“资源数量同步 + 缓冲区互斥”拆分,并将批量操作整体分析。
原资料中的单槽水果盘是两类生产者、两类消费者的具体变体:父亲只放苹果、母亲只放橘子;女儿只取苹果、儿子只取橘子。盘子容量为 1,初始为空,可这样设置:
semaphore plate = 1, apple = 0, orange = 0;
father() { P(plate); put_apple(); V(apple); }
mother() { P(plate); put_orange(); V(orange); }
daughter() { P(apple); take_apple(); V(plate); }
son() { P(orange); take_orange(); V(plate); }
四个函数可各自循环执行。plate 保证盘子空时仅有一个生产者能放入水果;apple/orange 分别保证对应消费者只能取到自己需要的水果。仅在这种单槽、一次放/取一个且三个信号量按上面配对使用的模型下,同步信号量已顺带保证盘子的互斥访问,不一定需要额外 mutex;换成多个槽或一次批量操作时必须重新分析,不能直接套用。
4.5.2 读者-写者
共享文件允许多个读者同时读;写者写入时不允许读者或其他写者访问。读者优先模型:
semaphore rw = 1; // 文件的读写互斥
semaphore mutex = 1; // 保护 readCount
int readCount = 0;
reader() {
P(mutex);
if (readCount == 0) P(rw); // 第一个读者阻止写者
readCount++;
V(mutex);
read_file();
P(mutex);
readCount--;
if (readCount == 0) V(rw); // 最后一个读者放行写者
V(mutex);
}
writer() { P(rw); write_file(); V(rw); }
读-读不互斥,读-写、写-读、写-写互斥。读者优先可能让写者饥饿;写者优先或公平策略需增加门闩/排队信号量,阻止新读者越过已等待的写者。
4.5.3 哲学家进餐
$n$ 名哲学家各需同时取得左右两支筷子才能进餐;每支筷子一次只能被一人使用。所有人都先取左再等待右会形成环路等待,可能死锁。
- 最多允许 $n-1$ 人同时尝试取筷子。
- 为筷子编号,统一按小编号到大编号申请,破坏循环等待。
- 引入服务员/全局锁,使“检查两支筷子都可用并占用”成为原子动作;不足则等待。
- 可为每支筷子设置资源信号量,但必须注意全局锁的释放位置,不能持锁等待筷子。
原资料中的“还需一只碗”变体:$n>3$ 名哲学家围坐,中央有 $m>1$ 只共享碗,每人须同时取得一只碗和左右两支筷子才能吃。任意相邻两人不能同时用餐,因此同时用餐人数的上界为
$$\min\left(m,\left\lfloor\frac n2\right\rfloor\right).$$
若希望避免“已拿到碗和一支筷子,却永远等另一支”的死锁,可让服务员在短暂持有互斥锁期间一次性检查并分配这三件资源;三件不齐时先释放该锁,再等待条件变化,不能持锁睡眠或让所有人先各占一件资源。就餐阶段不持服务员锁,归还资源时再短暂加锁并唤醒等待者。这样互不相邻且有碗可用的哲学家仍可并行用餐;具体唤醒顺序还需考虑公平性,避免个别人饥饿。
4.6 管程
4.6.1 基本思想
管程(monitor)是高级同步模块,封装:共享数据结构、初始化语句、操作共享数据的一组入口过程,以及管程名称。外部只能通过入口过程访问共享数据;同一时刻最多一个进程/线程在管程内执行,因此入口过程天然互斥。
它把 PV 代码中的锁、共享变量和操作封装起来,降低漏锁、错序的风险;管程不等同于管道。
4.6.2 条件变量
管程内的条件变量用于“条件不满足则等待、条件满足则唤醒”:
x.wait():调用者阻塞并插入x的等待队列,同时释放管程,以便其他进程进入。x.signal():若x队列有等待者,唤醒其中一个;若没有等待者,通常不累计资源数量。
条件变量用于同步,不是普通资源计数信号量。生产者-消费者可在管程中封装 count、缓冲区、full、empty 条件变量:满时生产者等待,空时消费者等待,插入或取出后唤醒对应队列。
IMPORTANT · 等待条件要重检 原 PDF 第 39 页示例用
if (count==N) wait(full)和if (count==0) wait(empty)表达条件等待。它不能无条件搬到常见的 Mesa 式条件变量:被唤醒者只是重新变为可竞争管程的线程,等它再次进入时,条件可能已被别人改变;实现还可能发生虚假唤醒。因此通常写成while (count==N) full.wait()、while (count==0) empty.wait(),醒后重新检查。只有在明确采用立即移交执行权、无虚假唤醒等更强语义的模型下,才可按相应前提讨论一次if检查。
相关知识
4.7 本节复盘
- 互斥保护同一时刻的资源访问;同步保证前驱事件先发生;一个题往往同时需要二者。
- 临界区要满足空闲让进、忙则等待、有限等待和让权等待;自旋锁不满足让权等待。
P申请/等待且可能阻塞,V释放/通知且可能唤醒一个进程;PV 必须原子执行。- 互斥锁初值通常为 1;资源计数按可用数量初始化;纯前驱同步信号量通常初值为 0。
- 生产者消费者中先
P(empty/full)后P(mutex);读者写者关注首读者加锁和末读者解锁;哲学家问题核心是破坏循环等待。 - 写完 PV 代码后,逐个核对每个
P的唤醒来源、每个共享计数的保护和是否存在持锁阻塞。