信号量:并发编程的交通信号灯

说实话,信号量这个概念,我第一次接触的时候觉得挺玄乎的。什么叫「信号量」?听着像物理课上的东西。后来在项目中真正用上了,才明白——它其实就是并发编程里的交通信号灯。

红灯停,绿灯行。信号量干的事,本质上就是这个。

信号量的核心思想

信号量是一个整数,它有两个基本操作:P(等待)V(释放)。有些教材也叫 wait 和 post。我个人习惯用 P/V,因为写起来短。

  • P 操作:信号量减1。如果结果小于0,线程阻塞。
  • V 操作:信号量加1。如果结果大于等于0,唤醒一个阻塞的线程。

你想想看,这像不像停车场门口的计数器?

停车场有10个车位。信号量初始值就是10。每进来一辆车,P操作,计数器减1。每出去一辆车,V操作,计数器加1。车位满了,后来的车就得排队等着。

关键点:信号量的值永远不能为负数。如果为负数,它的绝对值就是当前正在等待的线程数。

POSIX 信号量

在 Linux 下,我们最常用的是 POSIX 信号量。它有两种:命名信号量匿名信号量

类型适用场景创建方式
命名信号量多进程间同步sem_open()
匿名信号量多线程间同步sem_init()

我一般在多线程程序里用匿名信号量。多进程通信?说实话,我更倾向用消息队列或者共享内存+互斥锁。信号量在多进程场景下用起来有点别扭,个人看法。

匿名信号量的基本用法

#include <semaphore.h>

sem_t sem;

// 初始化,第二个参数0表示线程间共享
sem_init(&sem, 0, 1);

// P操作
sem_wait(&sem);

// 临界区代码
// ...

// V操作
sem_post(&sem);

// 销毁
sem_destroy(&sem);

嗯,这里要注意:sem_init 的第二个参数,如果是0,表示线程间共享;如果是非0,表示进程间共享。我见过有人把这个参数搞反了,结果程序跑起来各种诡异。

信号量解决同步问题

信号量最经典的应用场景,就是生产者-消费者问题。说白了,就是有人生产数据,有人消费数据,中间有个缓冲区。

我曾经在一个日志系统里用过这个模型。主线程产生日志,后台线程负责写入磁盘。如果不用信号量,要么丢日志,要么写重复。

生产者-消费者代码示例

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>

#define BUFFER_SIZE 5

int buffer[BUFFER_SIZE];
int in = 0, out = 0;

sem_t empty;  // 空槽位数量
sem_t full;   // 满槽位数量
sem_t mutex;  // 互斥锁

void* producer(void* arg) {
    int item = 0;
    while (1) {
        item++;
        
        sem_wait(&empty);  // 等待空槽位
        sem_wait(&mutex);  // 进入临界区
        
        buffer[in] = item;
        printf("生产: %d 到位置 %d\n", item, in);
        in = (in + 1) % BUFFER_SIZE;
        
        sem_post(&mutex);  // 离开临界区
        sem_post(&full);   // 增加满槽位
        
        sleep(1);
    }
    return NULL;
}

void* consumer(void* arg) {
    int item;
    while (1) {
        sem_wait(&full);   // 等待满槽位
        sem_wait(&mutex);  // 进入临界区
        
        item = buffer[out];
        printf("消费: %d 从位置 %d\n", item, out);
        out = (out + 1) % BUFFER_SIZE;
        
        sem_post(&mutex);  // 离开临界区
        sem_post(&empty);  // 增加空槽位
        
        sleep(2);
    }
    return NULL;
}

int main() {
    pthread_t prod, cons;
    
    sem_init(&empty, 0, BUFFER_SIZE);
    sem_init(&full, 0, 0);
    sem_init(&mutex, 0, 1);
    
    pthread_create(&prod, NULL, producer, NULL);
    pthread_create(&cons, NULL, consumer, NULL);
    
    pthread_join(prod, NULL);
    pthread_join(cons, NULL);
    
    sem_destroy(&empty);
    sem_destroy(&full);
    sem_destroy(&mutex);
    
    return 0;
}

小技巧:这里用了三个信号量。empty 和 full 控制缓冲区数量,mutex 保证对缓冲区的互斥访问。三个信号量各司其职,逻辑非常清晰。

信号量 vs 互斥锁

很多人问我:信号量和互斥锁到底有什么区别?

我打个比方你就明白了:

  • 互斥锁:像一扇只能一个人通过的门。一次只能进一个人。
  • 信号量:像一个有N个入口的停车场。可以同时进N个人。

互斥锁是信号量的一种特例——当信号量的初始值为1时,它就是互斥锁。这种信号量也叫二元信号量

避坑指南:我曾经在项目里用信号量当互斥锁用,结果出了个死锁。原因是信号量可以被不同线程释放,而互斥锁要求加锁和解锁必须在同一个线程。如果你需要严格的「谁加锁谁解锁」语义,请用互斥锁,别用信号量。

信号量的知识体系

下面这张图,是我梳理的信号量知识结构。你看一眼,心里就有数了。

信号量知识体系 信号量 P操作 (wait) V操作 (post) 命名信号量 (sem_open) 匿名信号量 (sem_init) 生产者-消费者 读者-写者问题 哲学家就餐 vs 互斥锁 (二元信号量)

信号量的陷阱

用信号量,有几个坑你得知道:

  1. 忘记初始化:信号量不初始化,默认值是0。如果你期望的是1,那第一个P操作就会阻塞。
  2. P和V不配对:多了一个P,线程可能永远阻塞。多了一个V,信号量值可能失控。
  3. 信号量值溢出:POSIX信号量的值有上限,一般是INT_MAX。如果你疯狂V操作,可能会溢出。

我曾经在一个网络服务器项目里,因为信号量P/V不配对,导致一个工作线程永远拿不到信号量。那个bug查了我整整两天。最后发现是异常处理路径里少了一个sem_post。从那以后,我写信号量代码都会格外小心异常分支。

信号量的性能考量

信号量涉及系统调用,性能比用户态的原子操作要慢。如果你只是保护一个简单的整数操作,用原子变量就够了。

我个人的经验是:

  • 保护短临界区(几条指令):用原子操作
  • 保护中等临界区(几十条指令):用互斥锁
  • 需要控制资源数量:用信号量

说白了,工具没有好坏,关键看场景。

信号量是个好东西,但别滥用。我见过有人用信号量实现线程池,结果性能还不如直接用互斥锁。为什么?因为信号量的P/V操作比互斥锁的lock/unlock要重一些。

好了,信号量的内容就这些。记住一句话:信号量是计数器,不是锁。理解了这个,你就掌握了它的精髓。


公众号:蓝海资料掘金营,微信deep3321