一、POSIX信号量:从概念到实战

大家好,我是你们的老朋友。今天我们来聊聊POSIX信号量。

信号量这东西,说白了就是一个计数器。它用来控制多个进程或线程对共享资源的访问。你想想看,如果两个进程同时往一个文件里写数据,那不乱套了吗?信号量就是用来解决这种问题的。

我个人习惯把信号量想象成一个「令牌箱」。进程想要干活,得先拿令牌。没令牌?那就等着。干完活再把令牌还回去。就这么简单。

1.1 POSIX信号量与System V信号量的区别

很多初学者会问:既然有System V信号量了,为什么还要搞POSIX信号量?

嗯,这里要注意。System V信号量是Unix早期就有的,功能很强大,但接口设计得比较「古老」。我当年刚接触System V信号量时,被那个semctl的第三个参数搞晕过好几次。

POSIX信号量是后来标准化的,设计上更简洁。我列个表给你看:

对比项 POSIX信号量 System V信号量
命名方式 以/somename形式命名 用key_t键值标识
创建/打开 sem_open() semget()
初始化 sem_init()(无名) semctl() SETVAL
等待操作 sem_wait() semop() 用-1
发布操作 sem_post() semop() 用+1
销毁 sem_destroy() / sem_unlink() semctl() IPC_RMID
内核持久性 随内核持续(命名) 随内核持续
接口复杂度 简单直观 较复杂

你看,POSIX信号量的接口名字就很直白:sem_wait就是等待,sem_post就是发布。而System V那边,一个semop函数要传结构体数组,说实话有点绕。

我在项目中遇到过这样一个场景:两个进程需要同步访问一块共享内存。用System V信号量,代码写了快50行。后来改成POSIX命名信号量,20行就搞定了。这就是差距。

1.2 命名信号量的创建:sem_open

命名信号量,顾名思义,就是有名字的信号量。这个名字在文件系统中可见,通常放在/dev/shm下。不同进程只要用同一个名字打开,就能共享这个信号量。

函数原型长这样:

#include <semaphore.h>
#include <fcntl.h>           // 为了 O_CREAT, O_EXCL 等

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);

参数说明:

  • name:必须以斜杠开头,比如/mysem
  • oflagO_CREAT表示创建,O_EXCL表示排他
  • mode:权限位,比如0666
  • value:信号量的初始值

举个例子:

#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <fcntl.h>

int main() {
    sem_t *sem;
    
    // 创建一个初始值为1的命名信号量
    sem = sem_open("/my_semaphore", O_CREAT | O_EXCL, 0666, 1);
    if (sem == SEM_FAILED) {
        perror("sem_open");
        exit(EXIT_FAILURE);
    }
    
    printf("信号量创建成功!\n");
    
    // ... 使用信号量 ...
    
    sem_close(sem);
    sem_unlink("/my_semaphore");
    
    return 0;
}
注意:如果信号量已经存在,再用O_CREAT | O_EXCL会失败。这时候应该只用O_CREAT,或者先sem_unlink再创建。

1.3 无名信号量的初始化:sem_init

无名信号量,也叫基于内存的信号量。它没有名字,只能用于线程间同步,或者有亲缘关系的进程间同步(比如父子进程共享内存)。

函数原型:

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数说明:

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

看个例子:

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

sem_t sem;

void *worker(void *arg) {
    int id = *(int *)arg;
    
    sem_wait(&sem);
    printf("线程%d进入临界区\n", id);
    sleep(1);
    printf("线程%d离开临界区\n", id);
    sem_post(&sem);
    
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;
    
    // 初始化无名信号量,初始值为1
    if (sem_init(&sem, 0, 1) != 0) {
        perror("sem_init");
        exit(EXIT_FAILURE);
    }
    
    pthread_create(&t1, NULL, worker, &id1);
    pthread_create(&t2, NULL, worker, &id2);
    
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    
    sem_destroy(&sem);
    return 0;
}

这里pshared传0,表示这个信号量只在当前进程的线程间使用。如果要在父子进程间用,就传非0值,并且信号量要放在共享内存中。

小技巧:我个人习惯在写多线程程序时优先用无名信号量。因为命名信号量需要管理名字、清理等,而无名信号量随用随销毁,省心不少。

1.4 信号量的等待与发布:sem_wait 和 sem_post

这两个函数是信号量的核心操作。

  • sem_wait:将信号量减1。如果信号量当前为0,则阻塞等待。
  • sem_post:将信号量加1。如果有进程在等待,则唤醒一个。

函数原型:

#include <semaphore.h>

int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);

两个函数都返回0表示成功,-1表示失败。

这里有个变种函数sem_trywait,它不会阻塞。如果信号量为0,直接返回-1并设置errno为EAGAIN。还有一个sem_timedwait,可以设置超时时间。

我曾经在项目中遇到过一个问题:一个生产者线程往缓冲区放数据,消费者线程取数据。如果缓冲区满了,生产者就得等。如果缓冲区空了,消费者就得等。用两个信号量完美解决:

#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <pthread.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\n", item);
        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\n", item);
        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);
    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;
}

这个例子很经典。两个信号量配合互斥锁,实现了生产者-消费者模型。你想想看,如果没有信号量,光用互斥锁,你怎么知道缓冲区有没有数据?轮询?那CPU就白烧了。

1.5 信号量的销毁:sem_destroy 和 sem_unlink

用完信号量要清理,这是好习惯。

  • sem_destroy:销毁无名信号量。调用后信号量对象不再可用。
  • sem_unlink:删除命名信号量的名字。注意,这不会立即销毁信号量,只是删除名字。已经打开的信号量还可以继续使用,直到最后一个sem_close

函数原型:

#include <semaphore.h>

int sem_destroy(sem_t *sem);
int sem_unlink(const char *name);

使用示例:

// 无名信号量销毁
sem_destroy(&sem);

// 命名信号量清理
sem_close(sem);       // 关闭当前进程的引用
sem_unlink("/mysem"); // 删除名字
警告:千万不要在还有线程等待信号量时调用sem_destroy。行为是未定义的。我曾经在调试时犯过这个错,结果程序莫名其妙崩溃,查了半天才发现是销毁时机不对。

1.6 POSIX信号量的应用场景

说了这么多,到底什么时候用信号量?我总结几个常见场景:

  1. 资源计数:比如限制同时访问数据库的连接数,初始值设为最大连接数。
  2. 生产者-消费者:上面那个例子就是典型。
  3. 进程同步:进程A做完某件事后,进程B才能继续。
  4. 实现锁:初始值为1的信号量,就是互斥锁。

但要注意,信号量不是万能的。如果你只是保护一个共享变量,用互斥锁就够了。信号量更适合「有资源计数需求」的场景。

1.7 知识体系总览

下面这张图总结了POSIX信号量的核心知识点:

POSIX信号量知识体系 命名信号量 无名信号量 sem_open() 创建/打开 sem_unlink() 删除名字 sem_init() 初始化 sem_destroy() 销毁 核心操作:sem_wait() 与 sem_post() 应用场景 资源计数 | 生产者-消费者 | 进程同步 | 实现锁

这张图把POSIX信号量分成了命名和无名两大类。左边是命名信号量的创建和删除,右边是无名信号量的初始化和销毁。中间是它们共用的核心操作——sem_waitsem_post。最下面是常见的应用场景。

核心要点:
  • 命名信号量用sem_open创建,用sem_unlink删除名字
  • 无名信号量用sem_init初始化,用sem_destroy销毁
  • sem_wait减1,sem_post加1
  • 信号量适合资源计数场景,纯互斥用互斥锁更简单

好了,POSIX信号量的核心内容就这些。说实话,信号量这东西,光看文档是学不会的。我建议你动手写几个小例子,比如生产者-消费者,或者两个进程通过命名信号量同步。踩过坑,才能真正理解。

记住一点:信号量是计数器,不是锁。虽然它可以当锁用,但它的本质是「资源管理」。想清楚这一点,很多设计问题就迎刃而解了。

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