一、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:必须以斜杠开头,比如/mysemoflag:O_CREAT表示创建,O_EXCL表示排他mode:权限位,比如0666value:信号量的初始值
举个例子:
#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信号量的应用场景
说了这么多,到底什么时候用信号量?我总结几个常见场景:
- 资源计数:比如限制同时访问数据库的连接数,初始值设为最大连接数。
- 生产者-消费者:上面那个例子就是典型。
- 进程同步:进程A做完某件事后,进程B才能继续。
- 实现锁:初始值为1的信号量,就是互斥锁。
但要注意,信号量不是万能的。如果你只是保护一个共享变量,用互斥锁就够了。信号量更适合「有资源计数需求」的场景。
1.7 知识体系总览
下面这张图总结了POSIX信号量的核心知识点:
这张图把POSIX信号量分成了命名和无名两大类。左边是命名信号量的创建和删除,右边是无名信号量的初始化和销毁。中间是它们共用的核心操作——sem_wait和sem_post。最下面是常见的应用场景。
- 命名信号量用
sem_open创建,用sem_unlink删除名字 - 无名信号量用
sem_init初始化,用sem_destroy销毁 sem_wait减1,sem_post加1- 信号量适合资源计数场景,纯互斥用互斥锁更简单
好了,POSIX信号量的核心内容就这些。说实话,信号量这东西,光看文档是学不会的。我建议你动手写几个小例子,比如生产者-消费者,或者两个进程通过命名信号量同步。踩过坑,才能真正理解。
记住一点:信号量是计数器,不是锁。虽然它可以当锁用,但它的本质是「资源管理」。想清楚这一点,很多设计问题就迎刃而解了。