7. 线程同步之信号量:信号量(sem_t)概念、POSIX信号量函数(sem_init/sem_wait/sem_post)、信号量与互斥锁的对比

说到线程同步,很多人第一反应就是互斥锁。但我在实际项目中遇到过不少场景,光靠互斥锁根本搞不定。比如生产者-消费者模型,你需要控制“有多少个资源可用”,而不是简单的“锁住/解锁”。这时候,信号量就派上用场了。

信号量,说白了就是一个计数器。它不像互斥锁那样非黑即白,而是允许你精确控制同时访问某个资源的线程数量。嗯,这个“数量”就是信号量的核心。

7.1 信号量(sem_t)概念

信号量本质上是一个整型变量,支持两种原子操作:P操作(等待)V操作(释放)。P操作让信号量减1,如果减完后小于0,线程就阻塞;V操作让信号量加1,如果加完后大于等于0,就唤醒一个等待的线程。

你想想看,这跟互斥锁有什么区别?互斥锁只有0和1两种状态,而信号量可以是任意非负整数。我个人的习惯是:需要控制资源数量时用信号量,只需要互斥访问时用互斥锁

信号量的两种类型:

  • 二元信号量:值只有0和1,行为上类似互斥锁
  • 计数信号量:值可以是任意非负整数,用于控制多个资源

我在项目中遇到过最典型的场景:一个线程池,最多允许10个线程同时工作。用互斥锁你只能保证“一个人进去,其他人等着”,但用信号量,你可以设置初始值为10,每进来一个线程就P一下,出去就V一下。简洁又高效。

7.2 POSIX信号量函数

POSIX标准提供了一套信号量操作函数,都在 <semaphore.h> 头文件里。常用的就三个:sem_initsem_waitsem_post。我刚开始用的时候也觉得函数不多,但真正用起来,这三个函数已经能覆盖90%的场景了。

7.2.1 sem_init — 初始化信号量

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);

参数说明:

  • sem:指向信号量对象的指针
  • pshared:0表示线程间共享,非0表示进程间共享
  • value:信号量的初始值

个人经验:大多数情况下 pshared 传0就行。进程间共享信号量需要把信号量放在共享内存里,坑比较多,我建议新手先别碰。

7.2.2 sem_wait — P操作(等待)

int sem_wait(sem_t *sem);

这个函数会尝试将信号量减1。如果减完后信号量大于等于0,函数立即返回;如果小于0,线程阻塞等待。

注意:sem_wait 是阻塞的。如果你不想让线程一直等,可以用 sem_trywait(非阻塞)或 sem_timedwait(带超时)。我曾经在一个网络服务里用 sem_wait 导致线程卡死,后来换成 sem_timedwait 才解决问题。

7.2.3 sem_post — V操作(释放)

int sem_post(sem_t *sem);

将信号量加1。如果有线程在等待,会唤醒其中一个。

来看一个完整的生产者-消费者示例:

#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;   // 已填充槽位数量
pthread_mutex_t mutex;

void* producer(void* arg) {
    int item = 0;
    while (1) {
        item++;
        sem_wait(&empty);          // 申请一个空槽位
        pthread_mutex_lock(&mutex); // 互斥访问缓冲区
        
        buffer[in] = item;
        printf("生产者生产: %d, 位置: %d\n", item, in);
        in = (in + 1) % BUFFER_SIZE;
        
        pthread_mutex_unlock(&mutex);
        sem_post(&full);           // 增加一个已填充槽位
        sleep(1);
    }
    return NULL;
}

void* consumer(void* arg) {
    int item;
    while (1) {
        sem_wait(&full);           // 申请一个已填充槽位
        pthread_mutex_lock(&mutex); // 互斥访问缓冲区
        
        item = buffer[out];
        printf("消费者消费: %d, 位置: %d\n", item, out);
        out = (out + 1) % BUFFER_SIZE;
        
        pthread_mutex_unlock(&mutex);
        sem_post(&empty);          // 增加一个空槽位
        sleep(2);
    }
    return NULL;
}

int main() {
    pthread_t prod, cons;
    
    sem_init(&empty, 0, BUFFER_SIZE);  // 初始有5个空槽位
    sem_init(&full, 0, 0);             // 初始没有已填充槽位
    pthread_mutex_init(&mutex, NULL);
    
    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);
    pthread_mutex_destroy(&mutex);
    
    return 0;
}

这个例子我用了很多次。你看,empty 信号量控制空槽位数量,full 控制已填充槽位数量。生产者生产前先申请空槽位,消费者消费前先申请已填充槽位。两个信号量配合使用,完美解决了“缓冲区满时生产者等待”和“缓冲区空时消费者等待”的问题。

核心逻辑:信号量负责“资源计数”,互斥锁负责“互斥访问”。两者各司其职,缺一不可。

7.3 信号量与互斥锁的对比

很多初学者会问:信号量和互斥锁到底有什么区别?我能不能只用一种?

我的回答是:可以,但不建议。互斥锁是“锁”,信号量是“计数器”。它们解决的问题不同。

对比维度 互斥锁 (mutex) 信号量 (semaphore)
本质 锁机制,只有0和1 计数器,可以是任意非负整数
用途 保护临界区,防止多个线程同时访问 控制资源数量,实现生产者-消费者等模式
谁可以解锁 只有加锁的线程可以解锁 任何线程都可以执行 sem_post
性能 通常更快(实现更简单) 稍慢(需要维护计数器)
典型场景 保护共享变量、链表、哈希表等 线程池、连接池、生产者-消费者

我曾经在一个项目中,看到有人用互斥锁来实现生产者-消费者。他让消费者不断轮询缓冲区,检查是否有数据。结果CPU占用率飙到100%,而且响应延迟还高。后来换成信号量,消费者在 sem_wait 上阻塞,CPU占用率直接降到接近0。这就是选错工具的代价。

我的建议:

  • 如果只是保护共享数据不被同时修改 → 用互斥锁
  • 如果需要控制同时访问的线程数量 → 用信号量
  • 如果既要保护数据又要控制数量 → 两者结合使用

嗯,这里要注意一点:信号量不能替代互斥锁。虽然二元信号量看起来跟互斥锁很像,但互斥锁有“所有权”的概念——只有加锁的线程才能解锁。而信号量没有这个限制,任何线程都可以执行 sem_post。这就可能导致一些隐蔽的bug。

举个例子,线程A执行 sem_wait 后,线程B不小心执行了 sem_post,信号量值就乱了。而互斥锁就不会有这个问题,因为只有线程A能解锁。

避坑指南:我曾经在一个多线程日志系统里,用二元信号量代替互斥锁保护日志缓冲区。结果某个线程异常退出前没来得及释放信号量,导致其他线程全部卡死。后来换成互斥锁,配合 pthread_mutex_timedlock 才解决。所以,保护临界区请用互斥锁,别图省事用信号量

最后,用一张图来总结信号量的核心逻辑:

信号量核心逻辑 信号量 (sem_t) 初始值 = N (资源数量) P操作 (sem_wait) 值减1,若<0则阻塞 V操作 (sem_post) 值加1,唤醒等待线程 典型应用场景 生产者-消费者模型 线程池/连接池 多资源访问控制 信号量 = 计数器 + 等待队列

信号量这东西,说白了就是“资源计数器 + 等待队列”。你设置初始值N,就表示有N个资源可用。每次P操作拿走一个资源,V操作归还一个资源。资源不够时,线程自动排队等待。简洁、高效、好用。

好了,信号量的内容就这些。记住一句话:互斥锁管“能不能进”,信号量管“能进几个”。两者配合使用,才能写出健壮的多线程程序。


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