信号量
信号量是一种互斥的机制,相当于多把锁,可以理解为是加强版的互斥锁。
锁的相关知识可以参考线程部分。
为什么信号量是“加强版互斥锁”?
互斥锁(Mutex)本质是二元开关(0/1),仅保证独占访问;而信号量(Semaphore)是资源计数器(非负整数),支持更复杂的同步逻辑。关键差异如下:
特性 | 互斥锁 | 信号量 |
---|---|---|
资源管理 | 仅保护单个资源(如全局变量) | 可管理N个同类资源(如数据库连接池) |
操作逻辑 | 加锁/解锁必须由同一线程完成 | PV操作可跨线程(生产者释放,消费者获取) |
适用场景 | 临界区互斥(如写文件) | 资源调度、线程同步(如生产者-消费者模型) |
本质关系 | - | 互斥锁 = 初始值为1的信号量特例 |
💡 核心加强点:
- 计数能力:信号量值 >1 时,允许多线程有限并发(如控制10个连接);
- 解耦操作:
wait()
(P操作)和signal()
(V操作)可跨线程,实现流程协同;- 同步扩展:通过初始值设计,既可模拟互斥锁(初值=1),也可实现线程唤醒链(初值=0)。
相关函数
定义信号量 sem_t sem;
c
int sem_init(sem_t *sem, int pshared, unsigned int value);
函数描述
- 初始化信号量
函数参数
sem
:信号量变量pshared
:0
表示线程同步,1
表示进程同步value
:最多有几个线程操作共享数据
函数返回值
- 成功返回
0
,失败返回-1
,并设置errno值
c
int sem_wait(sem_t *sem);
- 函数描述:调用该函数一次,相当于
sem--
,当sem
为0
的时候,引起阻塞 - 函数参数:信号量变量
- 函数返回值:成功返回
0
,失败返回-1
,并设置errno值
c
int sem_post(sem_t *sem);
函数描述
- 调用一次,相当于
sem++
函数参数
- 信号量变量
函数返回值
- 成功返回
0
,失败返回-1
,并设置errno值
c
int sem_trywait(sem_t *sem);
函数描述
- 尝试加锁,若失败直接返回,不阻塞
函数参数
- 信号量变量
函数返回值
- 成功返回
0
,失败返回-1
,并设置errno值
c
int sem_destroy(sem_t *sem);
函数描述
- 销毁信号量
函数参数
- 信号量变量
函数返回值
- 成功返回0,失败返回-1,并设置errno值
信号量代码片段
PV操作与底层原理
信号量的核心是原子操作PV,其行为由计数器(count
)和等待队列决定:
cpp
// 简化版PV操作伪代码[6,7](@ref)
void P(Semaphore s) { // wait()
s.count--;
if (s.count < 0) {
将当前线程加入等待队列;
阻塞线程;
}
}
void V(Semaphore s) { // signal()
s.count++;
if (s.count <= 0) {
从等待队列移出一个线程;
唤醒该线程;
}
}
- P操作:申请资源,若资源不足(
count≤0
)则阻塞; - V操作:释放资源,并唤醒等待队列中的线程。
⚠️ 关键设计原则:
- 原子性保障:PV操作由系统/硬件保证不可中断,避免竞态;
- 等待策略:阻塞线程而非忙等待(busy-waiting),节省CPU资源;
- 公平性:可通过队列唤醒策略(如FIFO或优先级)避免饥饿。
实践场景与代码示例
互斥场景(替代Mutex)
初始化信号量值为1,实现临界区保护:
cpp
// C语言示例(POSIX信号量)
sem_t mutex;
sem_init(&mutex, 0, 1); // 初值=1,模拟互斥锁
void thread_func() {
sem_wait(&mutex); // P操作
// 临界区操作(如修改共享变量)
sem_post(&mutex); // V操作
}
资源池管理(如数据库连接池)
通过信号量限制最大并发数:
java
// Java Semaphore示例
Semaphore pool = new Semaphore(10); // 允许10个并发连接
void queryDatabase() {
pool.acquire(); // P操作:获取连接
try {
executeSQL(); // 使用数据库连接
} finally {
pool.release(); // V操作:释放连接
}
}
生产者-消费者模型
双信号量协调空/满缓冲区:
cpp
// C++示例
sem_t empty, full;
sem_init(&empty, 0, BUFFER_SIZE); // 初始空槽数=缓冲区大小
sem_init(&full, 0, 0); // 初始满槽数=0
void producer() {
while (true) {
item = produce_item();
sem_wait(&empty); // 等待空槽
enqueue(item);
sem_post(&full); // 增加满槽计数
}
}
void consumer() {
while (true) {
sem_wait(&full); // 等待满槽
item = dequeue();
sem_post(&empty); // 增加空槽计数
consume(item);
}
}
何时选择信号量
场景 | 推荐机制 | 原因 |
---|---|---|
严格互斥(单资源独占) | 互斥锁 | 开销更低,语义更直接 |
资源池/并发度控制 | 信号量 | 天然支持多资源计数 |
跨线程流程同步(如A完成后B) | 信号量(初值=0) | 解耦等待与通知 |
避坑指南:
- 避免将信号量误用为互斥锁(如忘记
post()
导致死锁);- 优先使用高层同步工具(如条件变量+互斥锁组合),信号量更适用于资源计数场景。
通过理解计数器模型和PV原子操作,可灵活运用信号量解决复杂同步问题,其“加强”本质正在于资源抽象能力与调度灵活性的统一。
system V版本
system V版本的信号量实现生产者消费者
cpp
#include <stdio.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
/** 创建共享内存
* 第一个参数key设置为IPC_PRIVATE 将设置一个key为0的共享内存
* 第二个参数为共享内存的大小,需要设置为内存页大小的整数倍,如果不满一页底层页将会按照一页进行设置
* 第三个参数如果没有IPC_CREAT 没找到共享内存程序就会报错,如果有没找到就会创建一个
* | 0600 设置所创建的共享内存的权限为 600
*/
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0600);
if (shmid == -1) {
perror("shmget");
return -1;
}
/** 将共享内存映射到虚拟内存
* 第一个参数为创建共享内存时返回的 id
* 第二个参数需要填入一个虚拟内存的地址,填NULL可以让shmat自动分配
* 第三个参数一般设置为 0
*/
int *p = (int *) shmat(shmid, NULL, 0);
if (p == (void *) -1) {
perror("shmat");
return -1;
}
// 定义生产者和消费者之间共享的资源
p[0] = 10;// p[0] 表示空闲仓库的个数
p[1] = 0; // p[1] 表示商品的个数
/** 创建信号量
* 第一个参数key为一个整数,但是要注意 如果这个key值已经被创建并设置过信号量程序会报错
* 第二个参数为需要设置的信号量的个数,会为你创建一个数组,在设置信号量的时间需要填下标值
* 第三个参数和创建共享内存时的第三个参数一致
*/
int semid = semget(1000, 1, IPC_CREAT | 0600);
if (semid == -1) {
perror("semget");
return -1;
}
/** 设置信号量
* 第一个参数为创建的信号量的返回值
* 第二个参数为需要设置的信号量的下标值
* 第三个参数为需要进行的操作,设置信号量还需填入第四个参数
* 第四个参数为要将信号量的值设置为多少
*/
int ret = semctl(semid, 0, SETVAL, 1);
if (ret == -1) {
perror("semctl SETVAL");
return -1;
}
// 获取设置的信号量
ret = semctl(semid, 0, GETVAL);
if (ret == -1) {
perror("semctl GETVAL");
return -1;
}
printf("semval = %d\n", ret);
// 定义信号量PV操作的结构体
struct sembuf P, V;
P.sem_num = 0;//下标
P.sem_op = -1;//对资源的影响
P.sem_flg = SEM_UNDO;
V.sem_num = 0;
V.sem_op = 1;
V.sem_flg = SEM_UNDO;
if (fork() == 0) {
// 子进程 生产者
while (1) {
// 在对共享内存进行操作之前加锁 P操作
// 第三个参数为这个操作会影响到几个信号量
semop(semid, &P, 1);
if (p[0] > 0) {
printf("before produce, space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
--p[0];
++p[1];
printf("after produce, space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
}
// 操作完共享内存之后解锁 V操作
semop(semid, &V, 1);
}
} else if (fork() == 0) {
// 子进程 第二个生产者
while (1) {
semop(semid, &P, 1);
if (p[0] > 0) {
printf("before produce, space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
--p[0];
++p[1];
printf("after produce, space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
}
semop(semid, &V, 1);
}
} else {
// 父进程 消费者
while (1) {
semop(semid, &P, 1);
if (p[1] > 0) {
printf("before consume , space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
--p[1];
++p[0];
printf("after consume, space = %2d, good = %2d, total = %d\n", p[0], p[1], p[0] + p[1]);
}
semop(semid, &V, 1);
}
wait(NULL);
}
// 释放加载到虚拟内存的共享内存
shmdt(p);
// 删除共享内存
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
SEM_UNDO
可以在进程终止的时间将减去的资源加回来,从而来避免多线程访问共享资源而产生死锁的问题。