【Linux指南】进程通信系列(七):System V 信号量 —— 同步互斥的内核原语

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 (有等待者) 唤醒一个;      // 有人排队 → 叫醒一个

两个要点:

  1. sem-- 之后发现 sem < 0,进程阻塞挂起,挂在等待队列上 ------ 这就是为什么信号量的内核描述里必须有等待队列(第八篇看源码时留意它);
  2. P/V 都是内核保证的原子操作 ------ 上一节的鸡生蛋问题在这里被终结。

3.4 两种信号量

类型 初值 含义
二元信号量 只有 0 / 1 两态 就是互斥! 整个资源一个执行流独占
多元信号量 N > 1 资源划分成 N 个子资源,允许 N 个执行流各占一份

回忆:第五篇我们用 FIFO 只做了 "读方等写方" 的单向同步;二元信号量就是它的正规军版本。

3.5 信号量和 IPC 通信的关系?

两个问题

  1. 每个进程都要先看到同一个信号量才能做 P/V → 这还是 "看到同一份资源" 的问题,正是 System V 的老套路(ftok + key);
  2. 不是只有传递数据才叫 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,你的程序一启动就死锁。

六、常见坑清单

  1. 忘记 SETVAL ------ 创建了集合却没初始化,第 0 个信号量的值不确定,P 操作可能永久阻塞。"创建后立刻初始化" 写进肌肉记忆;
  2. P 了忘 V / P 之后崩溃 ------ 计数器永久减一,所有进程死锁。解法:SEM_UNDO 标志交给内核兜底;
  3. 两个进程都 SETVAL ------ 后来者覆盖初值,语义错乱。用 5.1 的 "创建者初始化" 分支模式;
  4. 混淆 semid 和 semnum ------ semid 是集合句柄,semnum 是集合里第几个计数器(下标 0 开始)。集合里只有 1 个信号量时最容易把两者当一回事;
  5. 忘记清理残留 ------ ipcs -s 检查、ipcrm -s 删除。System V 三兄弟共同的宿命。

七、总结与下一篇预告

7.1 本篇核心结论

  1. 概念链:共享资源 → 保护后成为临界资源 → 访问它的代码是临界区 → 用互斥(唯一访问)与同步(顺序性)保护 → 保护的本质是保护代码段;
  2. 锁操作本身必须原子,OS 靠 swap/xchg 等硬件指令解决 "鸡生蛋" ------ 信号量的 P/V 是内核保证的原生原子操作;
  3. 信号量本质是计数器 + 资源预订机制(电影院买票模型):P 申请(减一,不够则挂等待队列),V 释放(加一,唤醒等待者);
  4. 二元信号量 = 互斥;N 元信号量 = 把资源划分成 N 份子资源,支持受控并发;
  5. 接口三件套 semget / semctl(SETVAL 初始化、IPC_RMID 删除) / semop(sembuf);SEM_UNDO 防崩溃死锁,SEMOPM=32 允许原子复合操作;
  6. 信号量不传数据,传递的是 "协作状态" ------ 通知、同步、互斥都算 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 语言 "多态" 管住三种资源,解释你从前每篇记下的 "奇怪共性"。

相关推荐
GeW1 小时前
从"补短板"到"强优势"Linux与数据库认证成为产业跃迁人才支点
linux·数据库
HZZD_HZZD3 小时前
大口径摄像水表白皮书拆解:DN40加装改造与字轮识别实测
运维·物联网·腾讯云
PellyKoo3 小时前
【Ubuntu】GTX 1080 Ti 显卡占用为0但功率高达60W的排查与解决
linux·运维·ubuntu
Fruiticecake4 小时前
epoll 回显服务器是怎么工作的:从 accept 队列到就绪链表,以及它和 select / poll 的区别
linux
莫晓燚4 小时前
阿里云若依项目部署与日志采集---实战
运维·数据库·redis·阿里云·ruoyi
captain3764 小时前
MyBatis Plus(1)
java·服务器·mybatis
何中应4 小时前
Grafana如何共享面板
运维·grafana·可观测
科创致远5 小时前
ESOP 电子作业指导书 WiFi 网络应用实测报告
运维·服务器·网络
承渊政道5 小时前
Linux网络学习【从NAT与代理服务理解网络转发、探索内网穿透原理、再到认识DNS与ICMP、最后动手部署并测试内网穿透】
linux·网络·学习·内网穿透·dns·nat