Linux 进程间通信(IPC):从管道到共享内存,一篇打尽
进程间通信(IPC)是 Linux 系统编程的核心课题之一。本文结合经典原理与完整可运行代码,深度剖析匿名管道、命名管道、System V 共享内存、消息队列、信号量的接口、典型案例及易错点。全文约 1.7 万字,附经典代码解析与对比表格,并特别增加了核心系统调用解释,帮助读者从用户态到底层彻底搞懂 IPC。所有代码均可在 Linux 下编译运行,代码中包含了详细的中文注释,方便理解和复用。
前置知识:熟悉 C/C++ 基本语法、Linux 基础命令、进程概念(fork、exec、waitpid)。如果对虚拟内存和文件描述符不太熟悉,建议先阅读相关章节。
文章目录
📌 一、进程间通信概述
1.1 为什么需要 IPC?
| 目的 | 说明 |
|---|---|
| 数据传输 | 一个进程将数据发给另一个进程 |
| 资源共享 | 多个进程共享同一块内存或资源 |
| 通知事件 | 进程发生某种事件(如终止)时通知其他进程 |
| 进程控制 | 控制进程(如 debugger)拦截目标进程的所有陷入和异常,获取状态变化 |
1.2 IPC 发展脉络
- 管道(匿名管道、命名管道)—— 最古老,所有 Unix 系统都支持
- System V IPC(共享内存、消息队列、信号量)—— 功能强大,生命周期随内核
- POSIX IPC(消息队列、共享内存、信号量、互斥锁、条件变量等)—— 更现代,接口更简洁,线程安全
- 本文聚焦管道与 System V IPC,帮助读者理解经典 IPC 的本质。POSIX IPC 会在后续文章中详解。
1.3 什么是系统调用?
在深入具体 IPC 之前,我们先明确系统调用这个概念。
操作系统内核负责管理硬件、进程、内存等资源,用户程序运行在受限制的“用户态”,不能直接访问内核资源。当程序需要内核服务(如读写文件、创建进程、申请共享内存)时,必须通过系统调用(System Call)切换到“内核态”,由内核完成操作后返回结果。
例如,我们熟悉的 read()、write()、fork() 都是系统调用。IPC 相关函数如 pipe()、shmget()、semop() 等,封装层虽在 C 库中,但底层都会触发一个对应的系统调用,陷入内核执行。
了解每个 IPC 机制涉及的系统调用,有助于理解其性能开销、原子性及资源管理方式。
🧵 二、匿名管道(pipe)—— 亲缘进程的“数据河流”
2.1 核心原理与系统调用
系统调用:pipe()
#include <unistd.h>
int pipe(int pipefd[2]);
- 作用:创建一个管道,内核分配一块环形缓冲区,并返回两个文件描述符:
pipefd[0]为读端,pipefd[1]为写端。 - 内核行为:调用
pipe()时,进程陷入内核,内核在进程的打开文件表中分配两个文件描述符,指向同一个管道对象。该管道对象在内核中维护一个缓冲区、两个等待队列(读等待、写等待)和引用计数。 - 返回值:成功返回 0,失败返回 -1 并设置
errno。
与管道相关的其他系统调用:
-
read()/write():对管道的读写实际上是对文件描述符的操作,最终也通过系统调用在内核中搬运数据。 -
close():关闭管道的某一端,减少引用计数;当所有写端都关闭时,读端read返回 0;当所有读端都关闭时,写端触发SIGPIPE。 -
半双工:数据只能单向流动。如需双向通信,需创建两个管道。
-
生命周期:随进程 —— 所有打开管道的进程退出后,管道自动释放。
-
原子性:单次写入 ≤
PIPE_BUF(Linux 下 4096 字节)时,write是原子的,多进程同时写不会交错。
下面的示意图展示了 shell 命令行 who | wc 中管道的工作方式:who 进程的标准输出连接到管道写端,wc 进程的标准输入连接到管道读端,数据在内核中流动。

2.2 基础示例:父子进程单向通信
#include <unistd.h> // pipe, fork, read, write, close
#include <iostream> // std::cout, std::cerr
#include <cstring> // strlen
#include <sys/wait.h> // wait
int main() {
int fd[2]; // fd[0]读端, fd[1]写端
// 1. 创建匿名管道
if (pipe(fd) == -1) {
std::cerr << "pipe error\n";
return 1;
}
// 2. 创建子进程
pid_t pid = fork();
if (pid == -1) {
std::cerr << "fork error\n";
return 1;
}
if (pid == 0) { // 子进程:负责读数据
// 【重要】子进程不需要写端,立即关闭,否则会影响管道检测
close(fd[1]); // 关闭写端
char buf[128] = {0};
// 从管道读端读取数据,阻塞直到有数据或写端全部关闭
ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
std::cout << "child read: " << buf << std::endl;
} else if (n == 0) {
std::cout << "child: write end closed\n";
} else {
std::cerr << "read error\n";
}
close(fd[0]); // 关闭读端
exit(EXIT_SUCCESS);
} else { // 父进程:负责写数据
// 【重要】父进程不需要读端,立即关闭
close(fd[0]); // 关闭读端
const char *msg = "hello from parent";
if (write(fd[1], msg, strlen(msg)) == -1) {
std::cerr << "write error\n";
}
close(fd[1]); // 写完关闭写端,子进程read会返回0
wait(nullptr); // 等待子进程结束,防止僵尸进程
}
return 0;
}
⚠️ 易错点1:fork 之后,父子进程各自必须关闭不需要的一端,否则管道无法正确检测到对方关闭(例如写端一直不关,读端 read 会永远阻塞)。
下图展示了 pipe() 创建管道后,父进程和子进程各自拥有读端和写端的文件描述符(fork 复制了描述符表):

2.3 管道读写规则(重点)
| 场景 | 行为 |
|---|---|
| 读端在读,写端关闭 | read 返回 0(表示读到文件末尾) |
| 读端关闭,写端继续写 | 内核发送 SIGPIPE 信号,导致写进程默认终止。可捕获或忽略信号,write 返回 -1 并设置 errno=EPIPE |
| 管道空,读端以阻塞方式打开 | read 阻塞,直到有数据或所有写端关闭 |
管道空,读端以非阻塞方式打开 (O_NONBLOCK) |
read 返回 -1,errno 置为 EAGAIN |
| 管道满,写端以阻塞方式打开 | write 阻塞,直到有空间 |
| 管道满,写端以非阻塞方式打开 | write 返回 -1,errno 置为 EAGAIN |
为了在双向通信中正确关闭不需要的文件描述符,可以对照下面这张简化图理解:

2.4 高级应用:进程池
下面实现一个基于管道的进程池:父进程创建 N 个子进程,每个子进程通过管道接收任务码,执行对应的回调函数。代码由 Task.hpp、ProcessPool.hpp、Main.cc 组成,可完整编译运行。
🔍 代码结构梳理
| 文件 | 作用 |
|---|---|
Task.hpp |
定义任务函数及 TaskManager,管理任务码和分发执行 |
ProcessPool.hpp |
定义 Channel(封装写端 fd 和子进程 pid)、ChannelManager(轮询选择子进程)、ProcessPool(整体控制:创建进程、派发任务、回收) |
Main.cc |
创建进程池,循环派发任务,最后停止回收 |
🧩 关键类解析
① Task.hpp:任务管理
#pragma once
#include <vector>
#include <functional>
#include <cstdlib>
#include <ctime>
#include <iostream>
// 任务类型:返回void的无参函数,可以是普通函数、lambda、std::function
using task_t = std::function<void()>;
// 示例任务函数
void PrintLog() { std::cout << "Logging...\n"; }
void Download() { std::cout << "Downloading...\n"; }
void Upload() { std::cout << "Uploading...\n"; }
// 任务管理器:负责注册任务、随机选择任务码、执行任务
class TaskManager {
public:
TaskManager() {
srand(time(nullptr)); // 随机种子,用于Code()产生随机索引
}
// 注册一个任务,添加到任务列表中
void Register(task_t t) {
_tasks.push_back(t);
}
// 随机返回一个任务索引(0 到 tasks.size()-1)
int Code() {
return rand() % _tasks.size();
}
// 根据任务码执行对应的任务
void Execute(int code) {
if (code >= 0 && code < (int)_tasks.size())
_tasks[code](); // 调用函数
}
private:
std::vector<task_t> _tasks; // 存储任务对象
};
② ProcessPool.hpp:进程池核心
#pragma once
#include <unistd.h>
#include <sys/wait.h>
#include <vector>
#include <cstdlib>
#include <cerrno>
#include <iostream>
#include "Task.hpp"
// 通道类:封装一个子进程的管道写端和子进程PID
class Channel {
public:
// 构造:保存写端文件描述符和子进程ID
Channel(int fd, pid_t id) : _wfd(fd), _subid(id) {}
// 向子进程发送一个整数任务码
void Send(int code) {
// write 可能会被信号中断,这里简单处理,生产环境可重试
if (write(_wfd, &code, sizeof(code)) == -1)
std::cerr << "Channel::Send error\n";
}
// 关闭写端(触发子进程 read 返回 0)
void Close() {
close(_wfd);
}
// 等待子进程退出并回收资源
void Wait() {
waitpid(_subid, nullptr, 0);
}
private:
int _wfd; // 指向子进程的管道写端(父进程持有)
pid_t _subid; // 子进程PID
};
// 通道管理器:维护所有子进程的Channel,并提供轮询选择
class ChannelManager {
public:
// 添加一个子进程通道
void Insert(int fd, pid_t id) {
_channels.emplace_back(fd, id);
}
// 轮询选择一个通道(负载均衡)
Channel &Select() {
auto &c = _channels[_next];
_next = (_next + 1) % _channels.size(); // 循环递增
return c;
}
// 关闭所有通道的写端(通知所有子进程退出)
void StopSubProcess() {
for (auto &ch : _channels) ch.Close();
}
// 等待所有子进程结束
void WaitSubProcess() {
for (auto &ch : _channels) ch.Wait();
}
private:
std::vector<Channel> _channels;
size_t _next = 0; // 下一次选择的索引
};
// 进程池主类
class ProcessPool {
public:
ProcessPool(int num, TaskManager &tm) : _process_num(num), _tm(tm) {}
// 启动进程池:创建子进程和管道,初始化通道
bool Start() {
// 记录所有历史写端,供后续子进程关闭(关键!避免子进程持有额外写端导致无法检测EOF)
std::vector<int> previous_wfds;
for (int i = 0; i < _process_num; i++) {
int pipefd[2];
// 1. 创建管道
if (pipe(pipefd) == -1) {
std::cerr << "pipe error\n";
return false;
}
// 2. 创建子进程
pid_t subid = fork();
if (subid == -1) {
std::cerr << "fork error\n";
return false;
}
if (subid == 0) {
// ---------- 子进程执行代码 ----------
// 【重点】子进程需要关闭所有历史管道的写端(这些是父进程为之前子进程保留的)
// 如果不关闭,当前子进程的读端将无法检测到自己的写端关闭(因为还有其他进程持有写端)
for (int wfd : previous_wfds) {
close(wfd);
}
// 关闭当前管道的写端,只保留读端
close(pipefd[1]);
// 进入任务接收循环
Work(pipefd[0]);
close(pipefd[0]);
exit(EXIT_SUCCESS);
} else {
// ---------- 父进程执行代码 ----------
// 保存当前写端,供未来子进程关闭历史写端
previous_wfds.push_back(pipefd[1]);
// 父进程关闭读端,只保留写端
close(pipefd[0]);
// 将写端和子进程ID加入通道管理器
_cm.Insert(pipefd[1], subid);
}
}
return true;
}
// 派发一个任务:随机选择任务码,轮询选择一个子进程,发送任务码
void Run() {
int taskcode = _tm.Code(); // 获取随机任务码
auto &c = _cm.Select(); // 轮询选择一个子进程通道
c.Send(taskcode); // 通过管道发送任务码
}
// 停止进程池:关闭所有写端,等待子进程退出
void Stop() {
_cm.StopSubProcess(); // 关闭所有写端 -> 子进程 read 返回 0,退出循环
_cm.WaitSubProcess(); // 回收所有子进程
}
private:
// 子进程的工作循环:不断读取任务码并执行
void Work(int rfd) {
while (true) {
int code = 0;
ssize_t n = read(rfd, &code, sizeof(code));
if (n == sizeof(code)) {
_tm.Execute(code); // 执行任务
} else if (n == 0) {
// 写端关闭,正常退出
break;
} else if (n == -1) {
// 被信号中断则重试,否则退出
if (errno == EINTR) continue;
std::cerr << "read error\n";
break;
}
}
}
int _process_num; // 子进程数量
TaskManager &_tm; // 任务管理器引用
ChannelManager _cm; // 通道管理器
};
💡 设计亮点与易错说明:
- 为什么子进程必须关闭历史写端?
父进程每创建一个子进程,都会保留该子进程的管道写端(放入previous_wfds)。当创建第二个及以后子进程时,这些写端 fd 会被复制到子进程中。如果子进程不关闭这些历史写端,那么即使父进程后来关闭了当前子进程自己的写端,管道仍因为其他写端(历史)的存在而不会read返回 0,导致子进程无法正常退出。上述代码通过子进程遍历previous_wfds并关闭它们,完美解决了这个问题。Work()函数增加了对EINTR的处理,避免因信号中断而错误退出。- 轮询负载均衡简单有效,避免了部分子进程过载。
③ Main.cc
#include "ProcessPool.hpp"
int main() {
TaskManager tm;
// 注册三个任务函数
tm.Register(PrintLog);
tm.Register(Download);
tm.Register(Upload);
// 创建4个子进程的进程池
ProcessPool pool(4, tm);
if (!pool.Start()) {
std::cerr << "ProcessPool start failed\n";
return 1;
}
// 模拟派发 10 个任务
for (int i = 0; i < 10; i++) {
pool.Run(); // 派发一个任务
usleep(100000); // 休眠 0.1 秒,避免过快的派发
}
pool.Stop(); // 停止并回收子进程
return 0;
}
编译:
g++ -std=c++11 Main.cc -o pool && ./pool
📂 三、命名管道(FIFO)—— 无亲缘进程通信
3.1 与匿名管道的区别
| 对比项 | 匿名管道 (pipe) | 命名管道 (FIFO) |
|---|---|---|
| 适用范围 | 仅亲缘进程(父子、兄弟) | 任意两个进程 |
| 文件系统可见 | 无 | 有,磁盘上的一个特殊文件 |
| 创建方式 | pipe() |
mkfifo 命令 / mkfifo() 函数 |
| 打开方式 | 无需 open,直接使用 fd | 需 open() 像普通文件一样打开 |
| 读写阻塞规则 | 同左 | 同左 |
命名管道在内核缓冲区、数据流以及阻塞特性上均与匿名管道相同,唯一的区别在于它有一个文件系统中的入口,可以被任意进程打开。下图示意了命名管道的通信模型:

3.2 核心系统调用
mkfifo()
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
- 作用:在文件系统中创建一个 FIFO 特殊文件(命名管道)。这个文件只是入口,真正的缓冲区在内核中。
- 内核行为:调用
mkfifo后,内核在指定路径创建一个索引节点(inode),标记为 FIFO 类型,同时建立对应的内核管道对象(结构与匿名管道相似)。用户通过open这个路径来获取读写文件描述符。 - 关键细节:FIFO 文件大小始终为 0,数据不写入磁盘,只是内核缓冲区的一个引用点。
open()(作用于 FIFO)
- 对 FIFO 执行
open时,内核会检查是否有其他进程已经以互补模式打开(读对写)。默认阻塞直到配对成立。 - 若设置了
O_NONBLOCK,则读端立即成功,写端若无读端则返回 -1(ENXIO)。
read() / write()
- 与匿名管道完全一致,数据在内存中传输,不经过磁盘。
3.3 安全打开命名管道(避免死锁)
// 安全的写端打开函数:如果读端尚未打开,则等待(非阻塞轮询)
int open_fifo_safe(const char *path) {
int fd;
do {
// 使用 O_NONBLOCK 避免因读端未打开而永久阻塞
fd = open(path, O_WRONLY | O_NONBLOCK);
if (fd == -1 && errno == ENXIO) {
usleep(100000); // 等待读端打开,100ms
continue;
} else break;
} while (1);
// 取消非阻塞标志,之后的读写按阻塞方式(更符合预期)
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
return fd;
}
3.4 完整聊天示例:Server / Client
serverPipe.c(读端)
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h> // open
#include <unistd.h> // read, write, close, unlink
#include <sys/stat.h> // mkfifo, umask
#include <errno.h>
int main() {
const char *fifo_name = "./chatfifo";
umask(0); // 清除umask,确保创建的文件权限为0666
// 创建命名管道,如果已存在则不报错
if (mkfifo(fifo_name, 0666) == -1 && errno != EEXIST) {
perror("mkfifo");
exit(1);
}
printf("Server: waiting for client...\n");
// 以只读方式打开管道(阻塞直到有写端打开)
int fd = open(fifo_name, O_RDONLY);
if (fd == -1) {
perror("open");
exit(1);
}
printf("Server: client connected, reading...\n");
char buf[256];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Received: %s", buf);
if (buf[0] == 'q') break; // 收到 quit 命令退出
} else if (n == 0) {
break; // 写端关闭,退出循环
} else {
if (errno == EINTR) continue; // 被信号中断,重试
perror("read");
break;
}
}
close(fd);
unlink(fifo_name); // 删除管道文件
return 0;
}
clientPipe.c(写端,使用安全打开函数)
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
// 安全打开写端函数(见上文)
int open_fifo_safe(const char *path) {
int fd;
do {
fd = open(path, O_WRONLY | O_NONBLOCK);
if (fd == -1 && errno == ENXIO) {
usleep(100000);
continue;
} else break;
} while (1);
// 恢复阻塞模式
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
return fd;
}
int main() {
const char *fifo_name = "./chatfifo";
int fd = open_fifo_safe(fifo_name);
if (fd == -1) {
perror("open");
exit(1);
}
char buf[256];
while (1) {
printf("Enter message (q to quit): ");
fflush(stdout);
if (fgets(buf, sizeof(buf), stdin) == NULL) break;
// 写入管道,注意包含换行符
if (write(fd, buf, strlen(buf)) == -1) {
perror("write");
break;
}
if (buf[0] == 'q') break;
}
close(fd);
return 0;
}
编译:
gcc serverPipe.c -o server && gcc clientPipe.c -o client,先运行 server,再运行 client。
🚀 四、System V 共享内存 —— 最快的 IPC
4.1 原理图解
共享内存是内核将一块物理内存映射到多个进程的虚拟地址空间中。进程直接读写该内存,不经过内核拷贝,因此速度是所有 IPC 中最快的。
下图展示了两个进程通过页表映射到同一块物理内存区域,从而实现零拷贝数据共享:

4.2 核心系统调用
shmget() —— 创建或获取共享内存段
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
- 作用:向内核申请一段共享内存。如果
key不存在且标志包含IPC_CREAT,则新建一个大小为size(会向上圆整为页大小)的共享内存段;否则获取已有的。 - 内核行为:分配
struct shmid_kernel结构,挂入全局shm_ids数组中,返回用户态的标识符shmid。内核并不立即映射到进程地址空间。 - 注意:
size建议为 4096 的整数倍(页对齐)。
shmat() —— 挂接共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 作用:将共享内存段映射到当前进程的虚拟地址空间,返回映射后的起始地址。
- 内核行为:在进程的页表中建立虚拟页到共享物理页的映射。此后进程对这段内存的读写如同访问普通内存,不再需要任何系统调用。
- 参数:
shmaddr一般传NULL让内核选择地址;shmflg可设为SHM_RDONLY只读挂接。
shmdt() —— 脱离共享内存
int shmdt(const void *shmaddr);
- 作用:解除当前进程与共享内存的映射关系,但不删除内核中的对象。
- 内核行为:移除对应页表项,减少挂接计数。
shmctl() —— 控制操作
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 常用命令:
IPC_RMID(标记删除)、IPC_STAT(获取属性)、IPC_SET(修改权限)。 - 重要:
IPC_RMID只是标记删除,当所有进程都shmdt后,内核才真正释放该段内存。
4.3 基础例子与竞态条件问题
(原始未同步代码略)
⚠️ 严重缺陷:共享内存本身没有同步机制。多个进程同时读写会出现竞态条件,产生数据错乱。必须配合同步机制。
4.4 改进版:共享内存 + 命名管道同步
下面的设计实现了一个带访问控制的共享内存通信,用命名管道传递一个字节的“唤醒”信号,确保读写顺序。代码包含 Shm.hpp、Fifo.hpp、server.cc、client.cc。
🔧 Shm.hpp —— 共享内存封装
#pragma once
#include <sys/ipc.h> // ftok
#include <sys/shm.h> // shmget, shmat, shmdt, shmctl
#include <cstring>
#include <string>
#include <stdexcept>
class Shm {
public:
static constexpr int DEFAULT_SIZE = 4096;
static constexpr const char* CREATOR = "creator"; // 创建者角色
static constexpr const char* USER = "user"; // 使用者角色
// 构造函数:根据用户类型创建或获取共享内存,并挂接到进程地址空间
Shm(const std::string &pathname, int proj_id, const std::string &user_type, size_t size = DEFAULT_SIZE)
: _size(size), _user_type(user_type) {
// 生成唯一的key(需要pathname存在)
_key = ftok(pathname.c_str(), proj_id);
if (_key == -1) throw std::runtime_error("ftok failed");
if (_user_type == CREATOR) {
// 创建全新的共享内存:IPC_CREAT | IPC_EXCL 保证若已存在则失败
_shmid = shmget(_key, _size, IPC_CREAT | IPC_EXCL | 0666);
if (_shmid == -1) throw std::runtime_error("shmget create failed");
} else if (_user_type == USER) {
// 获取已存在的共享内存(不创建)
_shmid = shmget(_key, _size, 0666);
if (_shmid == -1) throw std::runtime_error("shmget get failed");
} else {
throw std::runtime_error("Invalid user type");
}
Attach(); // 挂接到当前进程
}
// 返回共享内存的虚拟地址指针
void* VirtualAddr() { return _start_mem; }
// 析构:脱离共享内存,如果是创建者则删除共享内存对象
~Shm() {
shmdt(_start_mem);
if (_user_type == CREATOR) {
if (shmctl(_shmid, IPC_RMID, nullptr) == -1)
perror("shmctl IPC_RMID");
}
}
private:
// 将共享内存挂接到进程地址空间
void Attach() {
_start_mem = shmat(_shmid, nullptr, 0);
if (_start_mem == (void*)-1) throw std::runtime_error("shmat failed");
}
int _shmid; // 共享内存标识符
key_t _key; // 唯一key
void* _start_mem; // 挂接后的虚拟地址
size_t _size; // 共享内存大小
std::string _user_type; // 角色:"creator" 或 "user"
};
🔧 Fifo.hpp —— 命名管道封装(用于同步信号)
#pragma once
#include <sys/stat.h> // mkfifo, umask
#include <fcntl.h> // open, O_RDONLY, O_WRONLY
#include <unistd.h> // read, write, close, unlink
#include <string>
#include <stdexcept>
class Fifo {
public:
// 构造函数:创建FIFO文件(仅当create==true时创建)
Fifo(const std::string &path, const std::string &name, bool create)
: _fifo_path(path + "/" + name) {
if (create) {
umask(0);
if (mkfifo(_fifo_path.c_str(), 0666) == -1 && errno != EEXIST)
throw std::runtime_error("mkfifo failed");
}
}
// 以只写方式打开管道(用于唤醒信号发送方)
void OpenForWrite() {
_fd = open(_fifo_path.c_str(), O_WRONLY);
if (_fd == -1) throw std::runtime_error("open for write failed");
}
// 以只读方式打开管道(用于等待唤醒信号)
void OpenForRead() {
_fd = open(_fifo_path.c_str(), O_RDONLY);
if (_fd == -1) throw std::runtime_error("open for read failed");
}
// 发送一个字节的唤醒信号
void Wakeup() {
char c = 'c';
if (write(_fd, &c, 1) == -1)
perror("Fifo::Wakeup");
}
// 等待唤醒信号(阻塞),返回true表示收到信号,false表示管道关闭
bool Wait() {
char c;
ssize_t n = read(_fd, &c, 1);
if (n == 1) return true;
if (n == 0) return false;
if (errno == EINTR) return Wait(); // 被信号中断,重试
perror("Fifo::Wait");
return false;
}
~Fifo() {
if (_fd >= 0) close(_fd);
}
private:
std::string _fifo_path;
int _fd = -1;
};
🔧 server.cc(服务端:创建共享内存 + 等待唤醒)
#include "Shm.hpp"
#include "Fifo.hpp"
#include <cstdio>
#include <string>
const std::string PATH = "./";
const std::string FIFO_NAME = "syncfifo";
const char* PATHNAME = "./shmfile"; // ftok 需要存在的文件
int main() {
try {
// 确保 ftok 使用的文件存在(临时创建)
FILE* f = fopen(PATHNAME, "w");
if (f) fclose(f);
// 1. 创建共享内存(作为创建者)
Shm shm(PATHNAME, 0x58, Shm::CREATOR);
// 2. 创建命名管道(作为创建者)
Fifo fifo(PATH, FIFO_NAME, true);
// 3. 以只读方式打开管道,等待客户端唤醒信号
fifo.OpenForRead();
char* mem = static_cast<char*>(shm.VirtualAddr());
// 循环等待唤醒信号,每次收到信号就打印共享内存中的内容
while (fifo.Wait()) {
printf("server received: %s\n", mem);
}
} catch (const std::exception& e) {
fprintf(stderr, "Server error: %s\n", e.what());
}
// 删除管道文件(由服务端负责)
unlink((PATH + FIFO_NAME).c_str());
return 0;
}
🔧 client.cc(客户端:获取共享内存,写入数据,唤醒服务端)
#include "Shm.hpp"
#include "Fifo.hpp"
#include <unistd.h>
#include <cstdio>
const std::string PATH = "./";
const std::string FIFO_NAME = "syncfifo";
const char* PATHNAME = "./shmfile";
int main() {
try {
// 1. 打开已存在的命名管道(非创建者)
Fifo fifo(PATH, FIFO_NAME, false);
fifo.OpenForWrite(); // 以写方式打开,用于发送唤醒信号
// 2. 获取已存在的共享内存(使用者)
Shm shm(PATHNAME, 0x58, Shm::USER);
char* mem = static_cast<char*>(shm.VirtualAddr());
// 3. 模拟写入数据,每次写入后唤醒服务端
for (char c = 'A'; c <= 'Z'; c += 2) {
sleep(1); // 间隔1秒
mem[0] = c; // 写入字符
mem[1] = '\0'; // 字符串结尾
printf("client wrote: %c\n", c);
fifo.Wakeup(); // 发送唤醒信号
}
} catch (const std::exception& e) {
fprintf(stderr, "Client error: %s\n", e.what());
}
return 0;
}
编译运行:
touch shmfile g++ -std=c++11 server.cc -o server g++ -std=c++11 client.cc -o client ./server & # 后台运行 ./client
4.5 补充:POSIX 共享内存(简要对比)
虽然本文主要聚焦 System V IPC,但作为对比,提一下 POSIX 共享内存(shm_open + mmap):
- 使用文件描述符,更像普通文件操作。
- 通过
ftruncate设置大小。 - 存在于
/dev/shm目录,命名规则不同。 - 生命周期同样随内核,需要手动
shm_unlink。 - 可移植性和现代接口更优,推荐在新项目中使用。
📨 五、System V 消息队列 —— 带类型的定向消息
5.1 核心系统调用
msgget() —— 创建/获取消息队列
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
- 内核行为:分配
struct msg_queue结构,包含消息链表、权限、队列大小等。返回用户态标识符msqid。
msgsnd() —— 发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
- 作用:将用户空间的消息拷贝到内核的消息队列中。消息以链表形式组织,按类型存放。
- 内核行为:如果队列已满(达到系统限制
msgmnb或总消息数限制),则阻塞直到有空间,除非指定IPC_NOWAIT。 - 消息格式:必须以
long mtype开头,后跟数据。msgsz是不包含mtype的数据长度。
msgrcv() —— 接收消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
- 内核行为:按
msgtyp规则查找第一个符合条件的消息,将数据拷贝到用户空间,然后从内核队列中移除该消息。 - msgtyp:>0 接收该类型的第一条消息;0 接收队列中第一条消息(任意类型);<0 接收类型值 ≤ |msgtyp| 的最小类型消息。
msgctl() —— 控制操作
IPC_RMID删除消息队列,立即唤醒所有阻塞的读者/写者,使其调用返回 -1(EIDRM)。
5.2 多客户端请求-响应示例(按类型区分)
下面是一个更经典的例子:多个客户端,每个客户端向服务端发送请求(type = 1),服务端处理后将响应发给对应的客户端(type = client_id + 100)。
common.h
#ifndef COMMON_H
#define COMMON_H
#define SERVER_TYPE 1 // 客户端请求消息的类型
#define BASE_REPLY_TYPE 100 // 响应消息的基准类型,实际使用 client_id + BASE_REPLY_TYPE
// 请求消息结构体(必须包含 long mtype 作为第一个字段)
struct req_msg {
long mtype; // 固定为 SERVER_TYPE
int client_id; // 客户端唯一标识(例如进程ID)
char text[200]; // 请求正文
};
// 响应消息结构体
struct resp_msg {
long mtype; // client_id + BASE_REPLY_TYPE
char reply[200]; // 响应正文
};
#endif
server.c(服务端:接收请求,按客户端ID返回响应)
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
#include <unistd.h>
#include "common.h"
int main() {
// 生成唯一的 key,使用 ftok
key_t key = ftok("./msgfile", 65);
if (key == -1) {
perror("ftok");
exit(1);
}
// 创建消息队列(如果不存在则创建,权限0666)
int msqid = msgget(key, IPC_CREAT | 0666);
if (msqid == -1) {
perror("msgget");
exit(1);
}
struct req_msg req;
struct resp_msg resp;
while (1) {
// 接收类型为 SERVER_TYPE 的请求消息(阻塞)
if (msgrcv(msqid, &req, sizeof(req.text), SERVER_TYPE, 0) == -1) {
perror("msgrcv");
break;
}
printf("Server: request from client %d: %s\n", req.client_id, req.text);
// 构造响应消息,消息类型为 client_id + BASE_REPLY_TYPE
resp.mtype = req.client_id + BASE_REPLY_TYPE;
snprintf(resp.reply, sizeof(resp.reply), "Hello client %d, your msg received", req.client_id);
// 发送响应(不阻塞,队列通常不会满)
if (msgsnd(msqid, &resp, strlen(resp.reply) + 1, 0) == -1) {
perror("msgsnd");
break;
}
}
// 删除消息队列(正常退出时清理)
msgctl(msqid, IPC_RMID, NULL);
return 0;
}
client.c(客户端:发送请求,等待自己的响应)
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
#include <unistd.h>
#include "common.h"
int main() {
key_t key = ftok("./msgfile", 65);
if (key == -1) {
perror("ftok");
exit(1);
}
// 获取已存在的消息队列(不创建,因为服务端已创建)
int msqid = msgget(key, 0666);
if (msqid == -1) {
perror("msgget");
exit(1);
}
// 使用进程ID的一部分作为客户端标识(简单起见)
int client_id = getpid() % 100;
struct req_msg req;
req.mtype = SERVER_TYPE;
req.client_id = client_id;
snprintf(req.text, sizeof(req.text), "Hello from client %d", client_id);
// 发送请求
if (msgsnd(msqid, &req, sizeof(req.text), 0) == -1) {
perror("msgsnd");
exit(1);
}
printf("Client %d: request sent\n", client_id);
// 接收自己的响应(消息类型 = client_id + BASE_REPLY_TYPE)
struct resp_msg resp;
if (msgrcv(msqid, &resp, sizeof(resp.reply), client_id + BASE_REPLY_TYPE, 0) == -1) {
perror("msgrcv");
exit(1);
}
printf("Client %d received: %s\n", client_id, resp.reply);
return 0;
}
编译:
gcc server.c -o server && gcc client.c -o client,先运行 server,再多次运行 client(可以开多个终端)。
🔐 六、System V 信号量 —— 进程同步/互斥利器
6.1 核心系统调用
semget() —— 创建/获取信号量集
int semget(key_t key, int nsems, int semflg);
- 作用:创建包含
nsems个信号量的集合。所有信号量初始值未定义,必须由semctl初始化。 - 内核行为:分配
struct sem_array,其中包含sem_base数组(每个信号量的值和等待队列)。
semop() —— 执行 P/V 操作
int semop(int semid, struct sembuf *sops, size_t nsops);
- 作用:对信号量集执行一组操作,这些操作是原子的(全部成功或全部阻塞)。
- 内核行为:遍历
sops数组,检查每个信号量的值是否满足操作(如 P 操作需值 ≥ |sem_op|)。若满足,则直接修改所有信号量的值;若不满足,则进程挂在该信号量的等待队列上,直到条件满足或收到信号。 sembuf结构:struct sembuf { unsigned short sem_num; // 信号量索引 short sem_op; // 操作值(-1为P,+1为V) short sem_flg; // SEM_UNDO, IPC_NOWAIT等 };SEM_UNDO:内核会记录进程对信号量的操作,当进程异常退出时自动反向执行(即释放其占用的信号量),防止因进程崩溃导致其他进程永久死锁。在生产者-消费者等关键场景中强烈推荐开启。
semctl() —— 控制与初始化
int semctl(int semid, int semnum, int cmd, ...);
SETVAL:设置单个信号量的值(初始化关键步骤)。GETVAL:获取当前值。IPC_RMID:删除信号量集。
6.2 经典生产者-消费者模型(使用 SEM_UNDO,带详细注释)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <sys/wait.h>
// 联合体,用于 semctl 初始化
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
// 初始化信号量集中指定索引的信号量值
void sem_init(int semid, int semnum, int val) {
union semun arg;
arg.val = val;
if (semctl(semid, semnum, SETVAL, arg) == -1) {
perror("semctl SETVAL");
exit(1);
}
}
int main() {
// 生成唯一的key用于共享内存和信号量
key_t shm_key = ftok("./semfile", 'A');
key_t sem_key = ftok("./semfile", 'B');
// 创建共享内存(存放一个整数,作为共享缓冲区)
int shmid = shmget(shm_key, sizeof(int), IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
int *p = (int*)shmat(shmid, NULL, 0);
*p = 0; // 初始化共享数据
// 创建信号量集:包含2个信号量
// sem[0]: 空槽数量(初始为1,表示缓冲区有一个空位)
// sem[1]: 产品数量(初始为0)
int semid = semget(sem_key, 2, IPC_CREAT | 0666);
if (semid == -1) {
perror("semget");
exit(1);
}
sem_init(semid, 0, 1); // empty = 1
sem_init(semid, 1, 0); // full = 0
pid_t pid = fork();
if (pid == 0) { // 子进程:消费者
struct sembuf wait_full = {1, -1, SEM_UNDO}; // P(full) :等待产品
struct sembuf signal_empty = {0, 1, SEM_UNDO}; // V(empty):释放空槽
for (int i = 0; i < 5; i++) {
semop(semid, &wait_full, 1); // 等待有产品
printf("Consumer got: %d\n", *p); // 消费数据
semop(semid, &signal_empty, 1); // 空槽数+1
sleep(1);
}
exit(0);
} else { // 父进程:生产者
struct sembuf wait_empty = {0, -1, SEM_UNDO}; // P(empty):占用空槽
struct sembuf signal_full = {1, 1, SEM_UNDO}; // V(full) :增加产品
for (int i = 1; i <= 5; i++) {
semop(semid, &wait_empty, 1); // 等待有空槽
*p = i; // 生产数据
printf("Producer put: %d\n", i);
semop(semid, &signal_full, 1); // 产品数+1
}
wait(NULL); // 等待子进程结束
// 清理资源
shmdt(p);
shmctl(shmid, IPC_RMID, NULL);
semctl(semid, 0, IPC_RMID);
}
return 0;
}
编译:
gcc producer_consumer.c -o pc && ./pc
📊 七、IPC 方案横向对比(含 POSIX IPC)
| IPC类型 | System V/管道 | POSIX 替代 | 通信范围 | 数据传输方式 | 同步机制 | 速度 | 生命周期 | 典型用途 |
|---|---|---|---|---|---|---|---|---|
| 匿名管道 | pipe |
- | 亲缘进程 | 字节流,无边界 | 读写阻塞 | 中等 | 随进程 | 父子进程简单通信 |
| 命名管道 | mkfifo |
- | 任意进程 | 字节流,无边界 | 读写阻塞 | 中等 | 文件系统节点持久 | 无亲缘进程字节流传输 |
| 消息队列 | msgget 等 |
mq_open 等 |
任意进程 | 带类型消息,有边界 | 收发阻塞 | 较慢 | 随内核 | 结构化消息定向收发 |
| 共享内存 | shmget/shmat |
shm_open/mmap |
任意进程 | 直接内存访问,无边界 | 无,需外部同步 | 最快 | 随内核 | 海量数据高速交换 |
| 信号量 | semget/semop |
sem_open 等 |
任意进程 | 不传输数据 | P/V 原子操作 | - | 随内核 | 多进程同步/互斥 |
补充说明:
- POSIX 消息队列支持消息优先级、异步通知,接口更现代。
- POSIX 共享内存通过文件描述符操作,可配合
mmap使用,更符合 Unix 哲学。 - System V 信号量集操作复杂,POSIX 信号量(命名/无名)更简单,适合线程。
🧠 八、内核如何管理 IPC 资源
8.1 内核数据结构
所有 System V IPC 对象都被内核统一管理,例如共享内存的 shmid_kernel 结构:
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // 权限、key、所有者
struct file *shm_file; // 共享内存的物理页面
unsigned long shm_nattch; // 挂接数
unsigned long shm_segsz; // 大小
time_t shm_atim, shm_dtim, shm_ctim;
pid_t shm_cprid, shm_lprid;
};
消息队列对应 struct msg_queue,信号量集对应 struct sem_array,均含有 kern_ipc_perm 用于权限管理。
8.2 常用管理命令
ipcs:查看共享内存-m、消息队列-q、信号量-s。ipcrm:ipcrm -m <shmid>删除共享内存。- 系统限制调整:
/proc/sys/kernel/shmmax、/proc/sys/kernel/msgmnb等。
8.3 资源泄漏与清理
- System V IPC 的生命周期随内核,进程退出不会自动销毁。必须手动调用
xxxctl(IPC_RMID)或ipcrm。 shmctl(IPC_RMID)只是标记删除,当最后一个进程shmdt后才释放物理内存。- 信号量删除时若有进程阻塞在上面,它们将被唤醒并返回错误(
EIDRM)。
🔧 九、调试技巧(实用工具)
-
strace跟踪系统调用strace -e trace=ipc,pipe,file ./your_program可以看到进程执行了哪些 IPC 相关的系统调用,参数和返回值,快速定位失败原因。
-
ipcs/ipcrm查看和清理残留资源ipcs -m # 查看共享内存 ipcrm -m <shmid> -
lsof查看进程打开的文件描述符lsof -p <pid>可以看到进程打开的所有文件、管道(FIFO)、共享内存映射等。
-
/proc文件系统cat /proc/<pid>/maps # 查看进程的内存映射(包括共享内存) ls -l /proc/<pid>/fd # 查看打开的文件描述符 -
valgrind --tool=helgrind检测多进程竞态条件(共享内存场景)
❗ 十、常见易错点与注意事项
- 管道未关闭多余端 →
read可能永不返回 0,必须关闭自己不需要的一端。 - 共享内存无同步 → 必须配合信号量、管道、文件锁等。
- System V IPC 资源泄漏 → 手动删除,或注册
atexit清理。 - 信号量初始值未设置 → 必须使用
semctl SETVAL初始化。 - 命名管道打开死锁 → 使用
O_NONBLOCK或严格约定打开顺序。 - 子进程回收 → 必须
waitpid防止僵尸进程。 - 共享内存大小对齐 → 建议 4096 整数倍。
- 信号中断 → 系统调用返回 -1 且
errno == EINTR时需重启调用。 ftok陷阱 → 路径必须存在且稳定,生产环境可硬编码 key。SEM_UNDO重要性 → 进程异常退出时可自动释放信号量,防止死锁。
📝 十一、总结与思考
本文从匿名管道出发,深入到命名管道、共享内存、消息队列、信号量,结合完整的可运行代码,并特别增加了每个阶段的核心系统调用解释,帮助读者理解用户态 API 背后的内核行为。通过进程池、共享内存同步、消息队列请求响应、生产者-消费者等实战案例,以及横向对比表格和易错点汇总,希望您能系统掌握 Linux 进程间通信。
思考题:
- 如何改造进程池,支持动态增减子进程数量?
- 如果不使用命名管道,改用 System V 信号量如何完成共享内存的读写同步?试写出代码框架。
- 消息队列与命名管道相比,在哪些场景下更有优势?请举例说明。
- 通过
/proc文件系统观察一个进程打开的文件描述符,能否找到管道或共享内存的痕迹?尝试解释。
最后送上一句:IPC 没有银弹,根据场景选择最合适的方案 —— 小数据用管道,大数据用共享内存,要同步用信号量,要结构化用消息队列。
如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我创作的最大动力! 😊
更多推荐

所有评论(0)