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. 总结

信号量是一种非常强大的并发控制工具,适用于多种同步场景。

本文通过定义信号量的基本操作、类型、实现机制,并结合多个经典并发问题,展示了信号量的实际应用方法。掌握信号量的使用,是编写高效、安全并发程序的重要基础。

总结要点:

问题 使用信号量类型 说明
临界区 二值信号量 控制资源互斥访问
顺序执行 二值信号量 控制代码执行顺序
生产者-消费者 计数信号量 控制缓冲区读写
读者-写者 二值 + 计数 控制读写并发
哲学家进餐 多个二值信号量 模拟资源竞争

📌 延伸阅读:


原始标题:What Is a Semaphore?