Linux 进程间通讯(IPC)
系列简介:本系列涵盖 Linux 下常见的 7 种进程间通信(IPC)机制,包括无名管道、有名管道、信号、消息队列、共享内存和信号量,适合物联网/嵌入式工程师系统学习。
为什么需要 IPC?
进程的地址空间隔离
每个进程都有自己独立的虚拟地址空间,彼此之间无法直接访问对方的内存。这是操作系统保护进程安全的核心机制。
图注:进程 A 和进程 B 各自拥有独立的虚拟地址空间,彼此隔离。如果需要交换数据,必须通过内核提供的 IPC 机制。
IPC 机制全景图
图注:Linux IPC 机制分为三大类:管道(无名/有名)、信号、System V IPC(消息队列/共享内存/信号量)。选择哪种取决于应用场景------大数据量选共享内存,同步选信号量,简单通信选管道。
IPC 与日常生活类比
通过生活化的场景来理解每种 IPC 机制的核心特征:
图注:每种 IPC 机制都可以在现实生活中找到对应------无名管道像传话筒(只能父子间用),有名管道像公共信箱(谁都能用),信号像手机通知(异步到达),消息队列像邮局分拣(按类型取件),共享内存像公共白板(直接看),信号量像红绿灯(控制通行顺序)。
第一部分:管道(Pipe)
第 1 节:无名管道
核心概念
无名管道(Anonymous Pipe) 是 Linux 下最基础的 IPC 方式,本质是内核维护的一块内存缓冲区 。它只能在具有亲缘关系的进程(如父子进程)之间使用。
一句话总结 :无名管道像一条单向水管,一头进水(写端)、一头出水(读端),只能连通父子进程。
通信模型
图注 :父进程通过
write()将数据写入内核管道缓冲区,子进程通过read()从缓冲区读取数据。
无名管道的特点
| 特性 | 说明 |
|---|---|
| 单向通信 | 数据只能从写端流向读端,不能双向同时传输 |
| 亲缘限制 | 仅用于父子进程或有共同祖先的进程之间 |
| 文件接口 | 读写端抽象为文件描述符,pipe() 返回 pipefd0(读)和 pipefd1(写) |
| 阻塞特性 | 管道空则读阻塞,管道满则写阻塞 |
| 原子性 | 写入不超过 PIPE_BUF(POSIX 要求 ≥ 512 字节,Linux 为 4096 字节)时保证原子操作 |
管道文件描述符的继承
pipe() 必须在 fork() 之前调用,这样子进程才能继承父进程的文件描述符。
图注 :
pipe()创建管道后,父子进程各有一对读写 fd。必须关闭各自不需要的一端,否则数据流向会混乱。
管道阻塞行为详解
图注:管道读操作在空管道时阻塞(直到有数据),写操作在满管道时阻塞(直到有空间)。读端关闭后写操作会收到 SIGPIPE 信号。
核心函数:pipe()
c
#include <unistd.h>
int pipe(int pipefd[2]);
// 参数:pipefd[0] --- 读端, pipefd[1] --- 写端
// 返回值:成功 0,失败 -1 并设置 errno
经典示例:模拟 ls | sort -r
场景 :这是 Shell 管道
ls | sort -r的 C 语言模拟。父进程执行ls命令,将输出写入管道;子进程从管道读取数据,执行sort -r反向排序输出。这是无名管道最经典的用法------一个进程的输出作为另一个进程的输入。
完整代码
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t cpid;
int ret;
int pipefd[2];
// 1. 创建管道(必须在 fork 之前!)
ret = pipe(pipefd);
if (ret == -1) {
perror("pipe() failed");
exit(EXIT_FAILURE);
}
// 2. 创建子进程
cpid = fork();
if (cpid == -1) {
perror("fork() failed");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ========== 子进程:扮演 sort -r ========== */
char buffer[4096] = {0};
ssize_t rbytes;
int total = 0;
close(pipefd[1]); // 关闭写端,只用读端
// 从管道读取所有数据(可能分多次)
while ((rbytes = read(pipefd[0], buffer + total,
sizeof(buffer) - total)) > 0) {
total += rbytes;
}
buffer[total] = '\0';
close(pipefd[0]);
// 反向排序并输出(模拟 sort -r 效果)
printf(">>> 子进程收到 %d 字节,反向排序如下:\n", total);
printf("----------------------------------------\n");
// 将数据按行分割并反向打印
char *lines[128];
int count = 0;
lines[count++] = buffer; // 第一行起始
for (char *p = buffer; *p; p++) {
if (*p == '\n') {
*p = '\0'; // 替换换行为结束符
if (*(p+1)) lines[count++] = p + 1;
}
}
// 反向打印
for (int i = count - 1; i >= 0; i--) {
printf("%s\n", lines[i]);
}
printf("----------------------------------------\n");
exit(EXIT_SUCCESS);
}
else if (cpid > 0) {
/* ========== 父进程:扮演 ls ========== */
char *files[] = {"bash", "zsh", "dash", "fish", "ksh", "tcsh", NULL};
close(pipefd[0]); // 关闭读端,只用写端
// 将文件列表写入管道
for (int i = 0; files[i] != NULL; i++) {
dprintf(pipefd[1], "%s\n", files[i]);
}
close(pipefd[1]); // 告诉子进程:我写完了
wait(NULL); // 等待子进程结束
printf(">>> 父进程:子进程已结束\n");
}
return 0;
}
执行结果
markdown
>>> 子进程收到 30 字节,反向排序如下:
----------------------------------------
zsh
tcsh
ksh
fish
dash
bash
----------------------------------------
>>> 父进程:子进程已结束
关键点:父进程写入后关闭写端,子进程的 read() 才能返回 0 表示结束。如果父进程不关闭写端,子进程将永远阻塞在 read() 上。
管道操作的三个关键行为
| 场景 | 行为 |
|---|---|
| 读空管道 | read() 阻塞,直到有数据写入 |
| 写端已关闭,读端读数据 | read() 读完剩余数据后返回 0(类似 EOF) |
| 读端已关闭,写端写数据 | 内核向写入进程发送 SIGPIPE 信号,进程默认终止 |
| 写入 > PIPE_BUF | 写操作可能被其他进程的写入穿插(非原子),需自行管理 |
严谨的社会类比
🏢 比喻:公司内部传话
无名管道就像同一家公司内部两个相邻工位之间的专用传话筒(单向)。
- 父进程是发送消息的人 ,子进程是接收消息的人
- 管道就是那根绑在两个纸杯之间的绳子
- 因为绳子必须两个纸杯都在同一家公司(亲缘关系)才能拉直,所以只能用于父子进程
- 消息只能从一个纸杯传到另一个,不能同时反向传(单向通信)
- 当纸杯空了(管道为空),接收方只能等待直到发送方说了什么(读阻塞)
- 当发送方把纸杯拿走了(写端关闭),接收方听完最后一句就知道结束了(read 返回 0)
第 2 节:有名管道(FIFO)
核心概念
有名管道(Named Pipe / FIFO) 突破了无名管道的亲缘限制,允许任意两个进程通过同一个管道文件进行通信。
一句话总结 :有名管道像公共场所的对讲机------任何两个人都可以用,但数据仍然是单向流动的。
与无名管道的对比
| 特性 | 无名管道 | 有名管道(FIFO) |
|---|---|---|
| 使用范围 | 仅亲缘关系进程 | 任意进程 |
| 文件系统可见 | 不可见 | 可见,有路径名 |
| 存储位置 | 内核内存 | 内核内存(文件系统仅作标识) |
| 创建方式 | pipe() |
mkfifo() 或 mkfifo 命令 |
| 操作接口 | 文件描述符 | 文件描述符(open/read/write) |
通信模型
图注:有名管道在文件系统中有可见路径,任意两个进程可通过 FIFO 文件通信。
核心函数:mkfifo()
c
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
// pathname : FIFO 文件路径
// mode : 文件访问权限(如 0644)
// 返回值 : 成功 0,失败 -1 并设置 errno
经典示例:时间服务器(任意进程间通信)
场景:一个进程持续获取当前时间并写入 FIFO(时间服务器),另一个进程从 FIFO 读取并显示时间(时间客户端)。两个进程没有亲缘关系,通过 FIFO 文件通信。
time_server.c(时间服务器)
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define FIFO_NAME "./time_fifo"
int main(void)
{
int fd;
time_t now;
char buffer[128];
// 创建 FIFO(如果不存在)
if (access(FIFO_NAME, F_OK) == -1) {
mkfifo(FIFO_NAME, 0644);
}
printf("[Server] 等待客户端连接...\n");
// 以只写方式打开(会阻塞直到有读端打开)
fd = open(FIFO_NAME, O_WRONLY);
if (fd == -1) {
perror("open() failed");
exit(EXIT_FAILURE);
}
printf("[Server] 客户端已连接,开始发送时间...\n");
// 每 2 秒发送一次当前时间
for (int i = 0; i < 5; i++) {
time(&now);
struct tm *local = localtime(&now);
int len = strftime(buffer, sizeof(buffer),
"[Server] %Y-%m-%d %H:%M:%S\n", local);
write(fd, buffer, len);
printf("[Server] 已发送: %s", buffer);
sleep(2);
}
close(fd);
printf("[Server] 发送完毕\n");
return 0;
}
time_client.c(时间客户端)
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define FIFO_NAME "./time_fifo"
int main(void)
{
int fd;
char buffer[128];
ssize_t bytes;
// 确保 FIFO 存在
if (access(FIFO_NAME, F_OK) == -1) {
mkfifo(FIFO_NAME, 0644);
}
printf("[Client] 等待服务器数据...\n");
// 以只读方式打开(会阻塞直到有写端打开)
fd = open(FIFO_NAME, O_RDONLY);
if (fd == -1) {
perror("open() failed");
exit(EXIT_FAILURE);
}
// 读取并打印服务器发来的时间
while ((bytes = read(fd, buffer, sizeof(buffer) - 1)) > 0) {
buffer[bytes] = '\0';
printf("[Client] 收到: %s", buffer);
}
close(fd);
printf("[Client] 服务器已断开\n");
return 0;
}
编译与执行
bash
# 终端 1:启动服务器
$ gcc time_server.c -o server
$ ./server
# 终端 2:启动客户端
$ gcc time_client.c -o client
$ ./client
执行结果
ini
=== 终端 1(服务器) ===
[Server] 等待客户端连接...
[Server] 客户端已连接,开始发送时间...
[Server] 已发送: [Server] 2026-07-09 14:30:15
[Server] 已发送: [Server] 2026-07-09 14:30:17
...
=== 终端 2(客户端)===
[Client] 等待服务器数据...
[Client] 收到: [Server] 2026-07-09 14:30:15
[Client] 收到: [Server] 2026-07-09 14:30:17
...
关键点 :FIFO 以
O_WRONLY打开时会阻塞 直到另一端以O_RDONLY打开。这不是"程序卡死",而是 FIFO 的同步机制!
编译与执行
bash
# 先启动读取端(阻塞等待数据)
$ gcc fifo_read.c -o read
$ ./read & # 后台运行
# 再启动写入端
$ gcc fifo_write.c -o write
$ ./write
执行结果
yaml
rbytes : 11 rbuffer : fifo pipe.
有名管道的打开行为
FIFO 的 open() 具有同步配对的特性------读端和写端必须同时存在才能成功打开。
图注 :以
O_RDONLY或O_WRONLY打开 FIFO 时,会阻塞直到另一端也打开。这个"配对阻塞"是 FIFO 的内置同步机制。
| 打开方式 | 行为 |
|---|---|
只读打开(O_RDONLY) |
阻塞,直到另一端以写方式打开 |
只写打开(O_WRONLY) |
阻塞,直到另一端以读方式打开 |
读写打开(O_RDWR) |
立即返回,不阻塞(但实践中不推荐,破坏了 FIFO 的设计意图) |
⚠️ 注意 :如果以
O_RDONLY打开 FIFO 而没有任何写端打开,open()会一直阻塞------这正是 FIFO 作为同步机制的设计。
有名管道的优缺点
| 优点 | 缺点 |
|---|---|
| 任意进程间通信 | 打开时若未配对会阻塞 |
| 操作接口与文件一致 | 半双工,多进程通信需多个管道 |
| 文件系统可见,便于管理 | 管道缓冲区有大小限制 |
严谨的社会类比
📮 比喻:单位内部信箱
有名管道就像公司走廊上挂的一个公共信箱。
- 任何一个部门的员工(任意进程)都可以往信箱里投信(写入)
- 任何一个部门的员工都可以从信箱里取信(读取)
- 信箱在走廊上是可见的(文件系统可见),但信件本身由后勤(内核)保管,并不占用办公桌空间(不占磁盘)
- 但是,如果你打开信箱时发现它是空的(读打开时无数据),你只能等着直到有人投信(阻塞)
- 如果你想去寄信但发现信箱口被封住了(写打开时无读端),你也只能等着直到有人来打开信箱
第二部分:信号(Signal)
第 3 节:信号基础
核心概念
信号(Signal) 是在软件层次上对硬件中断机制的一种模拟 ,是一种异步通信方式。
一句话总结 :信号就像手机的通知推送------你正在做一件事时随时可能收到通知,可以选择忽略、按默认方式处理,或者打开应用自定义处理。
信号的特点
- 异步性:进程运行时随时可能被信号打断,无法预测精确到达时间
- 三种处理方式:忽略、捕捉(自定义处理)、执行默认操作
- 实时性:信号会立即投递,不像消息队列需要排队
信号的来源
| 来源 | 示例 |
|---|---|
| 程序执行错误 | 内存访问越界 → SIGSEGV,除 0 → SIGFPE |
| 其他进程发送 | kill() 函数 |
| 终端操作 | Ctrl+C → SIGINT,Ctrl+\ → SIGQUIT,Ctrl+Z → SIGTSTP |
| 子进程状态变化 | 子进程退出 → 父进程收到 SIGCHLD |
| 定时器到期 | alarm() → SIGALRM |
常用信号一览
c
#define SIGHUP 1 // 终端挂起
#define SIGINT 2 // 键盘中断(Ctrl+C)
#define SIGQUIT 3 // 键盘退出(Ctrl+\)
#define SIGILL 4 // 非法指令
#define SIGABRT 6 // 异常终止(abort())
#define SIGFPE 8 // 浮点异常/除 0
#define SIGKILL 9 // 强制终止(不可忽略/捕捉)
#define SIGUSR1 10 // 用户自定义信号 1
#define SIGSEGV 11 // 段错误(无效内存引用)
#define SIGUSR2 12 // 用户自定义信号 2
#define SIGPIPE 13 // 管道破裂(读端已关闭时写入)
#define SIGALRM 14 // 定时器到期
#define SIGTERM 15 // 终止信号(kill 默认发送)
#define SIGCHLD 17 // 子进程状态变化
#define SIGCONT 18 // 继续执行已停止的进程
#define SIGSTOP 19 // 暂停进程(不可忽略/捕捉)
#define SIGTSTP 20 // 终端暂停(Ctrl+Z)
⚠️ 特别说明 :
SIGKILL(9)和SIGSTOP(19)不能被忽略、被捕捉或被阻塞,这是系统提供的终极控制手段。
信号的生命周期
从信号产生到被进程处理,完整经历四个阶段:
图注:信号产生后,如果进程当前正在执行关键代码且阻塞了该信号,信号会保持"pending(待处理)"状态,直到进程解除阻塞后才递达。
信号的 pending 与阻塞机制
图注:每个进程维护一个信号屏蔽字(决定哪些信号被阻塞)和一个挂起信号集(记录已到达但被阻塞的信号)。当进程解除某个信号的阻塞后,内核会立即递送该信号。
信号处理流程
图注 :进程 A 通过
kill()发送信号,内核检查目标进程的信号集后投递,进程 B 按已注册的处理方式响应。
核心函数:kill() 与 raise()
c
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig); // 向指定进程发送信号
int raise(int sig); // 向当前进程自身发送信号
| 函数 | 功能 | 成功 | 失败 |
|---|---|---|---|
kill(pid, sig) |
向 pid 进程发送 sig 信号 | 0 | -1,设置 errno |
raise(sig) |
向自己发送 sig 信号 | 0 | -1,设置 errno |
核心函数:pause()
c
#include <unistd.h>
int pause(void); // 使进程进入睡眠,直到捕获到一个信号
⚠️ 更正 :原始文档中描述 pause() 成功返回 0 是错误的。
pause()只有收到信号并执行完信号处理函数后才会返回- 返回值为 -1 ,且
errno被设置为EINTR(被信号中断)- 从不返回 0 ------ 这是一个典型的"调用永远不成功返回"的函数
示例 1:使用 kill() 和 raise() 控制子进程
需求 :创建一个子进程,子进程通过 raise(SIGSTOP) 暂停自己;父进程通过 kill(cpid, SIGKILL) 终止它。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid < 0) {
perror("[ERROR] fork() :");
exit(0);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
fprintf(stdout, "\tchild %d running.\n", getpid());
raise(SIGSTOP); // 暂停自己
// 下面的代码在 SIGSTOP→SIGCONT 之后才会执行
fprintf(stdout, "\t child %d exit\n", getpid());
exit(EXIT_SUCCESS);
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
int status, ret;
sleep(1);
ret = kill(cpid, SIGKILL); // 强制终止子进程
if (ret == 0) {
fprintf(stdout, "Father %d Killed child %d\n", getpid(), cpid);
}
waitpid(cpid, NULL, 0);
fprintf(stdout, "father %d exit\n", getpid());
exit(EXIT_SUCCESS);
}
return 0;
}
执行结果
yaml
child 1234 running.
Father 1233 Killed child 1234
father 1233 exit
注意 :子进程在执行
raise(SIGSTOP)后被暂停,因此不会 打印"child xx exit"。父进程发送SIGKILL直接将其终止。
示例 2:使用 pause() 等待信号
需求 :创建子进程,父进程调用 pause() 等待;子进程在 3 秒后给父进程发送 SIGUSR1 信号,唤醒父进程。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid == -1) {
perror("[ERROR] fork() ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
fprintf(stdout, "Child Process Start.\n");
sleep(3);
kill(getppid(), SIGUSR1); // 3 秒后唤醒父进程
exit(EXIT_SUCCESS);
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
sleep(1);
fprintf(stdout, "Main Process Start..\n");
pause(); // 等待信号,陷入睡眠
fprintf(stdout, "Main Process End.\n");
}
return 0;
}
执行结果
arduino
Child Process Start.
Main Process Start..
(等待约 2 秒...)
Main Process End.
注意 :
pause()一定要在收到信号之前调用,否则会错过信号导致进程一直休眠。
严谨的社会类比
🚨 比喻:消防演习
信号就像火灾报警器,是一种异步通知机制。
SIGKILL就像"立即撤离 "的红色警报------不可忽略、不可延迟,必须执行SIGINT(Ctrl+C)就像"暂停操作"的黄色警报------可以响应也可以忽略SIGALRM就像定时器闹铃------到了设定的时间,系统自动发出通知SIGCHLD就像老师听到学生敲门------学生(子进程)离开教室时,老师(父进程)会收到通知pause()就像消防员在值班室待命------什么也不做,就等着铃声响起raise(SIGSTOP)就像自己主动按了暂停键------让自己停下来kill(pid, sig)就像远程遥控别人的设备------向另一个进程发送指令
第 4 节:信号处理 ------ signal() 函数
核心概念
信号处理有三种方式:
- 忽略(Ignore):对信号不做任何处理
- 默认(Default):按内核预设的默认行为处理
- 用户自定义(Custom):实现自定义处理函数,由内核在信号到达时调用
一句话总结 :
signal()就像你在手机上为不同联系人设置专属来电铃声------每个信号(联系人)都可以有不同的处理方式(铃声)。
三种处理方式互斥
图注:信号到达时,内核根据进程注册的处理方式决定动作:SIG_IGN 忽略、SIG_DFL 执行默认动作、自定义函数地址则调用用户处理函数。
核心函数:signal()
c
#include <signal.h>
typedef void (*sighandler_t)(int); // 信号处理函数类型定义
sighandler_t signal(int signum, sighandler_t handler);
// signum : 信号编号
// handler : SIG_IGN(忽略)、SIG_DFL(默认)、自定义函数地址
// 返回值 : 成功返回之前的处理函数地址,失败返回 SIG_ERR 并设置 errno
⚠️ 更正 :原始文档中存在
typedef void (*)(int) sighandler_t的语法错误(缺少函数指针名)。
内核中的信号处理函数类型定义:
c
typedef void __signalfn_t(int);
typedef __signalfn_t *__sighandler_t;
#define SIG_DFL ((__sighandler_t)0) // 默认处理
#define SIG_IGN ((__sighandler_t)1) // 忽略处理
#define SIG_ERR ((__sighandler_t)-1) // 错误返回值
完整示例:自定义信号处理
需求 :创建一个子进程,父进程给子进程发送 SIGUSR1 信号,子进程使用自定义处理函数。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
// 自定义信号处理函数
void do_sig_usr(int sig)
{
printf("Receive %s\n", strsignal(sig)); // strsignal 将信号编号转为可读字符串
}
int main(void)
{
pid_t cpid;
// 注册信号处理函数(在 fork 之前!)
if (signal(SIGUSR1, do_sig_usr) == SIG_ERR) {
perror("[ERROR] signal(): ");
exit(EXIT_FAILURE);
}
cpid = fork();
if (cpid == -1) {
perror("fork(): ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
printf("Child Process <%d> start.\n", getpid());
pause(); // 等待信号
exit(EXIT_SUCCESS);
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
sleep(1);
kill(cpid, SIGUSR1); // 给子进程发送自定义信号
wait(NULL);
}
return 0;
}
执行结果
arduino
Child Process <1234> start.
Receive User defined signal 1
严谨的社会类比
📱 比喻:手机来电管理
信号处理方式就像你在手机上设置的联系人来电策略:
- SIG_IGN(忽略) = 把某个联系人设为静默------来电时手机完全不响不震,就像没发生过一样
- SIG_DFL(默认) = 使用系统默认铃声------不特别设置,来电时按标准方式提醒
- 自定义处理函数 = 为联系人设置专属铃声+特殊震动模式------当这个人来电时,手机会执行你预设的特定动作
signal(SIGINT, handler)就像设置"当来电显示是 XX 时,播放自定义铃声"
经典应用:守护进程热加载配置(SIGHUP)
场景 :服务器程序(如 Nginx、Apache)运行时,需要在不重启的情况下重新加载配置文件。业界标准做法是监听
SIGHUP信号。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <time.h>
// 模拟配置项
int g_log_level = 1; // 0=debug, 1=info, 2=error
int g_reload_count = 0;
volatile sig_atomic_t g_reload_flag = 0;
// SIGHUP 处理函数:标记重载(不直接做复杂操作)
void handle_sighup(int sig)
{
g_reload_flag = 1; // 仅设置标志,主循环处理
}
// 模拟加载配置文件
void load_config(void)
{
g_reload_count++;
// 真实场景中这里会读取配置文件
printf("[Config] 第 %d 次加载配置完成 (log_level=%d)\n",
g_reload_count, g_log_level);
}
int main(void)
{
// 注册 SIGHUP 处理函数
if (signal(SIGHUP, handle_sighup) == SIG_ERR) {
perror("signal(SIGHUP) failed");
exit(EXIT_FAILURE);
}
// 首次加载配置
load_config();
printf("[Server] PID=%d, 运行中... 发送 kill -HUP %d 热加载配置\n",
getpid(), getpid());
// 主循环:模拟服务器运行
int ticks = 0;
while (1) {
// 检查是否需要重载配置
if (g_reload_flag) {
g_reload_flag = 0;
load_config();
}
// 模拟正常工作
printf("[Server] 正在处理请求... (运行 %d 秒)\n", ++ticks);
sleep(1);
// 运行 10 秒后自动退出(避免无限循环)
if (ticks >= 10) {
printf("[Server] 演示结束\n");
break;
}
}
return 0;
}
执行结果
ini
[Config] 第 1 次加载配置完成 (log_level=1)
[Server] PID=12345, 运行中... 发送 kill -HUP 12345 热加载配置
[Server] 正在处理请求... (运行 1 秒)
[Server] 正在处理请求... (运行 2 秒)
(此时在另一个终端执行 kill -HUP 12345)
[Config] 第 2 次加载配置完成 (log_level=1)
[Server] 正在处理请求... (运行 3 秒)
[Server] 正在处理请求... (运行 4 秒)
...
[Server] 演示结束
关键点 :
SIGHUP热加载是 Linux 服务器编程的经典模式。处理函数中只设置标志位,实际重载操作在主循环中完成------这是因为信号处理函数应该保持简单,避免调用不可重入的函数。
第 5 节:定时器与子进程管理
核心函数:alarm()
c
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// seconds : 定时秒数
// 返回值 : 上一次定时器剩余的秒数(如果没有之前的定时器则返回 0)
要点:
- 定时器由内核管理 ,
alarm()只负责设置定时时间 - 定时超时后,内核向进程发送
SIGALRM信号 - 每个进程只有一个 实时定时器;再次调用
alarm()会覆盖之前的设置 - 设置
alarm(0)可以取消当前定时器
一句话总结 :
alarm()就像厨房定时器------设好时间(秒),到点铃响(SIGALRM),且一次只能设一个倒计时。
图注 :进程调用
alarm(5)通知内核启动 5 秒定时器,倒计时结束后内核自动发送SIGALRM信号。
示例 1:验证 alarm() 的返回值
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
unsigned int ret;
ret = alarm(5); // 设置 5 秒定时器
sleep(2); // 休眠 2 秒
printf("ret = %d\n", ret); // 上一轮 alarm 无剩余 → 0
ret = alarm(3); // 覆盖:新设 3 秒,返回上次剩余(约 3 秒)
sleep(1);
printf("ret = %d\n", ret); // 打印上次剩余(约 3 秒,取决于调度精度)
return 0;
}
执行结果
ini
ret = 0
ret = 3
解释:
- 第一次
alarm(5)之前没有定时器,返回 0- 休眠 2 秒后,定时器还有约 3 秒剩余
alarm(3)覆盖原有定时器,返回上次剩下的约 3 秒
示例 2:配合 SIGALRM 实现定时功能
需求 :设置 3 秒定时器,定时到期后处理 SIGALRM 信号。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
void do_alarm(int sig)
{
printf("Receive signal <%s>\n", strsignal(sig));
}
int main(void)
{
__sighandler_t sigret;
sigret = signal(SIGALRM, do_alarm); // 注册 SIGALRM 处理函数
if (sigret == SIG_ERR) {
perror("[ERROR] signal(): ");
exit(EXIT_FAILURE);
}
alarm(3); // 设置 3 秒定时器
pause(); // 等待信号
return 0;
}
执行结果
lua
(等待约 3 秒...)
Receive signal <Alarm clock>
SIGCHLD 信号:优雅管理子进程退出
问题
传统使用 wait() / waitpid() 的痛点:
- 阻塞等待:父进程什么也不能做
- 非阻塞轮询:消耗 CPU 资源
- 子进程退出是一个异步事件,轮询或阻塞都不是理想方案
解决方案
子进程退出时,内核自动 向父进程发送 SIGCHLD 信号。父进程只需捕获此信号并在处理函数中调用 wait() 即可。
图注 :子进程退出时内核自动向父进程发送
SIGCHLD信号,父进程在信号处理函数中调用wait()回收子进程,无需阻塞等待。
完整示例:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void do_sig_child(int sig)
{
printf("Receive signal <%s>\n", strsignal(sig));
wait(NULL); // 回收子进程
}
int main(void)
{
pid_t cpid;
__sighandler_t sigret;
sigret = signal(SIGCHLD, do_sig_child); // 注册 SIGCHLD 处理函数
if (sigret == SIG_ERR) {
perror("[ERROR] signal(): ");
exit(EXIT_FAILURE);
}
cpid = fork();
if (cpid == -1) {
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
printf("Child process <%d> start.\n", getpid());
sleep(2);
exit(EXIT_SUCCESS);
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
while (1) {
// 父进程可以做其他工作,无需阻塞或轮询
}
}
return 0;
}
执行结果
arduino
Child process <1234> start.
(约 2 秒后)
Receive signal <Child exited>
⚠️ 注意 :在信号处理函数中调用
wait()需要谨慎。如果多个子进程同时退出,SIGCHLD信号可能合并(不可靠信号不排队),此时应在处理函数中循环调用waitpid(-1, NULL, WNOHANG)直到返回 0。
严谨的社会类比
⏰ 比喻:快递定时取件
用
alarm()+SIGALRM的方案就像我们在网上购物时设置的快递定时提醒:
alarm(3)= 你设了一个3 小时的倒计时提醒- 内核 = 快递平台的后台系统,帮你计时
SIGALRM= 短信通知------系统到点自动给你发消息- 信号处理函数 = 你收到短信后做的事情(比如去取快递)
而
SIGCHLD的用法就像老师让学生自主自习:
- 传统方式 = 老师(父进程)一直盯着 学生(子进程)做完作业再走(阻塞 wait),或者每隔一分钟去看一眼(轮询 waitpid)
- SIGCHLD 方式 = 老师告诉学生"做完来找我 "(注册信号处理函数),然后自己安心备课(做其他工作)
- 学生做完后主动敲门(子进程退出 → 内核发送 SIGCHLD)
- 老师听到敲门后去签字验收(调用 wait 回收)
第三部分:System V IPC
第三部分导览 :前两部分我们学习了管道 (第 1-2 节)和信号(第 3-5 节),它们都是 Linux 下重要的 IPC 手段。
本部分聚焦 System V IPC ------由 Unix System V 引入的三套高级 IPC 机制,由内核统一维护,共用
ftok()生成 key。包括消息队列(第 6-7 节) 、共享内存(第 8-9 节) 、信号量(第 10-12 节) ,最后通过生产者-消费者经典问题(第 13 节) 综合运用。
第三部分概述:System V IPC
一句话定位:如果说管道和信号是 Linux IPC 的"轻量级工具",那么 System V IPC 就是"重型武器库"------功能更强、效率更高,但使用也更复杂。
管道、信号、System V IPC 的角色分工
前面我们已经学过的 IPC 机制:
| 已学部分 | 机制 | 核心特点 |
|---|---|---|
| 第一部分:管道 | 无名管道、有名管道 | 简单单向通信,基于文件接口 |
| 第二部分:信号 | 信号(Signal) | 异步通知,不传数据只传信号编号 |
它们都是 IPC,但各有局限。System V IPC 填补了它们力所不能及的领域:
System V IPC 三件套
图注 :三种 System V IPC 对象都由内核维护,消息队列支持按类型收发消息,共享内存实现零拷贝高速通信,信号量控制进程同步与互斥。它们通过
key来唯一标识,用ipcs命令查询。
三种 IPC 对象
| IPC 对象 | 功能 | 查询命令 |
|---|---|---|
| 消息队列(Message Queue) | 以消息为单位的 FIFO 通信 | ipcs -q |
| 共享内存(Shared Memory) | 进程间共享内存区域 | ipcs -m |
| 信号量(Semaphore) | 进程同步与互斥 | ipcs -s |
ipcs 命令查看示例
lua
------ Message Queues --------
key msqid owner perms used-bytes messages
0x00019879 1 ben 666 0 0
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00000000 11 ben 600 524288 2 dest
------ Semaphore Arrays --------
key semid owner perms nsems
核心工具函数:ftok() ------ 生成 IPC 键值
所有 System V IPC 对象都需要一个唯一的 key 来标识,ftok() 负责生成这个 key。
c
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
// pathname : 一个已存在的文件路径
// proj_id : 8 位整数(0~255)
// 返回值 : 成功返回合成的 key_t 值,失败返回 -1
key 的生成原理:
ini
key = 文件的 inode 节点号(低 24 位) + proj_id(低 8 位) 组合而成
shell
$ ls -i
3804600 install_for_developers.sh
3804597 INSTALL.py
3804598 LICENSE
一句话总结 :
ftok()就像用经纬度定位一个地点------文件路径(经度)+ 项目 ID(纬度),唯一确定一个 IPC 对象。
第 6 节:消息队列(一)------ 创建与删除
核心概念
消息队列(Message Queue) 是一个由内核维护的消息链表 ,具有 FIFO 特性,但比管道更灵活------消息有类型 ,支持按类型读取。
一句话总结 :消息队列就像邮局的分类信箱------每个消息都贴了"类型标签",收件人可以选择只取某一类邮件。
通信模型
图注:消息队列位于内核空间,支持按消息类型(type)选择性读取。进程 A 发送 type=100 的消息,进程 B 只接收 type=100 的消息,进程 C 只接收 type=200 的消息。
消息队列的核心特性------按类型读取
图注 :
msgrcv()的第四个参数msgtyp控制读取行为:> 0精确匹配该类型;= 0读取最早消息(FIFO);< 0读取类型 ≤ |msgtyp| 的最小值。
核心函数:msgget() ------ 创建/获取消息队列
c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
// key : ftok() 生成的 key
// msgflg : IPC_CREAT(创建)、IPC_EXCL(排他)、权限位
// 返回值 : 成功返回消息队列 ID,失败返回 -1
核心函数:msgctl() ------ 控制消息队列
c
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
// msqid : 消息队列 ID
// cmd : IPC_STAT(获取属性)、IPC_SET(设置属性)、IPC_RMID(删除队列)
// buf : 消息队列属性结构体指针(IPC_RMID 时传 NULL)
// 返回值: 成功 0,失败 -1
消息队列属性结构体:
c
struct ipc_perm {
key_t __key; /* 创建时使用的 key */
uid_t uid; /* 所有者有效 UID */
gid_t gid; /* 所有者有效 GID */
uid_t cuid; /* 创建者有效 UID */
gid_t cgid; /* 创建者有效 GID */
unsigned short mode; /* 权限位 */
unsigned short __seq; /* 序列号 */
};
示例:创建消息队列并删除
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define PATHNAME "."
#define PRO_ID 10
int main(void)
{
key_t key;
int msgid;
int ret;
// 1. 生成 key
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): "); // 原始文档中误写为 fotk
exit(EXIT_FAILURE);
}
// 2. 创建消息队列
msgid = msgget(key, IPC_CREAT | 0666);
if (msgid == -1) {
perror("msgget(): ");
exit(EXIT_FAILURE);
}
printf("msg id : %d\n", msgid);
// 3. 删除消息队列
ret = msgctl(msgid, IPC_RMID, NULL);
if (ret == -1) {
perror("msgctl(): ");
exit(EXIT_FAILURE);
}
return 0;
}
执行结果
bash
msg id : 1
使用 ipcs -q 验证:
diff
------ Message Queues --------
key msqid owner perms used-bytes messages
0x0a051dd0 1 ben 666 0 0 ← 刚创建
(程序运行后该队列已被 IPC_RMID 删除)
严谨的社会类比
🏣 比喻:公司收发室
消息队列像公司的收发室(内核维护的邮件架):
ftok()= 给收发室分配一个房间号(唯一标识)msgget(IPC_CREAT)= 租用这个房间放置邮件架msgsnd()= 往架子上放一封信(贴好类型标签)msgrcv(type=100)= 只取走"技术部"的邮件(按类型读取)msgctl(IPC_RMID)= 退租,清空并关闭收发室ipcs -q= 去行政部查询当前有哪些房间被租用
第 7 节:消息队列(二)------ 发送与接收
核心函数:msgsnd() ------ 发送消息
c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
// msqid : 消息队列 ID
// msgp : 消息结构体指针
// msgsz : 消息内容长度(不含 mtype)
// msgflg : 0(阻塞)或 IPC_NOWAIT(非阻塞)
// 返回值 : 成功 0,失败 -1
核心函数:msgrcv() ------ 接收消息
c
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
// msqid : 消息队列 ID
// msgp : 消息结构体指针
// msgsz : 消息缓冲区大小
// msgtyp : 消息类型(>0 只接收该类型,0 接收最早消息,<0 接收类型≤|msgtyp| 的最小值)
// msgflg : 0(阻塞)或 IPC_NOWAIT(非阻塞)
// 返回值 : 成功返回实际读取的字节数,失败返回 -1
消息结构体
c
struct msgbuf {
long mtype; /* 消息类型,必须 > 0 */
char mtext[1]; /* 消息数据(可定义为更大的数组) */
};
注意 :
mtype必须为正整数(>0),这是消息队列按类型读取的基础。
完整示例:两个无血缘关系的进程通过消息队列通信
msg_send.c(发送端)
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define PATHNAME "."
#define PRO_ID 10
#define MSG_TYPE 100
#define MSG_SZ 64
struct msgbuf {
long mtype;
char mtext[MSG_SZ];
};
int main(void)
{
key_t key;
int msgid, ret;
struct msgbuf msg;
// 1. 生成 key
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
exit(EXIT_FAILURE);
}
// 2. 获取消息队列
msgid = msgget(key, IPC_CREAT | 0666);
if (msgid == -1) {
perror("msgget(): ");
exit(EXIT_FAILURE);
}
printf("msg id : %d\n", msgid);
// 3. 构造消息
msg.mtype = MSG_TYPE; // 消息类型
strcpy(msg.mtext, "Hello msg queue"); // 消息内容
// 4. 发送消息
ret = msgsnd(msgid, (const void *)&msg, strlen(msg.mtext) + 1, 0);
if (ret == -1) {
perror("msgsnd(): ");
exit(EXIT_FAILURE);
}
printf("Message sent: %s\n", msg.mtext);
return 0;
}
msg_recv.c(接收端)------ 原始文档误命名为 msg_write.c
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#define PATHNAME "."
#define PRO_ID 10
#define MSG_TYPE 100
#define MSG_SZ 64
struct msgbuf {
long mtype;
char mtext[MSG_SZ];
};
int main(void)
{
key_t key;
int msgid, ret;
ssize_t rbytes;
struct msgbuf rcv_msg;
// 1. 生成 key
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
exit(EXIT_FAILURE);
}
// 2. 获取消息队列
msgid = msgget(key, IPC_CREAT | 0666);
if (msgid == -1) {
perror("msgget(): ");
exit(EXIT_FAILURE);
}
printf("msg id : %d\n", msgid);
// 3. 按类型接收消息
rbytes = msgrcv(msgid, (void *)&rcv_msg, MSG_SZ, MSG_TYPE, 0);
if (rbytes == -1) {
perror("[ERROR] msgrcv(): ");
exit(EXIT_FAILURE);
}
printf("mtype : %ld\n", rcv_msg.mtype);
printf("mtext : %s\n", rcv_msg.mtext);
// 4. 删除消息队列
ret = msgctl(msgid, IPC_RMID, NULL);
if (ret == -1) {
perror("msgctl(): ");
exit(EXIT_FAILURE);
}
return 0;
}
编译与执行
bash
# 先启动接收端(阻塞等待)
$ gcc msg_recv.c -o recv
$ ./recv &
# 再启动发送端
$ gcc msg_send.c -o send
$ ./send
执行结果
yaml
(接收端)msg id : 1
(发送端)msg id : 1
(发送端)Message sent: Hello msg queue
(接收端)mtype : 100
(接收端)mtext : Hello msg queue
消息队列 vs 管道
| 特性 | 管道 | 消息队列 |
|---|---|---|
| 数据单位 | 字节流(无边界) | 消息(有结构、有类型) |
| 读取方式 | 全部读出,无法选择 | 可按类型读取 |
| 通信范围 | 无名:父子;有名:任意 | 任意进程 |
| 生命周期 | 随进程 | 内核维护,显式删除 |
| 双向通信 | 需两个管道 | 一个队列即可(通过类型区分) |
严谨的社会类比
📬 比喻:公司内部邮件系统
消息队列的有类型读取能力非常像企业的内部邮件分拣系统:
msgsnd()= 你在邮件上贴了标签"财务部"(mtype=100)并投进收发室msgrcv(type=100)= 财务部的同事只取标签为"财务部"的邮件- 即使"技术部"的邮件堆满了架子,也不会影响财务部取走自己的邮件
- 这就是消息队列比管道更灵活 的地方------管道就像一根水管,所有数据混在一起流过去;而消息队列像一个带分类标签的邮件架,可以按主题提取
第 8 节:共享内存(一)------ 创建与删除
核心概念
共享内存(Shared Memory) 是效率最高的 IPC 方式之一------它将一块物理内存直接映射 到多个进程的虚拟地址空间中,进程间通信时无需内核介入拷贝数据。
一句话总结 :共享内存就像两个同事共用一块白板------A 写上去,B 直接就能看到,不需要经过任何中间人传达。
共享内存 vs 其他 IPC
图注:管道和消息队列需要数据在用户态和内核态之间拷贝两次(write + read);共享内存直接将物理内存映射到进程地址空间,实现零拷贝,效率最高。
通信模型
图注 :进程 A 和进程 B 分别通过
shmat()将同一块物理内存映射到各自的虚拟地址空间,双方对这块内存的读写直接生效。
共享内存的完整生命周期
图注:共享内存的完整生命周期:创建(shmget)→ 挂载(shmat)→ 读写(指针操作)→ 卸载(shmdt)→ 标记删除(shmctl IPC_RMID)。注意:IPC_RMID 只是标记删除,直到所有进程都 detach 后才真正释放。
共享内存 vs 其他 IPC 的零拷贝原理
查询共享内存
bash
$ ipcs -m
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00000000 11 ben 600 524288 2 dest
0x00000000 16 ben 600 524288 2 dest
0x00000000 35 ben 600 4194304 2 dest
核心函数:shmget() ------ 创建/获取共享内存
c
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
// key : ftok() 生成的 key
// size : 共享内存大小(字节)
// shmflg : IPC_CREAT、IPC_EXCL、权限位
// 返回值 : 成功返回共享内存 ID,失败返回 -1
核心函数:shmctl() ------ 控制共享内存
c
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// shmid : 共享内存 ID
// cmd : IPC_STAT(获取属性)、IPC_SET(设置属性)、IPC_RMID(标记删除)
// buf : 共享内存属性结构体指针
// 返回值: 成功 0,失败 -1
⚠️ 更正 :原始文档中 shmctl 的 cmd 描述误写为"消息队列属性",应为"共享内存属性"。
示例:创建共享内存,打印 ID 并删除
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PRO_ID 100
#define SZ 256
int main(void)
{
key_t key;
int shmid, ret;
// 1. 生成 key
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
exit(EXIT_FAILURE);
}
// 2. 创建共享内存
shmid = shmget(key, SZ, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget(): ");
exit(EXIT_FAILURE);
}
printf("shmid = %d\n", shmid);
// 3. 删除共享内存(IPC_RMID 标记删除,所有进程 detach 后真正释放)
ret = shmctl(shmid, IPC_RMID, NULL);
if (ret == -1) {
perror("[ERROR] shmctl(): ");
exit(EXIT_FAILURE);
}
return 0;
}
执行结果
ini
shmid = 0
使用 ipcs -m 进行验证:
bash
# 创建后
$ ipcs -m
key shmid owner perms bytes nattch status
0x0a051dd0 0 ben 666 256 0
# 删除后(IPC_RMID 标记了 dest)
0x0a051dd0 0 ben 666 256 0 dest
严谨的社会类比
🏢 比喻:共享办公室的白板
共享内存就像办公室里的一块公共白板:
shmget()= 行政部买了一块新白板挂到公共区域shmat()= 你走到白板前面,准备看/写- 直接读写共享内存 = 你在白板上直接写字或擦字,其他人立刻就能看到
shmdt()= 你离开白板前,回到自己工位shmctl(IPC_RMID)= 行政部撤走白板(但等人都不看了才真的搬走)- 整个过程中没有邮递员传递信息 (零拷贝),这就是共享内存为什么最快
第 9 节:共享内存(二)------ 映射与读写
核心函数:shmat() ------ 挂载共享内存
c
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
// shmid : 共享内存 ID
// shmaddr : 指定映射地址(传 NULL 由系统选择)
// shmflg : 0(读写)、SHM_RDONLY(只读)
// 返回值 : 成功返回映射地址,失败返回 (void *) -1
核心函数:shmdt() ------ 卸载共享内存
c
int shmdt(const void *shmaddr);
// shmaddr : shmat() 返回的映射地址
// 返回值 : 成功 0,失败 -1
完整示例:使用共享内存进行进程间通信
需求:进程 A 向共享内存写入数据,进程 B 从共享内存读取数据。
共享内存写入端
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PRO_ID 100
#define SZ 256
int main(void)
{
key_t key;
int shmid;
void *addr = NULL;
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
exit(EXIT_FAILURE);
}
shmid = shmget(key, SZ, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget(): ");
exit(EXIT_FAILURE);
}
printf("shmid = %d\n", shmid);
// 映射共享内存到本进程地址空间
addr = shmat(shmid, NULL, 0);
if (addr == (void *)-1) {
perror("[ERROR] shmat(): ");
exit(EXIT_FAILURE);
}
// 向共享内存写入数据
memset(addr, 'A', 10); // 写入 10 个 'A'
memcpy(addr + 10, "Hello", 6); // 继续写入 "Hello"
printf("Data written to shared memory.\n");
// 解除映射
shmdt(addr);
return 0;
}
共享内存读取端
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PRO_ID 100
#define SZ 256
int main(void)
{
key_t key;
int shmid, i;
char buffer[20] = {0};
void *addr = NULL;
key = ftok(PATHNAME, PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
exit(EXIT_FAILURE);
}
shmid = shmget(key, SZ, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget(): ");
exit(EXIT_FAILURE);
}
printf("shmid = %d\n", shmid);
// 映射共享内存
addr = shmat(shmid, NULL, 0);
if (addr == (void *)-1) {
perror("[ERROR] shmat(): ");
exit(EXIT_FAILURE);
}
// 从共享内存读取数据
memcpy(buffer, addr, 10); // 读取前 10 字节
printf("Data read from shared memory:\n");
for (i = 0; i < 10; i++) {
printf(" %c ", buffer[i]);
}
printf("\n");
printf("String at offset 10: %s\n", (char *)addr + 10);
// 解除映射
shmdt(addr);
return 0;
}
编译与执行
bash
# 先运行写入端
$ gcc shm_write.c -o shm_w
$ ./shm_w
# 再运行读取端
$ gcc shm_read.c -o shm_r
$ ./shm_r
执行结果
less
(写入端)
shmid = 0
Data written to shared memory.
(读取端)
shmid = 0
Data read from shared memory:
A A A A A A A A A A
String at offset 10: Hello
共享内存使用要点
| 操作 | 说明 |
|---|---|
shmget() |
创建共享内存,返回 ID |
shmat() |
将共享内存挂载到进程地址空间 |
| 直接指针读写 | 操作返回的地址如同操作 malloc 得到的内存 |
shmdt() |
卸载共享内存(进程不再能访问) |
shmctl(IPC_RMID) |
标记删除(等到所有进程都 detach 后才真正释放) |
⚠️ 重要 :共享内存本身不提供同步机制 !如果多个进程同时读写同一块共享内存,会发生数据竞争。需要结合信号量来保证同步与互斥。
严谨的社会类比
📋 比喻:公告栏
共享内存就像公司大厅的公共公告栏:
shmget()= 行政部门安装了一个公告栏shmat()= 你走到公告栏前面,可以看清上面的内容- 直接写内存 = 你在公告栏上贴通知
- 直接读内存 = 你看公告栏上的通知
shmdt()= 你走回自己工位,不再看公告栏shmctl(IPC_RMID)= 行政部门撤走公告栏(要等所有人都走开后才搬走)但是!公告栏有一个大问题 ------如果两个人同时去贴通知,会把对方的通知搞乱。这就需要一个管理员 来控制"同一时间只有一个人能贴通知"------那就是信号量要做的事。
第 10 节:信号量(一)------ 概念与创建
核心概念
当多个进程同时访问共享资源时,会产生资源竞争,最终导致数据混乱。
| 术语 | 定义 | 举例 |
|---|---|---|
| 临界资源 | 不允许同时有多个进程访问的资源 | 共享内存、打印机、文件 |
| 临界区 | 访问临界资源的代码段 | 读写共享内存的代码 |
| 互斥 | 同一时刻只有一个进程能访问临界资源 | 一个人用打印机 |
| 同步 | 在互斥基础上,控制访问顺序 | A 写完 B 才能读 |
一句话总结 :信号量就像交通信号灯------它不负责运输数据,而是控制进程访问共享资源的"通行顺序",避免混乱。
竞争问题演示
以下代码展示了父子进程同时访问终端(共享资源)导致的混乱:
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t cpid;
cpid = fork();
if (cpid == -1) {
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
while (1) {
printf("------------------------\n");
printf("C Start.\n");
sleep(1);
printf("C End.\n");
printf("------------------------\n");
}
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
while (1) {
printf("------------------------\n");
printf("P Start.\n");
sleep(1);
printf("P End.\n");
printf("------------------------\n");
}
wait(NULL);
}
return 0;
}
执行结果(混乱的输出)
markdown
------------------------
C Start.
------------------------
P Start.
C End.
P End.
------------------------
------------------------
父子进程的输出交叉混合在一起,因为终端是共享资源,没有互斥保护。
信号量的定义
信号量(Semaphore) 是由内核维护的一个非负整数,它支持以下操作:
- 设置 :将信号量设为一个具体值(
SETVAL) - 增加 :在信号量当前值上加一个数值(V 操作 / 释放资源)
- 减少 :在信号量当前值上减一个数值(P 操作 / 占用资源)
- 等待 0:等待信号量的值变为 0
信号量分类
| 类型 | 值 | 含义 |
|---|---|---|
| 二值信号量 | 0 或 1 | 对应单个资源(如一台打印机) |
| 计数信号量 | ≥ 2 | 对应多个资源(如 5 个数据库连接) |
查询信号量
bash
$ ipcs -s
------ Semaphore Arrays --------
key semid owner perms nsems
核心函数:semget() ------ 创建/获取信号量集合
c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
// key : ftok() 生成的 key
// nsems : 信号量集合中的信号量数量(≥ 1)
// semflg : IPC_CREAT、IPC_EXCL、权限位
// 返回值 : 成功返回信号量集合 ID,失败返回 -1
核心函数:semctl() ------ 控制信号量集合
c
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
// semid : 信号量集合 ID
// semnum : 信号量编号(从 0 开始)
// cmd : 控制命令
// SETVAL --- 设置信号量的值
// GETVAL --- 获取信号量的值
// IPC_RMID --- 删除信号量集合
// ... : 可变参数,取决于 cmd
// SETVAL 命令需要使用的 union semun
union semun {
int val; /* SETVAL 使用的值 */
struct semid_ds *buf; /* IPC_STAT/IPC_SET 使用的缓冲区 */
unsigned short *array; /* GETALL/SETALL 使用的数组 */
struct seminfo *__buf; /* IPC_INFO(Linux 特有) */
};
示例:创建信号量集合并初始化
需求:创建一个信号量集合,包含 1 个信号量,并将其值设为 1(二值信号量)。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#define SEM_PATHNAME "."
#define SEM_PRO_ID 100
union semun {
int val;
};
int main(void)
{
int semid, ret;
union semun s;
key_t key;
// 1. 生成 key
key = ftok(SEM_PATHNAME, SEM_PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
return -1;
}
// 2. 创建信号量集合(1 个信号量)
semid = semget(key, 1, IPC_CREAT | 0666);
if (semid == -1) {
perror("[ERROR] semget(): ");
return -1;
}
// 3. 将信号量 0 的值设置为 1
s.val = 1;
ret = semctl(semid, 0, SETVAL, s);
if (ret == -1) {
perror("[ERROR] semctl(): ");
return -1;
}
printf("Semaphore created and initialized to 1.\n");
printf("semid = %d\n", semid);
return 0;
}
执行结果
ini
Semaphore created and initialized to 1.
semid = 0
使用 ipcs -s 验证:
diff
------ Semaphore Arrays --------
key semid owner perms nsems
0x0a051dd0 0 ben 666 1
严谨的社会类比
🚦 比喻:交通信号灯
信号量就是对交通信号灯的完美模拟:
- 二值信号量(值为 1) = 一座单车道桥 ,一次只允许一辆车通过
- P 操作(减 1)= 绿灯变红灯,一辆车进入桥面
- V 操作(加 1)= 红灯变绿灯,车离开桥面,下一辆可以进入
- 计数信号量(值为 N) = N 车道高速公路 ,允许 N 辆车同时通行
- P 操作 = 占用一个车道,可用车道数减 1
- V 操作 = 释放一个车道,可用车道数加 1
- 当所有车道都被占满(信号量为 0)时,后续车辆必须等待(P 操作阻塞)
没有信号量的共享资源访问就像没有红绿灯的十字路口------车辆(进程)各自为政,必然发生碰撞(数据混乱)。
第 11 节:信号量(二)------ P/V 操作
核心函数:semop() ------ 信号量操作
c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semop(int semid, struct sembuf *sops, size_t nsops);
// semid : 信号量集合 ID
// sops : 信号量操作数组
// nsops : 操作的信号量数量
// 返回值: 成功 0,失败 -1
struct sembuf 结构体
c
struct sembuf {
unsigned short sem_num; /* 信号量编号(从 0 开始)*/
short sem_op; /* 信号量操作:
-1 : P 操作(占用/等待资源)
+1 : V 操作(释放资源)
0 : 等待信号量变为 0 */
short sem_flg; /* 操作标志:
IPC_NOWAIT --- 非阻塞
SEM_UNDO --- 进程退出时自动释放 */
};
P/V 操作语义
| 操作 | sem_op 值 |
含义 | 行为 |
|---|---|---|---|
| P 操作(占用) | -1 | semval -= 1 |
若 semval ≥ 1 则成功;若 semval = 0 则阻塞 |
| V 操作(释放) | +1 | semval += 1 |
总是成功,可能唤醒等待的进程 |
| 等待 0 | 0 | 等待 semval = 0 | 若 semval ≠ 0 则阻塞 |
P/V 操作流程
图注:P 操作将信号量减 1,若为 0 则阻塞;V 操作将信号量加 1,唤醒等待进程。初始值为 1 时实现互斥。
删除信号量
c
ret = semctl(semid, 0, IPC_RMID, NULL);
// 注意:IPC_RMID 时第三个参数被忽略(帮助文档已说明)
完整实战:使用信号量解决父子进程的终端竞争
我们将之前的竞争示例升级,用信号量实现互斥。
第一步:封装信号量操作(sem.h)
c
#ifndef __SEM_H_
#define __SEM_H_
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#define SEM_PATHNAME "." // ftok 路径
#define SEM_PRO_ID 100 // ftok 项目 ID
extern int sem_create(int nsems, unsigned short values[]);
extern int sem_p(int semid, int semnum); // P 操作
extern int sem_v(int semid, int semnum); // V 操作
extern int sem_del(int semid); // 删除信号量集合
#endif
第二步:实现信号量操作(sem.c)
c
#include "sem.h"
union semun {
int val;
unsigned short *array;
};
// 创建信号量集合并初始化所有信号量的值
int sem_create(int nsems, unsigned short values[])
{
int semid, ret;
key_t key;
union semun s;
key = ftok(SEM_PATHNAME, SEM_PRO_ID);
if (key == -1) {
perror("[ERROR] ftok(): ");
return -1;
}
semid = semget(key, nsems, IPC_CREAT | 0666);
if (semid == -1) {
perror("[ERROR] semget(): ");
return -1;
}
// SETALL:批量设置所有信号量的值
s.array = values;
ret = semctl(semid, 0, SETALL, s);
if (ret == -1) {
perror("[ERROR] semctl(SETALL): ");
return -1;
}
return semid;
}
// P 操作:占用资源(减 1)
int sem_p(int semid, int semnum)
{
struct sembuf sops;
sops.sem_num = semnum; // 信号量编号
sops.sem_op = -1; // P 操作
sops.sem_flg = SEM_UNDO; // 进程退出时自动释放
return semop(semid, &sops, 1);
}
// V 操作:释放资源(加 1)
int sem_v(int semid, int semnum)
{
struct sembuf sops;
sops.sem_num = semnum;
sops.sem_op = 1; // V 操作
sops.sem_flg = SEM_UNDO;
return semop(semid, &sops, 1);
}
// 删除信号量集合
int sem_del(int semid)
{
return semctl(semid, 0, IPC_RMID, NULL);
}
第三步:使用信号量保护临界区(main.c)
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include "sem.h"
int main(void)
{
pid_t cpid;
int semid;
unsigned short values[] = {1}; // 初始值为 1(二值信号量)
// 创建信号量集合(1 个信号量,初值 1)
semid = sem_create(1, values);
if (semid == -1)
return -1;
cpid = fork();
if (cpid == -1) {
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程 ---------- */
while (1) {
sem_p(semid, 0); // P操作:占用(信号量 1→0)
printf("------------------------\n");
printf("C Start.\n");
sleep(1);
printf("C End.\n");
printf("------------------------\n");
sem_v(semid, 0); // V操作:释放(信号量 0→1)
}
}
else if (cpid > 0) {
/* ---------- 父进程 ---------- */
sleep(1); // 保证子进程先运行
while (1) {
sem_p(semid, 0); // P操作:占用(信号量 1→0)
printf("------------------------\n");
printf("P Start.\n");
sleep(1);
printf("P End.\n");
printf("------------------------\n");
sem_v(semid, 0); // V操作:释放(信号量 0→1)
}
wait(NULL);
sem_del(semid);
}
return 0;
}
执行结果(有序输出)
markdown
------------------------
C Start.
C End.
------------------------
------------------------
P Start.
P End.
------------------------
------------------------
C Start.
C End.
------------------------
...
输出不再交叉混乱,父子进程严格互斥地访问终端。
严谨的社会类比
🎫 比喻:景区厕所的管理员
信号量的 P/V 操作就像景区厕所的一位管理员:
- 信号量 = 厕所的可用坑位数
- 初始值 = 厕所的总坑位数(假设 1 个坑位,即二值信号量)
- P 操作(sem_op = -1) = 你推开厕所门,管理员把可用数减 1
- 如果还有空位(信号量 > 0),你直接进去
- 如果没有空位了(信号量 = 0),你需要在外面排队等待
- V 操作(sem_op = +1) = 你从厕所出来,管理员把可用数加 1
- 如果有其他人在排队,现在他可以进去了
SEM_UNDO标志 = 假如你上到一半突然肚子疼跑了,管理员会自动释放这个坑位(进程退出时自动归还信号量),不会一直占着没有信号量的共享资源就像没有管理员的厕所------两个人同时冲进去,必然发生冲突。
第 12 节:信号量同步------实现 "ABA" 输出
核心概念
前面我们学习了信号量的互斥 (mutual exclusion),本节进一步实现同步 (synchronization)------不仅保证同一时间只有一个进程访问资源,还控制访问的顺序。
一句话总结:互斥是"不能同时用",同步是"谁先谁后都要排好"。
需求
创建父子进程,实现循环输出 ABA 字符串:
- 父进程 输出第一个
A - 子进程 输出
B - 父进程 输出第二个
A并换行 - 循环输出
ABA\n
控制思路
使用两个信号量来分别控制父进程和子进程的执行节奏:
图注 :通过两个信号量(SEM_P 控制父进程、SEM_C 控制子进程)的 P/V 操作交替,实现父→子→父的严格执行顺序,输出
ABA。
初始化
c
#define SEM_CONTROL_P 0 // 控制父进程的信号量(编号 0)
#define SEM_CONTROL_C 1 // 控制子进程的信号量(编号 1)
unsigned short values[2] = {1, 0};
// SEM_CONTROL_P = 1 : 父进程初始可以执行
// SEM_CONTROL_C = 0 : 子进程初始阻塞
完整代码
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include "sem.h" // 复用上一节封装的 sem.h
#define SEM_CONTROL_P 0 // 控制父进程的信号量
#define SEM_CONTROL_C 1 // 控制子进程的信号量
int main(void)
{
pid_t cpid;
int semid;
unsigned short values[2] = {1, 0}; // 父进程可用,子进程阻塞
// 创建包含 2 个信号量的集合
semid = sem_create(2, values);
if (semid == -1)
exit(EXIT_FAILURE);
cpid = fork();
if (cpid == -1) {
perror("[ERROR] fork(): ");
exit(EXIT_FAILURE);
}
else if (cpid == 0) {
/* ---------- 子进程:输出 B ---------- */
while (1) {
sem_p(semid, SEM_CONTROL_C); // 占用 C,等待父进程释放
printf("B");
fflush(stdout);
sem_v(semid, SEM_CONTROL_P); // 释放 P,唤醒父进程
}
}
else {
/* ---------- 父进程:输出 A A ---------- */
while (1) {
sem_p(semid, SEM_CONTROL_P); // 占用 P(初值 1,第一次不阻塞)
printf("A");
fflush(stdout);
sem_v(semid, SEM_CONTROL_C); // 释放 C,允许子进程输出 B
sem_p(semid, SEM_CONTROL_P); // 再次占用 P(此时已被子进程释放)
printf("A");
fflush(stdout);
sem_v(semid, SEM_CONTROL_P); // 释放 P(下次循环自己用)
sleep(1);
putchar('\n');
}
wait(NULL);
}
return 0;
}
执行结果
erlang
ABA
ABA
ABA
ABA
...
每行严格输出 ABA\n,顺序永不混乱。
同步原理详解
| 步骤 | 父进程信号量 P | 子进程信号量 C | 说明 |
|---|---|---|---|
| 初始 | 1 | 0 | 父进程可执行,子进程阻塞 |
| 父进程 P(P), 输出 A | 0 | 0 | 父进程占用 P,输出第一个 A |
| 父进程 V(C) | 0 | 1 | 释放子进程 |
| 父进程 P(P)(阻塞) | -1 | 1 | 父进程等待子进程释放 P |
| 子进程 P(C), 输出 B | -1 | 0 | 子进程被激活,输出 B |
| 子进程 V(P) | 0 | 0 | 子进程释放 P,父进程被唤醒 |
| 父进程输出 A | -1 | 0 | 父进程输出第二个 A |
| 父进程 V(P) | 0 | 0 | 父进程释放 P(下次自己用),输出换行 |
| (循环回到步骤 2) | 0 | 0 | ... |
严谨的社会类比
🎭 比喻:双人合唱
这个信号量同步的 ABA 问题就像两位歌手在舞台上交替演唱:
- 信号量 SEM_CONTROL_P = 歌手 P 的麦克风开关(初始为 ON)
- 信号量 SEM_CONTROL_C = 歌手 C 的麦克风开关(初始为 OFF)
makefile歌手P: (拿到自己的麦克风,唱 A)→ 打开C的麦克风 → 等自己的麦克风静音 歌手C: (拿到自己的麦克风,唱 B)→ 打开P的麦克风 → 等自己的麦克风静音 歌手P: (拿到自己的麦克风,唱 A)→ 打开自己的麦克风备用 → 鞠躬(换行)这就是**同步(Synchronization)与互斥(Mutual Exclusion)**的区别:
- 互斥 = 只保证"同一时间只有一个人在唱"
- 同步 = 不仅保证一个人唱,还规定了"P先唱 → C再唱 → P再唱"的严格顺序
第 13 节:经典 IPC 问题------生产者-消费者
问题描述
生产者-消费者(Producer-Consumer) 是 IPC 领域最经典的同步问题。一个或多个生产者进程生产数据放入共享缓冲区,一个或多个消费者进程从缓冲区取出数据消费。
需要解决的三个问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 互斥 | 生产者和消费者不能同时访问缓冲区 | 一个互斥信号量(初值 1) |
| 缓冲区满 | 生产者不能往满的缓冲区放数据 | 一个空位计数信号量(初值 = 缓冲区大小) |
| 缓冲区空 | 消费者不能从空的缓冲区取数据 | 一个数据计数信号量(初值 0) |
完整实现
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <sys/wait.h>
#include <string.h>
#define BUFFER_SIZE 5 // 缓冲区大小
#define SHM_PATHNAME "."
#define SHM_PRO_ID 200
#define SEM_PATHNAME "."
#define SEM_PRO_ID 300
// 共享内存中的缓冲区结构
struct shared_buffer {
int data[BUFFER_SIZE]; // 数据缓冲区
int in; // 生产者写入位置
int out; // 消费者读取位置
};
// 信号量索引
#define SEM_MUTEX 0 // 互斥信号量 (初值 1)
#define SEM_EMPTY 1 // 空位信号量 (初值 BUFFER_SIZE)
#define SEM_FULL 2 // 数据信号量 (初值 0)
#define SEM_COUNT 3 // 信号量总数
union semun { int val; };
// 初始化信号量
int init_semaphores(void)
{
key_t key = ftok(SEM_PATHNAME, SEM_PRO_ID);
int semid = semget(key, SEM_COUNT, IPC_CREAT | 0666);
if (semid == -1) return -1;
union semun s;
s.val = 1; // 互斥信号量
semctl(semid, SEM_MUTEX, SETVAL, s);
s.val = BUFFER_SIZE; // 空位信号量
semctl(semid, SEM_EMPTY, SETVAL, s);
s.val = 0; // 数据信号量
semctl(semid, SEM_FULL, SETVAL, s);
return semid;
}
// P 操作
void P(int semid, int idx)
{
struct sembuf op = {idx, -1, 0};
semop(semid, &op, 1);
}
// V 操作
void V(int semid, int idx)
{
struct sembuf op = {idx, 1, 0};
semop(semid, &op, 1);
}
int main(void)
{
// 创建共享内存
key_t shm_key = ftok(SHM_PATHNAME, SHM_PRO_ID);
int shmid = shmget(shm_key, sizeof(struct shared_buffer),
IPC_CREAT | 0666);
struct shared_buffer *buf = shmat(shmid, NULL, 0);
buf->in = 0;
buf->out = 0;
// 创建信号量
int semid = init_semaphores();
if (semid == -1) {
perror("semaphore init failed");
exit(1);
}
pid_t pid = fork();
if (pid == 0) {
/* ========== 生产者(子进程)========== */
srand(getpid());
for (int i = 1; i <= 10; i++) {
int item = rand() % 100; // 生产一个数据
P(semid, SEM_EMPTY); // 申请空位
P(semid, SEM_MUTEX); // 互斥访问
buf->data[buf->in] = item; // 放入缓冲区
printf("[Producer] 放入: %3d at[%d]\n", item, buf->in);
buf->in = (buf->in + 1) % BUFFER_SIZE;
V(semid, SEM_MUTEX); // 释放互斥
V(semid, SEM_FULL); // 增加数据计数
sleep(rand() % 2); // 随机等待
}
printf("[Producer] 生产完毕\n");
shmdt(buf);
exit(0);
}
else {
/* ========== 消费者(父进程)========= */
srand(getpid() + 100);
for (int i = 1; i <= 10; i++) {
P(semid, SEM_FULL); // 申请数据
P(semid, SEM_MUTEX); // 互斥访问
int item = buf->data[buf->out]; // 从缓冲区取出
printf("[Consumer] 取出: %3d from[%d]\n", item, buf->out);
buf->out = (buf->out + 1) % BUFFER_SIZE;
V(semid, SEM_MUTEX); // 释放互斥
V(semid, SEM_EMPTY); // 增加空位计数
sleep(rand() % 2); // 随机等待
}
wait(NULL);
// 清理
shmdt(buf);
shmctl(shmid, IPC_RMID, NULL);
semctl(semid, 0, IPC_RMID, NULL);
printf("[Main] 所有操作完成,资源已清理\n");
}
return 0;
}
执行结果
ini
[Producer] 放入: 83 at[0]
[Consumer] 取出: 83 from[0]
[Producer] 放入: 86 at[1]
[Producer] 放入: 77 at[2]
[Consumer] 取出: 86 from[1]
[Producer] 放入: 15 at[3]
[Consumer] 取出: 77 from[2]
[Producer] 放入: 93 at[4]
[Producer] 放入: 35 at[0] ← 环形缓冲区回到开头
[Consumer] 取出: 15 from[3]
...
关键点 :三个信号量各司其职------
SEM_MUTEX保证对缓冲区的互斥访问,SEM_EMPTY防止缓冲区满时生产者继续写入,SEM_FULL防止缓冲区空时消费者尝试读取。这是信号量解决同步 + 互斥的经典范式。
附录
附 1:六大 IPC 机制全景对比表
基础属性对比
| 对比维度 | 🚰 无名管道 | 📁 有名管道 | 📡 信号 | 📬 消息队列 | 🖥️ 共享内存 | 🚦 信号量 |
|---|---|---|---|---|---|---|
| 本质 | 内核缓冲区 | 内核缓冲区(文件系统可见) | 软件中断模拟 | 内核维护的消息链表 | 物理内存直接映射 | 内核维护的非负整数 |
| 通信方向 | 单向 | 单向 | 单向 | 双向(按类型区分) | 双向 | 不传数据,仅同步 |
| 进程范围 | 仅亲缘关系 | 任意进程 | 任意进程 | 任意进程 | 任意进程 | 任意进程 |
| 数据单位 | 字节流(无边界) | 字节流(无边界) | 信号编号(整数) | 结构化消息(有类型) | 任意数据 | 整数(计数器) |
| 是否需要内核拷贝 | ✅ 是(2 次) | ✅ 是(2 次) | ❌ 否(仅通知) | ✅ 是(2 次) | ❌ 零拷贝 | ❌ 否 |
| 通信效率 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 是否有内核缓冲区 | ✅ 有 | ✅ 有 | ❌ 无 | ✅ 有 | ❌ 无 | ❌ 无 |
| 生命周期 | 随进程(关闭即销毁) | 随文件系统(手动删除) | 瞬时 | 随内核(手动删除) | 随内核(标记删除) | 随内核(手动删除) |
| 是否自带同步 | ❌ 否 | ❌ 否 | ❌ 否 | ❌ 否 | ❌ 否 | ✅ 是(自身即同步工具) |
相同点与不同点
相同点
| 共同特征 | 涉及的 IPC 机制 |
|---|---|
| 都用于进程间数据交换或协调 | 全部 6 种 |
| 都是 Linux 内核提供的基础设施 | 全部 6 种 |
| 都需要通过系统调用(API)来使用 | 全部 6 种 |
| 都支持阻塞操作 | 管道、消息队列、共享内存(配合信号量)、信号量本身 |
| 在应用中通过文件描述符或 ID 来操作 | 管道(fd)、消息队列(msqid)、共享内存(shmid)、信号量(semid) |
| 操作失败时均返回 -1 并设置 errno | 全部 6 种 |
核心差异
| 差异维度 | 管道 | 信号 | 消息队列 | 共享内存 | 信号量 |
|---|---|---|---|---|---|
| 数据 vs 控制 | 传输数据 | 传递通知 | 传输数据 | 传输数据 | 控制同步 |
| 通信 vs 同步 | 通信 | 通信 | 通信 | 通信 | 同步 |
| 有结构 vs 无结构 | 无结构(字节流) | 无结构(整数) | 有结构(类型+数据) | 无结构(原始内存) | 无结构(整数) |
| 持续 vs 瞬时 | 持续(数据停留) | 瞬时(不排队) | 持续(排队存储) | 持续(直到删除) | 持续(直到删除) |
| 数据量大小 | 有限(几十 KB) | 极小(仅编号) | 有限(系统限制) | 大(可自定义) | 极小(仅整数) |
注意点(易错点)
| IPC 机制 | ⚠️ 最常犯的错误 | 正确做法 |
|---|---|---|
| 无名管道 | 先 fork() 再 pipe() | 必须在 fork() 之前 调用 pipe(),否则子进程无法继承管道 fd |
| 无名管道 | 读写端不关闭 | 父子进程应分别关闭不需要的一端,否则 read() 不会返回 0 标志结束 |
| 有名管道 | 以 O_RDONLY 打开后认为程序卡死 | FIFO 的 O_RDONLY 会阻塞等待写端打开,这是正常行为 |
| 信号 | 在信号处理函数中调用非可重入函数 | 信号处理函数中应避免使用 printf()、malloc() 等不可重入函数 |
| 信号 | 认为 SIGKILL/SIGSTOP 可以被忽略 | 这两个信号不可被忽略、不可被捕捉、不可被阻塞 |
| 消息队列 | msgrcv 读取时 msgtyp 参数传错 | msgtyp > 0 表示精确匹配类型,= 0 取最早消息,< 0 取类型 ≤ |msgtyp| 的最小值 |
| 消息队列 | 消息队列不删除导致资源泄漏 | System V IPC 对象不会随进程退出而销毁,必须调用 IPC_RMID |
| 共享内存 | 认为 shmdt 会删除共享内存 | shmdt 只是解除映射,不会删除共享内存,需要用 shmctl(IPC_RMID) |
| 共享内存 | 不加同步直接读写 | 共享内存不提供任何同步机制,必须配合信号量使用 |
| 信号量 | 忘记初始化信号量的值 | semget 创建后信号量的值未定义,必须用 SETVAL 或 SETALL 初始化 |
| 信号量 | P/V 操作不配对 | 有多少个 P 操作就要有对应数量的 V 操作,否则会导致死锁 |
| 信号量 | 死锁 | 多个信号量时,应确保所有进程按相同顺序进行 P 操作 |
典型使用场景
| IPC 机制 | 最适合的场景 | 实际应用举例 |
|---|---|---|
| 🚰 无名管道 | 父子进程间简单数据传输 | Shell 命令的管道符 `grep xxx |
| 📁 有名管道 | 无血缘关系的进程间简单通信 | 服务端和客户端交换简单命令 |
| 📡 信号 | 异步事件通知、进程控制 | kill -9 强制终止进程、Ctrl+C 终止前台程序 |
| 📬 消息队列 | 需要按类型分类的可靠通信 | 客户端请求排队、按优先级处理任务 |
| 🖥️ 共享内存 | 大数据量、高频次的数据交换 | 视频帧共享、数据库缓存、游戏引擎状态共享 |
| 🚦 信号量 | 保护共享资源、同步进程执行顺序 | 多线程/多进程互斥访问打印机、生产者-消费者模型 |
选型决策树
markdown
需要通信还是同步?
├── 同步(控制进程执行顺序)
│ └── 🚦 信号量
│
└── 通信(交换数据)
├── 只是通知/控制?(数据量极小)
│ └── 📡 信号
│
├── 需要高效的大数据量交换?
│ ├── 进程间有亲缘关系?
│ │ ├── 🚰 无名管道(简单场景)
│ │ └── 🖥️ 共享内存 + 🚦 信号量(高性能场景)
│ └── 任意进程?
│ └── 🖥️ 共享内存 + 🚦 信号量
│
└── 需要按类型/优先级读取?
├── 📬 消息队列
└── 不需要按类型?
├── 有亲缘关系?→ 🚰 无名管道
└── 任意进程?→ 📁 有名管道
附 2:管道专题对比
无名管道 vs 有名管道
| 对比维度 | 无名管道 | 有名管道 |
|--------------------|:-----------------------:|:------------------------------:|-----------------|
| 创建方式 | pipe() | mkfifo() |
| 文件系统可见 | ❌ 不可见 | ✅ 可见(ls -l 可看到 p 类型文件) |
| 占用磁盘空间 | ❌ 不占用 | ❌ 不占用(仅 inode,数据仍在内存) |
| 进程范围 | 仅亲缘关系 | 任意进程 |
| 打开方式 | 无需手动 open,pipe() 即返回 fd | 需要 open() 打开,且可能阻塞 |
| 关闭方式 | close(fd) | 同普通文件 close(fd) |
| 删除方式 | 进程退出即销毁 | 需 unlink() 或 remove() 删除文件 |
| 缓冲区大小 | Linux 默认 65536 字节(可调整) | 同无名管道 |
| PIPE_BUF(原子写入) | 4096 字节(POSIX 要求) | 4096 字节 |
| 使用场景 | 父子进程间临时通信 | 无血缘关系的进程间通信 |
| 典型命令 | `ls | grep txt` | mkfifo myfifo |
管道使用注意事项对比
| 场景 | 无名管道 | 有名管道 |
|---|---|---|
| 读空管道时 | read() 阻塞 | read() 阻塞 |
| 写满管道时 | write() 阻塞 | write() 阻塞 |
| 写端关闭时读 | read() 读完返回 0 | read() 读完返回 0 |
| 读端关闭时写 | 收到 SIGPIPE,进程终止 | 收到 SIGPIPE,进程终止 |
| 打开时无对应端 | 不适用(pipe 即创建一对) | open() 阻塞等待配对 |
附 3:信号专题对比
信号处理方式对比
| 处理方式 | 宏/函数 | 行为 | 适用范围 |
|---|---|---|---|
| 忽略 | SIG_IGN |
信号到达时丢弃,不做任何处理 | 除 SIGKILL/SIGSTOP 外的所有信号 |
| 默认 | SIG_DFL |
按内核预设的默认动作处理 | 所有信号 |
| 自定义 | 函数地址 | 调用用户注册的处理函数 | 除 SIGKILL/SIGSTOP 外的所有信号 |
信号默认行为分类
| 默认行为 | 包含的信号 |
|---|---|
| 进程终止 | SIGALRM, SIGHUP, SIGINT, SIGKILL, SIGPIPE, SIGTERM, SIGUSR1, SIGUSR2, SIGFPE, SIGSEGV, SIGILL 等 |
| 进程忽略 | SIGCHLD, SIGPWR, SIGURG, SIGWINCH |
| 进程暂停 | SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU |
| 进程继续 | SIGCONT |
不可被忽略/捕捉的信号
| 信号 | 编号 | 原因 |
|---|---|---|
| SIGKILL | 9 | 系统终极终止手段,确保管理员能杀死任何进程 |
| SIGSTOP | 19 | 系统终极暂停手段,确保能暂停任何进程 |
附 4:System V IPC 三件套对比
消息队列 vs 共享内存 vs 信号量
| 对比维度 | 📬 消息队列 | 🖥️ 共享内存 | 🚦 信号量 |
|---|---|---|---|
| 核心功能 | 按消息类型传递数据 | 高效率共享数据 | 同步与互斥 |
| 数据是否拷贝 | 用户→内核→用户(2 次拷贝) | 直接映射,零拷贝 | 仅整数值,无数据拷贝 |
| 通信效率 | 中等 | 最高 | 不传数据,仅同步 |
| 是否自带同步 | ❌ 否 | ❌ 否 | ✅ 是 |
| 数据大小限制 | 系统限制(通常 ≤ 8KB/条) | 可自定义(大至数 GB) | 仅整数 |
| 数据持久性 | 内核中排队 | 物理内存中常驻 | 内核中常驻 |
| 按类型读取 | ✅ 支持按 mtype 选择性读取 | ❌ 不支持 | ❌ 不支持 |
| FIFO 特性 | ✅ 有(但不强制) | ❌ 无 | ❌ 无 |
| 创建函数 | msgget() |
shmget() |
semget() |
| 控制函数 | msgctl() |
shmctl() |
semctl() |
| 操作函数 | msgsnd() / msgrcv() |
shmat() / shmdt() |
semop() |
| 查询命令 | ipcs -q |
ipcs -m |
ipcs -s |
| 删除命令 | ipcrm -q <id> |
ipcrm -m <id> |
ipcrm -s <id> |
三件套协作方式
scss
┌─────────────────┐
│ 共享内存 │ ← 高效传输数据
│ (Shared Memory) │
└────────┬────────┘
│ 需要保护
▼
┌─────────────────┐
│ 信号量 │ ← 控制访问顺序
│ (Semaphore) │
└─────────────────┘
┌─────────────────┐
│ 消息队列 │ ← 独立使用,用于
│ (Message Queue) │ 按类型通信
└─────────────────┘
协作模式 :共享内存负责传数据(快),信号量负责同步(稳),两者搭配使用是最经典的高性能 IPC 方案。消息队列则独立使用,适合需要按类型分类的中等量数据通信。
附 5:同步与互斥概念对比
| 概念 | 定义 | 比喻 | 实现方式 | 信号量初值 |
|---|---|---|---|---|
| 互斥(Mutual Exclusion) | 同一时刻只允许一个进程访问临界资源 | 单车道桥,一次只过一辆车 | 二值信号量(P/V 操作) | 1 |
| 同步(Synchronization) | 按特定顺序访问临界资源 | 合唱团按谱演唱,不能抢拍 | 多个信号量配合,控制执行节奏 | 根据初始状态设 0 或 1 |
| 死锁(Deadlock) | 多个进程互相等待对方释放资源,全部阻塞 | 十字路口四辆车互不相让 | 避免:固定加锁顺序 / 使用 trylock | --- |
互斥与同步的关键区别
| 特性 | 互斥 | 同步 |
|---|---|---|
| 目标 | 防止同时访问 | 控制访问顺序 |
| 信号量数量 | 1 个 | ≥ 2 个 |
| 典型模式 | P → 临界区 → V | P1 → V2 → P2 → V1 |
| 类比 | 厕所门锁 | 舞台上的轮流表演 |
附 6:阻塞与非阻塞操作对比
| IPC 操作 | 阻塞模式 | 非阻塞模式 |
|---|---|---|
| 管道 read() | 管道空时阻塞 | 通过 fcntl(fd, F_SETFL, O_NONBLOCK) 设置,空时返回 -1, errno = EAGAIN |
| 管道 write() | 管道满时阻塞 | 同上,满时返回 -1, errno = EAGAIN |
| msgrcv() | 队列无符合条件消息时阻塞 | 设置 IPC_NOWAIT,无消息时返回 -1, errno = ENOMSG |
| msgsnd() | 队列满时阻塞 | 设置 IPC_NOWAIT,满时返回 -1, errno = EAGAIN |
| semop() | 信号量值不足时阻塞 | 设置 IPC_NOWAIT,不满足时返回 -1, errno = EAGAIN |
| open(FIFO) | 读写不配对时阻塞 | 以 O_RDWR 打开可避免阻塞(但不推荐) |
附 7:效率排序与性能比较
IPC 效率从高到低
scss
最快 ────────────────────────────────────────── 最慢
│ │
▼ ▼
共享内存 > 信号量 > 信号 > 消息队列 > 有名管道 > 无名管道
(零拷贝) (仅整数) (仅通知) (2次拷贝) (2次拷贝) (2次拷贝)
效率差异详解:
- 共享内存 :零拷贝,进程直接读写物理内存,效率最高
- 信号量 :仅操作整数,无需拷贝数据,效率次之
- 信号 :仅传递编号,异步立即投递,效率很高
- 消息队列 :涉及 2 次用户态↔内核态拷贝,且消息有类型管理开销,效率中等
- 管道 :涉及 2 次拷贝,且是字节流无结构,效率偏低
延迟对比(典型值,非精确测量)
| IPC 机制 | 单次延迟(约) | 适用数据量 | 适用频率 |
|---|---|---|---|
| 共享内存 | 纳秒级 | 大到非常大 | 极高 |
| 信号量 | 微秒级 | 极小(仅同步) | 极高 |
| 信号 | 微秒级 | 极小(仅通知) | 高 |
| 消息队列 | 微秒~毫秒级 | 中(≤ 8KB) | 中 |
| 管道 | 微秒~毫秒级 | 中(≤ 64KB) | 中 |
附 8:常用命令速查
| 命令 | 用途 | 典型用法 |
|---|---|---|
ipcs -q |
查看消息队列 | ipcs -q |
ipcs -m |
查看共享内存 | ipcs -m |
ipcs -s |
查看信号量 | ipcs -s |
ipcs -a |
查看所有 IPC 对象 | ipcs -a |
ipcrm -q <msqid> |
删除消息队列 | ipcrm -q 0 |
ipcrm -m <shmid> |
删除共享内存 | ipcrm -m 11 |
ipcrm -s <semid> |
删除信号量 | ipcrm -s 1 |
kill -l |
列出所有信号 | kill -l |
kill -<sig> <pid> |
发送信号 | kill -9 1234 |
mkfifo <name> |
创建有名管道 | mkfifo myfifo |
ls -l <fifo> |
查看管道文件类型 | ls -l myfifo(显示 p 类型) |
ps aux |
查看进程列表 | `ps aux |
strace -e <syscall> |
追踪系统调用 | strace -e pipe ./a.out |
附 9:快速记忆口诀
IPC 选择口诀
css
小数据通知用信号,
亲缘管道最轻巧。
任意进程用 FIFO,
分类消息队列好。
量大频繁共享存,
别忘了加信号量。
同步互斥信号量,
P 减 V 加不能忘。
信号量 P/V 操作口诀
css
P 操作,减一减,
资源不够就休眠。
V 操作,加一加,
唤醒等待把队插。
初值设一为互斥,
初值设零为同步。