前言:篇27 给信号量只留了两句话——本质是计数器,P 加锁 V 解锁,三个接口也只点了名。这篇补上整块拼图:用电影院订票讲透"预订"本质,拆开 P/V 的三种语义和整套用法,再往内核走一步,看 OS 怎么用一张大表统一管理三件套。
目录
编辑
一、信号量是什么:本质是一个计数器
二、电影院订票:理解信号量的最佳类比
2.1 买票就是预订:先占座,再使用,用完归还
2.2 不买票就抢座的后果:数据互相覆盖
2.3 记住这套类比:后面每个概念都靠它解释
三、共享内存为什么非配它不可
3.1 危险在哪:太多进程同时乱写同一块内存
3.2 解决思路:把共享内存划分成固定区域
3.3 核心规则:访问任何一块之前,先申请
3.4 完整流程:一次规范使用要走六步
3.5 资源用完了怎么办:阻塞等待,不空转
四、申请(P)和释放(V)具体做了什么
4.1 内核给每个信号量记的四笔账
4.2 一次操作怎么填:写一张"操作单"交给内核
4.3 操作值的三种语义:申请 / 释放 / 等归零
4.4 两个重要标志:不等待 / 自动回滚
4.5 为什么要原子执行:要么全做,要么全不做
五、怎么用:创建、初始化、加减、删除
5.1 创建:怎么拿到一个信号量集(semget)
5.2 控制:初始化、查状态、删除都靠它(semctl)
5.3 传参用的联合体:为什么必须自己定义(semun)
5.4 真正的 P/V:一次能同时操作一批信号量(semop)
六、一次为什么要管多个信号量
6.1 两个参数别搞混:建几个 vs 动第几个
6.2 为什么要打包成"集":多个锁一次原子操作
6.3 系统上限:一个集最多能放多少信号量
七、进程退出它还在:生命周期与清理
7.1 怎么查看和删除:命令行 + 代码调用
7.2 进程退出它还在:用完必须自己删
7.3 删除的副作用:阻塞中的进程会被一并唤醒
八、内核怎么存放、又怎么统一管理三件套
8.1 内核怎么"描述"信号量:一张档案表(semid_ds)
8.2 档案开头的权限头:谁建的、权限多大(ipc_perm)
8.3 容易踩坑:同名结构,用户态快照 ≠ 内核实体
8.4 三类资源共用的"桥接头"(kern_ipc_perm)
8.5 组织方式:把描述结构统一挂进一张表
8.6 更正:现代内核已不用数组,换成 IDR 基数树
8.7 三个容易忽略的细节
8.8 一张图看懂全局:从内核命名空间到实际资源
九、信号 / 信号量 / 互斥锁:易混概念一次分清
9.1 信号 vs 信号量:一个"叫你一下",一个"数你一下"
9.2 信号量 vs 互斥锁:谁都能解 vs 只有主人能解
9.3 二元 vs 多元:一次放一个,还是放 N 个
9.4 三种档案结构对比:分别管什么、差在哪
十、面试官高频追问与参考答案
十一、小结
一、信号量是什么:本质是一个计数器
篇27 第七节已经把铺垫做完了:共享内存快是快,但没有同步互斥,多个进程一起写,数据当场就不一致。临界资源、临界区、互斥、同步这些词当时都拆过,这里不再重复。
现在正面回答那个被跳过的问题:信号量到底是什么?
信号量(semaphore)翻译过来就是"信号灯"。它的本质,是一个计数器。
这个计数器计的不是别的,就是临界资源里还剩几份资源可用。信号量保护的不是数据本身,是"资源还有几份"这个数——谁想用一份资源,先过计数器这一关。
用信号灯来想:
- 灯亮(计数 > 0):还有资源,可以进
- 灯灭(计数 = 0):资源用完了,等着
有种常见说法是"信号量本身不是共享资源"。这句话得按语境理解——它想强调的是"信号量不是被保护的那个临界资源",而不是说它不被共享。
事实正好相反:信号量本身必须由多个进程共享访问,否则同步根本不成立。
semval 是内核里的一份数据,所有进程通过同一个 semid 操作的是同一个值。A 进程 P 完减 1,B 进程必须看到这个减过的值——如果两个进程各看各的私有计数器,那 P/V 就纯粹是自我安慰,谁也拦不住谁。
还有个更硬的证据:内核专门给信号量加了自旋锁、把 semop 做成原子操作。如果它不被并发访问,这些保护根本没必要存在。需要保护,本身就是"它是共享的"的证明。
所以准确的分法是两层:
- 信号量是共享对象(内核里多进程可见可改,安全由内核锁兜底)
- 但它不是它保护的那份共享资源——被保护的临界资源是共享内存那块数据,信号量是管它的人
面试问"信号量是共享资源吗",只答一个"不是"会露怯;把这两层都讲出来才站得住脚。
那多个进程怎么找到同一个信号量?System V 的做法很直接:让多个进程通过同一个 key 看到同一个信号量。key 机制篇25、篇27 都讲过(ftok 生成、semget 拿它换 semid),三件套在这一层是同一套逻辑。
顺带埋个伏笔:信号量按取值分两种——
- 二元信号量:只有 0 和 1,一次只放一个进程进(互斥的极致)
- 多元信号量:初值 N,一次最多放 N 个(资源池)
这两种的差别后面易混点对比里细说。
二、电影院订票:理解信号量的最佳类比
讲信号量最好用的例子是电影院订票。把它讲透,后面的接口全是它的翻译。
把信号量想成一个电影院的票池:
| 电影院 | 信号量 |
|---|---|
| 放映厅的座位 | 临界资源里的一份份资源 |
| 座位总数 | 信号量初值 N |
| 买票 | P 操作(申请,计数 -1) |
| 看完退场 | V 操作(释放,计数 +1) |
| 票卖光了 | 计数归零,后来的人等着 |
2.1 买票就是预订:先占座,再使用,用完归还
买票这个动作,本质是先占号,再使用,用完归还:
- 你买到票,座位就被你预订了——哪怕你还没进场坐
- 你看完离场,这个座位重新回到票池,别人才能买
- 全程座位总数不变,变的只是"还剩几张票"这个数
信号量干的就是这件事:P 一次,等于从资源池里预订一份;V 一次,等于归还一份。
2.2 不买票就抢座的后果:数据互相覆盖
如果没有票,人人都直接冲进放映厅找座——两个人同时看上同一个座,都坐下,这就撞了。
放到进程世界:没有信号量,多个进程同时扑向同一块共享内存,你写你的我写我的,互相覆盖,数据不一致。这就是篇27 说的共享内存"裸奔"问题。
电影院不让你抢座,靠的是"必须先买票";OS 不让进程乱抢资源,靠的是"必须先申请信号量"。
2.3 记住这套类比:后面每个概念都靠它解释
记住这个电影院,它不止管这一节:
- sem_op 为什么分正负 → 买票是 -1,退票是 +1
- SEM_UNDO 是干嘛的 → 人跑了(进程崩了),票自动退回
- 信号量集是什么 → 多个影厅,每厅一套票
后面每节都会回来对一次表。
三、共享内存为什么非配它不可
有了"预订"这个概念,现在看它具体怎么落到共享内存上。
3.1 危险在哪:太多进程同时乱写同一块内存
问题起点很明确:最怕过多进程访问同一块内存。
共享内存快,就快在数据不用在内核和用户态之间倒手。但它没有任何访问控制——谁都能往同一块上写,写得越多,覆盖得越狠。所以有两个"不要":
- 不要多个进程访问同一个位置
- 不要过多进程同时压上来
3.2 解决思路:把共享内存划分成固定区域
信号量介入的思路,是把共享内存划分成不同区域,每块区域一次只给一个进程用。划分之后每块的大小是固定的——固定,才好配一张票(一个信号量单位)管一块。
3.3 核心规则:访问任何一块之前,先申请
核心规则一句话:所有进程访问临界资源的一小块,必须先申请信号量。
翻译成电影院:想坐座位,先买票。没有例外。
3.4 完整流程:一次规范使用要走六步
一次规矩的信号量使用,全流程六步:
- 创建/获取信号量集(semget)
- 初始化:设初值 = 座位总数(semctl SETVAL)
- 申请资源,计数 -1(semop 做 P)
- 使用共享资源
- 释放资源,计数 +1(semop 做 V)
- 删除信号量集(semctl IPC_RMID)
3.5 资源用完了怎么办:阻塞等待,不空转
计数归零时再来进程,P 操作会让它阻塞等待——注意是睡眠那种等,不是原地转圈的忙等(忙等就是 while 死循环白白烧 CPU)。等到有人 V(退票),内核把它唤醒接着干。
这也解释了为什么信号量叫"同步"工具:它不光拦人,还负责排队叫号。
四、申请(P)和释放(V)具体做了什么
前面一直说"P 减 1、V 加 1",但内核里这套操作到底长什么样?这一节拆开。
4.1 内核给每个信号量记的四笔账
先看 man page(semop(2))给的答案,集合里每一个信号量都有四个关联值:
unsigned short semval; /* 信号量当前值 */ unsigned short semzcnt; /* 等它变成 0 的进程数 */ unsigned short semncnt; /* 等它变大的进程数 */ pid_t sempid; /* 最后一个操作它的进程 PID */买票视角:semval 是剩余票数;semncnt 是排着队等放票的人数;semzcnt 是等着"票全部卖完"这个状态的人数;sempid 记着最后一张票是谁买的。
4.2 一次操作怎么填:写一张"操作单"交给内核
对信号量的每次操作,你都要填一张操作单(struct sembuf)交给 semop:
struct sembuf { unsigned short sem_num; /* 操作集合里第几个信号量(从 0 开始) */ short sem_op; /* 操作值:正 / 负 / 零,三种语义 */ short sem_flg; /* 标志:IPC_NOWAIT / SEM_UNDO */ };注意类型:sem_op 是 short(有符号),所以它能带正负号;另外两个是 unsigned short。这个类型细节别忽略,它就是三种语义的物理基础。
4.3 操作值的三种语义:申请 / 释放 / 等归零
| 操作值 | 含义 | 行为 | 电影院 |
|---|---|---|---|
| < 0 | P 操作,申请 |sem_op| 份资源 | 够减就减,不够就阻塞(semncnt++) | 买票,没票就排队 |
| > 0 | V 操作,释放 sem_op 份资源 | 直接加,永不阻塞 | 退票 |
| = 0 | 等待计数变成 0 | 已是 0 就过;不是 0 就阻塞(semzcnt++) | 等"票全部卖完"再进场收场 |
三种里只有 V 永不阻塞——退票永远是允许的,天经地义。
操作值 = 0 平时用得少,典型场景是等资源被"全部用完"再统一回收,比如管理进程等所有使用者散场。
4.4 两个重要标志:不等待 / 自动回滚
IPC_NOWAIT:不阻塞。拿不到资源直接返回错误,errno 置 EAGAIN。而且 man page 明文写了:整个操作数组一个都不会执行。对应电影院里"看没票了,不等了,走人"。
SEM_UNDO:进程退出时,内核自动回滚它做过的 P/V。对应"人跑了(进程崩了),票自动退回池子"。为什么需要它、内核怎么实现,第十节追问里专门讲——这里先记住电影院版本就够。
4.5 为什么要原子执行:要么全做,要么全不做
一次 semop 传进去的整个操作数组,要么全部执行完,要么一个都不执行,中间不会被别的进程插队。这也是系统调用进内核后由内核加锁保证的。为什么必须这样,第六节讲完信号量集再回来看。
五、怎么用:创建、初始化、加减、删除
System V 三件套的用法形态高度一致,信号量这套,就是篇27 消息队列那套的"信号量版"。逐个拆。
5.1 创建:怎么拿到一个信号量集(semget)
int semget(key_t key, int nsems, int semflg);- key:哪个信号量集(ftok 生成,篇25 讲过)
- nsems:这个集合里放几个信号量
- semflg:IPC_CREAT / IPC_EXCL / 权限位(0666 那套)
- 返回:semid
注意它创建的是"集",不是单个信号量——哪怕你只要一个,也是 nsems = 1 的集合。为什么设计成集,下一节讲。
5.2 控制:初始化、查状态、删除都靠它(semctl)
int semctl(int semid, int semnum, int cmd, ...);第四个参数是可变参数,类型是 union semun。
常用 cmd:
| cmd | 干什么 | 第四参用哪个成员 |
|---|---|---|
| SETVAL | 把第 semnum 个信号量设成指定值 | arg.val |
| GETVAL | 读第 semnum 个信号量的值 | 不用 |
| IPC_STAT / IPC_SET | 取/改整个集的属性 | arg.buf |
| IPC_RMID | 删掉整个信号量集 | 不用 |
| GETALL / SETALL | 一次读/写集合里全部值 | arg.array |
SETVAL 是初始化的唯一手段——创建完,信号量值不会自动是你想要的数,必须手动设。忘了设初值就用,是新手第一大坑。
5.3 传参用的联合体:为什么必须自己定义(semun)
这是信号量接口最出名的坑。semctl 第四参的类型 union semun,系统头文件不提供定义,必须你自己写。man page 里写得很明确("The calling program must define this union as follows"):
union semun { int val; /* SETVAL 操作使用的信号量值 */ struct semid_ds *buf; /* IPC_STAT、IPC_SET 操作使用的缓冲区 */ unsigned short *array; /* GETALL、SETALL 操作使用的数组 */ struct seminfo *__buf; /* IPC_INFO 操作使用的缓冲区 (Linux 系统特有) */ };四个成员一个不能少,第四个名字是__buf(双下划线开头)。为什么 POSIX 不给定义?历史遗留——各家 UNIX 实现对这个联合体的定义打架,标准干脆甩给应用程序自己声明。结果就是每一份 System V 信号量代码开头都贴着同一段。
5.4 真正的 P/V:一次能同时操作一批信号量(semop)
int semop(int semid, struct sembuf *sops, size_t nsops);- sops:struct sembuf数组
- nsops:数组里有几个操作
传数组意味着一次系统调用可以对集合里多个信号量同时操作,而且整体原子(第四节 4.5 说过)。
六、一次为什么要管多个信号量
前面创建接口埋的问题在这:为什么 semget 创建的是"集"?
6.1 两个参数别搞混:建几个 vs 动第几个
- nsems:创建时指定,这个集合里有几个信号量(内核里就是按数组来放的)
- semnum:操作时指定,动的是集合里第几个(从 0 数)
一个是"盖楼时定几层",一个是"进楼时去几层",别混。
6.2 为什么要打包成"集":多个锁一次原子操作
一个像样的并发场景往往不止一把锁。经典的生产者-消费者就要三个信号量:
- empty:空位还剩几个(生产者看它)
- full:货物攒了几个(消费者看它)
- mutex:缓冲区本身的互斥锁
如果只能一个一个创建,三个信号量三次创建、三次管理。设计成集:一次创建、一个 semid 全管,而且 semop 传数组可以一次原子地动它们仨——要么三个信号量的状态一起改成功,要么都不动。这在多锁场景是防死锁的关键:先拿全再动,不给人插队制造"拿了 A 等 B、拿了 B 等 A"的机会。
电影院版:多个影厅各一套票,可以一次买齐两场连映的票——要么两场都到手,要么一张都不买,不会出现"买了 1 号厅的票、2 号厅卖光了、票退不掉"的尴尬。
6.3 系统上限:一个集最多能放多少信号量
semget(2) man page 写得明白,几个关键限制:
| 限制 | 含义 | 老默认(<3.19) | 新默认(≥3.19) |
|---|---|---|---|
| SEMMSL | 单个集最多几个信号量 | 250 | 32000 |
| SEMMNI | 系统最多几个集 | 128 | 32000 |
| SEMMNS | 系统信号量总数 | = SEMMNI × SEMMSL | 同左 |
| SEMOPM | 单次 semop 最多几个操作 | 32 | 500 |
| SEMVMX | 信号量最大值 | 32767(硬编码,改不了) | 同左 |
实测方法(可以直接跑):
$ cat /proc/sys/kernel/sem 250 32000 32 128注意这四个数的顺序:SEMMSL SEMMNS SEMOPM SEMMNI——不是字母序,也不是"集在前"的直觉序。面试官就爱拿这个顺序钓人,答错顺序等于没查过。
七、进程退出它还在:生命周期与清理
7.1 怎么查看和删除:命令行 + 代码调用
$ ipcs -s # 查看系统里所有信号量集(-s = semaphore) $ ipcrm -s semid # 命令行删一个代码里删:semctl(semid, 0, IPC_RMID)(对应第五节讲的删除用法)。
7.2 进程退出它还在:用完必须自己删
和共享内存、消息队列一个脾气:信号量的生命周期随内核,不随进程。进程退了,信号量还在;重启才彻底清。所以:
- 用完必须显式删(IPC_RMID),忘了删就是内核资源泄漏
7.3 删除的副作用:阻塞中的进程会被一并唤醒
man page 里一条容易被忽略的细节:IPC_RMID 删集合时,所有阻塞在这个集上 semop 的进程会被一起唤醒,它们的 semop 返回失败,errno 置 EIDRM。不是让它们永远睡下去。
对应电影院:影厅直接拆了,排队的人当场散伙,各自收到通知。
八、内核怎么存放、又怎么统一管理三件套
问老问题:内核里这么多信号量,OS 怎么管?
老答案:先描述,再组织(篇25 讲共享内存时的 shmid_ds、篇27 的 msqid_ds,同一个套路)。信号量的"描述"就是 struct semid_ds。
8.1 内核怎么"描述"信号量:一张档案表(semid_ds)
篇27 只点了个名,这里给全(semctl(2) man page 原文):
struct semid_ds { struct ipc_perm sem_perm; /* 所有权与权限 */ time_t sem_otime; /* 最后一次 semop 时间 */ time_t sem_ctime; /* 创建 / 最后一次 semctl 改动时间 */ unsigned long sem_nsems; /* 集合里信号量个数 */ };三个时间戳 + 一个计数,管的是"整个集"的档案。单个信号量的 semval / semncnt 那四个值(4.1 节)是集合内部每个成员各自挂的,不在这个结构里。
8.2 档案开头的权限头:谁建的、权限多大(ipc_perm)
sem_perm 的类型,用户态完整定义(man page 原文):
struct ipc_perm { key_t __key; /* 你传给 semget 的那个 key */ uid_t uid; /* 所有者 */ gid_t gid; uid_t cuid; /* 创建者 */ gid_t cgid; unsigned short mode; /* 权限位,低 9 位有效 */ unsigned short __seq; /* 序列号(内核用) */ };对信号量来说,权限里的"写"实际含义是alter(改动)——能 P/V 就算"写",读 semval 算"读"。
8.3 容易踩坑:同名结构,用户态快照 ≠ 内核实体
注意区分两个名字很像的东西:
- 用户态 struct ipc_perm:IPC_STAT 拷出来给你看的"快照",字段是 uid_t、unsigned short mode
- 内核 struct kern_ipc_perm:内核里真正挂在资源头上的那个,字段是 kuid_t、umode_t,还带锁和引用计数
前者是给你看的报表,后者是仓库里真实的货架标签。长得很像,不是一个东西。8.4 的主角就是后者。
规律回指(篇27 3.2 讲过):shmid_ds / msqid_ds / semid_ds 三兄弟,开头都是 ipc_perm——三件套接口长得像,根子在这。
8.4 三类资源共用的"桥接头"(kern_ipc_perm)
说完了单个信号量的"描述",再看三类资源怎么"组织"起来:内核里躺着大量信号量集合、消息队列、共享内存段,它靠什么统一管这些?还是先描述,再组织:
- 描述:共享内存用 struct shmid_kernel,消息队列用 struct msg_queue,信号量用 struct sem_array
- 组织:把所有描述结构挂进同一张表统一管理
这三类描述结构体,第一个成员都是 struct kern_ipc_perm。
这就是"桥梁头":内核拿到一个 kern_ipc_perm 指针,不用管它背后是信号量、消息队列还是共享内存,就能统一挂表、统一按 key/id 查找、统一做权限检查。学过 C++ 的多态再看这个会非常眼熟——这就是拿"基类指针"管所有派生类的内核版。
8.5 组织方式:把描述结构统一挂进一张表
内核早期的组织方式(2.6 时代的代码):
struct ipc_id_ary { int size; /* 数组容量 */ struct kern_ipc_perm *p[0]; /* 柔性数组:每个元素指向一个资源 */ }; struct ipc_ids { int in_use; /* 当前在用几个 */ int max_id; unsigned short seq; /* 序列号,防 ID 重用 */ unsigned short seq_max; struct mutex mutex; struct ipc_id_ary *entries; /* ← 指向那张表 */ };p[0] 是 C 里的柔性数组技巧:结构体末尾留一个零长度数组,实际分配时多给一截内存,p 就成了"长度可变的指针数组"。全局一张这样的表,ID 就是数组下标,查资源 = 取下标,O(1)。
8.6 更正:现代内核已不用数组,换成 IDR 基数树
写这篇时查了内核源码,必须说清楚:ipc_id_ary 是 2007 年之前的模型,Linux 2.6.23 起被删除。
证据是 2007 年 LKML 上 Nadia Derbey 的补丁系列("Storing ipcs into IDRs"):struct ipc_id_ary 整个删掉,连配套的扩容函数 grow_ary() 一并移除,换成 IDR 基数树。现代内核(5.x / 6.x)的 struct ipc_ids 长这样:
struct ipc_ids { int in_use; unsigned short seq; unsigned short seq_max; struct rw_semaphore rw_mutex; struct idr ipcs_idr; /* ← IDR 基数树取代了柔性数组 */ };IDR 是内核里专门干"拿一个整数 ID 快速找对象"的树形结构(基数树的封装)。换它的原因很实际:IPC 对象数量动态增减,静态数组要么开大了浪费、要么不够了频繁扩容搬内存;基数树按需生长,查改删都高效。
但注意——思想一个字没变:换成 IDR 之后,树里存的仍然是指向 struct kern_ipc_perm 的指针。容器从数组换成了树,"统一桥接头 + 一张表管三种资源"的设计原封不动。
所以常见的讲法是一个简化模型:思想是真的(先描述再组织、kern_ipc_perm 统一挂表),容器是演进的(柔性数组 → IDR 基数树)。面试里能主动讲出"简化模型和真实内核差在这一步",是实打实的加分项。
8.7 三个容易忽略的细节
ID 不是下标。用户拿到的 semid = seq × SEQ_MULTIPLIER + 内核内部 id,其中 SEQ_MULTIPLIER = IPCMNI = 32768。seq 每创建一个对象加 1,内部 id 可以被复用——但 seq 不重用,所以同一个内部 id 在不同"轮次"拼出的用户 ID 不同。这样进程手里就算攥着过期的旧 ID,也几乎不可能错拿到复用后的新资源。
三类各一张表。struct ipc_namespace 里有 ids[3],下标顺序是:0 信号量、1 消息队列、2 共享内存。semid、msqid、shmid 各走各的表,互不干扰。
kern_ipc_perm 里都有什么(4.16 源码,字段挑重点):
struct kern_ipc_perm { spinlock_t lock; /* 自旋锁保护自己 */ bool deleted; int id; key_t key; /* 你给的那个 key */ kuid_t uid; kgid_t gid; kuid_t cuid; kgid_t cgid; umode_t mode; /* 权限 */ unsigned long seq; void *security; /* 4.15 起还加了 RCU、引用计数等并发相关成员,现阶段不用深究 */ };对照 8.2 的用户态 ipc_perm:字段名几乎一一对应,类型全换了内核私有的(kuid_t / umode_t),还多了锁。快照 vs 实体,区别就在这。
8.8 一张图看懂全局:从内核命名空间到实际资源
一张表(现在是树)、一个共同的头、三种资源。这就是"先描述,再组织"在 System V IPC 上的完整形态。
九、信号 / 信号量 / 互斥锁:易混概念一次分清
这一节全是高频混淆点,每个都给答案,不只摆问题。
9.1 信号 vs 信号量:一个"叫你一下",一个"数你一下"
| 信号 | 信号量 | |
|---|---|---|
| 是什么 | 异步通知机制 | 同步互斥的计数器 |
| 干什么 | 告诉进程"某事件发生了"(如 SIGKILL、SIGSEGV) | 管"资源还剩几份、谁能进临界区" |
| 用法 | kill 发送 / signal 注册处理函数 | semget / semop P、V |
名字只差一个字,机制完全无关。面试口头回答就一句:信号是"叫你一下",信号量是"数你一下"。
9.2 信号量 vs 互斥锁:谁都能解 vs 只有主人能解
| 二元信号量 | 互斥锁 | |
|---|---|---|
| 取值 | 0 / 1 | 锁上 / 没锁 |
| 所有权 | 无:谁都能 V | 有:谁加的锁谁解 |
| 典型误用 | A 拿了信号量,B 手滑 V 了一下——合法,但逻辑已乱 | B 解 A 的锁——直接报错拦住 |
二元信号量初值设 1,行为上很像锁,但没有所有权概念。互斥锁锁定了 owner,别的线程解不了;信号量谁都能 V。这就是"二元信号量 ≈ 互斥锁但不等价"的标准答案——大厂高频追问。
9.3 二元 vs 多元:一次放一个,还是放 N 个
- 二元(初值 1):互斥,一次放一个
- 多元(初值 N):资源池 / 限流,一次放 N 个(比如最多 5 个并发连接)
判断依据就一条:你的资源到底有几份。
9.4 三种档案结构对比:分别管什么、差在哪
| shmid_ds | msqid_ds | semid_ds | |
|---|---|---|---|
| 管什么 | 共享内存段 | 消息队列 | 信号量集 |
| 核心差异字段 | 分离/挂接时间、大小 | 队列字节数/条数、收发进程 | sem_nsems(集里几个) |
| 开头 | ipc_perm | ipc_perm | ipc_perm |
| 角色 | 管数据 | 管数据 | 管同步 |
一句话收束:共享内存和消息队列管的是"数据怎么过去",信号量管的是"谁有资格动"。
十、面试官高频追问与参考答案
Q1:信号量的本质是什么?
计数器,表明临界资源中剩余资源的数量。P 减 V 加,操作原子。
Q2:信号量本身是共享资源吗?
是,但要分两层答(只答一个"不是"会露怯)。
- 它是共享对象:semval 在内核里,多进程通过同一个 semid 操作同一个值。不共享就没法同步——A 进程 P 完,B 必须看到减过的值。反证它确实是共享的:内核给它加自旋锁、把 semop 做成原子,正是因为它要被并发访问。
- 但它不是"被保护的那个共享资源":在这套方案里它是管理员,被保护的临界资源是共享内存那块数据。
"信号量本身不是共享资源"这句话,讲的是第二层的角色分工,不能拿来否认第一层的物理事实。补充一点:它自己不需要再套一层信号量来保护,因为内核的原子 semop 已经兜底了。
Q3:P/V 为什么必须原子?内核怎么保证?
P/V 是"读值-判断-改值"三步,如果不原子,两个进程同时读到同一个旧值、都判断通过、各自减 1——一个名额放进俩人,互斥直接失效。保证手段:semop 是系统调用,进入内核后由内核加锁(自旋锁/信号量)串行执行,用户态插不了队。
Q4:sem_op 为正 / 为负 / 为 0 分别是什么语义?哪种永远不阻塞?
负 = P(申请),值不够就阻塞;正 = V(释放),永不阻塞;0 = 等计数变 0(wait-for-zero),不是 0 就阻塞。
Q5:semop 传数组(nsops > 1)有什么用?
一次系统调用原子地操作集合里多个信号量,要么全做要么全不做。多锁场景防死锁的关键:避免"拿了一半等另一半"给别的进程插队制造环形等待的机会(生产者-消费者的 empty/full/mutex 三件套就是典型应用)。
Q6:IPC_NOWAIT 失败时,数组里其他操作执行了吗?
一个都没执行,整体作废,errno = EAGAIN。原子性保证的"全或无"。
Q7:进程持有信号量时崩了怎么办?
两个层面答。用户态:sem_flg 设 SEM_UNDO,进程退出时内核自动回滚它的 P/V。系统态:就算没设 UNDO,其他进程也能对信号量做 V(无所有权),不至于永久死锁——但语义上已经靠人肉补偿了,工程上必须靠 SEM_UNDO。
Q8:SEM_UNDO 内核里到底怎么实现的?
机制(已对 v5.6 内核源码核实):
- 每个进程的 task_struct 里有 sysvsem.undo_list,挂一条"我做过的可撤销操作"链表
- 进程动过的每个信号量集对应一个 struct sem_undo:记着 semid 和一个 semadj 数组(集合里每个信号量一个调整值)
- 做 P(sem_op < 0)时,|sem_op| 累加进 semadj;做 V 时从 semadj 里减掉——semadj 记的是"我净欠了多少份资源"
- 进程退出时 do_exit() 调 exit_sem():遍历 undo_list,把每个 semadj[i] 加回对应信号量的 semval,结果夹在 [0, 32767] 区间,然后唤醒等资源的进程
电影院版一句话:影院给每个观众记一笔账(买了没退的票数),人不管怎么走的,散场时按账本把票全部回收进票池。
Q9:union semun 为什么要自己定义?
POSIX 标准把定义甩给了应用程序(各家 UNIX 实现历史分歧,标准索性不统一),Linux 头文件也就不给。所以每个程序要自己声明这四成员联合体:val / buf / array / __buf。手写不出来说明没真写过 System V 信号量代码。
Q10:/proc/sys/kernel/sem 里四个数是什么?什么顺序?
SEMMSL(每集最多信号量数)、SEMMNS(系统总信号量数)、SEMOPM(单次 semop 最多操作数)、SEMMNI(系统最多集数)。顺序就是 SEMMSL SEMMNS SEMOPM SEMMNI,反直觉,别按字母序背。
Q11:用户看到的 semid 是数组下标吗?
不是。semid = seq × 32768 + 内核内部 id。seq 防 ID 复用撞车。
Q12:ipc_perm 和 kern_ipc_perm 什么区别?
用户态的 ipc_perm 是 IPC_STAT 拷给应用程序看的快照(uid_t、unsigned short mode);kern_ipc_perm 是内核里真正挂在资源开头的实体(kuid_t、umode_t,带自旋锁、RCU、引用计数)。
Q13:三类 IPC 在内核里怎么统一管理?现代内核还是 ipc_id_ary 吗?
都是"描述结构第一个成员放 kern_ipc_perm"——内核拿这个桥梁头统一挂表、查找、做权限检查。但 ipc_id_ary 是老模型,2007 年 2.6.23 起换成 struct ipc_ids 里的 IDR 基数树(ipcs_idr),存的还是 kern_ipc_perm 指针。能答出"柔性数组模型 vs 现代内核的 IDR"这一层差异,就是加分项。
Q14:信号和信号量什么关系?
没关系。信号是异步事件通知,信号量是同步计数器。一个字之差,两个世界。
十一、小结
- 本质:计数器,数的是"资源还剩几份";它自己也是内核里的共享对象(由内核锁保证安全),但不是被它保护的那份共享资源
- 核心类比:电影院订票——买票是 P、退票是 V、票池初值是座位数、SEM_UNDO 是"人跑了票自动退"
- 保护思路:共享内存划分固定区域,所有进程访问一小块前必须先申请信号量
- 操作:sembuf 三字段,sem_op 正负零三种语义,原子执行
- 接口:semget(创建集)→ semctl SETVAL(初始化,union semun 自己定义)→ semop(P/V,可传数组)→ semctl IPC_RMID(删除);ipcs -s 查看、ipcrm -s 清场
- 内核组织:semid_ds 描述 → kern_ipc_perm 当桥梁头 → ipc_ids 统一管三件套;ipc_id_ary 是 2.6 早期模型,现代内核用 IDR 基数树,思想不变
信号量不难,难的是把"计数器"和"预订"这两层意思焊死在脑子里——想不通的时候回电影院看一眼,买票退票,就那么点事。