Linux下跑多进程程序,第一道坎就是进程通信。我在一个并发任务分发模块里遇到过这样的事:主进程fork出几个工人进程后,父进程里改了变量,子进程完全看不到,反过来也一样。原因其实不复杂——每个进程都有独立的地址空间,你手里的指针在别的进程里就是一段无意义的数字。要想在两个进程之间传数据,非得借助内核提供的通道不可,这就是进程通信(IPC,Inter-Process Communication)存在的意义。这个名为 Linux-进程通信-01 的系列,我打算从管道开始,把匿名管道和命名管道讲透,后续再一步步展开消息队列、共享内存、信号量等机制。如果你在学Linux应用开发,或者要写多进程服务,这篇刚好能当你的第一级台阶。
先说一个整体感受:管道是IPC家族里最容易上手的一种,但别看它简单,里面涉及的fd管理、阻塞IO、信号处理,全是后面所有通信方式的地基。把这些细节吃透了,后面学什么都会快很多。
1. 进程隔离的事实:为什么需要通信
1.1 每个进程都有自己的独立沙盒
现代操作系统为了让多个进程能同时跑在机器上,给每个进程分配了独立的虚拟地址空间。什么意思?就是说进程A里的变量、指针、堆栈,在进程B里完全是不可见的。你在进程A里写int x = 1,进程B里根本不存在这个x,甚至进程A里某个指针指向的地址,在进程B的地址空间里可能对应的是完全不同的数据。
这个设计最大的好处是隔离性:一个进程崩了,不会直接拖垮其他进程。但坏处也显而易见——进程之间的数据共享变得非常别扭。你想让两个进程协作完成一件事,比如一个负责读文件、一个负责处理数据,那读文件这个进程得到的结果怎么交给处理进程?直接改全局变量肯定不行,因为fork出来的两个进程虽然有相同的代码段,但数据段是各自一份拷贝,父进程改了数据子进程不会感知到。
这就需要内核提供一套跨进程传送数据的机制。这些机制统称进程通信,它们的核心思路只有几类:要么通过内核缓冲区转手数据(管道、消息队列),要么直接映射同一块物理内存(共享内存),要么用信号通知事件(信号),要么通过网络协议栈中转(Socket)。
1.2 八种IPC手段一张表看清
很多资料会把IPC分成几大流派,我整理了一张表,把常见的八种方式放在一起对比,学习的时候心里有个全景图会踏实很多。
| 通信方式 | 传输形态 | 是否需要血缘关系 | 特点 | 常见场景 |
|---|---|---|---|---|
| 匿名管道 | 字节流 | 需要(父子或兄弟进程) | 单向、半双工、实现简单 | 父子进程交互、命令管道 |
| 命名管道FIFO | 字节流 | 不需要 | 借助文件系统路径,任意进程可访问 | 本机两个无关进程交换数据 |
| 信号 | 事件通知 | 不需要 | 异步、信息量极小、开销小 | 进程状态变更、ctrl+c这类通知 |
| 消息队列 | 结构化消息 | 不需要 | 有边界、带类型、内核管理 | 模块解耦、异步削峰 |
| 共享内存 | 内存块 | 不需要 | 性能最高,必须配合同步机制 | 大数据量高频传输 |
| 信号量 | 计数器 | 不需要 | 自身不传业务数据,只做同步互斥 | 临界区保护、共享内存配合 |
| Unix Socket | 字节流/数据报 | 不需要 | 本机跨进程,也支持全双工 | 本地服务间通信、取代FIFO |
| TCP/UDP Socket | 字节流/数据报 | 不需要 | 跨主机,走网络协议栈 | 分布式系统、远程调用 |
选型的时候主要问三个问题:数据量多大?进程有没有血缘关系?要不要跨机器?数据量小且进程有关联,用匿名管道最省事;数据量大又频繁,优先考虑共享内存;两个无关进程在本机互相传,FIFO或者Unix Socket都行;跨机器,那就只能走Socket。
2. 为什么从管道开始:管道在IPC里的定位
2.1 管道是最小可用的通信模型
我见过不少初学者一上来就奔着共享内存去,理由是它“性能最好”。但性能最好的方案往往也是最容易踩坑的方案,因为你还没理解同步、互斥、竞争条件这些东西,直接上手共享内存大概率会被各种诡异问题折磨。
管道不一样。它的模型非常简单:一头写、一头读,数据从写端进入内核的环形缓冲区,再从读端取出来。整个过程没有锁、没有共享内存映射、没有复杂的同步机制,你把一个管道跑通,就等于亲手验证了“内核作为中转站”这个最基本的IPC思路。
而且管道是很多命令行的底层实现方式。你平时敲ps -ef | grep java,那个竖线就是管道,Shell把左边进程的输出接到右边进程的输入。所以学会管道,你还能顺便理解Shell里一大票命令为什么能串联起来。
2.2 先搞懂四个底层概念
管道这一个例子,能牵出四个贯穿所有IPC的底层概念。
第一个是文件描述符。管道创建出来,系统给你两个fd,一个读一个写。fd的管理贯穿Linux所有IO操作,共享内存、消息队列虽然API不同,但很多也绕不开fd。
第二个是阻塞IO。read一个空管道会一直等,write一个满管道也会一直等。这种“等”的行为本身就是一个重要的设计点,也是后面做并发、做事件驱动必须面对的问题。
第三个是内核缓冲区。管道数据不是直接从一个进程的内存拷贝到另一个进程的内存,而是先写进内核缓冲区,再由读端读走。这个中转过程决定了管道的大小限制、原子性、性能边界。
第四个是信号。当你把管道读端关掉,写端再写入时,内核会向写进程发送SIGPIPE信号,默认动作是终止进程。信号本身也是IPC的一种,管道里先接触它,后面单独讲信号时就不会陌生。
这四个概念,每个都是Linux应用开发的高频考点,管道就像一串钥匙,一次全给你串起来了。
3. 匿名管道实操:从pipe()到父子进程通信
3.1 一次系统调用创建一对端点
匿名管道的入口是pipe()系统调用,原型很简单:
int pipe(int fd[2]);调用成功返回0,失败返回-1并设置errno。调用成功后,fd[0]是读端,fd[1]是写端。这个顺序是固定的,千万别搞反,很多新手第一次写代码就把数据往读端写,结果返回的是EBADF。
管道在Linux内核里本质上是一个环形的缓冲区,数据按先进先出的顺序流式传输。这里的“流式”后面会专门说,它意味着没有消息边界。默认缓冲区大小在不同内核版本里不太一样,常见的是64KB。你可以用fpathconf(fd[0], _PC_PIPE_BUF)查到单次写操作的原子边界值,这个值通常跨平台都是4KB。也就是说,如果多个进程同时往一个管道写数据,每个写操作只要不超过PIPE_BUF,内核会保证它不被其他进程的写入穿插,这个特性对多写者场景很重要。
3.2 fork之后必须做对的两处收尾
创建管道之后,管道只属于创建它的那个进程。要让父子进程都用上这个管道,必须fork()。fork出来的子进程会复制父进程的文件描述符表,也就是说父子进程手里都各有一份fd[0]和fd[1]。
问题来了:如果两个进程都同时持有读写两端,管道里就存在两个读端和两个写端。你父进程只打算写,那读端留着没用;子进程只打算读,那写端留着也没用。这里必须做两件收尾工作:
- 子进程关闭写端
close(fd[1]) - 父进程关闭读端
close(fd[0])
为什么要这么较真?因为管道对象只有在所有指向它的文件描述符都关闭后才会销毁,EOF的判定也是基于“写端是否全部关闭”。如果子进程不关写端,父进程写完数据并close了自己的写端后,管道里其实还有一个写端活着,子进程的read就永远等不到EOF,可能一直卡住。同理,父进程不关读端,管道里就一直存在读端,数据就算写进去也没人真正消费,写满之后双方就会死锁。
这一步是管道编程最容易忽略的点,我可以说,十个在这里卡住的初学者,九个都是因为fd没关干净。
3.3 最小可运行示例:父写子读
来看一个完整的C语言示例,父进程写一条消息,子进程读取并打印:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/wait.h> #define BUFFER_SIZE 128 int main(void) { int fd[2]; pid_t pid; char buf[BUFFER_SIZE]; if (pipe(fd) == -1) { perror("pipe"); exit(EXIT_FAILURE); } pid = fork(); if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { /* 子进程:只保留读端 */ close(fd[1]); ssize_t n = read(fd[0], buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("child received: %s\n", buf); } close(fd[0]); exit(EXIT_SUCCESS); } /* 父进程:只保留写端 */ close(fd[0]); const char *msg = "hello from parent"; write(fd[1], msg, strlen(msg) + 1); close(fd[1]); wait(NULL); return 0; }编译运行:
gcc -o pipe_demo pipe_demo.c ./pipe_demo输出:
child received: hello from parent注意我在写入的时候用了strlen(msg) + 1,也就是把字符串结尾的'\0'也写进了管道。这样接收方拿到之后就可以直接把缓冲区当C字符串用,不用自己手动补结尾。这个细节在真实项目里很重要,因为管道是字节流,不会自动帮你区分“这条消息到哪结束”。
还有父进程最后的wait(NULL),作用是等待子进程退出并回收它的状态。如果漏掉这行,子进程先退出后就会变成僵尸进程,占用一个进程表项,数据量大的时候积累起来很闹心。
4. 管道的读写边界与阻塞行为
4.1 四种边界场景一张表
管道之所以让很多人觉得“玄学”,是因为它的读写行为在不同边界条件下表现完全不同。我把四种典型场景整理成一张表,随时可以回来看:
| 场景 | 读端表现 | 写端表现 |
|---|---|---|
| 管道里没有数据 | read阻塞,直到有数据或写端全部关闭 | write正常写入 |
| 管道缓冲区已满 | read正常读取 | write阻塞,直到有空间腾出 |
| 读端全部关闭 | 无 | write触发SIGPIPE,默认终止进程 |
| 写端全部关闭 | read返回0,表示EOF | 无 |
这四行几乎涵盖了管道编程九成以上的疑难问题。尤其是后面两行,一个是崩溃元凶,一个是逻辑判断依据,一定要刻在脑子里。
4.2 用代码演示SIGPIPE与EPIPE
单独看一下“读端全部关闭”这个场景。默认情况下,如果进程忽略SIGPIPE信号,在写一个已经没有任何读端的管道时,write会返回-1,同时errno被设置为EPIPE。下面这段代码演示了这个过程:
#include <stdio.h> #include <unistd.h> #include <string.h> #include <signal.h> #include <errno.h> int main(void) { int fd[2]; pipe(fd); signal(SIGPIPE, SIG_IGN); /* 先忽略信号,观察write返回值 */ close(fd[0]); /* 关闭唯一读端 */ ssize_t n = write(fd[1], "hello", 5); if (n == -1) { printf("write failed, errno=%d (%s)\n", errno, strerror(errno)); } close(fd[1]); return 0; }运行结果:
write failed, errno=32 (Broken pipe)如果去掉signal(SIGPIPE, SIG_IGN),程序默认会被SIGPIPE直接终止,退出码通常是141。很多服务端程序写着写着忽然退出,查下来根本不是逻辑挂了,而是往一个已经不存在的客户端fd写数据,触发了SIGPIPE。生产环境里一个常见的做法就是忽略SIGPIPE,然后靠EPIPE判断链路是否断开,再做清理和重连。
读到返回值的场景也很好验证:父进程写完数据后关闭写端,子进程再调用read,返回0。注意,0不是“读到空”的意思,而是“所有写端都已关闭、不会再有新数据”的意思。这两个概念初学者经常混淆,你要用read的返回值来判断对端是否关闭,这是唯一的EOF标识。
4.3 非阻塞模式:让通信不再“死等”
阻塞是默认行为,但很多时候你不想让一个线程无限期等下去。比如你写了一个监控程序,要同时监听多个管道的消息,如果每个管道都用阻塞read,那第一个没有数据的管道就会把整个线程卡死,后面的管道全看不了。
解决办法是把fd设为非阻塞:
#include <fcntl.h> int flags = fcntl(fd[0], F_GETFL, 0); fcntl(fd[0], F_SETFL, flags | O_NONBLOCK);设置之后,读空管道会立即返回-1,errno是EAGAIN,写满管道也一样。这时候你再配poll()或select()监听这批fd,就能实现“哪个管道有数据就读哪个”的高效模型。这个思路和网络编程里的非阻塞IO一模一样,管道这块弄明白了,后面看epoll都会顺很多。
5. 命名管道FIFO:让无关进程也能通信
5.1 mkfifo与匿名管道的真正区别
匿名管道最大的限制是必须有共同的祖先,你不能在终端A里创建个管道,然后让终端B的进程往里面写。命名管道FIFO就是来解决这个问题的。
FIFO在文件系统里以一个特殊文件的形式存在,用ls -l可以看到类型字符是p。创建方式有两种:
mkfifo /tmp/msg.fifo或者在C代码里调用:
#include <sys/stat.h> if (mkfifo("/tmp/msg.fifo", 0644) == -1) { perror("mkfifo"); }mkfifo只是创建了一个特殊文件,真正在内核里创建管道对象是open()发生的时候。匿名管道用pipe()创建完直接用,FIFO则必须走open,这也是它和匿名管道最大的使用差异。
5.2 阻塞式open:先开的进程会原地等待
FIFO的open行为非常有意思。默认情况下,如果你用只读方式打开一个FIFO,open会阻塞,直到有一个进程以只写方式打开同一个FIFO;反过来,只写方式open也会阻塞,直到有进程以只读方式打开。
也就是说,FIFO不但是你传数据的通道,本身就是一个同步机制。你在终端A执行cat /tmp/msg.fifo,终端会一直挂着,看起来像卡住了,直到终端B有人往这个管道里写数据。这个“卡住”是正常的,它正好说明两边已经通过FIFO完成了握手。
如果不想让open阻塞,可以加O_NONBLOCK标志。注意,O_RDONLY | O_NONBLOCK的open会立即返回,但只写方式加O_NONBLOCK时,如果当前没有读端打开,open会失败并返回ENXIO。这个坑我在最初接触FIFO时踩过,大家用非阻塞方式写端时要格外小心。
5.3 双进程收发消息的完整示例
先看看用Shell怎么快速验证FIFO。打开两个终端:
终端A:
mkfifo /tmp/msg.fifo cat /tmp/msg.fifo终端B:
echo "hello from bash" > /tmp/msg.fifo终端A的cat会立刻打印出hello from bash,然后两端同时结束。
再看C语言版本。读端程序,我们叫 reader:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #define FIFO_PATH "/tmp/msg.fifo" int main(void) { int fd; char buf[128]; if (access(FIFO_PATH, F_OK) == -1) { if (mkfifo(FIFO_PATH, 0644) == -1) { perror("mkfifo"); exit(EXIT_FAILURE); } } fd = open(FIFO_PATH, O_RDONLY); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("reader got: %s\n", buf); } close(fd); return 0; }写端程序,我们叫 writer:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <string.h> #define FIFO_PATH "/tmp/msg.fifo" int main(void) { int fd; const char *msg = "hello from writer"; if (access(FIFO_PATH, F_OK) == -1) { if (mkfifo(FIFO_PATH, 0644) == -1) { perror("mkfifo"); exit(EXIT_FAILURE); } } fd = open(FIFO_PATH, O_WRONLY); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } write(fd, msg, strlen(msg) + 1); close(fd); return 0; }运行顺序是先启动reader再启动writer:
./reader # 这时reader阻塞在open,直到writer执行 ./writerreader会打印reader got: hello from writer。这个例子虽然小,但把“无关进程通过FIFO通信”的完整链路都走通了。你在两个不同的Shell里分别执行这两个程序,出门就能跟人说自己写过进程间通信了。
5.4 用后清理与半双工的约束
FIFO用完记得删文件,rm /tmp/msg.fifo或者程序里unlink()都行。不删也没大问题,但下次启动可能因为文件已存在而遇到逻辑冲突,CI环境里这种残留文件尤其讨厌。
另外要提醒一句:FIFO和匿名管道一样是单向的。如果想要两个进程双向收发,就得建两个FIFO,一个方向用一个。这样搞虽然能跑,但维护起来很别扭,真正的生产项目里更喜欢用Unix Domain Socket,它天然支持全双工,而且在本地传输性能也很优秀。至于Socket那套,本文不展开,后面单独写一篇。
6. 高频问题与排查实录
6.1 read卡死,先查fd泄漏
这是管道通信最常见的问题,没有之一。现象是:写端明明已经写完了,数据也close了,但读端还一直卡在read里不动。排查第一步不是翻业务逻辑,而是看进程手里到底握着哪些fd。
ls -l /proc/<pid>/fd/你会看到类似这样的输出:
lrwx------ 1 user user 64 Oct 1 10:00 0 -> /dev/pts/0 lrwx------ 1 user user 64 Oct 1 10:00 1 -> /dev/pts/0 lrwx------ 1 user user 64 Oct 1 10:00 3 -> pipe:[123456] lrwx------ 1 user user 64 Oct 1 10:00 4 -> pipe:[123456]注意看后面那个 pipe:[123456],如果一个进程对一个管道同时保留了读写两端,而它本来应该只用一端,那问题基本就出在这。多进程场景更要小心,某个子进程里残留了一个写端fd,其他进程的read就等不到EOF。解决办法也很简单:每个进程只保留自己需要的那个端,用完立刻close。
我在实际调试一个多子进程分发数据的模块时,就遇到过某个子进程迟迟不退出,最后查出来是它fork时继承了一个不该有的写端fd,导致整个管道的写端引用计数没归零。一行close就解决的事,硬是排查了两个小时。
6.2 进程莫名退出,八成是SIGPIPE
程序莫名其妙地退出,退出码如果是141(128+13),那基本可以断定是被SIGPIPE信号杀的。常见原因是写端在写一个已经关闭了读端的管道,这种场景在服务端程序里尤其频繁:客户端断开连接,服务端还在往socket里写数据,触发了SIGPIPE。
处理办法有两种:一是忽略信号,然后检查write返回值,遇到EPIPE做清理逻辑;二是在程序初始化阶段调用signal(SIGPIPE, SIG_IGN),让它不再影响主流程。我个人推荐第二种,配合在业务代码里判断EPIPE一起用。
signal(SIGPIPE, SIG_IGN);这一行写在服务端程序的主函数开头,能省掉很多半夜查问题的精力。
6.3 read返回0到底意味着什么
很多新手以为read返回0就是“管道里没有数据了”,这是个误解。管道里没有数据,read会阻塞;read能立即返回0,唯一的含义是所有写端都已关闭,不会再有任何数据到达。这是一个EOF标志,表示通信到此结束。
这个语义在写协议时非常关键。比如你是读方,想判断对端是否正常完成发送,不能单看一次read是不是0,而要确认所有写方都已经关闭了对应的fd。如果你在某个分支里还保留着一个写端fd,哪怕你根本不会再用它,对端也会永远等不到EOF。
6.4 消息粘包与自定义长度头
管道是字节流,没有消息边界。你写端连续发送两次"hello"和"world",读端一次read可能读到"helloworld",也可能只读到"hello"的一部分。这是流式传输的本质,不是bug。
解决思路是应用层自己定义协议。最朴素的方案是“长度头+数据体”:每条消息前4个字节表示长度,后面跟对应长度的字节。读端先读4字节得到长度,再循环读取直到凑够这个长度。写伪代码就是:
uint32_t len = htonl(msg_len); write(fd, &len, 4); write(fd, msg, msg_len);读端就反过来:先read 4字节,解析长度,再按长度把剩余数据读完整。这个方案虽然简单,却是很多实际通信协议的原型,HTTP的Content-Length也是这个思路。要注意长度头最好用固定字节序转换,避免跨机器解析出错。
6.5 用strace追踪一次完整通信
遇到诡异问题又不想改代码打日志,我推荐用strace直接看系统调用:
strace -f -e trace=read,write,close,open ./pipe_demo-f表示同时跟踪fork出来的子进程,-e trace只筛选这几个关键系统调用。输出里你能清楚看到每个进程的fd变化:哪个read阻塞了,哪个write返回了EPIPE,哪个fd在什么位置被close。很多时候,问题在strace下面一眼就能看出来,尤其是fd泄漏和close顺序错误,根本不需要猜。
7. 实操心得与系列后续安排
7.1 实测下来最管用的三个习惯
项目写得多了,管道这套东西用过无数遍,我总结出三个每次都能帮我省时间的习惯。
第一,写管道代码前,先在纸上画一条链路:谁创建、谁fork、谁持有哪个fd、什么时候close。画完之后再动手写代码,基本不会出现fd泄漏的问题。别看这个动作简单,它能帮你把“进程间的关系”具象化。
第二,每个进程只保留自己需要的一端。子进程如果只读,就确保它手里没有写端fd;父进程如果只写,就确保它手里没有读端fd。这个原则在匿名管道和FIFO里都适用。
第三,read返回值分三种处理:大于0是数据,等于0是EOF,小于0要查errno。擦是EAGAIN就非阻塞重试,是其他错误就该记录日志。把这三类情况分开处理,通信逻辑就会清晰很多。
7.2 下一篇要写什么
管道只是一个基础,但它教会我们的东西一点都不基础。管道里遇到的字节流无边界问题,在消息队列里会被直接解决——消息队列自带消息边界和类型字段;管道里遇到的性能天花板,在共享内存面前会被彻底打破——共享内存绕过了内核缓冲区拷贝,但代价是必须自己处理同步。
这个系列的第一篇到这里刚好收住。下一篇我打算把消息队列讲透,重点对比它和管道在可靠性、边界、异步性上的差异。先把管道练熟,后面的路会顺很多。