第四章:进程间通信(IPC)——让进程们好好说话

进程间通信,简称IPC,说白了就是让不同进程之间能交换数据。你想想看,操作系统里跑着那么多进程,要是各干各的、老死不相往来,那系统就成一盘散沙了。我做了这么多年系统调优,可以负责任地告诉你:选对IPC方式,性能能差出几个数量级。

这一章我们重点讲五种IPC机制:管道、命名管道、消息队列、共享内存、信号量。它们各有各的脾气,用对了是神器,用错了是坑。

核心观点:没有最好的IPC,只有最合适的IPC。共享内存最快,但同步最麻烦;管道最安全,但速度最慢。

进程间通信(IPC)知识体系 IPC 五种机制 管道 (pipe) 半双工,亲缘进程 命名管道 (FIFO) 任意进程,文件系统 消息队列 消息类型,异步 共享内存 最快,需同步 信号量 同步原语,计数器 选择原则:数据量小用管道/消息队列,数据量大用共享内存+信号量

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是流式通信,消息队列是带结构的通信,共享内存是零拷贝通信,信号量是同步通信。搞清楚了这些,你就能在项目中做出正确的选择。

可可精品资料库