经典同步问题
同步问题设计
同步问题设计题通常要求根据题目描述,用 信号量 + P/V 操作 写出若干进程的同步关系。它不是背某一道固定代码,而是把题目中的 互斥关系 和 前驱关系 翻译成信号量。
解题时可以按下面的模板展开,整体步骤与三类信号量的对应关系如下:
找进程和循环结构
先把题目中的参与者抽象成进程,例如生产者、消费者、读者、写者、顾客、服务员等。
如果题目描述的是反复发生的行为,一般写成:
process_i() {
while (1) {
...
}
}
如果题目描述的是一次性流程,则按题目要求写出执行顺序即可,不一定要套 while (1)。
找互斥关系
只要多个进程会访问同一个共享资源,并且同一时刻只能有一个进程访问,就需要设置 互斥信号量。
常见写法是:
semaphore mutex = 1;
P(mutex);
... // 访问临界资源
V(mutex);
这里 mutex 初值为 1,表示资源一开始可用。进入临界区前执行 P(mutex),离开临界区后执行 V(mutex)。
互斥信号量只保护真正的临界区,不要把无关操作也包进去,否则容易导致并发度下降,甚至造成死锁。
找同步关系
如果一个操作必须在另一个操作完成之后才能执行,就需要设置 同步信号量。
常见写法是:
semaphore s = 0;
process_A() {
... // A 先执行的操作
V(s); // 通知 B 可以继续
}
process_B() {
P(s); // 等待 A 完成
... // B 后执行的操作
}
同步信号量的初值通常是 0,表示条件一开始不满足。先发生的进程在完成关键操作后执行 V(s),后发生的进程在执行前先 P(s) 等待。
找资源数量约束
如果题目中出现缓冲区、座位、车位、窗口、设备等有限数量资源,通常使用 计数信号量 表示资源个数。
例如缓冲区大小为 n 的生产者消费者问题中:
semaphore empty = n; // 空缓冲区数量
semaphore full = 0; // 已占用缓冲区数量
semaphore mutex = 1; // 缓冲区互斥访问
生产者需要先申请空缓冲区,再进入临界区放入数据:
P(empty);
P(mutex);
... // 放入数据
V(mutex);
V(full);
消费者需要先申请已占用缓冲区,再进入临界区取出数据:
P(full);
P(mutex);
... // 取出数据
V(mutex);
V(empty);
这种题最容易出错的地方是 P 操作顺序:一般先申请 资源型信号量,再申请 互斥信号量,避免一个进程占着互斥锁等待资源,导致其他进程无法进入临界区改变资源状态。
整理 P/V 位置
写完代码后,可以按下面的规则检查:
- 互斥成对出现:
P(mutex)和V(mutex)是否包住了同一个临界区。 - 同步方向正确:谁等待就写
P(s),谁唤醒就写V(s)。 - 资源数守恒:申请资源用
P,释放资源用V,资源总数不能凭空增加或减少。 - 避免死锁:不要在持有一个互斥锁时,长时间等待另一个可能由别人释放的条件。
- 不要遗漏并发角色:题目中每一类进程都要有对应伪代码。
常见信号量类型
| 类型 | 初值 | 作用 | 常见命名 |
|---|---|---|---|
| 互斥信号量 | 1 | 保护临界资源 | mutex、rw、fork[i] |
| 同步信号量 | 0 | 表示某个前驱事件尚未发生 | s1、done、ready |
| 资源信号量 | 资源数量 | 表示还有多少个资源可用 | empty、full、seat |
| 计数保护信号量 | 1 | 保护计数变量修改 | count_mutex、read_mutex |
同步问题设计题的核心不是代码语法,而是把自然语言翻译成信号量:互斥关系用初值为 1 的信号量,前驱关系用初值为 0 的信号量,有限资源用初值为资源数量的计数信号量。
生产者消费者问题
生产者消费者问题是并发编程中的经典问题,涉及到两种线程 —— 生产者 和 消费者,它们共享一个固定大小的缓冲区或存储区。
- 生产者的任务是生成数据并将其 放入缓冲区。
- 消费者的任务是 从缓冲区中取出 并消费这些数据。
关键的挑战在于确保生产者不会在缓冲区满时添加数据,同时确保消费者不会在缓冲区空时尝试消费数据。
semaphore mutex = 1; // 临界区互斥信号量
semaphore empty = n; // 空闲缓冲区数量
semaphore full = 0; // 忙缓冲区数量
producer() {
while (1) {
P(empty) // 等待一个空位置
P(mutex) // 进入临界区前先获取mutex
.... // 将数据项添加到缓冲区
V(mutex) // 离开临界区,释放mutex
V(full) // 增加一个数据项的计数
}
}
consumer() {
while (1) {
P(full) // 等待一个数据项
P(mutex) // 进入临界区前先获取mutex
... // 从缓冲区取出数据项并消费
V(mutex) // 离开临界区,释放mutex
V(empty) // 增加一个空位置的计数
}
}
生产者消费者过程可以参考以下流程图理解:
读者 - 写者问题
读者写者问题 是另一个经典的并发编程问题,涉及到对 共享数据 或 资源 的访问,这些 资源 可以被 多个读者 同时读取,但只能被 一个写者 写入,而且当 写者 正在写入数据时,没有其他 读者 或 写者 可以访问该 资源。
这个问题的挑战在于两点:
- 允许多个读者同时读取资源。
- 确保当有一个写者访问资源时,没有其他读者或写者可以同时访问。
int read_count = 0;
semaphore wrt = 1;
semphore mutex = 1;
reader() {
while (1) {
P(mutex) // 获取互斥访问权,以修改 read_count
read_count += 1
if (read_count == 1) { // 如果这是第一个读者,需要锁定资源,防止写者写入
P(wrt)
}
V(mutex) // 释放互斥访问权
... // 读取资源
P(mutex) // 获取互斥访问权,以修改 read_count
read_count -= 1
if (read_count == 0) { // 如果没有读者在读取,释放资源,允许写者写入
V(wrt)
}
V(mutex) // 释放互斥访问权
}
}
writer() {
while (1) {
P(wrt) // 获取资源的互斥访问权
... // 写入资源
V(wrt) // 释放资源的互斥访问权
}
}int read_count = 0;
int write_count = 0;
semaphore wrt = 1;
semaphore mutex = 1;
semaphore write_mutex = 1;
reader() {
while (1) {
P(write_mutex) // 在读取之前,确保没有写者正在等待或写入
P(mutex) // 获取互斥访问权,以修改 read_count
read_count += 1
if (read_count == 1) {
P(wrt)
}
V(mutex)
V(write_mutex)
... // 读取资源
P(mutex) // 获取互斥访问权,以修改read_count
read_count -= 1
if (read_count == 0) {
V(wrt)
}
V(mutex)
}
}
writer() {
while (1) {
P(write_mutex) // 获取互斥访问权,以修改 write_count
write_count += 1
if (write_count == 1) { // 如果这是第一个写者,锁定资源,防止新的读者读取
P(wrt)
}
V(write_mutex)
... // 写入资源
P(write_mutex) // 获取互斥访问权,以修改write_count
write_count -= 1
if (write_count == 0) { // 如果没有其他写者在等待或写入,释放资源
V(wrt)
}
V(write_mutex)
}
}int read_count = 0;
int write_count = 0;
semaphore wrt = 1;
semaphore mutex = 1;
semaphore queue = 1; // 新增队列信号量,以确保公平性
reader() {
while (1) {
P(queue); // 进入队列
P(mutex); // 获取互斥访问权,以修改 read_count
read_count += 1;
if (read_count == 1) {
P(wrt); // 如果是第一个读者,锁定资源
}
V(mutex);
V(queue); // 离开队列
... // 读取资源
P(mutex); // 获取互斥访问权,以修改 read_count
read_count -= 1;
if (read_count == 0) {
V(wrt); // 如果是最后一个读者,释放资源
}
V(mutex);
}
}
writer() {
while (1) {
P(queue); // 进入队列
P(wrt); // 锁定资源
... // 写入资源
V(wrt); // 释放资源
V(queue); // 离开队列
}
}试题中如果考察读者写者问题的话,一般考察的还是读者优先,读者优先的同步实现方案可以通过以下流程图进行理解:
哲学家就餐问题
-
假设有五位 哲学家 坐在一个 圆桌 周围,每两位哲学家之间有一把 叉子。哲学家的生活由 思考和吃饭 两种活动组成。为了吃饭,一个哲学家需要两把叉子——左边和右边的一把。问题在于,如何设计一个算法使得哲学家们可以正常就餐,而不会因为竞争叉子而导致死锁或饥饿。
哲学家就餐问题有多种解法,这里只提供一种 最直观的解法,对于包含 N 位哲学家的问题:- 前 N-1 个哲学家先拿起 左边的叉子,再拿起 右边的叉子 - 最后一个 哲学家先拿起 右边的叉子,再拿起 左边的叉子
semaphore fork[5] = {1, 1, 1, 1, 1}; // 五个叉子,初始都是可用的
void philosopher(int i) {
if (i < 5) {
// 对于前面的哲学家,先左后右
first = i;
second = (i + 1) % 5;
} else {
// 对于最后一个哲学家,先右后左
first = (i + 1) % 5;
second = i;
}
while (1) {
think();
P(fork[first]);
P(fork[second]);
eat();
V(fork[first]);
V(fork[second]);
}
}
哲学家就餐过程可以参考以下流程图理解: