Linux进程通信之管道详解:匿名管道与FIFO实战
发布时间:2026/10/21 6:03:21 锦皓数字建站

Linux下跑多进程程序第一道坎就是进程通信。我在一个并发任务分发模块里遇到过这样的事主进程fork出几个工人进程后父进程里改了变量子进程完全看不到反过来也一样。原因其实不复杂——每个进程都有独立的地址空间你手里的指针在别的进程里就是一段无意义的数字。要想在两个进程之间传数据非得借助内核提供的通道不可这就是进程通信IPCInter-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字节流不需要借助文件系统路径任意进程可访问本机两个无关进程交换数据信号事件通知不需要异步、信息量极小、开销小进程状态变更、ctrlc这类通知消息队列结构化消息不需要有边界、带类型、内核管理模块解耦、异步削峰共享内存内存块不需要性能最高必须配合同步机制大数据量高频传输信号量计数器不需要自身不传业务数据只做同步互斥临界区保护、共享内存配合Unix Socket字节流/数据报不需要本机跨进程也支持全双工本地服务间通信、取代FIFOTCP/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, errno32 (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);设置之后读空管道会立即返回-1errno是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行为非常有意思。默认情况下如果你用只读方式打开一个FIFOopen会阻塞直到有一个进程以只写方式打开同一个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。打开两个终端终端Amkfifo /tmp/msg.fifo cat /tmp/msg.fifo终端Becho 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程序莫名其妙地退出退出码如果是14112813那基本可以断定是被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 traceread,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 下一篇要写什么管道只是一个基础但它教会我们的东西一点都不基础。管道里遇到的字节流无边界问题在消息队列里会被直接解决——消息队列自带消息边界和类型字段管道里遇到的性能天花板在共享内存面前会被彻底打破——共享内存绕过了内核缓冲区拷贝但代价是必须自己处理同步。这个系列的第一篇到这里刚好收住。下一篇我打算把消息队列讲透重点对比它和管道在可靠性、边界、异步性上的差异。先把管道练熟后面的路会顺很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。