同步和互斥
同步和互斥是解答题中进行同步问题设计的基础,需要深入掌握原子性、阻塞与唤醒、资源计数和执行顺序。
临界区和互斥
在并发编程中,临界区(Critical Section)是指一个程序中仅允许 一个线程 或 进程 访问的代码片段。这些代码通常会访问 共享资源,比如内存、文件、数据库等。当多个线程或进程试图同时访问这些共享资源时,可能会导致数据不一致或其他并发问题。因此,需要一种机制来确保在任何时候,保证在 一个时刻只有一个线程或进程 能够访问 临界区 的代码。
这种机制就叫做 互斥:
互斥(Mutual Exclusion)是计算机科学中一种用于防止多个进程或线程同时访问共享资源或 临界区 的机制。其主要目标是避免资源竞争和数据不一致的问题。互斥保证在任何时刻,最多只有 一个线程或进程 可以访问特定的共享资源,从而确保数据的完整性。
临界资源
临界资源 是需要互斥使用、在同一时刻只允许一个进程或线程访问的共享资源。共享资源是更大的集合:只读共享数据或本身支持并发访问的资源未必是临界资源,不能把两者简单画等号。 比如一个进程的可写 全局变量 可能被多个线程并发修改;若这些访问不能安全交叠,该变量就是这些线程的临界资源。 再比如数据库中的某项数据可被多个进程请求访问;当某个更新操作要求独占该数据时,应把相应访问作为临界区并实施互斥。
提示
有 进程间的互斥,也有 线程间的互斥。二者的临界区思想相同,但共享对象和可用原语并不完全相同;408 的常见题目更多把一个进程内部的多个 线程 作为并发执行流。
| 讨论对象 | 共享什么 | 典型实现 |
|---|---|---|
| 线程间互斥 | 同一进程的全局变量、堆、文件描述符等;天然共享地址空间 | 互斥锁、条件变量、信号量 |
| 进程间互斥 | 共享内存、文件、设备、内核对象等 | 共享内存配合同步原语、文件锁、内核信号量等 |
下文为避免重复,统一使用 线程 一词表示“需要被协调的并发执行流”;遇到题目明确写“进程”时,只需把临界资源和同步原语换回题设的作用范围。
为何需要互斥
在 处理器调度 一节我们知道,为了避免 进程饥饿,现代操作系统常使用 时间片轮转 的方式来避免一个进程连续执行过长时间。
这意味着进程可能在执行完当前的机器指令后就被操作系统中断,由 运行态 进入 就绪态,然后等待下一次调度继续执行后续指令。 这种随时可能被中断的特性决定了若没有 临界区的互斥,多线程并发执行时 结果可能会出现不一致性。
如上图所示,一条高级语言语句常对应着多条汇编语句,而进程执行完汇编语句后可能立刻会被中断。 所以高级语言语句的执行通常不具备 原子性,进程可能在执行到一半时被中断或抢占并暂时停止,稍后再从保存的现场继续;这不是进程“终止”。
临界区 涉及到对于 临界资源 的访问,如果多个 线程 同时执行 临界区 代码访问 临界资源 时,实际执行的指令序列就具有不可预测性,结果也因此常常出现 不一致的情况。
为了解决以上问题,多线程在访问 临界资源 时必须是 互斥 的。也就是说,当一个 线程 在访问 临界资源 的过程中,不允许其他线程再访问临界资源。其他线程必须等待到当前访问者访问完,才能进入 临界区。
需要注意的是,互斥 并不表示线程在执行临界区代码的过程中不会被操作系统 中断。
互斥是一种逻辑上的概念,它保证了多个线程不能同时进入临界区。线程 A 可能在执行临界区代码的过程中被中断,但是与之互斥的线程 B 同样不能在线程 A 被中断之时进入临界区。
例子
下面以两个线程都执行一次 counter += 10 为例。初始 counter=100,顺序执行的预期结果应为 120;但读—改—写被交错后,两个线程都可能基于旧值 100 计算,最后只写回一次 110。这种“丢失更新”正是临界区必须互斥的可观察后果。
int counter = 100;
void add10() {
int temp = counter; // 读共享变量
temp = temp + 10; // 在寄存器/局部变量中计算
counter = temp; // 写回共享变量
}
没有互斥时:两次加 10 为什么只得到 110
逐步查看两个线程把同一条高级语言更新拆成读、改、写后如何交错;代码高亮与共享 counter 同步变化。
初始 counter=100。两个线程各执行一次加 10,顺序执行应得到 120;交错后第二次写回覆盖第一次写回。
01temp1 = counter;02temp1 = temp1 + 10;03counter = temp1;01temp2 = counter;02temp2 = temp2 + 10;03counter = temp2;初始值两个线程都尚未进入临界区,counter 为 100。
互斥
实现 互斥 的方法主要包含如下几种:
| 方法 | 说明 | 等待方式 |
|---|---|---|
| 软件互斥 | 依赖共享变量与算法保证临界资源独占,如 Peterson 算法 | 通常忙等 |
| 硬件互斥 | 使用 CPU 提供的原子读—改—写指令构造互斥 | 可构造自旋锁,也可配合阻塞队列 |
| 互斥锁 | 系统或线程库提供的同步原语,带有锁所有权 | 竞争失败时通常阻塞,也可能先短暂自旋 |
| 信号量 | 以计数值表示可用资源,并用原子 P/V 操作同步 | 资源不足时进入等待队列 |
互斥锁
互斥锁(Mutex,Mutual Exclusion)是操作系统提供的 同步原语,用以实现多线程对于 临界资源 的 互斥。 互斥锁提供了一种机制,确保在任何时刻只有 一个线程 能够持有该锁,从而确保 共享资源 的安全访问。
互斥锁通过以下特点实现 线程互斥:
- 任何时候,只有 一个线程 可以持有 互斥锁。其它试图获取该锁的 线程 将被 阻塞,直到持有锁的线程 释放 该锁。
- 只有锁的持有者才能释放它。这确保了非持有者不能误释放锁。
操作
互斥锁提供 加锁 和 解锁 这两个操作实现 互斥:
- 加锁(Lock 或 Acquire):当线程试图获取互斥锁时,如果锁已被其他线程持有,则该线程将被阻塞,直到锁被释放。
- 解锁(Unlock 或 Release):持有互斥锁的线程在完成其对共享资源的访问后,应该释放锁以使其他线程可以获取锁。
通过这两个操作,线程即可对 临界区 进行“上锁”:
mutex mtx;
void thread() {
non_critical_section();
lock(&mtx);
critical_section();
unlock(&mtx);
non_critical_section();
}
线程在进入 临界区之前 使用 lock(&mtx) 进行 加锁,在退出 临界区之后 使用 unlock(&mtx) 进行 解锁。
互斥的底层实现由操作系统完成,线程通过调用 mutex 的系统 API 即可实现 互斥。
实际实现常把“锁空闲时的原子获取”作为快速路径;竞争失败后再进入内核等待队列或短暂自旋。无论具体优化如何,必须保持两个边界:失败的获取不能让线程进入临界区,被唤醒只表示重新具备竞争资格,不表示已经立刻运行或已经持锁。
互斥锁竞争:阻塞、唤醒与重新获得 CPU 不是同一件事
逐步查看 T1 持锁、T2 阻塞、T1 解锁、T2 变为就绪并最终重新获得锁的路径;每步高亮对应 lock/unlock 代码。
T2 对已被 T1 持有的 mutex 调用 lock 后进入等待队列。T1 unlock 只让 T2 重新具备运行资格,T2 仍需被调度并重新取得锁。
- 空
- 空
01non_critical_section();02lock(&mtx);03critical_section();04unlock(&mtx);05non_critical_section();01non_critical_section();02lock(&mtx);03critical_section();04unlock(&mtx);05non_critical_section();初始:锁空闲T1、T2 都可运行,尚无人持有 mutex。
实现原理
MUTEX 的底层实现原理比较复杂,但是我们可以从逻辑上来理解其实现原理:
当 线程 尝试获取一个已经被持有的锁时,它会被 挂起,并且不会消耗 CPU 资源。只有当锁被释放并且该线程被选择为下一个获取锁的线程时,它才会恢复执行。这样的机制确保了 临界区 的 互斥访问,同时也尽量减少了不必要的 CPU 浪费。
第一个 加锁 的线程可以直接获取锁,后续 加锁 的线程会经历 阻塞 和 唤醒 这两个步骤:
- 阻塞:
- 当线程尝试获取一个已经被其他线程持有的 互斥锁 时,该线程会进入一个 等待状态,也称为 阻塞状态。
- 操作系统维护了一个与该 互斥锁 关联的 等待队列。尝试获取锁但未成功的 线程 会被放入这个队列中。
- 被 阻塞 的线程会从“运行”状态转为“等待”或“睡眠”状态。这意味着该线程将不再获得 CPU 时间,直到它被 唤醒。
- 唤醒:
- 当持有 互斥锁 的 线程 释放该锁时,操作系统会检查与该锁关联的 等待队列。
- 通常,队列中的下一个线程(取决于调度策略,可能是队列的第一个线程或其他)会被选中并被 唤醒,允许它获取锁。
- 被 唤醒 的线程转换为“就绪”状态,并在适当的时候由调度器重新分配 CPU 时间,从而继续执行。
其大致过程如下图所示:
软件互斥
软件互斥依赖 算法和程序设计 来确保资源的独占访问。常用的软件互斥算法不依赖特定的硬件支持,主要通过逻辑控制和变量来实现。
挑战
在没有硬件原子指令的情况下,仅依靠普通变量实现互斥并不是一件容易的事情。许多看似简单的方案都会出现竞态条件、死锁或饥饿等问题。
错误方案一:仅使用锁变量
最容易想到的方法是使用一个共享变量表示锁是否被占用。
bool lock = false;
void thread() {
while (lock); // 等待
lock = true; // 加锁
// 临界区
lock = false; // 解锁
}
这种实现存在严重问题。
假设两个线程几乎同时执行:
线程 1 线程 2
----------------------------------------
读取 lock=false
读取 lock=false
lock=true
lock=true
进入临界区
进入临界区
由于 判断 (lock==false) 和 修改 (lock=true) 并不是一个原子操作,因此两个线程可能同时进入临界区,互斥失败。这就是经典的 检查-再执行(Check-Then-Act)竞态条件。
错误方案二:严格轮流执行
另一种思路是规定两个线程必须轮流进入临界区。
int turn = 0;
void thread0() {
while (turn != 0);
// 临界区
turn = 1;
}
void thread1() {
while (turn != 1);
// 临界区
turn = 0;
}
这种方法能够保证互斥,但存在新的问题。
例如:
turn = 0
线程0:此后只在临界区外工作,不再请求进入
线程1:请求进入,但在 while (turn != 1) 中等待
此时临界区完全空闲,但只有线程 0 再次进入并把 turn 改为 1,线程 1 才能通过;而线程 0 已经没有进入需求。因此线程 1 发生了不必要的等待。
也就是说,它违背了 空闲让进(Progress) 原则:临界区空闲时,请求进入的线程应该能够立即进入,而不是等待另一个线程。
Peterson 算法
前两种方案分别解决了一部分问题:
- 锁变量:表达了线程是否想进入临界区,但无法避免竞态条件。
- 轮流变量(turn):能够解决竞争时谁先进入的问题,但会导致不必要的等待。
Peterson 算法将两者结合:
- 使用 flag[] 表示每个线程是否希望进入临界区;
- 使用 turn 在双方同时请求时决定谁先进入。
只有当 对方也想进入临界区,并且当前轮到对方 时,当前线程才会等待,从而既保证互斥,又避免了严格轮流带来的效率问题。
**成立条件:**经典 Peterson 算法假设两个线程对
flag和turn的读写是原子的,并按顺序一致的内存模型被观察。现代编译器与弱内存序处理器上必须使用具有合适内存序的原子变量,不能直接把普通变量版本当作生产代码。
// 初始化
bool flag[2] = {false, false}; // 两个线程的意愿
int turn = 0; // 当前运行的线程(0 或 1)
// 线程 0 希望进入临界区
void thread0() {
flag[0] = true;
turn = 1; // 通知线程 1 你可以运行了
while (flag[1] && turn == 1) {
// 等待线程 1 退出临界区或让出 CPU
}
// 进入临界区
// ...
// 退出临界区
flag[0] = false;
}
// 线程 1 希望进入临界区
void thread1() {
flag[1] = true;
turn = 0; // 通知线程 0 你可以运行了
while (flag[0] && turn == 0) {
// 等待线程 0 退出临界区或让出 CPU
}
// 进入临界区
// ...
// 退出临界区
flag[1] = false;
}
Peterson’s 算法通常用于理论教学和理解互斥原理,但在实际多线程编程中并不常用,因为它 只适用于两个线程之间的互斥。
下面把“错误锁变量”“严格轮流”和 Peterson 的关键代码放到同一个逐步面板中。前两个场景分别暴露了互斥失败和空闲不让进;Peterson 场景则展示同时请求时如何由 turn 让出一次优先权。
软件互斥三种关键路径:两个错误方案与 Peterson
切换场景,分别观察检查—再执行竞态、严格轮流违背空闲让进,以及 Peterson 如何用 flag[] 与 turn 处理同时请求。
普通读锁和写锁之间可被抢占;两个线程都看见 false 后都能写 true,互斥立即失效。
01while (lock) { }02lock = true;03critical_section();04lock = false;01while (lock) { }02lock = true;03critical_section();04lock = false;初始 lock=false两线程都准备尝试进入。
硬件互斥
上文中我们提到了 软件互斥,即用复杂的软件逻辑实现了类似 加锁 和 解锁 的操作。硬件互斥 与之类似,不过我们是使用 CPU 指令集中的 原子指令 来实现同等的功能。
使用 原子指令 并在失败时不断重试的锁一般叫做 自旋锁,因为加锁过程会持续占用 CPU 检查锁状态。自旋锁适合预期持锁时间很短、线程不宜睡眠的场景;持锁时间较长时,阻塞式互斥锁通常更合适。
原子性
原子性(Atomicity)指一个操作在并发环境中表现为 不可分割的整体: 要么全部完成,要么完全不发生;在执行过程中,不会被其他线程观察到中间状态。
原子指令(Atomic Instruction)是由 处理器架构定义的一类指令,其对外可观察效果 满足原子性语义 —— 即在多核/多线程并发访问下,不会与其他指令的执行结果交错。是否“原子”,不是由指令是否耗费一个 CPU 周期决定的,而是由体系结构对并发可见性的承诺决定的。
注意
所有机器指令都是原子的吗?
不是。
- 寄存器-寄存器、寄存器-立即数等不涉及共享内存的指令,通常天然不存在并发可见性问题,可视为“原子”的;
- 涉及内存访问的指令,并不自动具备原子性。 即使某条指令在硬件上分多步完成,只要架构保证其对并发内存访问是不可分割的,它才是原子指令;
- 只有被架构明确标注为原子的指令(如
lock前缀指令、LL/SC、CAS 等),才在多核环境中提供真正的原子性与一致性保证。
在 C 语言层面,普通语句都 不具备原子性保证:一条 C 语句通常会被编译为多条机器指令,其中的读-改-写过程可能被并发线程打断。
TAS 指令
Test-and-Set(TAS)指令:TAS 指令原子性地设置一个内存位置的值为 1,并返回该位置的先前值。 为了辅助各位读者理解,下述代码使用一个函数描述其内部逻辑。
// TAS 原子指令相当于以下函数被原子性地执行
int test_and_set(bool *lock) {
bool old = *lock;
*lock = true;
return old;
}
在使用 TAS 指令实现 自旋锁 时,锁可以用一个整数变量表示,0 表示锁是空闲的,1 表示锁已经被占用。 加锁 操作可以不断使用 TAS 指令来尝试将锁的值从 0 设置为 1,如果返回的先前值为 0,则表示 锁定成功,否则表示 锁定失败。 解锁 操作将锁的值设置为 0。
mutex = 0;
thread() {
while (test_and_set(&mutex) == 1); // 基于 TAS 实现自旋锁,如果 mutex 为 1 被占用,则循环等待。
critical_section(); // 临界代码
mutex = 0; // 解锁
}
TAS 实现 自旋锁 的具体工作原理如下:
- 当一个 线程 执行 TAS 指令时,它会读取一个共享变量 mutex 的值。
- 如果该值为 0(或 false),则 线程 将该值设置为 1(或 true),并继续执行 临界区 代码。
- 如果该值为 1(或 true),则 线程 将无法获取锁,需要等待锁被释放。
- 当 线程 完成 临界区 代码的执行后,它会将共享变量的值重置为 0(或 false),以 释放锁。
有一个常见的问题是,为什么不直接用以下的语句实现加锁和解锁呢:
mutex = 0;
thread() {
if (mutex == 0)
mutex = 1; // 加锁
critical_section(); // 临界代码
mutex = 0; // 解锁
}
因为如果这样的话,加锁的过程会变成 load, compare, store 三条指令,一个线程在执行这三条指令的过程中可能会被中断,在极端情况下会有多个线程同时进入 critical section,相当于互斥锁的作用就失效了。
CAS 指令
Compare-and-Swap(CAS)指令:CAS 指令原子性地比较内存中的一个值与预期值,如果相等,则将内存中的值替换为新值。
// CAS 原子指令相当于以下函数被原子性地执行
bool compare_and_swap(bool *lock, bool expected, bool new_value) {
if (*lock == expected) {
*lock = new_value;
return true; // 操作成功
} else {
return false; // 操作失败
}
}
在使用 CAS 指令实现 自旋锁 时,锁可以用一个整数变量表示,0 表示锁是空闲的,1 表示锁已经被占用。 加锁 操作可以使用 CAS 指令来将锁的值从 0 修改为 1,如果 CAS 操作成功,则表示 锁定成功。 解锁 操作可以使用 CAS 来将锁的值从 1 修改为 0。
mutex = 0;
thread() {
while (compare_and_swap(&mutex, 0, 1) == false); // 基于 CAS 实现自旋锁,则循环等待直到操作成功
critical_section(); // 临界代码
mutex = 0; // 解锁
}
上述用 CAS 实现 自旋锁 的思路其实和 TAS 一致,锁都是用一个整数变量表示,原子指令会一直自旋直到 mutex=0 时,然后原子性地将其设置为 1,表示当前 线程 占有了锁。
TAS 与 CAS 的区别在于原子原语返回/判断的形式:TAS 交换并返回旧值,CAS 只在“当前值仍等于期望值”时写入新值。两者用于自旋锁时都必须把“读锁状态 + 写入占用状态”合成一次原子读—改—写;下面可切换两种路径,观察失败线程为何只能继续自旋,不能越过临界区。
TAS 与 CAS:原子尝试失败时为何只能自旋
切换 TAS 与 CAS,逐步查看一次原子操作如何把锁从空闲改为占用;失败线程只能继续尝试,不能进入临界区。
TAS 原子地返回旧 lock 并写入 1。返回 0 的线程成功;返回 1 的线程继续循环。
01while (TAS(&lock) == 1) { }02critical_section();03lock = 0;01while (TAS(&lock) == 1) { }02critical_section();03lock = 0;初始 lock=0两个线程都准备尝试 TAS。
同步
同步是指多个 线程 为了协同完成某项任务,必须按照一定的顺序执行操作。它主要解决 线程 之间在执行过程中如何协调的问题,确保它们以预期的时序进行交互与合作。
补充
互斥和同步的关系?
互斥是 同步 的一种特例,它用于保证多个 线程 在访问共享资源时不会发生冲突,即在同一时刻最多只有一个 线程 可以访问该资源。
同步的范畴更广,不仅包括 互斥 控制,还包括 线程 之间的顺序协调、条件等待、信号传递等机制。
简单来说:
- 互斥:解决“同一时刻只能有一个 线程 访问共享资源”的问题;
- 同步:解决“线程 之间需要按照某种顺序协同执行”的问题。
同步原则
在实现 线程 或 进程 同步时,需要遵循以下基本原则:
- 空闲让进:如果没有其他线程处于临界区,则允许当前线程进入;
- 忙则等待:若已有线程在临界区,则其他线程必须等待;
- 有限等待:每个等待进入临界区的线程都有机会在有限时间内进入,避免 “死等”;
- 让权等待:不能进入临界区的线程应主动放弃 CPU(如进入等待队列),避免 “忙等”。
上述的进程同步原则涉及到 “死等” 和 “忙等” 这两个概念:
- 死等 状态:指一个 线程/进程 长期得不到资源或 临界区 的访问权,即使条件已经满足,也可能因为调度策略、优先级问题或实现缺陷,永远无法被唤醒或执行。
- 忙等 状态:指 线程/进程 未能进入 临界区,但却反复占用 CPU 执行检查操作,不断轮询某个变量或状态,以判断自己是否可以进入 临界区。
用更加通俗的话说,忙等 的本质就是“空转”,死等 的本质是“永远等不到”。
条件变量
条件变量(Condition Variable)是一种 同步原语,用于在多线程之间进行 有条件的协作。它允许线程在某个条件未满足时进入等待状态,并在等待期间自动释放已持有的锁,从而让其他线程有机会获取该锁并修改共享数据,使条件得以满足。
条件变量的主要作用:
- 实现条件同步:当线程必须等待某个特定条件才能继续执行时,条件变量提供了一种高效的等待机制。
- 避免忙等(busy-waiting):相比让线程不断轮询检查条件是否满足(浪费 CPU 资源),条件变量使线程在条件未满足时进入休眠,只有在条件被触发时才被唤醒。
操作
条件变量提供三种操作:
wait():以原子方式释放关联互斥锁并把调用线程加入条件变量等待队列;线程被唤醒后必须重新获得互斥锁,wait()才返回。signal():唤醒等待队列中的一个线程;若没有等待者,通知通常不会被保存。被唤醒不等于立即运行,它还要竞争互斥锁和 CPU。broadcast()或notify_all():这个操作唤醒所有等待在条件变量上的 线程。当共享数据的状态变化可能影响多个等待的 线程 时,这很有用。
条件变量表示“条件可能已变化”,不是资源计数。为防止虚假唤醒或其他线程先改变条件,等待方应在持锁状态下使用 while (!condition) wait(...) 重新检查谓词。
// 等待方:必须先持有 m,再用 while 反复检查谓词
lock(&m);
while (!ready) {
wait(&cv, &m); // 原子地释放 m,并加入 cv 等待队列
}
consume();
unlock(&m);
// 通知方:改变谓词后再通知等待者
lock(&m);
ready = true;
signal(&cv); // 被唤醒者仍要重新竞争 m
unlock(&m);
条件变量:wait 原子释放锁,signal 不等于立即运行
逐步查看消费者因条件不满足而 wait、生产者改变谓词并 signal、消费者重新竞争 mutex 后才返回的过程。
采用常见的 Mesa 风格语义:signal 只是让等待者可重新竞争锁;wait 返回前仍必须重新取得 mutex,并用 while 再检查条件。
- 空
- 空
- 空
01lock(&m);02while (!ready) {03 wait(&cv, &m);04}05consume();06unlock(&m);01lock(&m);02ready = true;03signal(&cv);04unlock(&m);初始:ready=false,mutex 空闲消费者需要等待数据,生产者尚未发布。
信号量
信号量(Semaphore)是一个 同步原语,相比 锁,信号量可以解决一些更加复杂的同步问题。
在逻辑上我们将 信号量 理解为一个整数计数值,用于表示可用资源的数量。
信号量提供两个原子操作:
- P(proberen,荷兰语的“尝试”之意)
- V(verhogen,荷兰语的“增加”之意)。
| 操作 | 作用 | 非负计数语义下的原子步骤 |
|---|---|---|
| P(wait) | 申请一个资源 | 若 ,令 并继续;否则把线程加入该信号量等待队列并阻塞 |
| V(signal) | 归还一个资源或发出一次通知 | 若有等待线程则唤醒一个并把本次资源直接交给它;否则令 |
P 操作
P 操作又称 wait,其执行过程如下:
- 检查信号量的当前值
- 若 信号量 ,则将其值 减 1,线程继续执行后续代码。
- 若 信号量 ,则线程被 阻塞,直至有线程执行 V 操作并将资源直接交给一个等待者。
简而言之,P 操作是“尝试把信号量减一”。只有在减一后不会使信号量变为负数时,尝试才算成功,线程得以继续;否则线程进入阻塞状态。
V 操作
V 操作又称 signal,其执行过程如下:
- 若等待队列非空,从中选择 一个 线程唤醒,并把这次归还的资源直接交给它;计数仍为 0。
- 若没有等待线程,令计数值 。
教材也常采用“允许 ”的 Dijkstra 记法:P 先做 ,若 则阻塞;V 先做 ,若 则唤醒一个等待者。负值的绝对值表示等待者数量。两套表示法结果一致,但不能把它们的判断条件混在同一段伪代码中。
下面采用本页前后一致的非负计数语义写成伪代码。enqueue_and_block 与 wake_one 都必须和计数判断处在同一原子保护范围内;否则会出现“资源已归还,但等待者尚未入队”的丢失唤醒。
P(S) { V(S) {
atomically { atomically {
if (S > 0) { if (wait_queue.empty())
S--; S++;
return; else
} wake_one(wait_queue);
enqueue_and_block(); }
} }
}
P/V 的非负计数语义:资源直接交给等待者
以初值 S=1 为例,逐步查看 P 成功、P 阻塞、V 直接唤醒一个等待者以及无等待者时计数加一;代码行同步高亮。
这里 S 只表示未被预留的资源数。等待队列非空时,V 不把 S 加到 1 再让所有人竞争,而是把本次资源直接交给一个等待者。
- 空
- 空
01if (S > 0) { S--; return; }02enqueue_and_block(S.wait_queue);01if (!S.wait_queue.empty()) wake_one();02else S++;初始:一个可用资源S=1,两个线程都可能申请资源。
实现原理
下面不展开底层细节,只从概念上说明 信号量 的工作流程。一个信号量本质上维护两样东西:
- 计数值:表示当前可用资源数量(采用非负计数语义时)。
- 等待队列:保存因资源不足而阻塞在该信号量上的线程。
在信号量上执行 P 操作和 V 操作会涉及线程状态变化:
- P 操作成功:线程继续运行,信号量不另设“运行队列”;线程仍由操作系统的运行/就绪队列管理。
- P 操作失败:线程原子地加入该信号量的等待队列并阻塞,避免“检查为 0 后、入队前”丢失唤醒。
- V 操作:若等待队列非空则唤醒一个等待者,使其进入就绪态;执行 V 的线程是否继续运行由调度方式决定。
采用本节表格中的 非负计数语义 时,线程执行 P 操作发现 (S=0) 就会阻塞,并原子地加入该信号量的等待队列;(S) 本身不会变为负数。若题目采用上文所述 Dijkstra 记法,则 P 先令 (S\leftarrow S-1),并在 (S<0) 时阻塞。两种表示必须分别判断,不能写成同一实现中“0 或负数都阻塞”。
当其他线程执行 一次 V 操作 时,若等待队列非空,就选择 一个 等待线程唤醒,并把这一次归还的资源直接交给它;若队列为空,才把非负计数值加 1。要唤醒多个等待者需要多次 V 操作或另行定义的批量接口,普通 V 不能一次唤醒多个线程。
信号量应用
信号量比较灵活,可以替代 锁 的功能,也能实现更加复杂的同步操作,这节主要谈论 信号量 最常见的几种用法。
实现锁
当 信号量 的初始值为 1 时,可以把 信号量 当作 锁 使用:
- 执行
P(sem)相当于lock(&mutex)。 - 执行
V(sem)相当于unlock(&mutex)。
semaphore S = 1;
P1() {
P(S);
// critical section
V(S);
}
P2() {
P(S);
// critical section
V(S);
}
实现简单同步
利用 信号量 可以实现线程间的 同步,保证不同线程间某些操作的 顺序关系。 这种简单同步用 条件变量 也可以实现,不过用 信号量 更为简单。
比如下述的代码,可以保证执行完 P1 中的 code1 之后,才会执行 P2 中的 code2:
semaphore S = 0; // 将信号量的初始值设置为 0
P1() {
code1; // 先执行 code1
V(S); // 告诉线程 P2,code1 已经完成
...
}
P2() {
...
P(S); // 检查 code1 是否完成
code2; // 检查无误,运行 code2
}
实现这种 P1 → P2 的同步关系,需要将 信号量 的初始值设置为 0,在 P1 的 code1 之后调用 V(S),并在 P2 的 code2 之前调用 P(S)。若 P2 先运行,它的 P(S) 会一直阻塞到 P1 执行 V(S);若 P1 先发出通知,计数会保留为 1,之后 P2 可直接通过。
实现前驱关系
线程的 前驱关系 是指在多线程并发执行的环境中,某些线程的执行必须依赖于其他线程的完成。 这种依赖关系可以通过一个 有向无环图(DAG)来描述,DAG 中的边表示了线程执行的 依赖关系。
如下图所示:S2 和 S3 必须在 S1 执行完之后才能执行,S4 和 S5 必须在 S2 执行完后才能执行,S6 必须在 S3、S4、S5 执行完之后才能执行。
两个线程之间的 前驱关系 在 DAG 中 表示为一条边,我们可以用一个 信号量 来表示这条边,将其初始值设置为 0,并使用上文的简单同步方法实现这种单向同步。
semaphore a1 = a2 = b1 = b2 = c = d = e = 0;
S1() { S2() {
...; P(a1);
V(a1); V(a2); ...
} V(b1); V(b2);
}
S3() { S4() {
P(a2); P(b1);
V(e); V(c);
} }
S5() { S6() {
P(b2); P(c); P(d); P(e);
V(d); }
}
下面依次演示“初值为 1 的信号量实现互斥”“初值为 0 的单向同步”和该前驱 DAG 的关键信号传递。代码高亮只显示当前原子操作;线程被 V 唤醒后仍会先进入就绪态,何时真正运行由调度器决定。
信号量应用:互斥、单向同步与前驱关系
切换三个场景,查看初值为 1 或 0 的信号量如何分别实现临界区互斥、code1→code2 的顺序和 DAG 前驱边。
P(S) 预留唯一资源,V(S) 归还它;等待者不能进入临界区。
- 空
- 空
01P(S);02critical_section();03V(S);01P(S);02critical_section();03V(S);初始 S=1一份许可尚未被任何线程预留。
管程
管程(Monitor)本质上是一种 对互斥 + 条件同步的高级封装机制,通过结构化编程方式将“对共享资源的访问”与“同步控制”统一封装成一个模块,提升了程序的安全性和可读性。
补充
🔍 为什么有了信号量/条件变量,还需要管程?
锁 和 信号量 是更低级的同步原语。尽管它们非常有用并且广泛应用,但在某些情况下直接使用它们可能导致代码复杂且难以理解。另外,使用这些低级原语可能会增加 死锁、饥饿 或其他同步问题的风险。
管程的设计是为了将这些低级的细节隐藏起来,并提供一个更高级、更抽象的接口,使得程序员可以更容易地编写 正确、安全 的并发代码。
管程的 核心思想 是确保在任意时刻,最多只有一个线程 能够执行管程内部的代码,从而实现对 共享资源 的自动 互斥。当其他线程尝试进入管程时,会被 阻塞,直到当前线程退出。
管程的 基本组成:
- 共享数据:管程封装了需要被多个 线程 共享和访问的数据。
- 方法:定义了如何操作 共享数据 的函数或方法。这些方法是唯一可以访问和修改 共享数据 的方式。
- 条件变量:用于控制 线程 的执行顺序,让 线程 在某些条件下等待,或通知等待的 线程 继续执行。
举个实际例子说明什么是管程:比如可以用管程封装本地的生产者—消费者问题,把共享缓冲区、互斥锁和“非空/未满”条件变量都收进同一个模块。管程本质上是对 OS 底层同步接口的结构化封装,为程序员提供更容易正确使用的访问入口。