1. 简介
在并发编程中,信号量(Semaphore) 是一种非常经典且强大的进程同步机制。本文将深入解析信号量的基本概念、操作原理、实现方式以及常见应用场景。
我们将探讨信号量的两个核心操作(wait 和 signal)、两种类型(二值信号量与计数信号量),并通过多个并发编程中的经典问题(如生产者-消费者、读者-写者、哲学家进餐等)展示其实际用法。
2. 什么是信号量?
信号量本质上是一个整型变量,用于在多个进程或线程之间共享,其主要作用是控制对共享资源的访问,实现进程/线程间的同步。
信号量的初始值通常取决于可用资源的数量。例如,如果有 5 个相同的资源,那么信号量的初始值就设为 5。
2.1. 信号量的操作
信号量有两个原子操作:
wait(S)
:尝试获取资源。若 S > 0,则 S 减 1 并继续执行;否则进程进入等待状态。signal(S)
:释放资源。若有等待进程,则唤醒其中一个;否则 S 加 1。
// wait 操作伪代码
function wait(S) {
if (S > 0) {
S--;
} else {
sleep(); // 进入等待状态
}
}
// signal 操作伪代码
function signal(S) {
if (有等待进程) {
唤醒一个等待进程;
} else {
S++;
}
}
⚠️ 注意: 这些操作必须是原子的,即不可中断的,否则会破坏同步机制。
2.2. 信号量的类型
二值信号量(Binary Semaphore)
只能取 0 或 1,常用于实现互斥访问(如临界区问题)。
⚠️ 注意: 二值信号量 ≠ 互斥锁(Mutex),虽然功能相似,但语义不同。计数信号量(Counting Semaphore)
可取任意非负整数值,用于控制多个资源的访问,如线程池、连接池等。
3. 信号量的实现
信号量一般由操作系统内核实现。为了防止忙等待(busy waiting),内核通常维护:
- 一个整型变量表示当前信号量值
- 一个等待队列,保存因资源不足而被阻塞的进程
当进程调用 wait()
时,若无法获得资源,就会被加入等待队列并进入休眠状态;当其他进程调用 signal()
时,会从队列中唤醒一个进程。
✅ Java 中的实现:
Java 提供了 java.util.concurrent.Semaphore
类,可直接用于多线程程序中。
4. 进程同步问题与信号量应用
4.1. 死锁(Deadlock)预防
死锁是指多个进程互相等待对方释放资源而陷入僵局。
常见死锁场景如下:
- 进程 A 占用资源 S,等待资源 Q;
- 进程 B 占用资源 Q,等待资源 S。
解决方案:
- 统一资源请求顺序
- 设置超时机制
- 使用死锁检测算法
4.2. 饥饿(Starvation)避免
饥饿是指某个进程因始终无法获得资源而被无限期阻塞。
例如在哲学家进餐问题中,若资源分配策略不当,可能导致某些哲学家永远吃不到饭。
解决方案:
- 公平调度算法(如 FIFO)
- 引入优先级机制
- 控制并发访问数量
4.3. 优先级反转(Priority Inversion)
优先级反转是指低优先级任务持有高优先级任务所需的资源,导致高优先级任务被阻塞。
解决方案:
- 优先级继承(Priority Inheritance)
- 优先级天花板(Priority Ceiling)
5. 信号量实际应用场景
5.1. 临界区问题(Critical Section)
使用二值信号量控制对共享资源的访问,确保同一时刻只有一个线程进入临界区。
Semaphore mutex = new Semaphore(1);
void criticalSection() {
try {
mutex.acquire();
// 临界区代码
} finally {
mutex.release();
}
}
5.2. 顺序执行控制(In-Order Execution)
确保某段代码 S2 在 S1 执行完成后才执行。
Semaphore x = new Semaphore(0);
// Process 0
S1();
x.release();
// Process 1
x.acquire();
S2();
5.3. 生产者-消费者问题(Producer-Consumer)
使用计数信号量控制缓冲区的读写。
Semaphore full = new Semaphore(0);
Semaphore empty = new Semaphore(bufferSize);
// Producer
empty.acquire();
produceItem();
full.release();
// Consumer
full.acquire();
consumeItem();
empty.release();
5.4. 有界缓冲区的生产者-消费者问题(Bounded Buffer)
引入三个信号量:
full
:表示已占用缓冲区数量empty
:表示空闲缓冲区数量mutex
:用于互斥访问缓冲区
Semaphore mutex = new Semaphore(1);
// Producer
empty.acquire();
mutex.acquire();
putItem();
mutex.release();
full.release();
// Consumer
full.acquire();
mutex.acquire();
getItem();
mutex.release();
empty.release();
5.5. 读者-写者问题(Readers-Writers Problem)
允许多个读者同时读取数据,但只允许一个写者写入。
int readCount = 0;
Semaphore mutex = new Semaphore(1);
Semaphore writeLock = new Semaphore(1);
// Reader
mutex.acquire();
readCount++;
if (readCount == 1) {
writeLock.acquire();
}
mutex.release();
readData();
mutex.acquire();
readCount--;
if (readCount == 0) {
writeLock.release();
}
mutex.release();
// Writer
writeLock.acquire();
writeData();
writeLock.release();
5.6. 哲学家进餐问题(Dining Philosophers)
使用数组形式的信号量模拟筷子资源。
Semaphore[] chopsticks = new Semaphore[5];
for (int i = 0; i < 5; i++) {
chopsticks[i] = new Semaphore(1);
}
// 哲学家 i
chopsticks[i].acquire();
chopsticks[(i + 1) % 5].acquire();
eat();
chopsticks[i].release();
chopsticks[(i + 1) % 5].release();
⚠️ 注意: 上述实现存在死锁风险。可使用如下策略避免:
- 限制最多 4 个哲学家同时尝试进餐
- 随机化请求顺序
- 引入服务员机制
6. 总结
信号量是一种非常强大的并发控制工具,适用于多种同步场景。
本文通过定义信号量的基本操作、类型、实现机制,并结合多个经典并发问题,展示了信号量的实际应用方法。掌握信号量的使用,是编写高效、安全并发程序的重要基础。
✅ 总结要点:
问题 | 使用信号量类型 | 说明 |
---|---|---|
临界区 | 二值信号量 | 控制资源互斥访问 |
顺序执行 | 二值信号量 | 控制代码执行顺序 |
生产者-消费者 | 计数信号量 | 控制缓冲区读写 |
读者-写者 | 二值 + 计数 | 控制读写并发 |
哲学家进餐 | 多个二值信号量 | 模拟资源竞争 |
📌 延伸阅读: