【Linux系统编程】Linux进程间通信:从管道到System V IPC,一篇讲透进程协作的底层逻辑
Linux进程间通信:从管道到System V IPC,一篇讲透进程协作的底层逻辑

📝 前言
在前面的文章中,我们已经学习了进程创建、进程控制和进程替换。但你是否想过一个问题:每个进程都有自己独立的虚拟地址空间,进程 A 根本看不到进程 B 的数据,它们是彼此隔离的——
| 对比项 | 进程内 | 进程间 |
|---|---|---|
| 数据可见性 | 同一地址空间,直接访问 | ❌ 互相隔离,看不到对方 |
| 通信方式 | 全局变量、传参即可 | ❌ 普通手段全部失效 |
🔍 问题来了: 进程之间既然彼此隔离,又该如何交换数据、协同工作呢?
答案就是——进程间通信(IPC,Inter-Process Communication)!而 IPC 的本质只有一句话:让不同的进程,看到同一份资源。这份资源必须由操作系统来提供。围绕"这份资源是什么、长什么样",就衍生出了管道、共享内存、消息队列、信号量等一整套通信方案。
文章目录
一、🌲 进程间通信的本质与分类
1.1 为什么进程间通信必须由操作系统介入
进程具有独立性,这是操作系统刻意设计的结果——地址空间隔离保证了安全和稳定。但代价是:进程之间想要通信,不能直接私发消息,必须找一个双方都能看到的"公共地带"。
这个公共地带只能是操作系统提供的:
- 一块内核缓冲区(管道)
- 一块物理内存(共享内存)
- 一个内核队列(消息队列)
- 一个内核计数器(信号量)
💡 所以理解 IPC 的第一性原理是:先由 OS 提供一份公共资源,再让不同进程都能"看到"它,通信自然就发生了。
1.2 通信的四大目的
| 目的 | 说明 |
|---|---|
| 数据传输 | 一个进程需要将它的数据发送给另一个进程 |
| 资源共享 | 多个进程之间共享同样的资源 |
| 通知事件 | 一个进程向另一个(或一组)进程发送消息,通知发生了某种事件(如进程终止时通知父进程) |
| 进程控制 | 有些进程希望完全控制另一个进程的执行(如 Debug 进程),拦截它的所有陷入和异常,及时知道它的状态改变 |
1.3 发展历程与分类
IPC 的发展经历了三个阶段:管道 → System V 进程间通信 → POSIX 进程间通信。
| 大类 | 具体方案 |
|---|---|
| 管道 | 匿名管道 pipe、命名管道 FIFO |
| System V IPC | 消息队列、共享内存、信号量 |
| POSIX IPC | 消息队列、共享内存、信号量、互斥量、条件变量、读写锁 |
本文按"管道 → 共享内存 → 消息队列 → 信号量"的顺序,把 Linux 中最核心的 IPC 方案逐一讲透。
二、🚿 匿名管道:最古老的 IPC
2.1 什么是管道
管道是 Unix 中最古老的进程间通信形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"。
你在命令行里敲过的 ls | grep xxx,其中的 | 就是管道——ls 的输出流向 grep 的输入,这就是一个典型的数据流。
2.2 pipe 函数:创建匿名管道
语法:
#include <unistd.h>
int pipe(int fd[2]);
// 参数 fd:文件描述符数组
// fd[0] —— 读端
// fd[1] —— 写端
// 返回值:成功返回 0,失败返回错误代码
2.3 用 fork 共享管道
pipe 创建的管道,进程自己一个人用没有意义(自己写自己读)。真正的用法是:先 pipe,再 fork——
💡 关键机制: fork 之后,子进程会继承父进程的文件描述符表。于是父进程的
fd[0]和fd[1],子进程也有一份,而且指向同一个管道。这就是"两个进程看到同一份资源"的实现。
站在文件描述符的角度:父进程 fork 前打开了读写两端,fork 后父子各持有一套指向同一管道的 fd。实践中通常是单向通信:
- 父进程关闭读端
fd[0],只写; - 子进程关闭写端
fd[1],只读。
2.4 站在内核角度:管道的本质
别被"管道"这个名字唬住,它的本质是内核里维护的一块内存级缓冲区,只不过 OS 把它包装成了"文件"的样子,让我们用 read/write/close 这套文件接口来操作。
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了 “Linux 一切皆文件” 的思想。
2.5 实例代码:父子进程通信
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
int pipefd[2];
if (pipe(pipefd) == -1)
ERR_EXIT("pipe error");
pid_t pid;
pid = fork();
if (pid == -1)
ERR_EXIT("fork error");
if (pid == 0) {
// 子进程:关闭读端,只写
close(pipefd[0]);
write(pipefd[1], "hello", 5);
close(pipefd[1]);
exit(EXIT_SUCCESS);
}
// 父进程:关闭写端,只读
close(pipefd[1]);
char buf[10] = {0};
read(pipefd[0], buf, 10);
printf("buf=%s\n", buf);
return 0;
}
2.6 管道的读写规则
当没有数据可读时:
| 设置 | 行为 |
|---|---|
| O_NONBLOCK 关闭(默认) | read 调用阻塞,进程暂停执行,直到有数据到来 |
| O_NONBLOCK 打开 | read 返回 -1,errno 值为 EAGAIN |
当管道写满时:
| 设置 | 行为 |
|---|---|
| O_NONBLOCK 关闭(默认) | write 调用阻塞,直到有进程读走数据 |
| O_NONBLOCK 打开 | write 返回 -1,errno 值为 EAGAIN |
读写端关闭的两种情况:
- 如果所有管道写端对应的文件描述符被关闭,read 返回 0(读到文件结尾的语义);
- 如果所有管道读端对应的文件描述符被关闭,write 操作会产生信号 SIGPIPE,进而可能导致 write 进程退出。
原子性: 当要写入的数据量不大于 PIPE_BUF 时,Linux 保证写入的原子性;大于 PIPE_BUF 时,则不再保证。
2.7 验证管道通信的 4 种情况
| 情况 | 现象 |
|---|---|
| 读正常 && 写满 | write 阻塞,等读端取走数据 |
| 写正常 && 读空 | read 阻塞,等写端写入数据 |
| 写关闭 && 读正常 | read 返回 0,表示读到了末尾 |
| 读关闭 && 写正常 | write 触发 SIGPIPE 信号,进程退出 |
2.8 管道的特点总结
- 只能用于具有亲缘关系的进程(共同祖先):通常一个进程创建管道后调用 fork,此后父子进程之间使用该管道;
- 管道提供流式服务;
- 一般而言,进程退出,管道释放,所以管道的生命周期随进程;
- 一般而言,内核会对管道操作进行同步与互斥(这一点后面和共享内存对比时非常重要);
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立两个管道。
2.9 进阶应用:用管道实现进程池
匿名管道最经典的应用之一就是进程池(Process Pool):父进程(master)预先创建一批子进程,每个子进程配一条管道;父进程通过往管道里写"任务编号"来派发任务,子进程从标准输入读命令并执行。
整体设计遵循 “先描述,再组织”:
// ========== Channel:描述一条信道 ==========
class Channel
{
public:
Channel(int wfd, pid_t who) : _wfd(wfd), _who(who)
{
// Channel-3-1234 这种命名,方便调试
_name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
}
void Send(int cmd) { ::write(_wfd, &cmd, sizeof(cmd)); } // 派发任务
void Close() { ::close(_wfd); }// ::是为了说明这是一种底层调用,表示在全局
pid_t Id() { return _who; }
int wFd() { return _wfd; }
private:
int _wfd; // 写端 fd
std::string _name;
pid_t _who; // 对应的子进程 pid
};
初始化进程池的核心逻辑:
// 2. 创建指定个数个进程
for (int i = 0; i < processnum; i++)
{
// 1. 先有管道
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0) return PipeError;
// 2. 创建进程
pid_t id = fork();
if (id < 0) return ForkError;
// 3. 建立通信信道
if (id == 0)
{
// 关闭历史 wfd:新子进程必须关掉之前所有管道的写端
for (auto &c : channels) c.Close();
::close(pipefd[1]);
dup2(pipefd[0], 0); // 把读端重定向到标准输入
work(); // 执行任务循环
::exit(0);
}
// 父进程:关闭读端,只保留写端
::close(pipefd[0]);
channels.emplace_back(pipefd[1], id);
}
⚠️ 两个容易踩的坑:
- 子进程必须关闭历史 wfd:fork 是层层继承的,第 3 个子进程手里也握着前两条管道的写端。如果不清掉,父进程关闭写端时,管道还有别的写端,子进程 read 永远不会返回 0,无法正常退出。
- 退出时要"先关 fd、再 wait":先关闭所有写端让子进程 read 到 0 退出,再 waitpid 回收,顺序反了会阻塞。
任务派发采用轮询(Round-Robin)方式,把任务均匀地分给每个子进程;子进程的 Worker 逻辑非常简单——从 fd 里读到一个整数就执行对应任务,读到 0(写端全部关闭)就退出:
void Worker()
{
while (true)
{
int cmd = 0;
int n = ::read(0, &cmd, sizeof(cmd));
if (n == sizeof(cmd))
{
tm.Excute(cmd); // 执行编号为 cmd 的任务
}
else if (n == 0)
{
break; // 写端全关了,退出
}
}
}
三、📁 命名管道 FIFO:让陌生进程也能通信
3.1 为什么需要命名管道
管道的一个明显限制:只能在具有共同祖先(亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用 FIFO 文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件——注意,它虽然叫"文件",但数据并不落盘,本质还是内核缓冲区,只是有了一个路径名,任何进程都能通过路径找到它。
3.2 创建命名管道
命令行方式:
$ mkfifo filename
程序内创建:
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
// 示例
int main(int argc, char *argv[])
{
mkfifo("p2", 0644);
return 0;
}
3.3 命名管道的打开规则
为读而打开 FIFO 时:
| 设置 | 行为 |
|---|---|
| O_NONBLOCK 关闭 | 阻塞直到有相应进程为写而打开该 FIFO |
| O_NONBLOCK 打开 | 立刻返回成功 |
为写而打开 FIFO 时:
| 设置 | 行为 |
|---|---|
| O_NONBLOCK 关闭 | 阻塞直到有相应进程为读而打开该 FIFO |
| O_NONBLOCK 打开 | 立刻返回失败,错误码为 ENXIO |
3.4 匿名管道与命名管道的区别
| 对比项 | 匿名管道 pipe | 命名管道 FIFO |
|---|---|---|
| 创建方式 | pipe 函数创建并打开 | mkfifo 创建,open 打开 |
| 适用场景 | 有亲缘关系的进程 | 任意进程 |
| 语义 | 一旦创建打开完成,两者具有相同的语义 | 同左 |
唯一的区别就在创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
3.5 实例:server & client 通信
server 端创建 FIFO 并读取:
// serverPipe.c
int main()
{
umask(0);
if (mkfifo("mypipe", 0644) < 0) {
ERR_EXIT("mkfifo");
}
int rfd = open("mypipe", O_RDONLY);
if (rfd < 0) ERR_EXIT("open");
char buf[1024];
while (1) {
buf[0] = 0;
printf("Please wait...\n");
ssize_t s = read(rfd, buf, sizeof(buf)-1);
if (s > 0) {
buf[s-1] = 0;
printf("client say# %s\n", buf);
} else if (s == 0) {
printf("client quit, exit now!\n");
exit(EXIT_SUCCESS);
} else {
ERR_EXIT("read");
}
}
return 0;
}
// clientPipe.c:写端,与 server 无亲缘关系
int main()
{
int wfd = open("mypipe", O_WRONLY); // 靠路径名 "mypipe" 找到同一个 FIFO
if (wfd < 0) ERR_EXIT("open");
char buf[1024];
while (1) {
buf[0] = 0;
printf("Please Enter# ");
fflush(stdout);
ssize_t s = read(0, buf, sizeof(buf)-1);
if (s > 0) {
buf[s] = 0;
write(wfd, buf, strlen(buf));
} else {
ERR_EXIT("read");
}
}
return 0;
}
💡 server 先运行时 open 会阻塞,直到 client 以写方式打开同一个 FIFO——这正是上面"打开规则"的体现。
四、🧠 System V 共享内存:最快的 IPC
4.1 原理:让两个进程映射同一块物理内存
System V 是一种 IPC 标准,Linux 支持这种标准,专门设计了一个 IPC 通信模块。再强调一遍 IPC 的本质:不同进程看到同一份资源。
共享内存的原理分两步:
- 先申请一块内存空间(物理内存);
- 映射(挂接):通过页表把这块物理内存同时映射到进程 A 的虚拟地址空间和进程 B 的虚拟地址空间。
进程A虚拟地址空间 物理内存 进程B虚拟地址空间
┌─────────────┐ ┌─────────┐ ┌─────────────┐
│ 栈 │ │ │ │ 栈 │
│ ↓ │ │ │ │ ↑ │
│ 共享区 ─────┼───页表──►│ 共享内存 │◄──页表───┼─ 共享区 │
│ ↑ │ │ │ │ ↓ │
│ 堆 │ └─────────┘ │ 堆 │
└─────────────┘ └─────────────┘
💡 三个关键细节:
- 所有工作(申请、映射、关联)都是由操作系统完成的(系统调用);
- 取消关联关系后,OS 才会将内存销毁;
- 系统中可能同时存在多组进程、使用不同的共享内存通信 ⇒ 每一个共享内存,都需要一个描述它的内核数据结构对象——又是熟悉的"先描述,再组织"。
4.2 共享内存的数据结构
操作系统为了管理共享内存,专门设计了描述结构体:
struct shmid_ds {
struct ipc_perm shm_perm; /* 操作权限 */
int shm_segsz; /* 段的大小(字节) */
__kernel_time_t shm_atime; /* 最后一次 attach 时间 */
__kernel_time_t shm_dtime; /* 最后一次 detach 时间 */
__kernel_time_t shm_ctime; /* 最后一次修改时间 */
__kernel_ipc_pid_t shm_cpid; /* 创建者的 pid */
__kernel_ipc_pid_t shm_lpid; /* 最后一个操作者的 pid */
unsigned short shm_nattch; /* 当前挂接数 */
...
};
struct ipc_perm {
__kernel_key_t __key; /* 用户层构建的 key */
__kernel_uid_t uid;
__kernel_gid_t gid;
__kernel_uid_t cuid; /* 创建者 */
__kernel_gid_t cgid;
__kernel_mode_t mode; /* 权限位 */
unsigned short __seq;
};
4.3 shmget 与 ftok:如何让两个进程看到同一块内存
语法:
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
// 参数 key:这个共享内存段的名字(唯一性标识)
// 参数 size:共享内存大小
// 参数 shmflg:由九个权限标志构成,用法和创建文件时的 mode 模式标志一样
// 返回值:成功返回一个非负整数,即该共享内存段的标识码(shmid,内核使用)
// 失败返回 -1
shmflg 的两个核心选项:
| 选项 | 含义 |
|---|---|
| IPC_CREAT | 共享内存不存在就创建并返回;已存在就获取并返回 |
| IPC_CREAT | IPC_EXCL | 共享内存不存在就创建并返回;已存在则出错返回 |
💡 单独使用 IPC_EXCL 没有意义。它和 IPC_CREAT "或"起来的意义是:保证调用者拿到的一定是一块全新的共享内存(通信的发起者用它)。
那么 key 从哪来?key 要保证全系统唯一,不同进程用 shm 通信时靠 key 标识共享内存的唯一性。它由用户通过约定好的算法生成:
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
// 传一个真实存在的路径 + 一个用户自定义的 id
// 该函数会构建出一个 key,成功返回 key,失败返回 -1
只要两个进程传相同的 pathname 和 proj_id,ftok 就会得到相同的 key,从而找到同一块共享内存。
💡 将共享内存与文件系统类比:
- fd 与 shmid 类似:fd 是进程私有的,shmid 是全局的;
- 文件名与 key 类似:文件名是"已有"的,而 key 需要"创建"出来(ftok 生成)。
- key 只用来在内核层面区分唯一性;后续的删除、控制等操作都不用 key,用户只使用 shmid。
4.4 shmat / shmdt / shmctl:挂接、分离与控制
挂接(attach)——把共享内存连接到进程地址空间,类似 malloc(在堆上、在虚拟空间中申请):
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
// 参数 shmid:共享内存标识
// 参数 shmaddr:指定连接的地址,一般传 NULL,让 OS 自动选择
// 参数 shmflg:标志位
// 0 —— 可读可写
// SHM_RDONLY —— 只读
// SHM_RND —— 地址自动向下调整为 SHMLBA 的整数倍
// 返回值:成功返回指向共享内存第一个字节的指针,失败返回 -1
// 成员 nattach 表示当前已挂接数
分离(detach)——将共享内存段与当前进程脱离:
int shmdt(const void *shmaddr);
// 参数:shmat 所返回的指针
// 返回值:成功返回 0,失败返回 -1
⚠️ 将共享内存段与当前进程脱离不等于删除共享内存段!分离只是断开了本进程的映射关系。
控制——删除、查属性、设属性:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 参数 cmd:将要采取的动作
// 参数 buf:指向保存共享内存模式状态和访问权限的数据结构(输出型参数,
// 删除等操作不需要读取属性时可以传 NULL)
// 返回值:成功返回 0,失败返回 -1
| cmd | 作用 |
|---|---|
| IPC_STAT | 查看属性 |
| IPC_RMID | 删除(即便仍有进程挂接,依旧删除) |
| IPC_SET | 设置属性/权限 |
4.5 完整实例:server 与 client 通信
先封装公共接口(comm.c):
// comm.h
#ifndef _COMM_H_
#define _COMM_H_
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PROJ_ID 0x6666
int createShm(int size); // 创建全新的共享内存(server 用)
int destroyShm(int shmid);
int getShm(int size); // 获取已存在的共享内存(client 用)
#endif
// comm.c
static int commShm(int size, int flags)
{
key_t key = ftok(PATHNAME, PROJ_ID);
if (key < 0) { perror("ftok"); return -1; }
int shmid = 0;
if ((shmid = shmget(key, size, flags)) < 0) {
perror("shmget"); return -2;
}
return shmid;
}
int destroyShm(int shmid)
{
if (shmctl(shmid, IPC_RMID, NULL) < 0) {
perror("shmctl"); return -1;
}
return 0;
}
int createShm(int size) { return commShm(size, IPC_CREAT|IPC_EXCL|0666); }
int getShm(int size) { return commShm(size, IPC_CREAT); }
server 端(读方):
int main()
{
int shmid = createShm(4096); // 建议创建 PAGE(4096) 的整数倍
char *addr = shmat(shmid, NULL, 0); // 挂接
int i = 0;
while (i++ < 26) {
printf("client# %s\n", addr); // 直接读地址,像读自己的内存一样
sleep(1);
}
shmdt(addr); // 分离
destroyShm(shmid); // 删除
return 0;
}
client 端(写方):
int main()
{
int shmid = getShm(4096);
char *addr = shmat(shmid, NULL, 0);
int i = 0;
while (i < 26) {
addr[i] = 'A' + i; // 直接写地址!没有任何系统调用
i++;
addr[i] = 0;
sleep(1);
}
shmdt(addr);
return 0;
}
4.6 两个命令 + 一个重要认知
$ ipcs -m # 查看共享内存(ipcs 不带选项则查看所有 IPC 资源)
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x66026a25 688145 root 666 4096 0
$ ipcrm -m 688145 # 删除共享内存,需要的是 shmid(不是 key!)
⚠️ 重要认知:共享内存的生命周期随内核!
进程结束,如果没有删除共享内存资源,它会一直存在。上面shmget: File exists报错,就是上次进程异常退出没清理导致的。删除方式有两种:命令行ipcrm,或者代码里shmctl(shmid, IPC_RMID, NULL)——删除 IPC 资源是进程自己该做的事情。消息队列、信号量同理:IPC 资源必须删除,否则不会自动清除,除非重启,所以 System V IPC 资源的生命周期随内核。
💡 大小的小细节: 共享内存在创建的时候,它的大小最好是 4KB(4096,即 PAGE 大小)的整数倍。内核实际会向上取整,比如申请 4097,内核会给你 8192——多出来的部分被浪费了。
4.7 共享内存为什么是最快的 IPC?又快在哪里、缺在哪里?
共享内存区是最快的 IPC 形式,原因有二:
- 映射完成后,读写能立即被对方看到——数据不经过内核中转;
- 读写无需系统调用——像 malloc 拿到地址后直接用一样,管道的读写却每次都要进入内核。
对比一下:管道的缓冲区属于内核,每次 read/write 都是一次系统调用;而共享内存挂接在进程的用户空间共享区,直接用地址访问即可。
⚠️ 但天下没有免费的午餐:共享内存没有同步与互斥机制!它缺乏访问控制,会带来并发问题。
通信双方对"对方写没写完"毫无感知,一边还在写、另一边已经开始读,数据就不一致了。
4.8 补救方案:用命名管道给共享内存加"访问控制"
思路:在 A、B 进程之间额外设计一条命名管道,发送方写好数据后向管道写一个通知,接收方收到通知再读共享内存。管道自带阻塞语义(没数据 read 就阻塞),天然实现了"顺序性":
// 发送方:写完共享内存后,向 FIFO 写一个整数通知
int fd = open(FIFO_NAME, O_WRONLY);
read(0, shmaddr, SHM_SIZE - 1); // 读用户输入到共享内存
uint32_t temp = 1;
write(fd, &temp, sizeof(temp)); // Signal:唤醒接收方
// 接收方:先等通知,再读共享内存
int fd = open(FIFO_NAME, O_RDONLY);
uint32_t temp = 0;
read(fd, &temp, sizeof(temp)); // Wait:阻塞,直到发送方通知
printf("%s\n", shmaddr); // 临界区:安全读取
💡 这里管道扮演的角色,其实就是"同步与互斥"的雏形——而这个问题的标准化解决方案,就是我们最后要讲的信号量。
五、📮 System V 消息队列
5.1 原理
消息队列的原理:将两个进程的数据块引入队列中,可实现双向传递。
结论:
- 消息队列提供了一个进程给另一个进程发送有类型的数据块的方法——每个数据块都有一个类型,接收进程可以按类型选择接收,这就区分了发送方与接收方;
- 它与共享内存很类似:同属 System V 标准。
5.2 接口
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
// 与 shmget 类似,成功返回标识符 msgid
// 发送
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
// 标识符 结构体指针 数据块大小
// 接收(可按类型接收)
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
// ↑ 按类型挑选数据块
// 数据块的格式:自带类型字段
struct msgbuf {
long mtype; /* 类型 */
char mtext[1]; /* 内容,柔性数组,实际大小由 msgsz 决定 */
};
生命周期:随内核。查看与删除用 ipcs -q 和 ipcrm -q。
💡 消息队列的特点:数据有边界、有类型,读写天然带"先进先出"的队列语义,适合结构化的消息传递,但每次收发都要经过内核,效率不如共享内存。选学了解即可。
六、🚦 System V 信号量:保护公共资源的方案
6.1 为什么需要信号量
前面说过:共享内存需要保护机制,而信号量就是一种具体的解决方案。要理解它,得先铺垫几个多执行流(进程)并发访问的概念:
| 概念 | 定义 |
|---|---|
| 共享资源(公共资源) | 多个执行流能看到的同一份资源 |
| 临界资源 | 被保护起来的公共资源 |
| 临界区 | 进程中访问临界资源的代码段(你的代码 = 临界区 + 非临界区) |
| 互斥 | 任何时刻,只允许一个执行流访问资源 |
| 同步 | 多个执行流访问临界资源时,具有一定的顺序性 |
| 原子性 | 要么做,要么不做,没有中间状态 |
💡 一句话串联: 对共享资源进行保护,本质是对访问共享资源的代码(临界区)进行保护。保护的常见方式就是互斥与同步,而互斥 ⇌ 互斥资源。
┌──────────┐
│ 非临界区 │
├──────────┤ ← 加锁(申请锁时必须是原子的!)
│ 临界区 │
├──────────┤ ← 解锁
│ 非临界区 │
└──────────┘
🔍 一个尖锐的问题: 锁本身也是多个执行流共享的,那谁来保护锁的安全?——所以申请锁这个动作本身必须是原子的,这是所有保护机制的地基。
6.2 信号量是什么:电影院模型
信号量本质是一个计数器,用来表明临界资源中资源的数量多少(就像路口的信号灯,所以也叫信号灯)。
用电影院来类比:
- 电影院的座位 = 临界资源被划分成的多个区域;
- 买票 = 对资源的预订机制 = 申请信号量;
- 票数有限 ⇒ 不要让多余的人(进程)进来(计数器减到 0 就进不去了);
- 一人一座 ⇒ 不要重复访问同一位置(票和座位一一对应);
- 效果:把共享内存分区、分区使用,多个进程并发访问且不出问题!
操作方面,信号量只有两种操作:
| 操作 | 计数器变化 | 含义 |
|---|---|---|
| P 操作 | 申请资源,计数器 -- | 原子性 |
| V 操作 | 释放资源,计数器 ++ | 原子性 |
两个重要细节:
- 信号量本身就是共享资源——所有进程都要先"看到同一个信号量"才能协同。既然它自己也是被并发访问的,那么 P/V 操作必须是原子的(这就是为什么内核专门实现它);
- 当计数器取值只有 0 或 1 时,称之为二元信号量,它等价于互斥功能——一进一出,同一时刻只有一个执行流能访问资源。
🔍 问题:信号量为什么不能是一个普通整数变量?
因为对普通整数的++/--编译后是多条指令(读内存 → 修改 → 写回),并不是原子的,并发执行时可能丢失更新。信号量的 P/V 操作由内核保证原子性,这正是它存在的意义。
6.3 信号量与通信的关系
两个关键认知:
- 要协同访问共享内存,每个进程必须先看到同一个信号量 → 由 System V 解决(它和共享内存、消息队列一样,也是 key 标识的公共资源);
- 不只有传递数据才是 IPC——通知事件、同步互斥也算通信!
6.4 接口
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(key_t key, int nsems, int semflg);
// 创建信号量集,nsems 是信号量的个数(集合数)
int semctl(int semid, int semnum, int cmd, ...);
// 控制第 semnum 个信号量,cmd 如 SETVAL
// 可变参数对应一个联合体,里面有 val 字段,用来设置计数器的初始值
int semop(int semid, struct sembuf *sops, size_t nsops);
// 对信号量做 P/V 操作:sops 是数组,nsops 是数组个数
struct sembuf {
unsigned short sem_num; // 对第几个信号量操作
short sem_op; // 具体操作:-1 即 P,+1 即 V
short sem_flg;
};
和共享内存一样:三个接口都靠 key 区分唯一性;生命周期随内核;用 ipcs -s 查看、ipcrm -s 删除。
6.5 内核是如何组织管理 IPC 资源的
一个很有意思的问题:共享内存 shmid、消息队列 msqid、信号量 semid,这三种"看起来完全不同"的资源,内核是怎么统一管理的?
答案还是那四个字:先描述,再组织。在 Linux 内核(以 2.6.x 为参考)中:
- 所有 System V IPC 资源由一个统一的
ipc_ids结构管理,其中的entries指向一个系统级指针数组; - 数组中存放的都是 kern_ipc_perm * 类型的指针——不管底层是
msg_queue(消息队列)、sem_array(信号量集)还是shmid_kernel(共享内存),入口处都是同一个权限描述结构; - 每种资源都"自带"一个 ipc_perm 成员,通过强制类型转换,就能用同一套函数进行统一管理;
- 而我们拿到的 xxxid(shmid/msqid/semid),实际上是数组的下标!
ipc_ids
┌─────────┐
│ *entries │────► 系统数组(ipc_id_ary)
└─────────┘ ┌──────────────────┐
│ [0] kern_ipc_perm* ──► msg_queue (消息队列)
│ [1] kern_ipc_perm* ──► sem_array (信号量集)
│ [2] kern_ipc_perm* ──► shmid_kernel (共享内存)
│ ... 指针类型统一
└──────────────────┘
💡 顺便一提: 这种"入口结构统一 + 按实际类型强转分发"的手法,正是 C 语言实现多态的经典方式——面向对象的多态思想,在内核里随处可见。
七、📊 全文总结:五种 IPC 横向对比
| 方案 | 数据形态 | 速度 | 同步互斥 | 生命周期 | 适用场景 |
|---|---|---|---|---|---|
| 匿名管道 | 字节流(流式服务) | 中 | ✅ 内核提供 | 随进程 | 有亲缘关系的进程间单向通信 |
| 命名管道 | 字节流 | 中 | ✅ 内核提供 | 随进程 | 任意进程间通信,也可给共享内存做访问控制 |
| 共享内存 | 原始内存,直接读写 | ✅ 最快 | ❌ 完全没有 | 随内核 | 大数据量、高频交互 |
| 消息队列 | 有类型的数据块 | 中 | ✅ 队列语义 | 随内核 | 结构化消息传递 |
| 信号量 | 不传数据,传"状态" | — | ✅ 它就是干这个的 | 随内核 | 同步与互斥、保护临界资源 |
最后记住三句话:
- IPC 的本质是让不同进程看到同一份资源,资源由 OS 提供,一切通过系统调用完成;
- 共享内存最快但最"裸"——没有同步互斥,需要配合管道或信号量做访问控制;
- System V IPC 资源的生命周期随内核——用完必须由进程自己删除,
ipcs查一查,别让内存"泄漏"在内核里。
八、🤔 核心思考题
-
为什么匿名管道只能用于有亲缘关系的进程?
因为管道是 fork 前创建的,靠子进程继承文件描述符表才能指向同一根管道;没有亲缘关系的进程拿不到对方的 fd。命名管道靠"路径名"解决这个问题。 -
共享内存为什么是最快的 IPC?
映射完成后读写直接操作用户空间地址,①数据不经过内核中转,②无需 read/write 系统调用,省去了用户态与内核态的切换开销。 -
信号量为什么不能是一个普通整数?
++/--不是原子操作(读-改-写三条指令),并发执行会丢失更新。信号量的 P/V 操作由内核保证原子性。 -
shmid 和 fd 有什么异同?
二者都是"标识符",但 fd 是进程私有的,shmid 是全局的;文件名与 key 类似——文件名是现成的,key 需要通过 ftok 创建,且 key 只服务于内核层的唯一性区分,用户层操作只用 shmid。 -
为什么 System V IPC 资源的生命周期随内核?
它们是内核维护的全局资源(ipc_ids 数组统一管理),进程退出不会自动回收,必须显式删除(shmctl/msgctl/semctl 或 ipcrm 命令),除非重启。
本节完
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)