【System V 信号量】从 P/V 原语到 Builder 封装:写出可控、可清理的进程互斥组件

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :从信号量的计数语义出发,完整串起 ftoksemgetsemctlsemopSEM_UNDO,再使用轻量建造者模式封装一个二元 System V 信号量,完成父子进程临界区互斥实验。

💡 学习目标 :分清教材中的"负数信号量模型"和 Linux 的实际内核字段;理解 sem_op 为负、为零、为正时的三种语义;掌握信号量集的创建、显式初始化、连接、使用和删除;看懂 Builder 与 RAII 在系统编程中的职责边界。

📌 阅读说明 :示例面向 Linux,使用 C++17。本文沿用原材料的实验主线,但修正 P/V 伪代码、连接标志、错误处理与 fork 后资源所有权等细节。System V 信号量适合学习内核级进程同步;新项目是否选用它,还应结合 POSIX semaphore、共享内存、文件锁等方案综合判断。


文章目录


一、先看问题:为什么两个进程的输出会互相穿插

假设父进程希望反复打印一个完整片段:

text 复制代码
F F

子进程希望反复打印:

text 复制代码
C C

为了让竞争更容易出现,我们故意在同一片段的两次输出之间加入短暂休眠:

cpp 复制代码
printf("F");
fflush(stdout);
usleep(...);
printf("F ");
fflush(stdout);

如果没有同步,调度器可能在第一次 printf 后切走当前进程。于是屏幕上看到的就不再是完整的 F FC C,而可能是:

text 复制代码
F C F C C F ...

这里真正需要保护的不是某一个字符,而是"打印第一个字符到打印第二个字符"的整个操作片段:

text 复制代码
进入临界区
    ↓
打印第一个字符
    ↓
短暂休眠
    ↓
打印第二个字符
    ↓
离开临界区

只要同一时刻最多允许一个执行流进入这段临界区,两个字符就不会被另一个进程插进来。

互斥锁可以解决这个问题,初值为 1 的计数信号量同样可以。区别在于,信号量的原始抽象比"锁住 / 未锁住"更一般:它描述的是还有多少份资源许可可被获取


二、信号量的核心:它记录的不是"锁住了没有"

2.1 把资源想成一组许可

假设某类资源最多允许 3 个执行流同时使用,那么可以把信号量初值设为 3

text 复制代码
semval = 3

每当一个执行流成功执行 P 操作,就消费一个许可:

text 复制代码
3 → 2 → 1 → 0

当计数已经为 0,新的申请者无法继续,只能等待;某个使用者执行 V 操作归还许可后,计数增加,等待者才有机会继续。

因此:

  • 初值为 N:最多允许 N 个执行流同时进入;
  • 初值为 1:退化为二元信号量,可用于互斥;
  • 初值为 0:常用于事件同步,等待另一方先"生产"许可。

2.2 互斥与同步有什么区别

互斥强调:

同一时刻,只能有一个执行流进入临界区。

同步强调:

多个执行流必须按照某种先后条件推进。

二元信号量设为 1,常被当作互斥工具;设为 0,由生产者执行 V、消费者执行 P,则可以表达"数据未准备好时等待,准备好后继续"的同步关系。

2.3 教材里的负数 S 与 Linux 的 semval 不是一回事

经典教材常用一个抽象整数 S 同时描述资源与等待者:

  • S > 0:还有 S 份可用资源;
  • S = 0:没有可用资源;
  • S < 0|S| 表示等待进程数。

这个模型很适合推导 P/V 原语,但不要直接把它等同于 Linux System V 信号量的 semval

在 Linux 的 System V 接口里,单个信号量相关状态包含:

cpp 复制代码
unsigned short semval;   // 当前信号量值
unsigned short semzcnt;  // 等待 semval 变为 0 的执行流数量
unsigned short semncnt;  // 等待 semval 增加的执行流数量
pid_t          sempid;   // 最后修改它的进程 PID

也就是说,实际实现把"信号量值"和"等待计数"拆成了不同字段。semval 本身不需要变成负数来编码等待者数量。

这一区分非常重要:

教材模型回答"P/V 为什么能表达同步";System V 字段回答"Linux 接口具体维护了什么状态"。


三、先纠正 P、V 原语中的两个常见笔误

3.1 教材模型中的正确伪代码

在允许抽象计数器变为负数的经典模型里,P 操作可以写成:

cpp 复制代码
P(S)
{
    --S.value;
    if (S.value < 0)
    {
        // 把当前进程置为等待状态
        // 将 PCB 插入 S.queue
        // 主动让出处理器
    }
}

V 操作可以写成:

cpp 复制代码
V(S)
{
    ++S.value;
    if (S.value <= 0)
    {
        // 从 S.queue 中唤醒一个等待进程
        // 将其置为就绪状态并加入就绪队列
    }
}

3.2 为什么不能写 s.value = s.value--

下面这种写法并不等价于"让值减一":

cpp 复制代码
s.value = s.value--;

后置 -- 先产生旧值,再安排递减副作用,外层赋值又把旧值写回;在不同语言版本的求值规则下,它至少是语义混乱、不可取的写法,在旧规则下还可能涉及未定义行为。

想减一就直接写:

cpp 复制代码
--s.value;

V 操作同理,不要写:

cpp 复制代码
s.value = s.value++;

3.3 为什么 V 的判断条件是 <= 0

在负数教材模型中,V 操作先执行 ++S.value。如果更新后的值仍然小于或等于 0,说明操作前存在等待者,需要唤醒一个。

例如:

text 复制代码
操作前 S = -2:有两个等待者
执行 V 后 S = -1:仍有一个等待者,应该唤醒其中一个

若错误地判断 S.value > 0,反而会在没有等待者时尝试唤醒,在真正存在等待者时什么都不做。

3.4 P/V 必须是原子原语

伪代码看起来只是"减一、判断、入队",但整个状态转换必须由内核保证原子性。否则两个进程可能同时读到相同旧值,各自修改后造成许可丢失或超发。

应用代码不应该自己用普通整数模仿跨进程信号量;System V 的 semop 会在内核里完成检查、修改、阻塞与唤醒。


四、System V 对象模型:key、semid 与信号量集

System V IPC 的消息队列、共享内存与信号量都采用类似的定位方式:

text 复制代码
路径 + 项目标识
    ↓ ftok
key_t key
    ↓ semget
int semid
    ↓ semop / semctl
内核 IPC 对象

4.1 key:跨进程约定的查找键

常见写法:

cpp 复制代码
key_t key = ftok("/tmp", 0x77);

ftok 根据已有路径所指文件的身份信息与 proj_id 的低 8 位生成 key_t。两个进程使用相同文件与相同项目标识,通常会得到相同 key,从而定位同一个 System V IPC 对象。

但要注意:

  1. 路径必须已经存在并且可访问;
  2. proj_id 不能为 0,实际只使用低 8 位;
  3. ftok 不承诺全局唯一,可能发生碰撞;
  4. key 只是查找条件,不是后续每次操作都使用的句柄。

教学实验用 /tmp 没问题,工程项目更适合使用应用自己的稳定文件,并把碰撞和权限纳入设计。

4.2 semid:内核返回的信号量集标识符

semget 成功后返回一个非负整数:

cpp 复制代码
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);

后续 semctlsemop 都围绕这个 semid 工作。

不要把 key 与 semid 混为一谈:

  • key 用于"找到或创建哪个对象";
  • semid 用于"对已经找到的对象执行操作"。

4.3 System V 创建的是"信号量集"

semget 的第二个参数叫 nsems,表示集合中包含多少个信号量:

cpp 复制代码
int semget(key_t key, int nsems, int semflg);

即使我们只想模拟一把锁,也是在创建一个"只含 1 个成员的信号量集":

text 复制代码
Semaphore Set
└── sem[0]

nsems = 3,集合可以表示:

text 复制代码
Semaphore Set
├── sem[0]
├── sem[1]
└── sem[2]

struct sembuf 里的 sem_num 决定一次操作具体作用于哪个成员,编号从 0 开始。

4.4 semid_ds 管理集合级元数据

内核还为整个集合维护 semid_ds

cpp 复制代码
struct semid_ds
{
    struct ipc_perm sem_perm;   // 所有者、创建者与权限
    time_t          sem_otime;  // 最近一次 semop 时间
    time_t          sem_ctime;  // 创建或最近控制操作时间
    unsigned long   sem_nsems;  // 集合中的信号量个数
};

其中 ipc_perm 包含所有者 UID/GID、创建者 UID/GID、权限位等信息。它解释了为什么另一个用户可能能看到一个信号量集,却没有权限修改或删除它。


五、三个核心接口:semget、semctl、semop

5.1 semget:创建或获得信号量集

函数原型:

cpp 复制代码
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

创建方通常使用:

cpp 复制代码
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);

含义如下:

  • IPC_CREAT:不存在时创建;
  • IPC_EXCL:与 IPC_CREAT 配合,若已经存在则以 EEXIST 失败;
  • 0666:所有者、组、其他用户的读写权限位;后续操作仍要通过 System V IPC 的权限与能力检查。

连接方更适合明确写成:

cpp 复制代码
int semid = semget(key, 0, 0);

此时不负责创建;对象不存在就以 ENOENT 失败。

不建议把连接模式定义成单独的 IPC_CREAT。那样当真正的创建者尚未运行时,连接方可能意外创建一个没有按协议初始化的集合。

5.2 新建信号量不能依赖默认值

Linux 上新建信号量常能观察到初值为 0,但可移植程序不应依赖它。创建方应该立即通过 semctlSETVALSETALL 显式初始化。

这也是为什么完整流程必须把"创建"和"初始化"拆开:

5.3 semctl:查询、初始化、修改与删除

原型:

cpp 复制代码
int semctl(int semid, int semnum, int cmd, ...);

常用命令包括:

cmd 作用 第四参数
GETVAL 读取 semnum 对应的当前值 不使用
SETVAL 设置某个信号量的值 union semun.val
GETALL 读取集合中所有值 union semun.array
SETALL 设置集合中所有值 union semun.array
IPC_STAT 读取集合元数据 union semun.buf
IPC_SET 修改权限/所有者字段 union semun.buf
IPC_RMID 立即删除整个集合 semnum 被忽略

在 glibc 环境里,程序通常需要自行声明 union semun

cpp 复制代码
union semun
{
    int              val;
    struct semid_ds* buf;
    unsigned short*  array;
    struct seminfo*  __buf;
};

初始化第 0 个信号量:

cpp 复制代码
union semun arg{};
arg.val = 1;

if (semctl(semid, 0, SETVAL, arg) == -1)
{
    perror("semctl SETVAL");
}

删除整个集合:

cpp 复制代码
if (semctl(semid, 0, IPC_RMID) == -1)
{
    perror("semctl IPC_RMID");
}

IPC_RMID 会移除整个信号量集,并使阻塞在相关 semop 上的执行流以 EIDRM 返回。它不是普通"释放一个 C++ 对象"的动作,而是影响所有使用者的内核级所有权操作。

5.4 semop:真正执行等待与修改

原型:

cpp 复制代码
int semop(int semid, struct sembuf* sops, size_t nsops);

sops 指向操作数组,nsops 是数组元素个数。关键结构为:

cpp 复制代码
struct sembuf
{
    unsigned short sem_num;  // 集合中的信号量下标
    short          sem_op;   // 负数、0、正数对应不同操作
    short          sem_flg;  // 0、IPC_NOWAIT、SEM_UNDO 等
};

最简单的 P 操作:

cpp 复制代码
struct sembuf op{0, -1, SEM_UNDO};
semop(semid, &op, 1);

最简单的 V 操作:

cpp 复制代码
struct sembuf op{0, +1, SEM_UNDO};
semop(semid, &op, 1);

但"负一等于 P、正一等于 V"只是二元信号量实验中的常用取值,sem_op 的完整能力远不止这两种。


六、sembuf:一个字段为什么能表达三种操作

6.1 sem_op < 0:申请许可

假设:

cpp 复制代码
op.sem_op = -3;

含义不是"简单减三",而是"原子申请 3 份许可"。

  • semval >= 3,立即把 semval 减 3;
  • 若当前许可不足且未设置 IPC_NOWAIT,调用者阻塞;
  • 若许可不足且设置 IPC_NOWAIT,立即失败并返回 EAGAIN

二元信号量的 P 操作用 -1,本质是申请唯一的一份许可。

6.2 sem_op == 0:等待归零

这是 System V 信号量很有特色的一种语义:

cpp 复制代码
op.sem_op = 0;
  • semval 已经为 0,立即完成;
  • 若不为 0,默认阻塞,直到它变为 0;
  • 若设置 IPC_NOWAIT,立即返回 EAGAIN

它可以表达"等待所有资源都已归还"或"等待某个状态计数清零"。

6.3 sem_op > 0:归还许可

例如:

cpp 复制代码
op.sem_op = 2;

表示把 semval 增加 2。增加后的值如果满足某些阻塞操作的条件,内核会尝试唤醒等待者。

6.4 多个操作可以原子提交

semop 最容易被低估的能力,是 sops 数组会按照数组顺序作为一个原子单元执行:要么全部完成,要么在无法完成时不留下部分修改。

例如,把一个许可从 sem[0] 转移到 sem[1]

cpp 复制代码
struct sembuf ops[2] = {
    {0, -1, SEM_UNDO},
    {1, +1, SEM_UNDO}
};

if (semop(semid, ops, 2) == -1)
{
    perror("semop");
}

若第一个信号量没有足够许可,第二个操作不会先执行。这个保证对复杂资源分配协议非常重要。

6.5 IPC_NOWAITSEM_UNDO

IPC_NOWAIT 把"等待资源"改成"资源不足就立即失败",适合事件循环或自行重试的程序。

SEM_UNDO 会为进程维护调整记录。当进程终止时,内核尝试把带该标志的数值调整反向应用,以降低"进程拿到许可后崩溃,许可永久丢失"的风险。

但它不是事务系统:

  • 只补偿信号量数值调整;
  • 不会回滚临界区里的文件、共享内存或数据库修改;
  • 不会自动修复处于半更新状态的业务数据;
  • fork 出来的子进程不会直接继承父进程的 undo 调整列表。

因此,SEM_UNDO 是故障缓解手段,不是业务一致性的替代品。


七、为什么要用 Builder 封装信号量

直接使用 System V API 时,创建一个可用信号量至少要处理:

text 复制代码
选择 key 来源
    ↓
调用 ftok
    ↓
设置 semget 标志与权限
    ↓
区分创建还是连接
    ↓
创建方执行 SETVAL
    ↓
保存 semid
    ↓
决定谁负责 IPC_RMID

如果把这些逻辑散落在业务代码里,调用者很容易漏掉初始化、混淆创建与连接,或者让多个进程都以为自己拥有删除权。

7.1 Builder 负责"怎样构造"

我们希望调用代码接近自然语言:

cpp 复制代码
auto sem = SemaphoreBuilder{}
    .SetKeySource("/tmp", 0x77)
    .SetInitialValue(1)
    .Create();

调用者只表达:

text 复制代码
使用哪个 key 来源
初值是多少
我要创建对象

至于 ftoksemgetsemctl 的顺序、错误检查和失败清理,都交给 Builder。

7.2 Semaphore 负责"怎样使用与释放"

构造成功后,Semaphore 只暴露稳定操作:

cpp 复制代码
sem->P();
// 临界区
sem->V();

并记录:

  • _semid:操作哪个内核集合;
  • _owner:当前包装对象是否拥有删除责任;
  • _ownerPid:创建者进程 PID,防止 fork 后子进程误删。

7.3 这里不需要 Director

经典 Builder 模式常包含 Director,用固定步骤指挥不同 ConcreteBuilder 构造复杂产品。

本例只有一种产品 Semaphore,构造步骤也不需要多套排列。链式 SemaphoreBuilder 已能完成:

  • 参数收集;
  • 合法性校验;
  • Create / Open 分流;
  • 失败回滚;
  • 所有权标记。

因此不必为了"形式完整"额外引入 Director。设计模式的意义是控制复杂度,不是堆叠角色名称。


八、完整实现:Sem.hpp

下面给出一个比最小教学代码更完整的版本。它仍然保持结构轻量,但补上了错误处理、创建/连接分离和 fork 后删除权检查。

cpp 复制代码
#pragma once

#include <cerrno>
#include <cstring>
#include <memory>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>

#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/types.h>
#include <unistd.h>

// glibc 通常要求应用自行声明 union semun。
#if defined(_SEM_SEMUN_UNDEFINED)
union semun
{
    int              val;
    struct semid_ds* buf;
    unsigned short*  array;
    struct seminfo*  __buf;
};
#endif

class SemaphoreBuilder;

class Semaphore final
{
    friend class SemaphoreBuilder;

public:
    Semaphore(const Semaphore&) = delete;
    Semaphore& operator=(const Semaphore&) = delete;

    void P()
    {
        Operate(-1);
    }

    void V()
    {
        Operate(+1);
    }

    int GetValue() const
    {
        int value = ::semctl(_semid, 0, GETVAL);
        if (value == -1)
        {
            throw std::system_error(errno,
                                    std::generic_category(),
                                    "semctl(GETVAL)");
        }
        return value;
    }

    int Id() const noexcept
    {
        return _semid;
    }

    ~Semaphore() noexcept
    {
        // fork 会复制用户态对象。只有真正的创建进程才有资格删除集合。
        if (_owner && ::getpid() == _ownerPid)
        {
            ::semctl(_semid, 0, IPC_RMID);
        }
    }

private:
    Semaphore(int semid, bool owner, pid_t ownerPid)
        : _semid(semid), _owner(owner), _ownerPid(ownerPid)
    {
    }

    void Operate(short delta)
    {
        struct sembuf op {};
        op.sem_num = 0;
        op.sem_op = delta;
        op.sem_flg = SEM_UNDO;

        while (::semop(_semid, &op, 1) == -1)
        {
            if (errno == EINTR)
            {
                continue;
            }

            throw std::system_error(errno,
                                    std::generic_category(),
                                    delta < 0 ? "semop(P)" : "semop(V)");
        }
    }

private:
    int   _semid;
    bool  _owner;
    pid_t _ownerPid;
};

using sem_sptr = std::shared_ptr<Semaphore>;

class SemaphoreBuilder
{
public:
    SemaphoreBuilder& SetInitialValue(int value)
    {
        _initialValue = value;
        return *this;
    }

    SemaphoreBuilder& SetKeySource(std::string pathname, int projectId)
    {
        _pathname = std::move(pathname);
        _projectId = projectId;
        return *this;
    }

    sem_sptr Create() const
    {
        if (_initialValue < 0)
        {
            throw std::invalid_argument(
                "initial value must be nonnegative");
        }

        key_t key = MakeKey();

        int semid = ::semget(key,
                             1,
                             IPC_CREAT | IPC_EXCL | 0666);
        if (semid == -1)
        {
            throw std::system_error(errno,
                                    std::generic_category(),
                                    "semget(create)");
        }

        union semun arg {};
        arg.val = _initialValue;

        if (::semctl(semid, 0, SETVAL, arg) == -1)
        {
            int savedErrno = errno;
            ::semctl(semid, 0, IPC_RMID);
            throw std::system_error(savedErrno,
                                    std::generic_category(),
                                    "semctl(SETVAL)");
        }

        return sem_sptr(new Semaphore(semid, true, ::getpid()));
    }

    sem_sptr Open() const
    {
        key_t key = MakeKey();

        // nsems 为 0 表示连接已有集合时不关心成员数。
        int semid = ::semget(key, 0, 0);
        if (semid == -1)
        {
            throw std::system_error(errno,
                                    std::generic_category(),
                                    "semget(open)");
        }

        return sem_sptr(new Semaphore(semid, false, -1));
    }

private:
    key_t MakeKey() const
    {
        if (_projectId == 0)
        {
            throw std::invalid_argument("project id must be nonzero");
        }

        key_t key = ::ftok(_pathname.c_str(), _projectId);
        if (key == static_cast<key_t>(-1))
        {
            throw std::system_error(errno,
                                    std::generic_category(),
                                    "ftok");
        }
        return key;
    }

private:
    std::string _pathname = "/tmp";
    int _projectId = 0x77;
    int _initialValue = -1;
};

8.1 为什么 Operate 要处理 EINTR

阻塞中的 semop 可能被信号处理过程打断并返回 EINTR。Linux 的 semop 不会因为设置了 SA_RESTART 就自动重新开始。

因此,P 操作至少要明确选择一种策略:

  • 遇到 EINTR 重新等待;
  • 把中断暴露给上层,由上层决定退出;
  • 在关闭流程中结合原子退出标志处理。

示例选择"继续等待",以保持接口简单。

8.2 为什么初始化失败后要立即 IPC_RMID

semget 已经成功、SETVAL 却失败时,内核里可能留下一个未按协议初始化的集合。如果直接抛异常,后续创建者会因为 IPC_EXCL 得到 EEXIST,却不知道对象处于半成品状态。

所以 Builder 在构造失败时执行回滚:

text 复制代码
semget 成功
    ↓
SETVAL 失败
    ↓
保存 errno
    ↓
IPC_RMID 删除半成品
    ↓
抛出原始错误

8.3 为什么不在析构函数里抛异常

析构函数可能发生在栈展开期间;如果再次抛异常,程序可能直接终止。因此析构中的 IPC_RMID 采用尽力而为策略,不抛出异常。

对于必须确认清理成功的生产程序,可以再提供一个显式 Remove() 接口,把错误报告放在正常控制流中,析构仅作为兜底。


九、实验程序:父子进程竞争二元信号量

9.1 Writer.cc

cpp 复制代码
#include "Sem.hpp"

#include <cstdio>
#include <cstdlib>
#include <iostream>

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

namespace
{
void PrintFragment(const sem_sptr& sem, char value)
{
    sem->P();

    std::printf("%c", value);
    std::fflush(stdout);
    ::usleep(static_cast<useconds_t>(std::rand() % 95270));

    std::printf("%c ", value);
    std::fflush(stdout);
    ::usleep(static_cast<useconds_t>(std::rand() % 43990));

    sem->V();
}
}

int main()
{
    try
    {
        SemaphoreBuilder builder;

        auto parentSem = builder
            .SetKeySource("/tmp", 0x77)
            .SetInitialValue(1)
            .Create();

        pid_t child = ::fork();
        if (child == -1)
        {
            throw std::system_error(errno,
                                    std::generic_category(),
                                    "fork");
        }

        if (child == 0)
        {
            // 演示"连接方"语义。子进程也可以直接使用继承的 semid,
            // 这里重新 Open,是为了把创建与连接路径展示清楚。
            auto childSem = SemaphoreBuilder{}
                .SetKeySource("/tmp", 0x77)
                .Open();

            for (int i = 0; i < 10; ++i)
            {
                PrintFragment(childSem, 'C');
            }
            return 0;
        }

        for (int i = 0; i < 10; ++i)
        {
            PrintFragment(parentSem, 'F');
        }

        int status = 0;
        while (::waitpid(child, &status, 0) == -1)
        {
            if (errno != EINTR)
            {
                throw std::system_error(errno,
                                        std::generic_category(),
                                        "waitpid");
            }
        }

        std::printf("\n");
        return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
    }
    catch (const std::exception& ex)
    {
        std::cerr << "error: " << ex.what() << '\n';
        return 1;
    }
}

9.2 编译与运行

bash 复制代码
g++ Writer.cc -std=c++17 -O2 -Wall -Wextra -pedantic -o sem_demo
./sem_demo

一次可能的输出:

text 复制代码
F F F F C C F F C C C C F F C C ...

观察重点不是父子进程是否严格交替,而是:

  • 不应该出现 F C F
  • 不应该出现 C F C
  • 每个 F FC C 片段内部不会被另一个进程插入字符。

9.3 为什么不能承诺严格轮流

初值为 1 的信号量保证互斥,但没有在应用层规定"父进程一次、子进程一次"。父进程执行 V 后,完全可能再次抢到许可,于是连续打印多个 F F

如果必须严格交替,需要把同步协议写得更具体。例如使用两个信号量:

text 复制代码
sem[0]:父进程许可,初值 1
sem[1]:子进程许可,初值 0

父进程:

text 复制代码
P(sem[0]) → 打印 F F → V(sem[1])

子进程:

text 复制代码
P(sem[1]) → 打印 C C → V(sem[0])

这才是"互斥"进一步升级为"确定顺序同步"。

9.4 怎样观察内核对象

程序运行期间,在另一个终端执行:

bash 复制代码
ipcs -s

可以查看当前 System V 信号量集。Linux 也会在:

text 复制代码
/proc/sysvipc/sem

暴露类似信息。

若程序异常退出后留下了测试对象,可以先通过 ipcs -s 确认精确 semid,再谨慎删除:

bash 复制代码
ipcrm -s <semid>

不要在不确认目标的情况下清理其他程序正在使用的 IPC 对象。


十、SEM_UNDO、RAII 与内核对象生命周期

10.1 信号量集跟随内核,不跟随创建进程

System V 信号量集不是创建进程堆上的普通对象。创建进程退出后,内核对象通常仍可存在,直到:

  • 某个有权限的进程执行 semctl(..., IPC_RMID)
  • 管理员通过 ipcrm 删除;
  • 系统重启或 IPC namespace 被销毁。

这与匿名管道等"最后一个文件描述符关闭后自然消失"的对象不同。

10.2 RAII 能做什么

RAII 可以把"拥有者退出时执行 IPC_RMID"挂到 C++ 对象析构:

text 复制代码
Semaphore 对象生命周期结束
    ↓
确认 owner == true
    ↓
确认 getpid() == ownerPid
    ↓
semctl(IPC_RMID)

它能减少正常控制流中的忘记清理,但前提是所有权已经设计清楚。

10.3 fork 会复制包装对象,不能复制删除权

父进程在 fork 前创建:

cpp 复制代码
auto sem = builder.Create();
fork();

子进程会得到用户态 shared_ptrSemaphore 状态的副本。若包装类只记录一个 _owner = true,父子两边都可能在析构时调用 IPC_RMID

因此示例还保存创建时的 PID:

cpp 复制代码
if (_owner && getpid() == _ownerPid)
{
    semctl(_semid, 0, IPC_RMID);
}

这确保"被 fork 出来的对象副本"不会自动继承内核资源删除权。

10.4 shared_ptr 管的是包装对象,不是内核引用计数

多个 shared_ptr 共享一个 Semaphore 包装对象,只能说明同一进程地址空间里有多少 C++ 引用。

内核不会因为 shared_ptr::use_count() 变成 0 就自动理解"所有进程都不用这个信号量集了"。跨进程生命周期仍要依赖明确协议:

  • 创建者是谁;
  • 连接者何时退出;
  • 创建者是否要 waitpid
  • 独立进程如何通知"最后一个使用者已经离开";
  • 崩溃时怎样发现并处理残留对象。

10.5 SEM_UNDOIPC_RMID 解决不同问题

两者经常一起出现,但完全不是一回事:

  • SEM_UNDO:进程终止时,尝试反向补偿该进程带标志的信号量调整;
  • IPC_RMID:删除整个内核信号量集,影响所有使用者。

前者缓解"许可泄漏",后者管理"对象是否还存在"。


十一、原始实现中值得修正的工程细节

11.1 创建与连接不能都使用 IPC_CREAT

如果连接方这样写:

cpp 复制代码
#define GET_SEM IPC_CREAT

那么真正的创建方未运行时,连接方可能自己创建集合。由于它不执行 SETVAL,对象会处于协议之外的初始状态。

更清晰的约定是:

cpp 复制代码
// 创建方
semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);

// 连接方
semget(key, 0, 0);

11.2 不要吞掉 semop 返回值

下面的写法会把所有异常都隐藏掉:

cpp 复制代码
int n = semop(_semid, &op, 1);
(void)n;

失败原因可能包括:

  • EINTR:等待被信号中断;
  • EIDRM:集合等待期间被删除;
  • EINVAL:semid 无效或参数非法;
  • EACCES:没有相应权限;
  • EAGAIN:使用 IPC_NOWAIT 且条件暂不满足;
  • ERANGE:结果超过系统允许的信号量值范围。

系统编程里,"暂时阻塞"和"已经失败"必须被区分。

11.3 初始化不是幂等写入,不能随便重复

连接已有集合后再次执行:

cpp 复制代码
semctl(semid, 0, SETVAL, arg);

可能直接改掉其他进程正在使用的同步状态。更隐蔽的是,SETVAL/SETALL 还会清除相关信号量的 undo 调整记录。

所以必须明确:

  • 创建成功的进程负责初始化;
  • 连接进程只使用,不重复初始化;
  • 多个无主次进程竞争初始化时,需要额外协议判断谁是初始化者。

11.4 ftok 的 key 可能碰撞

ftok 是便利函数,不是 UUID 生成器。生产代码至少要:

  • 使用应用专属、稳定存在的 key 文件;
  • 创建时配合 IPC_EXCL
  • EEXIST 做明确处理;
  • 通过权限、集合成员数或协议字段验证对象是否真属于本应用。

11.5 权限 0666 适合演示,不一定适合生产

0666 让更多用户具备读写可能,便于本机教学实验,但共享主机上的程序通常应该收紧到:

cpp 复制代码
0600

或按照同组协作需要使用:

cpp 复制代码
0660

权限设计要与运行用户、容器、IPC namespace 和部署方式一起考虑。

11.6 P 与 V 成对不等于异常安全

下面的代码在临界区抛异常时会漏掉 V:

cpp 复制代码
sem->P();
DoSomething();  // 可能抛异常
sem->V();

更进一步可以写一个守卫:

cpp 复制代码
class SemaphoreGuard
{
public:
    explicit SemaphoreGuard(sem_sptr sem)
        : _sem(std::move(sem))
    {
        _sem->P();
    }

    ~SemaphoreGuard() noexcept
    {
        try
        {
            _sem->V();
        }
        catch (...)
        {
            // 析构不能抛;生产代码应记录严重错误
        }
    }

    SemaphoreGuard(const SemaphoreGuard&) = delete;
    SemaphoreGuard& operator=(const SemaphoreGuard&) = delete;

private:
    sem_sptr _sem;
};

使用时:

cpp 复制代码
{
    SemaphoreGuard lock(sem);
    DoSomething();
}

这才把"信号量对象的生命周期"和"一次临界区的生命周期"分别交给两个 RAII 类型。

11.7 二元信号量不等于严格公平锁

System V 信号量能阻塞等待者并在条件满足时唤醒,但应用不应把实验结果理解成严格 FIFO、公平轮转或无饥饿承诺。

如果业务依赖公平性,需要查看目标平台语义并设计更明确的排队协议,而不是根据一次输出顺序推断保证。


十二、常见误区、高频面试题与练习

12.1 常见误区

误区一:信号量就是互斥锁

信号量是计数许可模型。初值为 1 时可以承担互斥,但它还能表示容量为 N 的资源池、等待归零和跨进程顺序同步。

误区二:教材里 S 可以为负,所以 Linux 的 semval 也会变成负数

教材负数模型把等待者数量编码进抽象计数器。Linux System V 实现把当前值与等待计数分开维护,不能机械对应。

误区三:semget 成功后,信号量就已经初始化为期望值

不对。创建方应显式使用 SETVALSETALL 初始化,不能把 Linux 上常见的零初值当成可移植协议。

误区四:IPC_CREAT 表示只连接现有对象

恰恰相反,它允许不存在时创建。只连接现有对象时可以使用 semget(key, 0, 0)

误区五:sem_op 只能取 -1+1

负数可一次申请多份许可,零表示等待归零,正数可一次归还多份许可。

误区六:nsops 大于 1 只是循环调用的语法糖

不是。操作数组作为一个原子单元执行,不会在中间失败时留下部分结果。

误区七:使用 SEM_UNDO 后,临界区一定一致

它只补偿信号量数值。若进程在修改共享数据一半时崩溃,业务数据仍可能损坏。

误区八:创建进程退出后,信号量集自动消失

System V IPC 对象通常持续存在于内核,直到显式 IPC_RMID、管理员删除或命名空间销毁。

误区九:shared_ptr 能统计跨进程使用者

fork 后各进程拥有独立的用户态引用计数副本;独立启动的进程更不共享 C++ 控制块。它不能替代跨进程生命周期协议。

误区十:打印没有交叉,就说明父子进程严格轮流

互斥只保证临界区片段不交叉。调度与唤醒后,某个进程仍可能连续多次抢到许可。

12.2 高频面试题

问题 1:什么是 P、V 操作?

P 操作申请资源许可,资源不足时等待;V 操作归还许可,并可能使等待者具备继续运行的条件。两者必须由同步实现保证原子性。

问题 2:System V 信号量是单个信号量还是集合?

接口操作的是信号量集。nsems 决定成员数量,sem_num 选择本次操作的成员。

问题 3:key 与 semid 有什么区别?

key 用于创建或查找 IPC 对象;semid 是内核返回的集合标识符,供 semctlsemop 后续操作。

问题 4:IPC_CREAT | IPC_EXCL 有什么作用?

仅在对象不存在时创建;若相同 key 已关联现有集合,则以 EEXIST 失败,便于确定唯一初始化者。

问题 5:为什么创建后还要 SETVAL

可移植程序不能依赖新建集合的默认值。同步协议必须显式建立初始许可数。

问题 6:sem_op 为零表示什么?

等待所选信号量的 semval 变成 0;若已经为 0 则立即通过。

问题 7:IPC_NOWAIT 有什么效果?

当操作当前不能完成时不阻塞,而是立即失败并设置 errno = EAGAIN

问题 8:SEM_UNDO 如何工作?

内核为进程记录带该标志的信号量调整;进程终止时尝试施加反向调整。它不回滚业务数据。

问题 9:semop 的操作数组有什么保证?

数组按顺序作为一个原子单元执行:要么整体完成,要么不留下部分修改。

问题 10:为什么析构函数里要检查 PID?

fork 会复制包装对象。若只依赖布尔 owner,子进程也可能把自己误认为删除者;保存创建 PID 可以限制删除动作只发生在原创建进程。

问题 11:如何查看和删除残留信号量?

使用 ipcs -s 查看精确标识符,再由有权限的用户执行 ipcrm -s <semid>。删除前要确认不是其他程序正在使用的对象。

问题 12:互斥与严格交替有什么区别?

互斥保证同一时刻只有一个执行流进入临界区;严格交替还要求下一次许可必须交给指定对端,通常需要两个信号量或额外状态协议。

12.3 排查清单

遇到"进程一直卡住""第二次运行 EEXIST"或"输出仍然交叉"时,可以逐项检查:

  • ftok 使用的路径是否真实存在并可访问?
  • 不同进程是否使用相同路径和相同 proj_id
  • 是否检查了 ftok-1 返回值?
  • 创建方是否使用 IPC_CREAT | IPC_EXCL
  • 连接方是否误用了 IPC_CREAT
  • semget 的权限位是否允许当前用户操作?
  • 创建成功后是否显式执行 SETVAL / SETALL
  • sem_num 是否落在集合成员范围内?
  • P 操作的 sem_op 是否为负数?
  • V 操作的 sem_op 是否为正数?
  • 是否错误设置了 IPC_NOWAIT,导致等待变成 EAGAIN
  • semop 返回 EINTR 时是否有明确策略?
  • 临界区范围是否覆盖了完整复合操作?
  • P 与 V 是否在异常路径上仍然成对?
  • fork 后子进程是否可能误执行所有者析构?
  • 创建者是否等到所有使用者退出后再 IPC_RMID
  • 异常运行后是否通过 ipcs -s 检查残留对象?
  • 是否把一次看似公平的输出误当成严格公平保证?

12.4 建议练习

练习一:去掉信号量

注释 P/V 操作,保留两次打印之间的随机休眠,观察片段如何交叉。

练习二:把初值改为 2

解释为什么父子进程都可能同时进入临界区,并观察输出重新交叉。

练习三:加入 IPC_NOWAIT

让 P 操作在拿不到许可时立即返回,打印 errno,观察 EAGAIN

练习四:严格父子交替

创建包含两个成员的集合,初值设为 {1, 0},让父进程与子进程互相交接许可。

练习五:一次申请两个许可

把初值设为 3,令某个执行流使用 sem_op = -2,验证许可不足时整体阻塞。

练习六:测试 wait-for-zero

增加一个观察进程,使用 sem_op = 0 等待资源全部归还,再打印"all released"。

练习七:制造异常退出

让子进程在 P 成功后调用 _exit,分别比较设置与不设置 SEM_UNDO 时的结果。

练习八:模拟创建失败回滚

故意让 SETVAL 使用非法值,检查 Builder 是否删除半成品集合。

练习九:实现显式 Remove()

让显式删除返回错误,析构只负责兜底,再设计重复删除时的幂等策略。

练习十:加入临界区守卫

完善 SemaphoreGuard,处理 V 失败的日志策略,并验证临界区抛异常时许可仍会归还。

12.5 参考资料


总结

这次 System V 信号量实验,表面上是在父子进程之间保护两次 printf,背后却连接了四层知识:

text 复制代码
抽象层:P / V 与资源许可
    ↓
内核层:信号量集、等待队列与 undo 调整
    ↓
接口层:ftok、semget、semctl、semop
    ↓
设计层:Builder、RAII、创建/连接分流与删除权

整条主线可以压缩为:

text 复制代码
ftok 生成 key
    ↓
创建者用 semget + IPC_EXCL 建立集合
    ↓
semctl(SETVAL) 显式初始化
    ↓
连接者只打开已有集合
    ↓
semop(-1) 申请许可,进入临界区
    ↓
semop(+1) 归还许可,离开临界区
    ↓
所有使用者退出后,由明确 owner 执行 IPC_RMID

真正值得记住的是六条原则:

  1. 信号量是许可计数,不只是"锁住 / 未锁住"。
  2. 教材中的负数 S 是抽象模型,不等于 Linux 的 semval 会变成负数。
  3. System V 操作的是信号量集,key、semid、sem_num 各有职责。
  4. 创建与初始化必须绑定,连接者不能悄悄创建半成品。
  5. SEM_UNDO 只补偿数值,不保证业务数据一致。
  6. RAII 能承接清理,但不能替你发明跨进程所有权协议。

当这些边界都清楚以后,Builder 就不再只是"把代码写得像链式调用",而是把一组容易出错的系统调用变成一个具备前置条件、失败回滚和所有权语义的组件。这也是设计模式在系统编程里最实用的样子:不是增加抽象层数,而是把危险细节关进一个边界清晰的对象里。

相关推荐
今天AI了吗1 小时前
什么是 AI Agent?它与直接调用大模型 API 有何区别
java·网络·人工智能·架构·java-ee
傲世仙尊1 小时前
System V 进程间通信详解:共享内存、消息队列与信号量(CSDN博客)
linux·开发语言·c++
隐退山林1 小时前
JavaEE进阶:SpringAOP
java·java-ee
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之方法级安全注解
java·安全·spring
码农小韩1 小时前
Linux应用开发(六)——进程间通信
linux·linux驱动·嵌入式软件开发·嵌入式操作系统·linux应用
cvby1 小时前
C++11
开发语言·c++
JavacKaka2 小时前
LiteFlow规则引擎实战博客-2026-09-09
java
学渣超2 小时前
记一次复杂业务功能重构——从过程式长链路,到模板方法构建体系
java·架构·代码规范
Bs_MoneyMagnet2 小时前
基于springboot+vue的旅游景区点评系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·旅游