
System V 三兄弟的压轴成员 ------ 信号量(Semaphore) ------ 是最特别的一位:它根本不传数据 。消息队列传消息、共享内存传字节,信号量只干一件事:当保镖 。上一篇我们说共享内存 "快但裸奔",用 FIFO 手工搭了 Wait/Signal;而信号量就是 OS 提供的、原生的 Wait/Signal ------ P 操作申请资源,V 操作释放资源 ,由内核保证原子性。本篇先补齐一组绕不开的概念(临界资源、临界区、互斥、同步、原子性),再用 "电影院买票" 模型吃透信号量的本质,最后上手 semget/semctl/semop 三件套,给共享内存配上真正的锁。
文章目录
-
- 一、概念地基:并发世界的五个名词
-
- [1.1 概念链条](#1.1 概念链条)
- [1.2 回到我们的老问题](#1.2 回到我们的老问题)
- 二、核心难点:锁本身也是共享资源(鸡生蛋问题)
- [三、信号量的本质:计数器 + 预订机制](#三、信号量的本质:计数器 + 预订机制)
-
- [3.1 计数器视角](#3.1 计数器视角)
- [3.2 电影院模型](#3.2 电影院模型)
- [3.3 P / V 操作(核心)](#3.3 P / V 操作(核心))
- [3.4 两种信号量](#3.4 两种信号量)
- [3.5 信号量和 IPC 通信的关系?](#3.5 信号量和 IPC 通信的关系?)
- [四、接口详解:semget / semctl / semop](#四、接口详解:semget / semctl / semop)
-
- [4.1 semget:创建 / 获取信号量集](#4.1 semget:创建 / 获取信号量集)
- [4.2 semctl:控制(设初值、删除、看属性)](#4.2 semctl:控制(设初值、删除、看属性))
- [4.3 semop:执行 P/V(核心!)](#4.3 semop:执行 P/V(核心!))
- 五、动手实验:封装与实战
-
- [5.1 CreateSem / P / V / DestroySem](#5.1 CreateSem / P / V / DestroySem)
- [5.2 共享内存 + 信号量 = 完整体](#5.2 共享内存 + 信号量 = 完整体)
- 六、常见坑清单
- 七、总结与下一篇预告
-
- [7.1 本篇核心结论](#7.1 本篇核心结论)
- [7.2 下一篇预告](#7.2 下一篇预告)
一、概念地基:并发世界的五个名词
1.1 概念链条
多个执行流(进程)的世界里,下面这些概念环环相扣,一个都不能含糊:
| 概念 | 含义 |
|---|---|
| 共享资源 | 多个执行流都能看到的同一份公共资源 |
| 临界资源 | 被保护起来的共享资源 |
| 临界区 | 进程中访问临界资源的那段代码(注意:是代码,不是资源本身) |
| 互斥 | 任何时候,只允许一个执行流访问资源 |
| 同步 | 多个执行流访问临界资源时,具有一定的顺序性 |
| 原子性 | 要么不做,要做就一次做完,中间不可被打断 |
其中最容易被误解的是 "临界区":你写的代码 = 访问临界资源的代码(临界区)+ 不访问临界资源的代码(非临界区)。
所谓的对共享资源进行保护,本质上是对访问共享资源的代码进行保护。
保护临界区,就是变相保护临界资源。落到代码结构上就是:
text
------------------------------
加锁 ← 申请锁(必须原子!)
------------------------------
临界区 ← 访问临界资源的代码
------------------------------
解锁 ← 释放锁(必须原子!)
------------------------------
非临界区

1.2 回到我们的老问题
第五篇的共享内存为什么 "裸奔"?现在有了 vocabulary 可以精确描述:共享内存是一份共享资源 ,但没有被保护,所以它还不是安全的临界资源 ;server/client 对它的访问代码是临界区,却没有任何机制保证互斥 ------ 读到半截数据、覆盖丢失,全是这么来的。
"保护" 的经典姿势就是 1.1 的加锁结构。但这里藏着一个深刻的问题,下一节展开。
二、核心难点:锁本身也是共享资源(鸡生蛋问题)
锁要被所有执行流共享,那谁来保证锁的安全?答案:申请锁(加锁/解锁)的操作本身必须是原子的 ,这是由 OS 在内核层面保证的(通常靠硬件指令,如单条 swap/xchg ------ 一个时钟周期内完成读-改-写,不可被打断)。
由此产生一个 "鸡生蛋" 问题 :假想我们用一个普通变量 sem 当锁,用普通 C 语句操作它:
c
// 伪代码:两步走的 "减一" ------ 有 bug!
int sem = 16; // 16 个座位
// 进程 A: // 进程 B:
// reg = sem (读到 16) // reg = sem (也读到 16!)
// reg = reg-1 (算出 15) // reg = reg-1 (也算出 15)
// sem = reg (写回 15) // sem = reg (写回 15) ← 两个进程都"预订成功"!
sem-- 展开成 读、改、写 三条指令,中间可以被调度打断 ------ 两个进程各减一次,结果只减了一次。票卖多了。所以:
需要一个专门的、OS 保证操作原子性 的机制 ------ 这就是信号量 。它的
--和++不是你写的 C 语句,而是内核在关中断/上锁保护下替你完成的单步操作。
这也是理解信号量最反直觉的一点:信号量本身就是共享资源,对它的操作必须原子 ------ 而 OS 用硬件级手段替我们兜住了这个 "鸡生蛋",我们只需要放心调用 P/V。
三、信号量的本质:计数器 + 预订机制
3.1 计数器视角
信号量本质是一个计数器,用来描述临界资源中 "资源数量" 的多少。
关键认知:临界资源不一定被当成 "一个整体" 使用。把一个放映厅想象成临界资源,它内部包含很多子资源(座位 )------ 座位就是子资源,计数器的值就是空座数。对应到共享内存:把一块共享内存按区域划分 ,不同进程申请信号量后使用不同区域 → 并发访问 && 不出问题。
3.2 电影院模型
- 买票 = 对资源的预订机制。想访问临界资源的一小块,必须先 "买票"(申请信号量);买到了票,这个座位就预留给你的,等你随时访问,没人跟你抢;
- 电影院最怕两个问题:票卖多了 (计数器减到负数以下 → 超卖)、票号重复(两个进程拿到同一个子资源);
- 所以规则是:1. 不要访问同一个位置;2. 不要放入过多的进程。
3.3 P / V 操作(核心)
text
P(sem): sem--; // 申请/预订资源(荷兰语 Proberen=测试)
if (sem < 0) 阻塞挂起; // 没票了 → 挂到等待队列上睡
V(sem): sem++; // 释放/归还资源(Verhogen=增加)
if (有等待者) 唤醒一个; // 有人排队 → 叫醒一个
两个要点:
sem--之后发现sem < 0,进程阻塞挂起,挂在等待队列上 ------ 这就是为什么信号量的内核描述里必须有等待队列(第八篇看源码时留意它);- P/V 都是内核保证的原子操作 ------ 上一节的鸡生蛋问题在这里被终结。
3.4 两种信号量
| 类型 | 初值 | 含义 |
|---|---|---|
| 二元信号量 | 只有 0 / 1 两态 | 就是互斥! 整个资源一个执行流独占 |
| 多元信号量 | N > 1 | 资源划分成 N 个子资源,允许 N 个执行流各占一份 |
回忆:第五篇我们用 FIFO 只做了 "读方等写方" 的单向同步;二元信号量就是它的正规军版本。
3.5 信号量和 IPC 通信的关系?
两个问题
- 每个进程都要先看到同一个信号量才能做 P/V → 这还是 "看到同一份资源" 的问题,正是 System V 的老套路(ftok + key);
- 不是只有传递数据才叫 IPC!通知、同步、互斥,都算通信。 信号量不传数据,但它传递了 "协作状态" ------ 这就是它被归为 System V IPC 三大件之一的原因。回扣第一篇的四大目的:数据传输、资源共享、通知事件、进程控制 ------ 信号量完美对应第三条。
四、接口详解:semget / semctl / semop
头文件三件套:sys/types.h、sys/ipc.h、sys/sem.h。
⚠️ 和消息队列不同,System V 信号量操作的是信号量集合(semaphore set) ------ 一次创建一组信号量(几个计数器打包卖)。
4.1 semget:创建 / 获取信号量集
c
int semget(key_t key, int nsems, int semflg); // 返回 semaphore set identifier
key:ftok 生成,标识唯一性(老套路);nsems:信号量的个数 ------ 即这个集合里有几个计数器;semflg:IPC_CREAT/IPC_CREAT | IPC_EXCL/ 权限位,用法与 msgget 完全一致。
4.2 semctl:控制(设初值、删除、看属性)
c
int semctl(int semid, int semnum, int cmd, ...);
semnum:操作集合中第几个信号量(下标从 0 开始);- 常用 cmd:
| cmd | 用途 |
|---|---|
SETVAL |
把第 semnum 个信号量的值设为 arg.val → 用来初始化 |
IPC_RMID |
立即删除整个信号量集合 |
IPC_STAT / IPC_SET |
读取/设置 semid_ds 属性 |
GETALL / SETALL |
读取/设置所有信号量的值 |
"信号量的初始值是多少?" ------ 创建时默认值不确定,必须显式 SETVAL。这是信号量接口最著名的坑。
第四个可变参数是 union semun ------ 注意 它不在系统头文件里,需要自己定义:
c
union semun {
int val; /* SETVAL 用的值 */
struct semid_ds *buf; /* IPC_STAT / IPC_SET */
unsigned short *array; /* GETALL / SETALL */
};
4.3 semop:执行 P/V(核心!)
c
int semop(int semid, struct sembuf *sops, size_t nsops);
struct sembuf {
unsigned short sem_num; /* 操作集合中第几个信号量 */
short sem_op; /* 操作:-1 就是 P,+1 就是 V */
short sem_flg; /* 标志位 */
};
sem_op就是 P/V 的载体:sem_op < 0(如 -1)→ P 操作:申请资源,值不够时阻塞;sem_op = 0→ 等待信号量变为 0(少见,了解);sem_op > 0(如 +1)→ V 操作:释放资源;
nsops:本次调用执行多少个 sembuf 操作(一次可以原子地做多个操作 ,内核上限SEMOPM = 32)------ "同时预订两个资源" 这种复合需求就靠它,多个 sembuf 打包在一次系统调用里,要么全成功要么都不动;sem_flg两个重要标志:IPC_NOWAIT:资源不够不阻塞,直接返回EAGAIN(对应管道的 O_NONBLOCK);SEM_UNDO:进程意外退出时,内核自动撤销它的操作 ------ 防止 "P 之后崩溃,信号量永远为 0" 导致的死锁。生产代码建议默认加上。
内核描述结构 struct semid_ds ------ 老朋友了:
c
struct semid_ds {
struct ipc_perm sem_perm; /* 所有权和权限 */
time_t sem_otime; /* 最后一次 semop 的时间 */
time_t sem_ctime; /* 最后一次变更时间 */
unsigned long sem_nsems; /* 集合中信号量的个数 */
};
一条值得划红线的结论:"struct XXXid_ds 的结构 → key!" ------ 消息队列、共享内存、信号量的 XXXid_ds 里都有 ipc_perm,ipc_perm 里都有 key。这是三种 IPC 被统一管理的伏笔,第八篇揭晓。
五、动手实验:封装与实战
5.1 CreateSem / P / V / DestroySem
cpp
// comm.hpp
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <cstdio>
#include <cstdlib>
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
// 创建 + 初始化
int CreateSem(int nsems)
{
key_t key = ftok(".", 0x6666);
int semid = semget(key, nsems, IPC_CREAT | IPC_EXCL | 0666);
if (semid > 0)
{
union semun su;
su.val = 1; // 二元信号量,初值设为 1
semctl(semid, 0, SETVAL, su); // 初始化第 0 个信号量
}
else
{
semid = semget(key, nsems, 0); // 已存在,直接获取
}
return semid;
}
// P 操作:申请资源
void P(int semid)
{
struct sembuf sb = { 0, -1, 0 }; // 第 0 个信号量,-1
semop(semid, &sb, 1);
}
// V 操作:释放资源
void V(int semid)
{
struct sembuf sb = { 0, +1, 0 };
semop(semid, &sb, 1);
}
// 销毁
void DestroySem(int semid)
{
semctl(semid, 0, IPC_RMID);
}
注意 CreateSem 的双分支写法:第一个到达的进程走 IPC_CREAT|IPC_EXCL 分支并且顺手 SETVAL;后来者直接获取。这解决了 "谁初始化" 的时序问题 ------ 否则两个进程都初始化,初值就被覆盖了。
5.2 共享内存 + 信号量 = 完整体
用第五篇的共享内存,配上今天的信号量,把 "裸奔" 改造成 "互斥":
cpp
// 进程 A 和 B 都执行:
int semid = CreateSem(1);
// ... ftok + shmget + shmat 挂接共享内存 ...
P(semid); // 进临界区前申请
/* 访问共享内存(临界区) */
V(semid); // 出临界区后释放
对比第五篇的 FIFO 版 Wait/Signal:
| FIFO 版(第五篇) | 信号量版(本篇) | |
|---|---|---|
| 语义 | 单向 "数据就绪通知" | 通用互斥 / 同步原语 |
| 原子性保证 | PIPE_BUF 单次写入 | 内核级 P/V |
| 容量语义 | 有(会积压多个信号) | 计数器,可精确控制并发数 |
| 扩展到 N 个并发 | 困难 | 初值设 N 即可 |
信号量版还顺手升级了共享内存的使用模式:如果是多元信号量(初值 N),配合 3.1 说的 "按区域划分共享内存",N 个进程可以同时访问不同区域 ------ 并发与安全兼得。
命令行查看与删除(生命周期随内核,老规矩):
bash
$ ipcs -s # 查看 Semaphore Arrays(key, semid, owner, perms, nsems)
$ ipcrm -s <semid> # 删除信号量集
⚠️ 和消息队列一样,信号量随内核持久,进程退出不会自动释放,务必 IPC_RMID 或 ipcrm 清理 。而且信号量的残留比消息队列更隐蔽:队列残留顶多是 EEXIST,而信号量残留会让 "后来者直接获取" 时拿到一个值不确定的计数器 ------ 有人 P 了没 V,你的程序一启动就死锁。
六、常见坑清单
- 忘记 SETVAL ------ 创建了集合却没初始化,第 0 个信号量的值不确定,P 操作可能永久阻塞。"创建后立刻初始化" 写进肌肉记忆;
- P 了忘 V / P 之后崩溃 ------ 计数器永久减一,所有进程死锁。解法:
SEM_UNDO标志交给内核兜底; - 两个进程都 SETVAL ------ 后来者覆盖初值,语义错乱。用 5.1 的 "创建者初始化" 分支模式;
- 混淆 semid 和 semnum ------ semid 是集合句柄,semnum 是集合里第几个计数器(下标 0 开始)。集合里只有 1 个信号量时最容易把两者当一回事;
- 忘记清理残留 ------
ipcs -s检查、ipcrm -s删除。System V 三兄弟共同的宿命。
七、总结与下一篇预告
7.1 本篇核心结论
- 概念链:共享资源 → 保护后成为临界资源 → 访问它的代码是临界区 → 用互斥(唯一访问)与同步(顺序性)保护 → 保护的本质是保护代码段;
- 锁操作本身必须原子,OS 靠
swap/xchg等硬件指令解决 "鸡生蛋" ------ 信号量的 P/V 是内核保证的原生原子操作; - 信号量本质是计数器 + 资源预订机制(电影院买票模型):P 申请(减一,不够则挂等待队列),V 释放(加一,唤醒等待者);
- 二元信号量 = 互斥;N 元信号量 = 把资源划分成 N 份子资源,支持受控并发;
- 接口三件套
semget / semctl(SETVAL 初始化、IPC_RMID 删除) / semop(sembuf);SEM_UNDO防崩溃死锁,SEMOPM=32允许原子复合操作; - 信号量不传数据,传递的是 "协作状态" ------ 通知、同步、互斥都算 IPC。
7.2 下一篇预告
至此 System V 三大件全部到齐,而且我们反复看到一个彩蛋:shmid_ds、msqid_ds、semid_ds 的第一个成员都是 ipc_perm,里面都藏着 key;三种 XXXid 都是小整数;进程退出后三种资源都随内核持久。**这不是巧合 ------ 内核把三种 IPC 当成同一种资源统一管理。**下一篇《进程通信系列(八):内核视角 ------ 一套框架管理所有 System V IPC》,我们钻进 Linux 2.6 内核源码,看 struct ipc_ids 如何用一套数组 + 序号 + C 语言 "多态" 管住三种资源,解释你从前每篇记下的 "奇怪共性"。