Linux系统篇28——通信(五):信号量专篇:从电影院订票到内核一张大表,把信号量一次讲透

前言:篇27 给信号量只留了两句话------本质是计数器,P 加锁 V 解锁,三个接口也只点了名。这篇补上整块拼图:用电影院订票讲透"预订"本质,拆开 P/V 的三种语义和整套用法,再往内核走一步,看 OS 怎么用一张大表统一管理三件套。


目录

​编辑

一、信号量是什么:本质是一个计数器

二、电影院订票:理解信号量的最佳类比

[2.1 买票就是预订:先占座,再使用,用完归还](#2.1 买票就是预订:先占座,再使用,用完归还)

[2.2 不买票就抢座的后果:数据互相覆盖](#2.2 不买票就抢座的后果:数据互相覆盖)

[2.3 记住这套类比:后面每个概念都靠它解释](#2.3 记住这套类比:后面每个概念都靠它解释)

三、共享内存为什么非配它不可

[3.1 危险在哪:太多进程同时乱写同一块内存](#3.1 危险在哪:太多进程同时乱写同一块内存)

[3.2 解决思路:把共享内存划分成固定区域](#3.2 解决思路:把共享内存划分成固定区域)

[3.3 核心规则:访问任何一块之前,先申请](#3.3 核心规则:访问任何一块之前,先申请)

[3.4 完整流程:一次规范使用要走六步](#3.4 完整流程:一次规范使用要走六步)

[3.5 资源用完了怎么办:阻塞等待,不空转](#3.5 资源用完了怎么办:阻塞等待,不空转)

四、申请(P)和释放(V)具体做了什么

[4.1 内核给每个信号量记的四笔账](#4.1 内核给每个信号量记的四笔账)

[4.2 一次操作怎么填:写一张"操作单"交给内核](#4.2 一次操作怎么填:写一张"操作单"交给内核)

[4.3 操作值的三种语义:申请 / 释放 / 等归零](#4.3 操作值的三种语义:申请 / 释放 / 等归零)

[4.4 两个重要标志:不等待 / 自动回滚](#4.4 两个重要标志:不等待 / 自动回滚)

[4.5 为什么要原子执行:要么全做,要么全不做](#4.5 为什么要原子执行:要么全做,要么全不做)

五、怎么用:创建、初始化、加减、删除

[5.1 创建:怎么拿到一个信号量集(semget)](#5.1 创建:怎么拿到一个信号量集(semget))

[5.2 控制:初始化、查状态、删除都靠它(semctl)](#5.2 控制:初始化、查状态、删除都靠它(semctl))

[5.3 传参用的联合体:为什么必须自己定义(semun)](#5.3 传参用的联合体:为什么必须自己定义(semun))

[5.4 真正的 P/V:一次能同时操作一批信号量(semop)](#5.4 真正的 P/V:一次能同时操作一批信号量(semop))

六、一次为什么要管多个信号量

[6.1 两个参数别搞混:建几个 vs 动第几个](#6.1 两个参数别搞混:建几个 vs 动第几个)

[6.2 为什么要打包成"集":多个锁一次原子操作](#6.2 为什么要打包成"集":多个锁一次原子操作)

[6.3 系统上限:一个集最多能放多少信号量](#6.3 系统上限:一个集最多能放多少信号量)

七、进程退出它还在:生命周期与清理

[7.1 怎么查看和删除:命令行 + 代码调用](#7.1 怎么查看和删除:命令行 + 代码调用)

[7.2 进程退出它还在:用完必须自己删](#7.2 进程退出它还在:用完必须自己删)

[7.3 删除的副作用:阻塞中的进程会被一并唤醒](#7.3 删除的副作用:阻塞中的进程会被一并唤醒)

八、内核怎么存放、又怎么统一管理三件套

[8.1 内核怎么"描述"信号量:一张档案表(semid_ds)](#8.1 内核怎么"描述"信号量:一张档案表(semid_ds))

[8.2 档案开头的权限头:谁建的、权限多大(ipc_perm)](#8.2 档案开头的权限头:谁建的、权限多大(ipc_perm))

[8.3 容易踩坑:同名结构,用户态快照 ≠ 内核实体](#8.3 容易踩坑:同名结构,用户态快照 ≠ 内核实体)

[8.4 三类资源共用的"桥接头"(kern_ipc_perm)](#8.4 三类资源共用的"桥接头"(kern_ipc_perm))

[8.5 组织方式:把描述结构统一挂进一张表](#8.5 组织方式:把描述结构统一挂进一张表)

[8.6 更正:现代内核已不用数组,换成 IDR 基数树](#8.6 更正:现代内核已不用数组,换成 IDR 基数树)

[8.7 三个容易忽略的细节](#8.7 三个容易忽略的细节)

[8.8 一张图看懂全局:从内核命名空间到实际资源](#8.8 一张图看懂全局:从内核命名空间到实际资源)

[九、信号 / 信号量 / 互斥锁:易混概念一次分清](#九、信号 / 信号量 / 互斥锁:易混概念一次分清)

[9.1 信号 vs 信号量:一个"叫你一下",一个"数你一下"](#9.1 信号 vs 信号量:一个"叫你一下",一个"数你一下")

[9.2 信号量 vs 互斥锁:谁都能解 vs 只有主人能解](#9.2 信号量 vs 互斥锁:谁都能解 vs 只有主人能解)

[9.3 二元 vs 多元:一次放一个,还是放 N 个](#9.3 二元 vs 多元:一次放一个,还是放 N 个)

[9.4 三种档案结构对比:分别管什么、差在哪](#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 完整流程:一次规范使用要走六步

一次规矩的信号量使用,全流程六步:

  1. 创建/获取信号量集(semget)
  2. 初始化:设初值 = 座位总数(semctl SETVAL)
  3. 申请资源,计数 -1(semop 做 P)
  4. 使用共享资源
  5. 释放资源,计数 +1(semop 做 V)
  6. 删除信号量集(semctl IPC_RMID)

3.5 资源用完了怎么办:阻塞等待,不空转

计数归零时再来进程,P 操作会让它阻塞等待------注意是睡眠那种等,不是原地转圈的忙等(忙等就是 while 死循环白白烧 CPU)。等到有人 V(退票),内核把它唤醒接着干。

这也解释了为什么信号量叫"同步"工具:它不光拦人,还负责排队叫号。


四、申请(P)和释放(V)具体做了什么

前面一直说"P 减 1、V 加 1",但内核里这套操作到底长什么样?这一节拆开。

4.1 内核给每个信号量记的四笔账

先看 man page(semop(2))给的答案,集合里每一个信号量都有四个关联值:

cpp 复制代码
unsigned short semval;   /* 信号量当前值 */
unsigned short semzcnt;  /* 等它变成 0 的进程数 */
unsigned short semncnt;  /* 等它变大的进程数 */
pid_t          sempid;   /* 最后一个操作它的进程 PID */

买票视角:semval 是剩余票数;semncnt 是排着队等放票的人数;semzcnt 是等着"票全部卖完"这个状态的人数;sempid 记着最后一张票是谁买的。

4.2 一次操作怎么填:写一张"操作单"交给内核

对信号量的每次操作,你都要填一张操作单(struct sembuf)交给 semop:

cpp 复制代码
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)

cpp 复制代码
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)

cpp 复制代码
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"):

cpp 复制代码
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)

cpp 复制代码
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(硬编码,改不了) 同左

实测方法(可以直接跑):

cpp 复制代码
$ cat /proc/sys/kernel/sem
250 32000 32 128

注意这四个数的顺序:SEMMSL SEMMNS SEMOPM SEMMNI------不是字母序,也不是"集在前"的直觉序。面试官就爱拿这个顺序钓人,答错顺序等于没查过。


七、进程退出它还在:生命周期与清理

7.1 怎么查看和删除:命令行 + 代码调用

bash 复制代码
$ 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 原文):

cpp 复制代码
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 原文):

cpp 复制代码
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 时代的代码):

cpp 复制代码
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;  /* ← 指向那张表 */
};

p0 是 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 长这样:

cpp 复制代码
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 里有 ids3,下标顺序是:0 信号量、1 消息队列、2 共享内存。semid、msqid、shmid 各走各的表,互不干扰。

kern_ipc_perm 里都有什么(4.16 源码,字段挑重点):

cpp 复制代码
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,把每个 semadji 加回对应信号量的 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 基数树,思想不变

信号量不难,难的是把"计数器"和"预订"这两层意思焊死在脑子里------想不通的时候回电影院看一眼,买票退票,就那么点事。

相关推荐
广州灵眸科技有限公司1 小时前
灵眸科技EAI3572-Core-L核心板即将发布!八核+4TOPS NPU,面向工业与边缘AI
linux·运维·服务器·数据库·yolo
码农小韩1 小时前
Linux应用开发(三)——Linux的文件属性与目录
linux·嵌入式软件开发·linux操作系统·嵌入式操作系统·linux应用开发
张洛闻Eren1 小时前
云原生k8s【第七课】:集群监控与可观测性
linux·运维·docker·云原生·容器·kubernetes·k8s
1 小时前
Python 图片处理:裁剪缩放至300×200,格式JPG/PNG/GIF,控制大小≤100KB
linux·前端·python
青禾8379 小时前
Linux 操作系统入门指南:从目录结构到常用命令
linux·运维·服务器
zhengqweasd10 小时前
流量没跑满、带宽空闲,业务依然卡顿
运维·服务器·网络
闖6410 小时前
入门Linux操作系统|零基础吃透原理、目录、权限、常用命令
linux
bruk_spp11 小时前
linux pinctrl
linux