🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :从信号量的计数语义出发,完整串起
ftok、semget、semctl、semop、SEM_UNDO,再使用轻量建造者模式封装一个二元 System V 信号量,完成父子进程临界区互斥实验。💡 学习目标 :分清教材中的"负数信号量模型"和 Linux 的实际内核字段;理解
sem_op为负、为零、为正时的三种语义;掌握信号量集的创建、显式初始化、连接、使用和删除;看懂 Builder 与 RAII 在系统编程中的职责边界。📌 阅读说明 :示例面向 Linux,使用 C++17。本文沿用原材料的实验主线,但修正 P/V 伪代码、连接标志、错误处理与
fork后资源所有权等细节。System V 信号量适合学习内核级进程同步;新项目是否选用它,还应结合 POSIX semaphore、共享内存、文件锁等方案综合判断。
文章目录
- 一、先看问题:为什么两个进程的输出会互相穿插
- 二、信号量的核心:它记录的不是"锁住了没有"
- [三、先纠正 P、V 原语中的两个常见笔误](#三、先纠正 P、V 原语中的两个常见笔误)
- [四、System V 对象模型:key、semid 与信号量集](#四、System V 对象模型:key、semid 与信号量集)
- 五、三个核心接口:semget、semctl、semop
- 六、sembuf:一个字段为什么能表达三种操作
- [七、为什么要用 Builder 封装信号量](#七、为什么要用 Builder 封装信号量)
- 八、完整实现:Sem.hpp
- 九、实验程序:父子进程竞争二元信号量
- [十、SEM_UNDO、RAII 与内核对象生命周期](#十、SEM_UNDO、RAII 与内核对象生命周期)
- 十一、原始实现中值得修正的工程细节
- 十二、常见误区、高频面试题与练习
- 总结
一、先看问题:为什么两个进程的输出会互相穿插
假设父进程希望反复打印一个完整片段:
text
F F
子进程希望反复打印:
text
C C
为了让竞争更容易出现,我们故意在同一片段的两次输出之间加入短暂休眠:
cpp
printf("F");
fflush(stdout);
usleep(...);
printf("F ");
fflush(stdout);
如果没有同步,调度器可能在第一次 printf 后切走当前进程。于是屏幕上看到的就不再是完整的 F F 或 C 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 对象。
但要注意:
- 路径必须已经存在并且可访问;
proj_id不能为 0,实际只使用低 8 位;ftok不承诺全局唯一,可能发生碰撞;- key 只是查找条件,不是后续每次操作都使用的句柄。
教学实验用 /tmp 没问题,工程项目更适合使用应用自己的稳定文件,并把碰撞和权限纳入设计。
4.2 semid:内核返回的信号量集标识符
semget 成功后返回一个非负整数:
cpp
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
后续 semctl 与 semop 都围绕这个 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,但可移植程序不应依赖它。创建方应该立即通过 semctl 的 SETVAL 或 SETALL 显式初始化。
这也是为什么完整流程必须把"创建"和"初始化"拆开:

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_NOWAIT 与 SEM_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 来源
初值是多少
我要创建对象
至于 ftok、semget、semctl 的顺序、错误检查和失败清理,都交给 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 F与C 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_ptr 与 Semaphore 状态的副本。若包装类只记录一个 _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_UNDO 与 IPC_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 成功后,信号量就已经初始化为期望值
不对。创建方应显式使用 SETVAL 或 SETALL 初始化,不能把 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 是内核返回的集合标识符,供 semctl、semop 后续操作。
问题 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 参考资料
- Linux manual page:
semget(2) - Linux manual page:
semctl(2) - Linux manual page:
semop(2) - Linux manual page:
ftok(3) - Linux manual page:
sysvipc(7) - Linux manual page:
proc_sysvipc(5) - util-linux manual page:
ipcs(1) - util-linux manual page:
ipcrm(1)
总结
这次 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
真正值得记住的是六条原则:
- 信号量是许可计数,不只是"锁住 / 未锁住"。
- 教材中的负数 S 是抽象模型,不等于 Linux 的 semval 会变成负数。
- System V 操作的是信号量集,key、semid、sem_num 各有职责。
- 创建与初始化必须绑定,连接者不能悄悄创建半成品。
- SEM_UNDO 只补偿数值,不保证业务数据一致。
- RAII 能承接清理,但不能替你发明跨进程所有权协议。
当这些边界都清楚以后,Builder 就不再只是"把代码写得像链式调用",而是把一组容易出错的系统调用变成一个具备前置条件、失败回滚和所有权语义的组件。这也是设计模式在系统编程里最实用的样子:不是增加抽象层数,而是把危险细节关进一个边界清晰的对象里。