先说清楚一件事:这条路走到这里,你手里的进程已经会"递消息"了——管道、消息队列、共享内存都能让 A 进程把某种"东西"交给 B 进程。但有一个问题它们统统绕不开:当两个进程同时去改同一块共享数据时,会不会乱套?
举个最朴素又最危险的例子。设想两个进程都要往同一个文件末尾写一行日志。进程 A 读到文件当前的末尾位置是 100,正准备写;同一瞬间进程 B 也来读末尾位置,读到的还是 100,也跟着往下写。结果两条日志错位、互相覆盖,甚至只剩一条。两个人同时踩一条独木桥,任何一人稍一犹豫,另一个人就把桥当成了自己的立足点。
这种"被多个执行流(进程或线程)同时访问的共享资源",我们叫它临界资源(critical resource);程序里访问这块临界资源的那一段代码,叫临界区(critical section),也叫关键区。注意,临界区不是一个"内存地址区域",而是指"访问共享资源的那段代码"。当两个以上执行流可能交错进入临界区,就可能产生竞态(race condition)——最终结果取决于谁先谁后、完全不确定的 bug。这类 bug 出了名的难查:它不报错、不崩溃,只是偶尔给你算错一个数、写坏一行日志。
要守住临界区,就得做到两件事之一:
- 互斥(mutual exclusion):同一时刻只允许一个执行流进入临界区。我进去了,你就得在门口等着。
- 同步(synchronization):让执行流之间达成"谁先谁后"的约定。比如写入必须发生在读取之前。
前者是"排他",后者是"排队"。而今天的主角——信号量(semaphore)——恰好能同时解决这两件事。它由计算机科学家 Dijkstra(迪杰斯特拉) 提出,已经在操作系统里稳稳跑了大半个世纪。
顺带澄清一个高频困惑:这里的**信号量(semaphore)和 Linux 另一套信号(signal)**完全是两码事——信号量是"协调多个执行流访问资源的计数器/闸门",而信号是用来向进程"通知事件"的软中断机制,别因为它们名字里都带"信号"就混为一谈。
信号量和 P、V 原语:由 Dijkstra 提出的同步基石
1930 年出生的 Edsger W. Dijkstra 是一位神人,最短路径、银行家算法、操作系统里的 PV 原语全跟他有关。1965 年前后,他在解决"并发程序如何互斥、如何同步"这个难题时,给出了一个极其优雅的小装置——信号量。
信号量的常规理解:执行流之间的互斥与同步
一句话,信号量就是为"执行流之间的互斥与同步"而生的。你可以把它想象成一个带计数的闸门:
- 闸门前竖着一块牌子,写着"还剩几张通行证";
- 每个执行流想进去,先拿走一张通行证(这张证没了,就排队等);
- 执行流出来时,把手里的通行证再放回牌子下面。
这个"牌子上的数字"就是信号量的值。当它被当成一把"只允许一个人进门"的锁(初始值为 1)用时,我们叫它二元信号量(binary semaphore),行为上等价于一把互斥锁(mutex);当它同时放行多个资源(初始值为 N)时,就是计数信号量(counting semaphore),用于管理一批同类可用资源。
信号量值的含义:S>0 / S=0 / S<0
信号量的值 S,在不同取值下含义完全不同,把它背下来是入门第一步:
- S > 0:表示当前还有 S 个可用资源。比如 S=3,说明还有 3 张通行证可以发。
- S = 0:表示没有可用资源,且暂时没有执行流在等待。此时再来一个执行流申请资源,就得排队。
- S < 0:表示资源已耗尽,且有 -S 个执行流正排队等待。比如 S=-2,说明有 2 个执行流在桥上或者桥头排队。
这里我想重点标记一件事,因为它既是教科书的老规矩,也恰恰是 Linux 的一个细节差异(后面讲 semop 时还会再回到它):Dijkstra 的原始模型里,P 操作会让 S 减到负数,用负值记录"有多少个等待者";但 Linux 内核里,一个 System V 信号量的实际值 semval 永远不会变成负数——内核另外用独立的计数器(semncnt、semzcnt)去记录"有多少个进程在等",而不让真正的信号量值掉进负区。这一点后面我们会专门展开,现在你只需知道"负值是教学模型,Linux 用别的办法计数",两者描述的是同一件事的两种记法。
信号量的本质:一个计数器加一条等待队列
信号量本质上就是下面这个结构。要理解并发原理,这段伪代码是必须吃透的地基:
// 信号量本质上是一台"计数器 + 一条等待队列"的组合体
struct semaphore
{
int value; // 计数器的值:S 含义参见上文
pointer_PCB queue; // 等待队列:装着所有因拿不到资源而睡眠的进程(PCB 是进程控制块)
};value 记录"还剩多少资源",queue 记录"谁在为拿不到资源而排队"。每次有人放回资源,就去 queue 头唤醒一个;每次有人拿资源而资源不够,就把它塞进 queue 尾。这就是信号量最底层的运转方式。
P 原语:想拿资源,先"举手"
P 原语(字母 P 来自荷兰语 "proberen",意为"测试/尝试")是申请资源的操作,对应"进入临界区"这一动作。教科书式的伪代码如下:
P(s)
{
s.value = s.value - 1; // 先扣一张通行证
if (s.value < 0) // 扣完发现变负了,说明没证了
{
// 把当前进程的状态置为"等待"(阻塞)
// 将该进程的 PCB(进程控制块)插入等待队列 s.queue 的末尾
// 一旦入队,当前进程就睡眠,等待别人 V 时唤醒
}
}把它翻译成人话:进门前我先把计数器减 1;如果减完还剩不小于 0,说明我成功抢到了资源,直接进门;如果减完成了负数,说明资源已经被抢光,我就把自己挂到等待队列里睡大觉,等着别人释放。关键一点:P 是"不成功的申请会让自己卡住"的操作,这正是"P 会阻塞"的原因。
注意一个细节:s.value = s.value - 1 以及后面的判断,在操作系统中必须是原子操作——也就是说检查与扣减连成一体、不可被打断,否则两个进程同时读到 value=1,各自减 1 后不知道谁抢到了。这正是它要被做成"原语",并由操作系统(内核)保证其原子性的原因。
V 原语:用完了记得"还"
V 原语(V 来自荷兰语 "verhogen",意为"增加")是释放资源的操作,对应"离开临界区"这一动作。它和 P 恰好相反:
V(s)
{
s.value = s.value + 1; // 放回一张通行证
if (s.value > 0) // 加了之后还是正数,说明有人在等资源
{
// 唤醒等待队列 s.queue 中头一个等待的进程
// 把它从"等待"变为"就绪"
// 再交给操作系统调度器,放进就绪队列
}
}人话版:办事完了我先把计数器加 1,表示还回来一张证;如果加完后值大于 0,说明在我占用的这段时间里曾经有人来排队,我就去等待队列头唤醒一个,让它接着干。注意 V 几乎不会"让自己卡住",它只会唤醒别人。
把 P、V 配对使用,就是最标准的临界区保护模板——进入临界区前调 P,离开临界区前调 V:
P(s); // 进入临界区:申请资源,拿不到就等
// …… 只有我(独占)在访问共享资源,别人进不来 ……
V(s); // 离开临界区:释放资源,唤醒排队的人这中间被 P、V 夹住的那段代码,就是我们反复强调的"关键区/临界区"。P/V 一进一出,互斥和同步两只鸟一次全打到。
写到这里,给一个最容易犯的错提个醒:P 和 V 必须成对出现。如果某个分支里只 P 不 V,那么有执行流会在"拿到资源却没还"的情况下消失,信号量值会越用越小,最终所有人都被卡死在门口——这就是大名鼎鼎的死锁(deadlock) 根源之一,也是最常见的并发 bug。
System V 信号量的结构:信号量集 semid_ds 与 ipc_perm
刚才讲的是"纯理论"的信号量。现在进入 Linux 里真实存在的那套东西。Linux(以及几乎所有 UNIX 流派)实现了不只一种信号量:POSIX 信号量(sem_init/sem_wait 等,常和 pthread 线程一起用)和 System V 信号量(semget/semop/semctl,历史悠久,主要面向进程间)。今天我们讲 System V 这套,与它同门的还有 System V 消息队列和 System V 共享内存,三者并称 System V IPC。
一个和直觉有点反差的点是:System V 信号量在创建时就指定好"集合里包含几个信号量"。也就是说,semget 一次给你的是一个信号量集(semaphore set),里面可以装 1 个或多个独立的信号量,你用一个整数编号去区分它们。管理这样一个集合的内核结构叫 semid_ds,定义在 <sys/sem.h> 里:
struct semid_ds {
struct ipc_perm sem_perm; /* 属主、用户权限等(ipc_perm 结构) */
time_t sem_otime; /* 最近一次 semop 成功执行的时间 */
time_t sem_ctime; /* 最近一次改变集合的时间 */
unsigned long sem_nsems; /* 这个集合里一共装了几个信号量 */
};对应地,需要一个描述"这个 IPC 对象归谁管、谁有权限碰"的通用结构 ipc_perm(System V 消息队列、共享内存、信号量三套对象共用这一份"户口本"):
struct ipc_perm {
key_t __key; /* 创建时传入的键 key */
uid_t uid; /* 属主的有效用户 ID */
gid_t gid; /* 属主的有效用户组 ID */
uid_t cuid; /* 创建者的有效用户 ID */
gid_t cgid; /* 创建者的有效用户组 ID */
unsigned short mode; /* 权限位:读/写(与文件的 0666 同思路) */
unsigned short __seq; /* 序列号:内核分配 id 时的递增序号 */
};这里正好把两个你迟早要拎清的角色摆出来:
- 键 key:一个
key_t整数,相当于这个 IPC 对象在"全院共享的名字"。多个进程只要用同一个 key,就能"认出"同一个信号量集。它是面向所有进程的全局身份。 - 标识符 id(semid):
semget返回的一个非负整数,相当于内核给当前进程的一张"取号牌"。绝大多数后续调用(semop、semctl)认的是 id,不是 key。它和文件的文件描述符 fd 在概念上很像:同一个对象,在不同进程里可能是不同的 id,但只要 key 相同,它们指向同一个底层信号量集。
展开一点讲"认亲"的过程:进程 A 用 key=100 创建了信号量集,得到 semid=5;进程 B 也用 key=100 去打开(而不是创建),内核会把同一个集合的另一个 id(比如 semid=9)递给 B。A 手上的 5 和 B 手上的 9 编号不同,但背后的信号量集是同一个,所以 A 的 P 能被 B 的 V 唤醒。id 是"本地名",key 是"全球名",key 相同 → 指向同一个集合。
此外,sem_perm.mode 里保存权限,只有"创建者"或特权进程能用 IPC_SET 去改它(这就是 semctl 手册里说"高亮字段可用 IPC_SET 修改"的意思)。
信号量操作接口:semget / semctl / semop
System V 信号量只有三个重要接口,功能各管一摊,记住铁三角就掌握了全局:
| 接口 | 干什么 | 一句话 |
|---|---|---|
semget | 创建 / 打开一个信号量集 | 拿"id 取号牌" |
semctl | 控制 / 初始化 / 查询 / 删除 | "后厨"总管,干杂活 |
semop | 对集合里的信号量做 P/V 操作 | 真正"进出临界区" |
semget:创建或打开一个信号量集
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);key:信号量的"键"。它可以由ftok计算出来,也可以传常量IPC_PRIVATE(见下文)。nsems:表示这个集合里要装几个信号量。创建时必须给出 ≥1 的合法个数;打开已存在的集合时传 0 即可(集合里到底几个以内核为准)。semflg:一组旗标,主要三类——权限位(0xxx,与文件权限同思路,如0666表示任何人可读可写)、IPC_CREAT(不存在则创建)、IPC_EXCL(和IPC_CREAT同时给,若 key 已存在则返回错误EEXIST)。
返回值:成功返回非负整数,也就是信号量集标识符(semid);失败返回 -1 并设置 errno。
三个经典组合,建议背下来:
IPC_CREAT | 0666:不存在就建,存在就直接打开——"要么建要么用",幂等,最常用。IPC_CREAT | IPC_EXCL | 0666:必须是我新建的才肯开门,已存在就报错——用来保证"只让一个进程初始化",避免大家一起初始化把初始值覆盖掉。IPC_PRIVATE作为 key:内核根本不用 key,而是为这个进程单独造一个全新集合,返回的 id 谁也不知道(除了这个进程和它的 fork 后代)。适合"就想自己用一对特殊子进程、不想和人共享 key"的场景。
semctl:控制与初始化,必须知道"第四参是联合体"
int semctl(int semid, int semnum, int cmd, ...);semid:semget 返回的集合标识符。semnum:集合中第几个信号量(编号从 0 开始)。对某些命令(比如IPC_RMID)它会被忽略,传 0 即可。cmd:要执行的动作。常用的几个:SETVAL:把第semnum个信号量设成指定初始值(最常用,用于初始化)。GETVAL:读出第semnum个信号量当前的值。GETPID/GETNCNT/GETZCNT:读"谁最后一次改它 / 有多少进程在等它增加 / 有多少进程在等它归零"。GETALL/SETALL:一次性读写集合里所有信号量的值表。IPC_STAT/IPC_SET:读 / 改semid_ds(含权限)。IPC_RMID:删除整个信号量集。
上面这些命令,前三个参数跑不掉;而当命令需要一个附加参数时,第 4 个参数的类型是 union semun 这个联合体。这里是一个著名的坑:union semun 在系统头文件里并不预定义(glibc 出于兼容性考虑特意不给),必须你自己定义。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 就无从谈起。
semop:真正的 P/V 操作,参数是一个潜在的"结构数组"
int semop(int semid, struct sembuf *sops, size_t nsops);semop 对集合里选中的信号量执行一组操作。第三个参数 nsops 表示 sops 指向的结构数组里有几个操作。每个操作由一个 struct sembuf 描述,它对"对第几个信号量做加减多少":
struct sembuf {
unsigned short sem_num; /* 集合中第几个信号量(从 0 数起) */
short sem_op; /* 操作量:负数=P,正数=V */
short sem_flg; /* 旗标:IPC_NOWAIT、SEM_UNDO */
};struct sembuf 的三个字段怎么填,直接决定了你在干什么:
sem_num:操作目标是集合里第几个信号量。第一个是 0,第二个是 1,以此类推。只造了一个信号量的集合,这里就填 0。sem_op:操作的本质。负数是 P(减),正数是 V(加),0 是"等到信号量值归零为止"的同步操作:sem_op > 0:把该信号量的值加上sem_op,即 V 操作,释放资源。这个操作永远能立即完成,从不阻塞。sem_op < 0:把该信号量的值减去|sem_op|,即 P 操作,申请资源。值不够减就阻塞睡眠(或IPC_NOWAIT直接失败)。sem_op == 0:等信号量的值变成 0 才通过,用于"等某资源清空"的同步点。
sem_flg:行为开关,两个值:IPC_NOWAIT:如果这次操作无法立刻进行(比如 P 但值不够),不睡眠、直接返回 -1 并置 errno 为 EAGAIN。类似非阻塞 I/O。SEM_UNDO:见下文专门章节。
返回值:全部操作成功返回 0;否则 -1 并设置 errno。常见 errno:EAGAIN(配了 IPC_NOWAIT 却做不了)、EINTR(被信号打断,睡眠被唤醒且没做成)、EIDRM(集合被别的进程 IPC_RMID 删了)。
一次 semop 的原子性:要么全做,要么全不做
这是这个接口里最值得掰扯的一个设计,也是课件里"忘记 semop 的结构数组行为"要讲的点。
第一,sops 是一个数组,不是单个结构。nsops 是它有几个元素。你要对集合里的多个信号量同时做操作,就填一个数组、传 nsops=N。这是 semop 和 "一次只碰一个信号量" 的想像不同之处——它可以一口气对 N 个信号量做 N 个 P/V。
第二,这套操作是原子的:内核把数组里所有操作当一个整体来执行——要么全部立即成功,要么一个都不做(整体阻塞等待,或整体 EAGAIN 失败)。也就是说,不存在"前两个 P 成功、第三个 P 失败,然后前两个白做了"的情形;要么进,要么不进,绝不留下"只拿到一半资源"的中间态。这正是多资源同时申请时不产生"部分占有→死锁"隐患的机制保障。
第三,也是最容易栽的坑:struct sembuf 在栈上是没初始化的垃圾值,三个字段你必须全部显式填好。 很多新手只填了 sem_op=-1,忘了 sem_num 和 sem_flg,结果程序"偶尔"对错误的信号量做操作、或者带了一堆乱七八糟的旗标,行为完全不可预期(未定义行为)。规矩只有一条:每个 sembuf 的三个字段都写上,别省。
(另一个和它关联的规模限制:一次 semop 的操作数(nsops)内核有上限(Linux 的 SEMOPM 一般是 32)。别幻想一次原子地锁一千个信号量。)
P 会阻塞、V 会唤醒,以及"值不为负"的真相
把 semop 的行为和前面的 P/V 理论对齐一遍:
P 阻塞:当 sem_op 为负、而信号量当前值不够减时,进程被内核挂起睡眠(除非 IPC_NOWAIT)。这事什么时候结束?被挂起的前提是值不够,它被唤醒的唯一充分条件是"值够了"——也就是有别的进程做了一次 V。这就是"P 阻塞、V 唤醒"的闭环。
V 唤醒:当某个进程做了 V、把值加到足够大,内核会立刻检查等待队列,把满足条件的等待者唤醒(Linux 里唤醒是"主动给予"操作,即内核直接把信号量交给等在门口的那个进程,并非只广播一下、让大家再抢一次)。
"值不为负"的真相:前面说过,教科书说"P 会让 S 变负,负值表示等待者数量"。但在 Linux 内核里,真实值 semval 永远不小于 0。semval < |sem_op| 时进程直接进睡眠队列,等待者数量由 semncnt(等值升高的进程数)等独立计数器记录,而不是把 semval 扣成负数。所以:你永远看不到一个 System V 信号量的值是 -3;负的只有你脑海里那套教学模型,实战中以 GETVAL 查出来的一定是 ≥0 的整数。这一节我依据 Linux 内核 ipc/sem.c 的实现和 semop(2) 手册整理,建议你把它和理论的"负值记等待数"对照着记,两套对应关系就绪。
SEM_UNDO:课件里那个神秘的四字"图因素志"
先破个谜:课件/PDF 正文里出现的"图因素志"四个字,其实是 SEM_UNDO 这个宏被音译/转录时的口误误写。它的真名是 SEM_UNDO,读作"撤销操作",我下面统一用它的本名。
SEM_UNDO 是 sem_flg 里最实用的一个重要旗标,作用一句话:当进程退出(无论正常 return、被 kill,还是段错误崩溃)时,内核自动"回滚"这个进程没做完的信号量操作。
这背后是内核的一个叫 semadj(semaphore adjustment,信号量调整值)的账本:每个进程对每个信号量,只要用了 SEM_UNDO,内核就记录它的"净操作量"。P 了一格记 +1,V 了一格记 -1(对的,是相反符号,内核记的是"该还回去的量")。进程正常退出的瞬间,内核把这个 semadj 反向应用回信号量:补上它欠下的 P。
为什么这件事救命?回到信号量当锁用的场景:某进程 P(s) 把锁关了,正在临界区里"干活",结果它突然崩溃(比如 abort、收到 SIGSEGV)。如果没有 SEM_UNDO,它来不及 V(s) 就死了,锁永远关着,其他所有进程将永久卡死——这是共享内存 + 信号量最经典的生产事故。有了 SEM_UNDO,进程一死内核立刻把它欠的 P 还回去,锁自动解开,其他进程得以继续运行。
struct sembuf sb;
sb.sem_num = 0;
sb.sem_op = -1; // P 操作
sb.sem_flg = SEM_UNDO; // 带上"撤销"旗标:进程异常退出时自动补还三个和 SEM_UNDO 相关的边界,同样别踩:
- 它只跟"进程"走,不跟特定线程或特定代码走。也就是说"记账"的粒度是该进程对某信号量做的累计净操作。进程里任何线程用了带
SEM_UNDO的 P,都会记到这个进程的账上。 - 内核在进程退出做 UNDO 时,不会让信号量值变负,而是把值截断到 0(宁可锁被放开,也不让进程退出被卡住)。这意味着完全靠
SEM_UNDO兜底的边界情形下,信号量可能不是"精确还原"而是"尽量还原"。多数业务场景这已足够。 SEM_UNDO调整值有上限(Linux 中单个调整值被限制在 0 与SEMVMX之间,SEMVMX一般为 32767)。别用SEM_UNDO去做"一次性加几万"的巨型操作,那超出了它设计的语义范围。
一句话总结:写商用并发代码,P/V 的 sem_flg 一定带上 SEM_UNDO,这是防崩溃死锁的廉价保险。
亲手敲一遍:一个能跑的最小 C 程序
理论讲了一箩筐,是时候让手见见血。下面这个 C 程序把上面三个接口串起来:父进程分叉出子进程,两个进程用同一个信号量当"门锁",各自占用临界区打印一个小符号。编译运行一下,你会直观看到"同一时刻只有一个人能进门"。
/*
* svsem_mutex_demo.c
* 父、子两个进程用 System V 信号量当"二元锁",交替占用临界区打印
* 编译:gcc svsem_mutex_demo.c -o svsem_mutex_demo
* 运行:./svsem_mutex_demo
*/
#include <stdio.h> /* printf / perror / fflush */
#include <stdlib.h> /* exit */
#include <unistd.h> /* usleep / fork */
#include <sys/wait.h> /* wait */
#include <sys/types.h> /* key_t 等类型 */
#include <sys/ipc.h> /* ftok, IPC_CREAT 等 */
#include <sys/sem.h> /* semget / semop / semctl, struct sembuf */
/* glibc 出于历史原因不对外提供 union semun,必须自己定义(semctl(4) 也要求) */
union semun {
int val; /* 供 SETVAL:要设的初始值 */
struct semid_ds *buf; /* 供 IPC_STAT / IPC_SET */
unsigned short *array; /* 供 GETALL / SETALL */
};
/* P 操作:把第 0 个信号量减 1(申请资源,拿不到就阻塞) */
static int P(int semid)
{
struct sembuf sb;
sb.sem_num = 0; /* 操作这个集合的第 0 个信号量 */
sb.sem_op = -1; /* 负数 = P 操作 = 减 1 */
sb.sem_flg = SEM_UNDO; /* 带上撤销旗标:进程崩溃时自动还回去 */
return semop(semid, &sb, 1); /* 只操作 1 个信号量 */
}
/* V 操作:把第 0 个信号量加 1(释放资源,唤醒等待者) */
static int V(int semid)
{
struct sembuf sb;
sb.sem_num = 0; /* 仍是第 0 个信号量 */
sb.sem_op = 1; /* 正数 = V 操作 = 加 1 */
sb.sem_flg = SEM_UNDO; /* 同样带上撤销旗标 */
return semop(semid, &sb, 1);
}
int main(void)
{
key_t key;
int semid;
union semun arg;
pid_t pid;
int i;
/* 1. ftok 造"键":用当前目录 + 'S' 工程代号混成一个 key_t */
/* 路径必须真实存在、对参与进程都可访问 */
key = ftok(".", 'S');
if (key < 0) { perror("ftok"); exit(1); }
/* 2. semget 创建集合(含 1 个信号量) */
/* IPC_CREAT|IPC_EXCL:必须由我新建,已存在立刻报 EEXIST */
/* 0666:任何人可读可改 */
semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
if (semid < 0) {
perror("semget");
/* 若返回 EEXIST,多半是上次没删干净,可用 ipcrm -s 清理 */
exit(2);
}
/* 3. semctl 初始化:把第 0 个信号量的值设为 1(= 一把开着的锁) */
arg.val = 1; /* 二元信号量:1 空闲,0 被占用 */
if (semctl(semid, 0, SETVAL, arg) < 0) { perror("semctl SETVAL"); exit(3); }
/* 4. 分叉出子进程,父、子共享同一个信号量集(靠 key,不是靠 fork 继承) */
pid = fork();
if (pid < 0) { perror("fork"); exit(4); }
if (pid == 0) {
/* 子进程:打印 10 组括号,每组都先 P 后 V */
for (i = 0; i < 10; i++) {
if (P(semid) < 0) break; /* 进入临界区:关锁 */
printf("["); fflush(stdout); /* 只被锁保护的一段输出 */
usleep(20000); /* 故意睡 20ms 模拟耗时 */
printf("]"); fflush(stdout);
if (V(semid) < 0) break; /* 离开临界区:开锁 */
}
exit(0);
}
/* 父进程:打印 10 组尖括号,同样先 P 后 V */
for (i = 0; i < 10; i++) {
if (P(semid) < 0) break;
printf("<"); fflush(stdout);
usleep(20000);
printf(">"); fflush(stdout);
if (V(semid) < 0) break;
}
wait(NULL); /* 等子进程结束并收尸,避免僵尸进程 */
/* 5. 用毕销毁:IPC_RMID 删除整个集合(此时 semnum 被忽略,传 0 即可) */
if (semctl(semid, 0, IPC_RMID) < 0) { perror("semctl IPC_RMID"); exit(5); }
printf("\nall done, set removed.\n");
return 0;
}运行后你会看到类似输出:
$ gcc svsem_mutex_demo.c -o svsem_mutex_demo
$ ./svsem_mutex_demo
[<<><><><><><><><>]
all done, set removed.
注意观察:因为 P/V 保证了互斥,[ 和 ] 一定连在一起、< 和 > 也一定连在一起,绝不可能出现 [< 或 >] 这种串台。你在 usleep 那 20ms 里本是最容易被打断的时刻,但锁保证了没人能插队。把 P/V 试着删掉一个,再跑一次,输出立刻乱套——这就是信号量存在的意义,亲身踩一遍胜过读十遍。
(小提示:如果重复运行报 semget: File exists,说明上一次程序被中途打断、集合没被 IPC_RMID 删掉。Linux 的 System V 信号量生命周期随内核,不是随进程,不删就一直活着。用 ipcrm -s <semid> 清掉即可,下一节详细讲。)
用建造者模式封装 Sem:把裸接口藏起来的正确姿势
直接用 semget/semop/semctl 三个裸接口编程,代码能跑,但写着难受:要先造 key、记挂着那个得自己定义的 union semun、还得小心翼翼记得 IPC_RMID……而且 semop 的"底层实现细节"全挤在业务代码里,平台一旦有变化就得满文件地改。
于是就有了课件里这个经典的做法:用面向对象把信号量包起来,再用建造者模式(Builder Pattern)把"复杂的创建过程"从"使用"里剥离出来。 下面这段出自课件配套代码(原仓库见课件给的 Gitee 链接),我们逐行拆给你看。
为什么需要封装:裸接口的三宗苦
动手之前先共情一下裸接口的痛点,你才知道封装在解决什么问题:
- 创建繁琐且分散:
union semun得自己定义、SETVAL得自己写、IPC_EXCL语义要想半天——这些"样板代码"不该打扰使用者。 - 销毁是雷区:谁创建谁负责删,删漏了信号量就变成"内核里的幽灵",下次运行直接
EEXIST卡住。 - P/V 名字不直观:接受教育的人都习惯
P/V两个字母,但新手看semop(semid, sb_array, 1)一脸懵——封装成obj.P()/obj.V()之后,语义一目了然。
封装的目标很朴素:使用者只需要说"我要一个初始值为 1、能当锁用的信号量",剩下的交给代码去办。
Sem.hpp:一个会"自己关心死活"的信号量类
第一层封装是 Semaphore 类。它把 semid 和"这集合是否归我所有"这个 flag 记在对象里,对外只暴露 P() 和 V(),并在析构函数里自动销毁。
// Sem.h —— 信号量类的单文件封装(课件配套代码的核心部分)
#pragma once // 防止头文件被重复包含
#include <iostream> // std::cout / std::cerr
#include <string> // std::string
#include <memory> // std::shared_ptr
#include <cstdlib> // exit
#include <sys/types.h> // key_t
#include <sys/ipc.h> // ftok / IPC_CREAT
#include <sys/sem.h> // semget / semop / semctl
#include <unistd.h> // POSIX 类型
const std::string pathname = "/tmp"; /* ftok 第一参:一个真实存在的目录路径 */
int proj_id = 0x77; /* ftok 第二参:工程代号(1~255 内取值) */
/* GET_SEM:只"打开"已存在的集合。注意这里没给权限位,因为集合早已建好 */
#define GET_SEM IPC_CREAT
/* BUILD_SEM:创建集合。IPC_EXCL 保证"已存在则报错";0666 给全读写权限 */
#define BUILD_SEM (IPC_CREAT | IPC_EXCL | 0666)
class Semaphore
{
public:
Semaphore(int semid, int flag) : _semid(semid), _flag(flag)
{
}
/* P 操作:申请资源(关锁) */
void P()
{
struct sembuf sb = {0, -1, SEM_UNDO}; /* 第0号,减1,带撤销旗标 */
int n = ::semop(_semid, &sb, 1); /* 对集合做一个操作 */
(void)n; /* 这里不细究返回值,先忽略 */
}
/* V 操作:释放资源(开锁) */
void V()
{
struct sembuf sb = {0, 1, SEM_UNDO}; /* 第0号,加1,带撤销旗标 */
int n = ::semop(_semid, &sb, 1);
(void)n;
}
/* 析构函数:没人再引用这个对象时自动执行 */
~Semaphore()
{
if (_flag == GET_SEM) return; /* 我只是"借用"别人的集合,别删 */
/* 只有用 BUILD_SEM 亲手建出来的集合,析构时才顺手销毁 */
/* IPC_RMID 时参数 semnum 会被忽略,传 0 即可 */
int n = ::semctl(_semid, 0, IPC_RMID);
(void)n;
std::cout << "sem set destroy!" << std::endl; /* 打印一条销毁日志 */
}
private:
int _semid; /* semget 返回的信号量集合标识符 */
int _flag; /* 记住当初是 GET 还是 BUILD,决定析构时要不要删 */
};
/* 给 shared_ptr<Semaphore> 起个别名,用起来省手 */
using sem_sptr = std::shared_ptr<Semaphore>;逐行要点:
- RAII 思想:资源的生命周期跟着对象走,对象销毁(析构)时自动做清理(
IPC_RMID)。这就彻底规避了"忘记删"的噩梦。 _flag是"是否归我所有"的开关:借来的(GET_SEM)绝不动手删,自己建的(BUILD_SEM)才负责销毁。 这是"谁创建谁负责(owner-of-record)"原则的一个体现。P()/V()里用的是带SEM_UNDO的操作,等于把"崩溃自恢复"的保险也内置进去了(这正是上一节"图因素志/SEM_UNDO"的现实落点)。- 用两个宏分别代表"打开已有"和"新建"。配合下面这个建造者,语义非常清晰。
SemaphoreBuilder:建造者模式,"把建造和使用分离"
建造者模式(Builder Pattern)是一种创建型设计模式。它把"如何构造一个复杂对象"这件事,从"如何使用这个对象"里拆出来,放到一个专门的"建造者"类里,好处是:
- 创建步骤可以高度定制、可以被复用地复用;
- 使用方不用关心内部到底怎么调
ftok、semget、SETVAL; - 建造过程可以被拆成"先设置参数、后统一制造"两段,参数可以链式给出(这正是把"建造"和"使用"分离的关键)。
这里二次基建出一个 SemaphoreBuilder,它负责:记住初始值 → 造 key → semget → SETVAL 初始化 → 打包成 shared_ptr<Semaphore> 返回。
// Sem.h(续)—— 用简单的建造者模式去构建"仅含一个信号量"的集合
class SemaphoreBuilder
{
public:
SemaphoreBuilder() : _val(-1) /* 初始值默认设非法值 -1,逼你先 SetVal */
{
}
/* 链式接口:返回自身的引用,支持 sb.SetVal(1).SetVal(...) 连续写 */
SemaphoreBuilder &SetVal(int val)
{
_val = val;
return *this; /* 返回 *this,让调用可以在它后面继续 .Build() */
}
/* 依据 flag(GET_SEM / BUILD_SEM)"制造"一个 Semaphore 对象 */
sem_sptr Build(int flag)
{
/* 0. 合法性检查:没先 SetVal 就不许建造 */
if (_val < 0)
{
std::cerr << "you must init first!" << std::endl;
return nullptr;
}
/* 1. 申请 key:ftok(路径, 工程号) 揉出唯一键 */
key_t k = ::ftok(pathname.c_str(), proj_id);
if (k < 0)
exit(1); /* 造键失败,直接退出(教学场景从简) */
/* 2. 建/开一个只含 1 个信号量的集合 */
int semid = ::semget(k, 1, flag);
if (semid < 0)
exit(2); /* 创建/打开失败 */
/* 3. 只有"新建"(BUILD_SEM)才需要初始化;GET_SEM 复用已有值 */
if (BUILD_SEM == flag)
{
/* union semun 系统不预定义,须自己定义(课件特别强调此点) */
union semun
{
int val; /* 供 SETVAL 用 */
struct semid_ds *buf; /* 供 IPC_STAT / IPC_SET 用 */
unsigned short *array; /* 供 GETALL / SETALL 用 */
struct seminfo *__buf; /* 供 IPC_INFO(Linux 专有)用 */
} un;
un.val = _val; /* 把建造者记住的初始值写下去 */
if (::semctl(semid, 0, SETVAL, un) < 0)
exit(3); /* 初始化失败 */
}
/* 4. 打包成对象返回:shared_ptr 保证没人用时自动析构(含销毁集合) */
return std::make_shared<Semaphore>(semid, flag);
}
~SemaphoreBuilder()
{
}
private:
int _val; /* 所有要设置的初始值(本例只造单信号量,用它足够) */
};拆解建造者的四个步骤,对照着裸接口的顺序,你会看得很清楚:
- 第 0 步校验:把"必须先设置初始值"变成强制约束,直接拦掉一半误用。
- 第 1 步造 key:
ftok调用被封装在这里,使用者看不到也不需要关心。 - 第 2 步建/开集合:一个
flag决定GET_SEM(打开)还是BUILD_SEM(新建),使用者只传一个常量,不必记IPC_CREAT|IPC_EXCL|0666那一串。 - 第 3 步只在新建时初始化:这是个很关键的正确性细节——
GET_SEM复用一个已存在的集合,绝不能再去SETVAL,否则会把别人正在用的信号量值偷袭覆盖掉。建造者把"是否该初始化"替你判断好了。 - 第 4 步返回托管对象:用智能指针包装,自动获得"没有人引用时自动销毁"的 RAII 收益。
这里尤其要给第 3 步划重点:教学代码里最容易犯的错,就是对"打开已有集合"也做一次 SETVAL,把它重置为你想要的值。想一想,如果两个进程都要往同一个信号量上做 SETVAL,那它们就根本无法互斥了——A 刚把值设成 1,B 又设成 0,锁形同虚设。建造者把"初始化"隔离成"只在 BUILD 分支执行",从根上堵住了这种覆写。
Writer.cc:用二元信号量让显示器"交替打印"
封装好之后,使用方干净到令人舒适。下面这个 Writer.cc 就用上面两个类,实现课件里那个经典演示:父、子进程共享一个"初始值为 1"的二元信号量,交替独占标准输出(当作临界区)打印一串字符。
// Writer.cc —— 用 Sem 类 + 建造者模式,让父子进程交替打印
#include "Sem.h" // 引入上面的 Semaphore / SemaphoreBuilder
#include <cstdio> // printf / fflush
#include <cstdlib> // rand / srand
#include <time.h> // time(给 srand 播种用)
#include <unistd.h> // fork / usleep
int main()
{
/* 建造者:先设初始值 1,再以 BUILD_SEM 创建并初始化集合 */
SemaphoreBuilder sb;
auto fsem = sb.SetVal(1).Build(BUILD_SEM); /* 值为1的信号量,当锁用 */
if (fsem == nullptr)return 1; /* 建造失败守卫 */
/* 给随机数播种,让下面的 usleep 延迟多样化,效果更直观 */
srand((unsigned int)time(nullptr));
if (fork() == 0)
{
/* 子进程:用 GET_SEM 重新打开同一集合(key 相同 → 同一信号量) */
auto csem = sb.Build(GET_SEM);
int cnt = 10;
while (cnt--)
{
csem->P(); /* 进入临界区:关锁 */
printf("C"); fflush(stdout);
usleep(rand() % 95270); /* 随机睡 0~95ms,模拟耗时 */
printf("C "); /* 因受锁保护,"CC" 不会被打断 */
usleep(rand() % 43990); /* 再随机睡一小段 */
fflush(stdout);
csem->V(); /* 离开临界区:开锁 */
}
exit(0);
}
/* 父进程:打印 50 组 */
int cnt = 50;
while (cnt--)
{
fsem->P();
printf("F"); fflush(stdout);
usleep(rand() % 95270);
printf("F ");
usleep(rand() % 43990);
fflush(stdout);
fsem->V();
}
return 0;
}编译运行:
g++ -std=c++11 Writer.cc -o writer
./writer一个典型输出长这样(每次运行因随机延迟不同排序各异):
$ ./writer
CC CC CC CC FF FF FF FF FF CC CC FF FF FF FF CC CC FF ...
关键观察:
- "CC" 或 "FF" 一定是连体的。因为从
fsem->P()到fsem->V()之间是临界区,同一时刻只有一个进程能进来,所以"先打印的 C / F"和"后打印的 C / F"不会和对方串台。 - 父进程用
fsem、子进程用csem,两个是同一个集合上的两把"钥匙"。它们的semid可能不同编号,但都通过相同的 key 指向同一个底层信号量,所以能互相"认出"彼此的 P/V。 - 子进程先
fork前创建了集合,子进程再用GET_SEM打开,通过_flag==GET_SEM保证子进程析构时不会误删集合;只有父进程那个BUILD_SEM创建的对象析构时才IPC_RMID。这一借一建的边界,正是建造者里flag参数的价值。
值得再点一次——SetVal(1).Build(BUILD_SEM) 这一行,是建造者模式"链式编程"的直观体现:SetVal 返回 *this 引用,于是可以立刻接 .Build(...);使用方只描述"我要什么",不碰任何底层细节,这正是设计模式的魅力。
生命周期、权限与命令行管理:信号量与内核同呼吸
生命周期随内核:不删就一直在
System V 信号量的生命周期跟随内核,而不是跟随某个进程。 这一条要刻进脑子里。
- 进程创建它之后,无论进程退出、崩溃循环,信号量集只要没被
IPC_RMID,就一直在内核里存活。 - 也正因如此,它天然就没有"阻塞在信号量上的多个进程"被回收时连信号量一起回收的机制——你必须显式删除。
- 所以商用代码里的"谁创建谁
IPC_RMID",和前面 RAII 封装里的析构销毁,都是这个事实的直接回应。
想验证"它出生早、死得比进程晚":起一个程序建信号量后立刻睡一会儿,另一终端 ipcs -s 能看到它;程序退出后它依然在,直到某进程 IPC_RMID 或手工清理。
ipcs 与 ipcrm:命令行体检与拆弹
两个日用命令(System V IPC 全家都是这俩用法,信号量只是其一):
ipcs -s # 例表信号量集:key、semid、属主、权限、集合内信号量个数
ipcrm -s <semid> # 按 semid 删除指定的信号量集实操演示:
$ ipcs -s
------ Semaphore Arrays --------
key semid owner perms nsems
0x77010024 327680 user 666 1
# 不满意的集合直接拆掉:
$ ipcrm -s 327680当你 semget 报 File exists(EEXIST),十有八九是残留的集合没删,ipcs -s 一查、ipcrm -s 一删,问题立刻解决。把这两条命令当作基础技能记下来,排查 System V IPC 的日常体检全靠它们。
权限:读权限与修改权限是两本账
System V 信号量的权限用 semflg 里和文件权限同思路的三位八进制(0600/0644/0666)指定,由 sem_perm.mode 保存。但有两点和文件不同,容易踩:
- 权限分"读"和"改"两类,不是"读/写"那么简单:
sem_op == 0(等信号量归零)只要求读权限;而sem_op != 0(真正的 P/V,即加减值)要求**修改(alter)**权限。也就是说,一个"只读者"可以看值、可以等归零,但动不了值;只有"可改者"能 P/V。 - 创建时的权限只对"新建"有意义。打开已存在的集合时(课件的
GET_SEM就是IPC_CREAT不加权限位),权限检查用的是集合当初创建时写下的那组(比如创建时给了0666,那谁都打得开)。这也是课件里#define GET_SEM IPC_CREAT // 不给权限这一注释的真正含义——不是"不需要权限",而是"权限在建造那一刻已经定死了,打开时不必再写"。
自测与思考(附详解答案)
下面每道题都请先自己琢磨两分钟,再对照详解,效果最好。
思考题 1:为什么"P 会阻塞、V 会唤醒",而不是反过来?
详解:这是资源保护模型决定的。P(proberen,测试)意味着"我想拿资源",当资源不足(值不够减)时,正确的行为是"等着别人放了再来拿",所以申请方可能睡眠;V(verhogen,增加)意味着"我还资源",还资源这件事永远不会"缺",所以释放方永远不需要等,它只负责把等待的人叫醒。方向完全由"申请-释放"的语义决定:拿要排队,还不用。如果你设计成"V 阻塞、P 唤醒",那反而是"还资源还要排队"的荒诞世界。
思考题 2:信号量的值会是负数吗?负数代表什么?
详解:分两层回答。**教科书(Dijkstra 原模型)**里会:P 之后值变负,负数绝对值表示"还在等待的进程数"。但 Linux 内核里不会:System V 信号量的真实值 semval 永不小于 0;进程申请不到资源时被放入等待队列,等待者数量由独立的 semncnt/semzcnt 记录,而不是把 semval 扣成负数。所以用 GETVAL 查出来的永远是 ≥0。两者描述同一件事,一个用"负值记账",一个用"独立计数器记账",对应清楚即可。
思考题 3:semop 的第二个参数到底是什么类型?"结构数组"意味着什么?为什么会在这里翻车?
详解:第二个参数是 struct sembuf * 指针,指向一个 sembuf 数组,nsops 是数组长度。它意味着一次 semop 可以原子地对多个信号量做多个操作——要么全部成功,要么一个都不做(整体阻塞或整体失败)。翻车点主要有三:(1) 每个 sembuf 的三个字段(sem_num/sem_op/sem_flg)必须全部显式初始化,否则栈上的垃圾值会让操作作用到错误信号量或带错误旗标,行为未定义;(2) 新手总以为一次只能操作一个信号量,其实对应一个信号量集里的多把锁,可以用一个数组在一条调用里同时申请多个资源,规避"部分占有";(3) 别忘了阵列越界,nsops 必须真实等于数组元素个数,多传或传 0 都会出错。
思考题 4:为什么通常应该给 sem_flg 加 SEM_UNDO?"图因素志"是什么意思?代价是什么?
详解:"图因素志"是课件/PDF 里对 SEM_UNDO 的音译笔误,真名即 SEM_UNDO(撤销旗标)。它让内核在每个进程退出时自动"回滚"该进程用 SEM_UNDO 标记过的信号量操作。当信号量当锁用时,若持有锁的进程崩溃,没它锁就永远关着、其他进程全部死锁;有它则进程一退出内核自动补还 P,锁解开。代价/边界:记账粒度是"整个进程的累计净操作";进程退出做 UNDO 时值不会被扣成负数(截断到 0);单个进程的撤销调整值有上限(如 32767)。它是"防崩溃死锁"的廉价保险,商用代码默认都加。
思考题 5:为什么课件里 GET_SEM 只写 IPC_CREAT 而不加 0666 权限?
详解:GET_SEM 用于"打开"一个已经存在的集合。System V 信号量的权限在创建那一刻由创建者的 semflg 写进 sem_perm.mode,此后访问检查用的是这份已定死的权限。既然集合当初是用 BUILD_SEM(带 0666)建的、全局可读写,打开时就不必(也没必要)再给自己补一份权限了——给也行,但那段符号没意义,反而误导人以为"没有权限位"。注意权限分"读"(sem_op==0)和"改"(P/V,sem_op!=0)两档,都要被创建时定的权限约束。
思考题 6:ftok 用"路径 + 工程号"造 key,有什么要当心的?
详解:ftok 用一个真实存在、且所有协作进程都能访问的路径的(设备号 + inode 号),再叠加工程号 proj_id,算出一个 key_t。要当心:(1) 如果路径不通、或不同进程看到的 inode 不同(比如用了符号链接的不同目标),各部算出的 key 不一致,就"形同陌路";(2) 工程号 proj_id 各进程必须一致,一般取 1 到 255 之间的小整数;(3) 只要 inode 与工程号相同,key 稳定,但 inode 变化(文件被删重建)key 就会变——所以工程上更多用一个固定的路径文件(比如服务器上约定的配置文件)来作"锚"。终极替代方案是直接用 IPC_PRIVATE 配 fork/消息通道,绕开 key 的坑。
思考题 7:为什么"谁负责 IPC_RMID"必须在设计时就定清楚?
详解:System V 信号量生命周期随内核、随显式删除,与创建它的进程寿命无关。如果所有人都不删,它会作为"内核里的幽灵"无限留存,导致下次运行 EEXIST 或资源泄漏。所以必须明确单一负责人:要么约定"创建者负责在退出路径上 IPC_RMID"(课件用 RAII 析构实现),要么有额外的心跳/看守进程。借用方的原则是"借来的不删、自己建的负责删"——课件里 _flag 区分 GET_SEM/BUILD_SEM 正是为此。这也是生产环境里经常出现"信号量泄漏"事故的根源。
参考与资料
本文关于 semget/semop/semctl 的接口细节、semval 永不为负、semncnt/semzcnt、SEM_UNDO/semadj 调整机制、权限的读/改区分以及 EAGAIN/EINTR/EIDRM 等 errno,主要依据 Linux semop(2) / semget(2) / semctl(2) 手册页以及 Linux 内核 ipc/sem.c 的实现整理。union semun 需自定、struct sembuf 数组原子性、SEMOPM/SEMVMX 等内核限制,均已在正文标注来源口径(内核 include/uapi/linux/sem.h 与内核源码注释)。课件中关于 Dijkstra 的 P/V 原语模型、semid_ds/ipc_perm、建造者模式封装与 Writer.cc 交替打印演示,源自《Linux 系统编程》加餐课件及其配套 Gitee 仓库(linux-plus-meal/sem)。若要与 POSIX 信号量做对比,可参考 POSIX 信号量的 sem_init/sem_wait/sem_post 手册页。
注:课件正文中出现的"图因素志"四字,经核对为 SEM_UNDO 标志的音译笔误,已在正文以本名呈现并说明。
写在最后
把这一课的骨架收进记忆:信号量是 Dijkstra 留给并发世界的闸门——通过 P(申请、可阻塞)与 V(释放、唤醒别人)一进一出,锁住了临界区,既解决了互斥(同一时刻只放一个人进门)也解决了同步(严格的先后顺序)。在 Linux 里它落地为 System V 的 semget、semctl、semop 三板斧,配上一个需要你自己定义的 union semun、一个可能装着多个操作的 sembuf 数组,以及一套"生命周期随内核"的脾气。最后,我们用 RAII 和建造者模式把它包进一个甜美的 Semaphore::P()/.V() 接口,把复杂留给了建造者,把简单还给了使用者。
别忘了那几个坑:P/V 必须成对、信号量的值在 Linux 里不会为负、sembuf 三个字段要写全、SEM_UNDO 是防止崩溃死锁的保险、权限分成"读"和"改"两档、谁创建谁 IPC_RMID。把它们记牢,再看一眼 ipcs -s 和 ipcrm -s 这两个体检命令,System V 信号量就算真正上了手。
信号量这条线,紧连着你接下来会深入的系统编程地形——共享内存要靠它护航,生产者-消费者要靠它调度,多进程序号控制也离不开它。把今天这课抠实了,后面翻山越岭时你就比别人多一条坚实的安全绳。
还没有评论 — 第一条由你来留。