POSIX 信号量解析:计数语义及其与条件变量的本质区别
核心结论:信号量内部保存可用资源数量,
sem_wait()消费数量,sem_post()归还数量;条件变量则不保存通知,只负责提醒线程重新检查外部状态。
问题背景
mutex 可以保证同一时刻只有一个线程进入临界区,但很多系统管理的并不是“只能使用一次”的资源。例如:
- 连接池中有 16 个连接。
- 任务系统最多允许 8 个任务并行执行。
- 有界队列中还有 100 个空槽位。
这些场景需要保存“当前还有多少份资源”。POSIX semaphore(信号量)就是一个带阻塞等待能力的计数器。
信号量的计数模型
POSIX 无名信号量使用 sem_t 表示:
#include <semaphore.h>
sem_t semaphore;
sem_init(&semaphore, 0, 3);
sem_init() 的第三个参数是初始计数。这里的 3 表示最初存在三个可用资源。
第二个参数 pshared 为 0 时,信号量供同一进程内的线程使用;非零值用于放在共享内存中的进程间信号量,并且要求平台支持相应能力。
信号量的两个核心操作是:
sem_wait(&semaphore);
sem_post(&semaphore);
可以用下面的抽象过程理解它们。
sem_wait():申请一个资源
- 如果计数大于
0,将计数减一并立即返回。 - 如果计数等于
0,当前线程阻塞。 - 其他线程执行
sem_post()后,某个等待线程获得继续执行的机会。
检查计数和减少计数是一个原子操作,因此两个线程不会同时消费最后一个资源。
sem_post():归还或产生一个资源
- 增加信号量计数,或者把新增资源交给某个等待线程。
- 如果存在等待者,唤醒其中一个。
sem_post()不要求由执行过sem_wait()的同一个线程调用。
信号量的内部实现不一定真的把计数保存为负数来表示等待者数量。可靠的语义只有:可用资源为零时 wait 会阻塞,post 会产生一个可消费的许可。
使用信号量限制并发任务数量
下面的程序创建六个线程,但只允许三个线程同时执行任务:
#include <errno.h>
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
#define WORKER_COUNT 6
#define MAX_CONCURRENCY 3
sem_t slots;
int wait_for_slot(sem_t *semaphore)
{
int result;
do {
result = sem_wait(semaphore);
} while (result == -1 && errno == EINTR);
return result;
}
void *worker(void *argument)
{
int id = *(int *)argument;
if (wait_for_slot(&slots) == -1) {
perror("sem_wait");
return NULL;
}
printf("worker %d entered\n", id);
sleep(1);
printf("worker %d leaving\n", id);
if (sem_post(&slots) == -1) {
perror("sem_post");
}
return NULL;
}
int main(void)
{
pthread_t threads[WORKER_COUNT];
int ids[WORKER_COUNT];
if (sem_init(&slots, 0, MAX_CONCURRENCY) == -1) {
perror("sem_init");
return 1;
}
for (int i = 0; i < WORKER_COUNT; ++i) {
ids[i] = i;
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
for (int i = 0; i < WORKER_COUNT; ++i) {
pthread_join(threads[i], NULL);
}
sem_destroy(&slots);
return 0;
}
编译命令为:
gcc -std=c11 -Wall -Wextra -pthread semaphore_demo.c -o semaphore_demo
初始计数为 3。前三个线程分别执行一次 sem_wait() 后,计数降到 0;其他线程只能等待。每当一个线程调用 sem_post() 归还许可,才会有另一个等待线程进入。
这里的信号量不保护某个共享变量的一致性,而是限制同时进入任务区域的线程数量。如果任务内部还要修改共享链表或计数器,仍然可能需要 mutex。
信号量为什么能保存通知
假设 worker 先执行:
sem_post(&ready);
主线程稍后才执行:
sem_wait(&ready);
只要信号量计数没有被其他线程消费,这次 sem_post() 产生的许可会保留在计数中。主线程执行 sem_wait() 时可以直接消费它,不会因为“通知发生得太早”而永久阻塞。
条件变量不同:
pthread_cond_signal(&cond);
如果当时没有等待者,这次 signal 不会累积成一个待消费通知。条件变量的正确性依靠外部谓词:即使错过 signal,后来获得 mutex 的线程也应通过检查谓词发现条件已经满足,而不是无条件进入等待。
因此,两种机制都能写出正确同步程序,但状态保存的位置不同:
- 信号量把状态压缩成内部计数。
- 条件变量把状态保存在用户定义的共享变量中。
条件变量和信号量的本质区别
| 对比项 | 条件变量 | 信号量 |
|---|---|---|
| 是否保存状态 | 不保存业务状态 | 保存资源计数 |
| 等待依据 | 外部任意谓词,如 queue_size > 0 |
内部计数是否大于 0 |
| 是否必须配合 mutex | 必须用 mutex 保护谓词和等待协议 | wait/post 本身不要求额外 mutex |
| 通知是否累积 | signal 本身不累积 |
post 会产生可消费许可 |
| 一次通知的效果 | signal 唤醒一个,broadcast 唤醒全部 |
一次 post 产生一个许可 |
| 适合场景 | 等待复杂状态变化 | 资源计数、并发配额、生产者与消费者计数 |
可以把区别压缩成两句话:
- 条件变量表达的是:“某个状态可能变了,醒来重新检查。”
- 信号量表达的是:“现在增加了一份可以被消费的资源。”
二值信号量不是 mutex 的同义词
把信号量初始值设为 1 后,它在表面上也能限制同一时刻只有一个线程进入:
sem_init(&binary, 0, 1);
但二值信号量和 mutex 的语义仍然不同:
- mutex 有所有者,通常必须由加锁线程解锁。
- semaphore 没有所有者,一个线程可以
wait,另一个线程可以post。 - mutex 的目标是保护共享数据不被并发破坏。
- semaphore 的目标是传递许可或表示资源数量。
如果需求是保护临界区,优先使用 mutex。只有确实需要跨线程传递许可时,才应使用二值信号量。
如何选择同步机制
使用 mutex
需要保证一段共享状态在同一时刻只能被一个线程修改,例如保护链表、哈希表或对象状态。
使用条件变量
线程要等待一个由共享状态表达的条件,例如:
queue_size > 0
state == READY
stopped || has_new_task
这些条件可能由多个变量共同决定,不能简单压缩成资源数量。
使用信号量
需要计数资源或限制并发度,例如连接池槽位、队列中的项目数、可用 buffer 数量。
使用 pthread_join()
需要等待某个线程彻底退出并回收线程资源。它不是通用的事件通知机制。
常见错误
初始计数设置错误
互斥式许可通常初始化为 1,等待事件通常初始化为 0,资源池则初始化为实际资源数量。初值不同,整个同步语义也不同。
执行 sem_wait() 后忘记 sem_post()
错误路径、提前 return 和异常清理最容易遗漏资源归还,最终导致所有线程永久阻塞。
重复执行 sem_post()
多执行一次 post 会凭空增加许可,使并发数量超过设计上限。信号量不会自动判断这次归还是否合理。
忽略 EINTR
sem_wait() 可能被信号处理打断并返回 -1,同时设置 errno = EINTR。如果业务语义要求继续等待,应像示例中的 wait_for_slot() 一样重试。
仍有线程等待时销毁信号量
调用 sem_destroy() 前,必须保证没有线程仍在使用或等待该信号量,否则行为未定义。
总结
POSIX 信号量适合管理“有多少份许可”,条件变量适合等待“共享状态是否满足任意谓词”;选择同步原语时应先识别要表达的是计数、状态、互斥还是线程生命周期。