一、信号量的核心本质与设计初衷
先通过通俗比喻理解:信号量就像停车场的车位计数器 ------ 初始值是总车位数(比如 10),每开进一辆车(P 操作)计数器减 1,每开出一辆车(V 操作)计数器加 1;当计数器为 0 时,新的车辆必须等待(进程阻塞),直到有车开出(其他进程执行 V 操作)。
1. 官方定义
信号量是 Linux 内核维护的原子性计数器 + 等待队列的组合体,核心作用是实现多个进程 / 线程之间的:
- 同步:控制进程 / 线程的执行顺序(比如 A 必须等 B 完成后再执行);
- 互斥:保护共享资源(如共享内存、打印机)不被并发修改。
2. 核心原理 ------P/V 原语(原子操作)
信号量的所有逻辑都基于两个原子操作(不可被中断,避免竞态条件):
操作 英文名 逻辑 场景 P 操作 Wait/Pend sem -= 1,若结果 < 0,当前进程 / 线程阻塞并加入等待队列;若 ≥0,继续执行获取资源 V 操作 Post/Release sem += 1,若结果 ≤0,唤醒等待队列中的一个进程 / 线程; 若 >0,无唤醒释放资源 原子性的重要性:如果 P/V 操作可被中断,多个进程同时修改计数器会导致错误(比如两个进程同时 P 操作,计数器本该减 2 却只减 1)。Linux 内核通过自旋锁(spinlock)保证 P/V 操作的原子性。
二、Linux 中信号量的两大类型
Linux 提供两种主流实现,核心逻辑一致,但 API 和适用场景差异显著:
什么是 System V 信号量?
System V 信号量是 Linux/Unix 内核提供的进程间通信(IPC)机制 ,属于 System V IPC 家族(另外两个是共享内存、消息队列),核心作用是实现多进程间的同步与互斥,保护「临界资源」(如共享内存、打印机、标准输出)不被多个进程同时访问,避免数据竞争或操作混乱。
特性 System V 信号量 POSIX 信号量(无名 / 有名) 操作单位 信号量集(多个信号量组合) 单个信号量 标识方式 IPC 键(key_t)+ 信号量集 ID 无名(进程内)/ 文件名(跨进程) 生命周期 随内核(需手动删除) 无名:随进程;有名:随文件系统 核心优势 支持批量信号量操作、内核级持久化 轻量、接口简单、支持线程 / 进程 典型场景 多进程复杂同步(如共享内存) 简单互斥 / 同步(线程 / 进程) 规则
信号量的本质是一个整数计数器,围绕「P 操作(申请资源)」和「V 操作(释放资源)」展开,核心规则:
- 信号量值
> 0:表示有n个可用资源,P 操作(减 1)后仍有剩余;- 信号量值
= 0:无可用资源,P 操作会阻塞,直到其他进程执行 V 操作;- 信号量值
< 0:(仅内核维护)表示有-n个进程正在阻塞等待该信号量;- V 操作(加 1):释放资源,若有进程阻塞则唤醒其中一个。
根据值的范围,信号量分为两类:
- 二值信号量:值仅为 0 或 1,等价于「互斥锁」(你之前代码中用的就是这种,保护 printf 输出);
- 计数信号量:值可大于 1,用于控制同时访问资源的进程数(如最多 3 个进程访问数据库连接池)。
System V 信号量的核心概念
在使用 System V 信号量前,必须理解以下 5 个核心概念:
1. IPC 键(key_t)
- 作用:唯一标识内核中的 IPC 对象(信号量集 / 共享内存 / 消息队列),让不同进程能找到同一个信号量集;
- 生成方式:通过
ftok()函数将「文件路径 + 项目 ID」转换为唯一的key_t类型值;- 注意:路径必须是存在且可访问的(如当前目录 "."),项目 ID 通常用 0-255 的整数;若路径被删除重建,即使参数相同,生成的 key 也会变化。
2. 信号量集(semaphore set)
System V 信号量的最小操作单位,不是单个信号量 ------ 一个信号量集可以包含多个独立的信号量,每个信号量有自己的「下标」(从 0 开始)。
- 比如你之前的代码中,创建了「包含 1 个信号量的集合」,下标为 0;
- 若需同时控制多个资源(如读、写锁),可创建包含 2 个信号量的集合,下标 0 对应读锁,下标 1 对应写锁。
3. sembuf 结构体
描述「单个信号量操作」的结构体,是
semop()函数的核心参数,定义如下:
cppstruct sembuf { unsigned short sem_num; // 信号量在集合中的下标(如 0) short sem_op; // 操作类型:-1=P操作,1=V操作,0=等待信号量值为0 short sem_flg; // 标志:0=阻塞,IPC_NOWAIT=非阻塞,SEM_UNDO=自动撤销 };
sem_op = -1:P 操作(申请资源,信号量减 1);sem_op = 1:V 操作(释放资源,信号量加 1);sem_op = 0:特殊操作,阻塞直到信号量值变为 0;sem_flg = SEM_UNDO:核心优化,进程异常退出时,内核会自动撤销该进程的信号量操作(避免信号量值被永久占用导致死锁)。4. semun 联合体
semctl()函数的第四个参数(控制信号量的载体),现代 Linux 系统头文件不会自动定义,必须手动声明(你之前代码中已实现):
union semun { int val; // SETVAL:设置单个信号量的初始值 struct semid_ds *buf; // IPC_STAT/IPC_SET:获取/设置信号量集属性 unsigned short *array; // GETALL/SETALL:批量获取/设置所有信号量值 struct seminfo *__buf; // IPC_INFO:获取系统级信号量资源限制(Linux 特有) };不同的
semctl()命令对应使用不同的成员(如SETVAL用val,SETALL用array)。5. 信号量集 ID(semid)
由
semget()函数返回的整数,是操作信号量集的「句柄」------ 后续semop()、semctl()都通过这个 ID 定位到具体的信号量集。
System V 信号量的核心函数
System V 信号量的使用依赖 4 个核心函数,按使用流程逐一讲解:
1. ftok ():生成 IPC 键
#include <sys/ipc.h> key_t ftok(const char *pathname, int proj_id);功能 :将「文件路径 + 项目 ID」转换为唯一的 IPC 键;
参数 :
pathname:存在且可访问的文件 / 目录路径(如 "." 表示当前目录);
proj_id:项目标识(仅低 8 位有效,通常用 0-255 的整数,如 123);
返回值 :成功返回key_t类型键值,失败返回 -1(可通过perror("ftok")查看原因);
实战注意:若路径被删除后重建,即使参数相同,生成的 key 也会不同(内核根据文件的 inode 号计算 key)。2. semget ():创建 / 获取信号量集
#include <sys/sem.h> int semget(key_t key, int nsems, int semflg);功能 :创建新的信号量集,或获取已存在的信号量集;
参数:
key:由ftok()生成的 IPC 键;
nsems:信号量集中包含的信号量数量(如 1 表示单个信号量);
semflg:标志位 + 权限位(组合使用):
IPC_CREAT:若信号量集不存在则创建;IPC_EXCL:与IPC_CREAT配合,若信号量集已存在则返回错误(避免重复创建);- 权限位:同文件权限(如 0666 表示所有用户可读可写);
返回值 :成功返回信号量集 ID(semid),失败返回 -1;
示例 :你之前代码中的semget(key, 1, IPC_CREAT | IPC_EXCL | 0666)表示「创建包含 1 个信号量的集合,若已存在则报错」。3. semctl ():控制信号量集(初始化 / 删除 / 查询)
#include <sys/sem.h> int semctl(int semid, int semnum, int cmd, ...);功能 :对信号量集执行控制操作(初始化值、删除集合、查询值等);
参数:
semid:信号量集 ID(由semget()返回);
semnum:信号量在集合中的下标(如 0);若cmd是IPC_RMID,该参数无意义(传 0 即可);
cmd:操作命令(核心常用命令如下);第四个参数:
semun联合体(仅部分cmd需要);核心 cmd 命令:
命令 作用 是否需要 semun 返回值 SETVAL 设置单个信号量的初始值 是(用 val) 成功 0,失败 -1 GETVAL 获取单个信号量的当前值 否 成功返回信号量值 IPC_RMID 删除整个信号量集(释放内核资源) 否 成功 0,失败 -1 IPC_STAT 获取信号量集的属性(如创建时间、权限) 是(用 buf) 成功 0,失败 -1 示例 :
semctl(semid, 0, SETVAL, arg)表示「将下标 0 的信号量初始化为 arg.val(1)」;semctl(semid, 0, IPC_RMID)表示「删除整个信号量集」。4. semop ():执行信号量操作(P/V 核心)
#include <sys/sem.h> int semop(int semid, struct sembuf *sops, size_t nsops);功能 :执行一个或多个信号量操作(P/V 操作的核心实现);
参数:
semid:信号量集 ID;
sops:struct sembuf类型的数组(可批量操作多个信号量);
nsops:数组长度(如 1 表示仅操作 1 个信号量);
返回值 :成功返回 0,失败返回 -1;
示例 :P 操作:
struct sembuf sops = {0, -1, 0}; // 下标 0,P 操作(-1),阻塞模式 semop(semid, &sops, 1);V 操作:
struct sembuf sops = {0, 1, 0}; // 下标 0,V 操作(+1),阻塞模式 semop(semid, &sops, 1);
实战注意事项(避坑关键)
1. 必须手动删除信号量集
System V 信号量集的生命周期随内核 ,进程退出后不会自动释放 ------ 若不调用
semctl(IPC_RMID),信号量集会一直残留在内核中,导致后续创建同名 key 的信号量集失败。
- 查看残留的信号量集:
ipcs -s;- 删除残留的信号量集:
ipcrm -s <semid>(semid 可从 ipcs 结果中获取)。2. 建议添加 SEM_UNDO 标志
在
sembuf的sem_flg中添加SEM_UNDO:
struct sembuf sops = {0, -1, SEM_UNDO}; // P 操作作用:进程异常退出(如被 kill)时,内核会自动撤销该进程的信号量操作(比如将 P 操作减的 1 加回来),避免信号量值被永久占用导致死锁。
3. 处理非阻塞场景
若不想让进程阻塞等待信号量,可设置
sem_flg = IPC_NOWAIT:
struct sembuf sops = {0, -1, IPC_NOWAIT}; if (semop(semid, &sops, 1) == -1) { if (errno == EAGAIN) { printf("信号量被占用,非阻塞退出\n"); } }4. 避免死锁
若使用多个信号量,需保证所有进程的 P/V 操作顺序一致(如先申请信号量 A,再申请信号量 B),否则会导致死锁。
5. 权限问题
semget()的权限位(如 0666)需合理设置,否则其他用户 / 进程无法访问该信号量集(报错 Permission denied)
完整示例:System V 信号量实现进程互斥
功能:使用System V信号量实现父子进程互斥输出
核心:通过二值信号量(初始值1)保证同一时间只有一个进程执行打印操作,避免输出混乱
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/sem.h>
#include <sys/wait.h>
// 注意:System V信号量的semun联合体在现代系统中不会被头文件自动定义,必须手动声明
// 该联合体是semctl函数的第四个参数,不同操作对应不同成员
union semun {
int val; // 用于SETVAL操作:设置单个信号量的初始值
struct semid_ds *buf; // 用于IPC_STAT/IPC_SET操作:获取/设置信号量集的属性
unsigned short *array; // 用于GETALL/SETALL操作:获取/设置所有信号量的值
struct seminfo *__buf; // 用于IPC_INFO操作:获取系统级的信号量信息(Linux特有)
};
/**
* 初始化信号量的值
* @param semid 信号量集的ID(由semget返回)
* @param semnum 信号量集中的信号量下标(单个信号量时为0)
* @param value 要设置的信号量初始值
* @return 成功返回0,失败返回-1(错误原因可通过perror查看)
*/
int init_sem(int semid, int semnum, int value) {
union semun arg; // 定义semun联合体变量,用于传递初始化值
arg.val = value; // 设置要初始化的信号量值
// semctl函数:控制信号量集
// 参数1:信号量集ID;参数2:信号量下标;参数3:操作类型(SETVAL=设置值);参数4:联合体参数
return semctl(semid, semnum, SETVAL, arg);
}
/**
* P操作(申请资源/减锁):获取信号量,若信号量值为0则阻塞等待
* 核心逻辑:将信号量值减1,实现"申请资源"的互斥逻辑
* @param semid 信号量集的ID
* @param semnum 信号量集中的信号量下标
* @return 成功返回0,失败返回-1
*/
int p_sem(int semid, int semnum) {
// sembuf结构体:描述单个信号量操作
struct sembuf sops = {
semnum, // 要操作的信号量下标
-1, // sem_op:-1表示P操作(申请资源,信号量值减1)
0 // sem_flg:0表示阻塞模式(信号量为0时等待,而非立即返回)
};
// semop函数:执行信号量操作(可批量操作多个信号量,这里只操作1个)
// 参数1:信号量集ID;参数2:操作数组;参数3:操作数组长度
return semop(semid, &sops, 1);
}
/**
* V操作(释放资源/解锁):释放信号量,唤醒等待该信号量的进程
* 核心逻辑:将信号量值加1,实现"释放资源"的互斥逻辑
* @param semid 信号量集的ID
* @param semnum 信号量集中的信号量下标
* @return 成功返回0,失败返回-1
*/
int v_sem(int semid, int semnum) {
struct sembuf sops = {
semnum, // 要操作的信号量下标
1, // sem_op:1表示V操作(释放资源,信号量值加1)
0 // 阻塞模式(此处V操作不会阻塞,仅保持参数统一)
};
return semop(semid, &sops, 1);
}
/**
* 删除信号量集(释放IPC资源)
* @param semid 信号量集的ID
* @return 成功返回0,失败返回-1
*/
int del_sem(int semid) {
// IPC_RMID:删除整个信号量集,第二个参数(semnum)无意义,传0即可
return semctl(semid, 0, IPC_RMID);
}
int main() {
// 1. 生成唯一的IPC键值(用于标识信号量集)
// ftok函数:将路径(当前目录".")和项目ID(123)转换为唯一的key_t类型键值
// 注意:路径必须是存在且可访问的目录/文件,项目ID通常用0-255的整数
key_t key = ftok(".", 123);
if (key == -1) { // ftok失败(如路径不存在)
perror("ftok"); // 打印错误原因
exit(1); // 异常退出程序
}
// 2. 创建信号量集
// semget函数:创建/获取信号量集
// 参数1:IPC键值;参数2:信号量集中的信号量数量(这里只需要1个);参数3:标志位+权限
// IPC_CREAT:若信号量集不存在则创建;IPC_EXCL:若已存在则返回错误(避免重复创建);0666:信号量集的访问权限(同文件权限)
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid == -1) { // semget失败(如信号量集已存在)
perror("semget");
exit(1);
}
// 3. 初始化信号量为1(二值信号量,实现互斥:1=资源可用,0=资源被占用)
if (init_sem(semid, 0, 1) == -1) {
perror("init_sem");
exit(1);
}
// 4. 创建子进程(fork后会复制父进程的内存空间,包括semid、key等变量)
pid_t pid = fork();
if (pid == 0) { // 子进程分支(fork返回0)
// 子进程循环5次,每次都要先获取信号量再打印
for (int i = 0; i < 5; i++) {
p_sem(semid, 0); // P操作:申请信号量(若被父进程占用则阻塞)
// 临界区:只有获取到信号量的进程才能执行这部分代码
printf("子进程(%d):打印第%d次\n", getpid(), i+1); // 打印子进程ID和次数
sleep(1); // 模拟临界区内的耗时操作(如处理数据)
v_sem(semid, 0); // V操作:释放信号量(让父进程可以获取)
sleep(1); // 非临界区的休眠(让父进程有机会获取信号量,避免子进程重复抢占)
}
exit(0); // 子进程正常退出
} else if (pid > 0) { // 父进程分支(fork返回子进程PID)
// 父进程循环5次,逻辑与子进程一致
for (int i = 0; i < 5; i++) {
p_sem(semid, 0); // P操作:申请信号量
// 临界区:互斥打印
printf("父进程(%d):打印第%d次\n", getpid(), i+1);
sleep(1); // 模拟耗时操作
v_sem(semid, 0); // V操作:释放信号量
sleep(1); // 非临界区休眠
}
wait(NULL); // 等待子进程退出(避免子进程变成僵尸进程)
del_sem(semid); // 父进程最后删除信号量集(释放IPC资源,必须执行!)
} else { // fork失败(pid=-1)
perror("fork");
del_sem(semid); // 即使fork失败,也要清理已创建的信号量集
exit(1);
}
return 0; // 程序正常退出
}
编译运行:
gcc sem_systemv.c -o sem_systemv
./sem_systemv
输出效果(父 / 子进程不会同时打印,实现互斥):
父进程(1234):打印第1次
子进程(1235):打印第1次
父进程(1234):打印第2次
子进程(1235):打印第2次
...
2. POSIX 信号量(更现代、易用)
POSIX 信号量分为无名信号量 (基于内存)和命名信号量(基于文件系统),API 更简洁。
(1) 无名信号量(适用于亲缘进程 / 线程)
| 函数 | 作用 | 关键参数 |
|---|---|---|
sem_init() |
初始化 | pshared=0(线程共享)、value(初始值) |
sem_wait() |
P 操作(阻塞) | 信号量指针 |
sem_post() |
V 操作 | 信号量指针 |
sem_destroy() |
销毁 | 信号量指针 |
(2) 命名信号量(适用于无亲缘进程)
| 函数 | 作用 | 关键参数 |
|---|---|---|
sem_open() |
创建 / 打开 | name(路径名如/my_sem)、value(初始值) |
sem_close() |
关闭 | 信号量指针 |
sem_unlink() |
删除 | 信号量名称 |
完整示例:POSIX 无名信号量实现线程互斥
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <semaphore.h>
sem_t sem; // 全局无名信号量(线程共享)
// 线程1函数
void *thread1(void *arg) {
for (int i = 0; i < 5; i++) {
sem_wait(&sem); // P操作
printf("线程1:打印第%d次\n", i+1);
sleep(1);
sem_post(&sem); // V操作
sleep(1);
}
pthread_exit(NULL);
}
// 线程2函数
void *thread2(void *arg) {
for (int i = 0; i < 5; i++) {
sem_wait(&sem);
printf("线程2:打印第%d次\n", i+1);
sleep(1);
sem_post(&sem);
sleep(1);
}
pthread_exit(NULL);
}
int main() {
pthread_t tid1, tid2;
// 初始化信号量:线程共享(pshared=0),初始值=1
if (sem_init(&sem, 0, 1) == -1) {
perror("sem_init");
exit(1);
}
// 创建线程
pthread_create(&tid1, NULL, thread1, NULL);
pthread_create(&tid2, NULL, thread2, NULL);
// 等待线程结束
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
// 销毁信号量
sem_destroy(&sem);
return 0;
}
编译运行(需链接 pthread 库):
gcc sem_posix.c -o sem_posix -lpthread
./sem_posix
三、信号量的典型应用场景
1. 互斥访问临界资源
用二值信号量(初始值 = 1)实现 "互斥锁",保证同一时间只有一个进程 / 线程访问临界资源(如共享内存、全局变量)。
2. 生产者 - 消费者模型
需要两个信号量:
empty(空缓冲区):初始值 = 缓冲区大小,生产者 P 操作后生产full(满缓冲区):初始值 = 0,消费者 P 操作后消费- 可选:加一个互斥信号量保护缓冲区修改
3. 进程同步
控制执行顺序(如 A 等 B 完成初始化):
- 信号量初始值 = 0;
- B 完成后执行 V 操作;
- A 执行前 P 操作(阻塞直到 B 的 V 操作)
四、使用注意事项
1. 避免死锁
- 死锁场景:进程持有对方需要的信号量且不释放、自身重复 P 操作
- 解决:按固定顺序获取信号量、保证 P/V 配对、使用
sem_trywait()(非阻塞 P)
2. 防止 System V 信号量泄漏
- 查看残留信号量:
ipcs -s - 删除指定信号量:
ipcrm -s <semid>
3. 合理设置初始值
- 互斥:1;同步:0;资源池:资源总数(如线程池大小)
4. 错误处理
所有信号量 API 都可能失败,必须检查返回值(如sem_wait返回 - 1 表示失败)
总结
- 核心本质:信号量是 "原子计数器 + 等待队列",通过 P(减 1 / 阻塞)、V(加 1 / 唤醒)原子操作实现进程 / 线程的同步与互斥,解决并发竞态问题
- 两大类型:System V 信号量(IPC 集、跨无亲缘进程、需手动删除)和 POSIX 信号量(分无名 / 命名、更易用、线程 / 进程共享),核心逻辑一致但 API 不同
- 关键注意事项:避免死锁(保证 P/V 配对、固定获取顺序)、防止 System V 信号量泄漏、合理设置初始值、做好 API 错误处理