第四章:进程间通信(IPC)——让进程们好好说话
进程间通信,简称IPC,说白了就是让不同进程之间能交换数据。你想想看,操作系统里跑着那么多进程,要是各干各的、老死不相往来,那系统就成一盘散沙了。我做了这么多年系统调优,可以负责任地告诉你:选对IPC方式,性能能差出几个数量级。
这一章我们重点讲五种IPC机制:管道、命名管道、消息队列、共享内存、信号量。它们各有各的脾气,用对了是神器,用错了是坑。
核心观点:没有最好的IPC,只有最合适的IPC。共享内存最快,但同步最麻烦;管道最安全,但速度最慢。
4.1 管道(pipe)——最朴素的通信方式
管道是IPC里最古老的方式,也是我最早接触的。它的设计思想很简单:一个进程往里写,另一个从另一头读。就像一根水管,水从一头进,另一头出。
特点:
- 半双工通信——数据只能单向流动
- 只能用于有亲缘关系的进程(比如父子进程)
- 数据是字节流,没有消息边界
我的经验:管道虽然简单,但有个坑——默认是阻塞的。如果读端关闭了,写端还在写,内核会发送SIGPIPE信号,进程直接挂掉。我曾经在线上环境遇到过这个问题,排查了半天才发现是读进程异常退出了。
#include <unistd.h>
#include <stdio.h>
int main() {
int fd[2];
pipe(fd); // fd[0]读端,fd[1]写端
if (fork() == 0) {
// 子进程:关闭写端,只读
close(fd[1]);
char buf[100];
read(fd[0], buf, sizeof(buf));
printf("子进程收到:%s\n", buf);
close(fd[0]);
} else {
// 父进程:关闭读端,只写
close(fd[0]);
write(fd[1], "Hello from parent!", 18);
close(fd[1]);
}
return 0;
}
4.2 命名管道(FIFO)——突破亲缘限制
管道只能用于父子进程,这限制太大了。命名管道(FIFO)就是来解决这个问题的。它通过文件系统中的一个特殊文件来通信,任何进程只要知道这个文件名,就能参与通信。
和管道的区别:
| 特性 | 管道(pipe) | 命名管道(FIFO) |
|---|---|---|
| 进程关系 | 仅亲缘进程 | 任意进程 |
| 持久性 | 随进程消失 | 文件系统中存在 |
| 创建方式 | pipe()系统调用 | mkfifo()系统调用 |
注意:FIFO的打开操作是阻塞的。默认情况下,open一个FIFO用于读会阻塞,直到有写端打开;反之亦然。我曾经见过一个新手写的程序,读写两端都用了O_NONBLOCK,结果数据全丢了——因为非阻塞模式下,读端没数据就直接返回了。
// 创建FIFO
mkfifo("/tmp/myfifo", 0666);
// 写进程
int fd = open("/tmp/myfifo", O_WRONLY);
write(fd, "data", 4);
close(fd);
// 读进程
int fd = open("/tmp/myfifo", O_RDONLY);
read(fd, buf, sizeof(buf));
close(fd);
4.3 消息队列——带标签的数据
管道和FIFO都是字节流,你没法区分一条消息的边界。消息队列就不一样了,每条消息都有类型和长度,接收方可以按类型读取。这在实际项目中太有用了。
核心API:
msgget()— 创建或获取消息队列msgsnd()— 发送消息msgrcv()— 接收消息(可按类型接收)msgctl()— 控制操作(删除、查询等)
性能提示:消息队列在内核空间维护,每次发送和接收都需要在内核态和用户态之间拷贝数据。如果消息体很大(比如超过4KB),性能会明显下降。我建议消息体控制在1KB以内,大块数据用共享内存。
struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[100]; // 消息内容
};
// 发送
struct msgbuf msg = {1, "hello"};
msgsnd(msgid, &msg, sizeof(msg.mtext), 0);
// 接收类型为1的消息
msgrcv(msgid, &msg, sizeof(msg.mtext), 1, 0);
4.4 共享内存——最快的IPC
说到性能,共享内存是当之无愧的冠军。它让多个进程直接映射同一块物理内存,数据不需要在内核和用户空间之间拷贝。说白了,就是大家直接读写同一块内存区域。
为什么快?管道和消息队列每次通信都要经过内核做数据拷贝,共享内存直接绕过了这一步。我做过测试,同样传输1MB数据,共享内存比管道快了将近100倍。
但是——共享内存有个大问题:同步。多个进程同时读写同一块内存,数据竞争是必然的。这时候就需要信号量来帮忙了。
// 创建共享内存
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
void *ptr = shmat(shmid, NULL, 0);
// 写数据
sprintf((char*)ptr, "Hello from process A");
// 读数据
printf("读到:%s\n", (char*)ptr);
// 分离
shmdt(ptr);
4.5 信号量——同步的守护者
信号量不是用来传数据的,它是用来保护共享资源的。你可以把它理解成一个计数器,P操作(申请资源)减1,V操作(释放资源)加1。当计数器为0时,P操作会阻塞。
两种信号量:
- POSIX信号量:简单,适合线程间同步
- System V信号量:功能强大,支持多个信号量操作,适合进程间同步
我的习惯:在项目中我倾向于用POSIX信号量配合共享内存。System V的信号量API太复杂了,而且清理起来容易留下垃圾。POSIX的命名信号量用sem_open/sem_close/sem_unlink,生命周期管理更清晰。
// POSIX信号量示例
#include <semaphore.h>
sem_t *sem = sem_open("/mysem", O_CREAT, 0666, 1);
// 临界区前
sem_wait(sem); // P操作
// 访问共享内存...
// 临界区后
sem_post(sem); // V操作
sem_close(sem);
sem_unlink("/mysem");
4.6 如何选择?——我的实战建议
说了这么多,到底该用哪个?我根据多年经验总结了一个简单原则:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 父子进程简单通信 | 管道 | 够用,简单 |
| 无关进程少量数据 | 命名管道或消息队列 | 灵活,有消息边界 |
| 大块数据(>1MB) | 共享内存 + 信号量 | 性能最优 |
| 需要按优先级处理 | 消息队列 | 支持消息类型过滤 |
| 多进程同步访问 | 信号量 | 专门干这个的 |
避坑指南:我曾经在一个项目中用了消息队列传大图片,结果性能惨不忍睹。后来换成共享内存+信号量,吞吐量直接翻了20倍。记住:数据量大就别走内核。
嗯,IPC这部分内容就这些。每种机制都有它的用武之地,关键是要理解它们的本质区别。管道和FIFO是流式通信,消息队列是带结构的通信,共享内存是零拷贝通信,信号量是同步通信。搞清楚了这些,你就能在项目中做出正确的选择。