1. 先从"管道"说起:为什么进程间需要这条"水管"
如果你做Linux下的多进程开发,迟早会遇到这个需求:两个八竿子打不着的进程,一个负责生产数据,一个负责消费数据,它们既没有父子关系也没有共同的祖先,怎么把数据从A进程送到B进程?
匿名管道(pipe)只能解决一部分问题——它必须在fork之前创建好,因此只有父子进程或者有共同祖先的兄弟进程能拿到同一根管道。两个完全独立的服务进程,比如一个采集程序和一个告警程序,各自被不同的服务管理器拉起,互相不认识,匿名管道根本搭不上线。
命名管道(FIFO / Named Pipe)就是为了打破这个限制而生的。它通过一个真实存在于文件系统中的"路径名",把两个完全独立的进程牵到一起。任何进程,只要知道这个路径,愿意遵守同样的权限约束,就可以打开它、往里面写、从里面读,就像两个人在同一个邮箱里投递和收取信件。
这篇文章我会用最直白的方式,把命名管道的创建、打开、读写、清理讲透。这是这个系列的第一篇,先把地基打牢,后续再聊非阻塞、多路复用、双工通信等进阶话题。无论你是刚学系统编程的学生,还是在生产环境里排查IPC问题的工程师,读完应该都能直接上手。
命名管道在Linux里其实是个很巧妙的设计:它看起来是个文件,有路径、有inode、有权限位,但它又不是一个真正的文件。数据的流向、缓冲、阻塞行为,本质上是管道的那一套。把这两层身份分清,"叫什么管道""和普通文件有什么区别"这个问题就迎刃而解。
2. 认识核心API:从创建到读写全流程
2.1 两条创建路径:命令mkfifo与系统调用mkfifo()
命名管道在使用之前必须显式创建。创建方式有两种,一种是在命令行里敲,一种是在代码里调函数。
命令行方式最简单:
mkfifo /tmp/ipc_fifo执行完以后,用ls -l看一下这个文件,注意第一个字符是p,不是普通文件的-,也不是目录的d:
prw-r--r-- 1 user user 0 Jan 1 12:00 /tmp/ipc_fifo这个p表示它是一个FIFO,也就是命名管道。文件大小显示为0,这一点后面会专门讲——它不存数据,所以没有数据块大小。
程序方式则是调用系统接口:
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);第一个参数是路径,第二个参数是权限位,常见的是0664或者0600。如果文件已经存在,mkfifo会返回-1,errno被设置为EEXIST。实际开发里,经常是先调access检查是否存在,不存在才创建,避免每次都因为EEXIST报错。
注意:mkfifo创建出来的FIFO也会受umask影响。比如你写0644,但umask是0022,实际权限会变成0644 & ~0022 = 0644。如果不想被umask干扰,要么创建后主动chmod,要么在程序启动时临时把umask改掉再恢复。
2.2 打通读写的完整流程:从不存在的文件到数据流动
命名管道的完整生命周期包含四步:创建、打开、读写、关闭/清理。
第一步,创建通道,就是上面说的mkfifo,相当于在两个人的通信地址之间先铺好一根管子。
第二步,打开通道。这一步是命名管道最容易让新手懵圈的地方。你需要调用open,而且指定打开模式。open成功之后,你拿到的是一个普通的文件描述符,后面read、write、close都和操作普通文件完全一样。
第三步,读写数据。往fd里write,另一个进程从fd里read。数据的格式由你决定,是纯字节流,和匿名管道没有本质区别。
第四步,关闭并清理。close只是关闭当前进程对管道的引用。真正让这个FIFO从文件系统里消失,需要unlink或者rm。忘记清理的话,/tmp下会积累一堆没用的FIFO文件,虽然不占磁盘数据块,但会一直占用inode和目录项。
2.3 阻塞打开规则:开一条"水管"之前要先找到另一头
命名管道最反直觉的行为发生在open这一步。普通文件open一下就返回了,不管有没有人读,但FIFO不是这样。
如果进程以只读方式打开一个FIFO:
int fd = open("/tmp/ipc_fifo", O_RDONLY);这个open调用会阻塞,直到另一个进程以写方式打开同一个FIFO。换句话说,你在终端里执行cat /tmp/ipc_fifo,如果没有任何进程往这个管道里写数据,cat会一直卡在那里,像挂死了一样。
反过来也一样。以只写方式打开:
int fd = open("/tmp/ipc_fifo", O_WRONLY);如果没有任何进程以只读方式打开这个FIFO,open也会一直阻塞,等待读端出现。
这个设计的意义在哪里?管道是用来搬运数据的,光有单端连接没有意义。如果写端open成功但读端根本不存在,数据写进去无人接收,写端就成了一个往黑洞里倒水的工具。阻塞式open强制保证两端都就位了,通信才开始。这是命名管道和网络socket的一个关键区别:socket可以单端先listen或connect,而FIFO必须先完成读写端的配对。
Linux还提供了第三种打开方式O_RDWR,这个模式下open不会阻塞。但我不推荐在普通场景里用,因为它会破坏FIFO的EOF语义,读端永远等不到read返回0,逻辑容易写歪。后面第5章会再聊。
3. 手把手实操:用命名管道实现跨进程日志传输
3.1 场景设计:一个采集进程,一个监控进程
我模拟一个常见场景:有一个采集进程,不断产生监控日志;另一个监控进程,负责接收并打印这些日志。两个进程没有父子关系,各自独立启动。
目录结构很简单,就两个源文件,加一个由采集进程负责创建的FIFO。
先看负责收集数据的采集端代码,它相当于给管道灌水的角色:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <unistd.h> #define FIFO_PATH "/tmp/ipc_fifo_demo" int main(void) { // 管道不存在时创建,存在则直接使用 if (access(FIFO_PATH, F_OK) == -1) { if (mkfifo(FIFO_PATH, 0664) == -1) { perror("mkfifo"); exit(EXIT_FAILURE); } } int fd = open(FIFO_PATH, O_WRONLY); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } const char *messages[] = { "temperature: 36.5", "temperature: 36.8", "temperature: 37.1", "temperature: 36.9", }; int count = sizeof(messages) / sizeof(messages[0]); for (int i = 0; i < count; i++) { // 注意+1,把字符串末尾的 也写进去 ssize_t n = write(fd, messages[i], strlen(messages[i]) + 1); if (n == -1) { perror("write"); break; } printf("collector wrote: %s\n", messages[i]); sleep(1); } close(fd); return 0; }负责接收的监控端代码,是管道另一头的取水角色:
#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #include <unistd.h> #define FIFO_PATH "/tmp/ipc_fifo_demo" int main(void) { int fd = open(FIFO_PATH, O_RDONLY); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } char buf[128]; ssize_t n; printf("monitor waits for data...\n"); // 只要写端没全部关闭,read就会一直阻塞等数据 while ((n = read(fd, buf, sizeof(buf))) > 0) { printf("monitor received: %s\n", buf); } if (n == 0) { printf("all writers closed, out of data\n"); } else { perror("read"); } close(fd); return 0; }注意一点:监控端这里的read循环并不是理想的生产者消费者模型。它依赖"写端关闭"作为结束条件——采集进程只写固定的4条数据然后close,read就会返回0,循环退出。如果这个监控进程要长期服务,不能靠写端关闭退出,那就需要另想办法,比如写端不关、监控端不退出;或者做成可中断的循环,这个问题放到第5章的排查部分详细说。
3.2 运行过程与现象:先开读端还是先开写端
先把编译命令准备好:
gcc collector.c -o collector gcc monitor.c -o monitor然后在一个终端里先启动监控进程:
./monitor执行之后观察:程序卡住了,屏幕上只输出了一行monitor waits for data...,没有任何数据,因为open还没返回,它被阻塞在open调用那里,等待写端出现。
现在开另一个终端,启动采集进程:
./collector采集进程一启动,open瞬间返回,两边配对成功。紧接着监控端屏幕上开始滚动数据,每隔一秒出现一条。等采集进程把4条数据写完、关闭文件描述符,监控端的read返回0,打印出all writers closed, out of data,程序正常退出。
这个顺序反过来也可以。你先启动collector,它会阻塞在open(O_WRONLY)上;再启动monitor,open(O_RDONLY)返回,数据开始流动。无论哪边先启动,只要最终两端都到达open调用,阻塞就会解除。
这就是命名管道和socket最大的体验差异:你不需要知道对方的地址、不需要connect建立连接、不需要处理三次握手。双方的约定只有一个路径名,到了就能开闸放水。
3.3 用shell也能快速验证:一条命令行出魔术
如果你不想写C代码,shell命令行就能立刻感受FIFO的阻塞行为。
开两个终端。终端A执行:
cat /tmp/ipc_fifo_demo终端B执行:
echo "hello from shell" > /tmp/ipc_fifo_demo你会看到终端A立刻打印出hello from shell。因为终端B的echo写完后立刻关闭了写端,所以终端A的cat也会随即退出。
再试一次更直观的:终端A再次执行cat,这次不要开终端B,它就会一直卡住,像挂死了一样。这个"卡住"不是bug,而是FIFO在等另一端出现。你在生产环境里如果发现某个进程在打开FIFO时hang住,第一反应应该是:它配对的另一端进程是否真的启动了。
4. 深入内核:为什么命名管道能"隔空传话"
4.1 FIFO在VFS中的表示:伪装成文件的管道
前面说过,ls -l看到FIFO文件的类型是p,大小是0。这里面的原因值得拆开讲。
Linux一切皆文件,命名管道也是以文件的面目出现在文件系统中的。它拥有一个inode,拥有路径,拥有权限。但当我们打开它的时候,内核发现这个inode的类型是FIFO,于是不会像普通文件那样去磁盘块上读取数据,而是走管道的那套机制:在内核内存中创建一个环形缓冲区,把读端和写端通过这个缓冲区连接起来。
所以FIFO文件在磁盘上只有元信息,没有数据块。数据从write进入内核缓冲区,从read离开内核缓冲区,从未落到磁盘上。这也意味着:FIFO的传输速度只受内核缓冲区大小和调度影响,没有磁盘IO参与。
4.2 数据到底存在哪:一块内核托管的环形缓冲区
匿名管道和命名管道,在内核这一层用的是同一套pipe机制。它们都有自己的读端队列、写端队列,以及一块环形缓冲区。
有同学会问:缓冲区多大?默认的情况,管道的容量通常是16个内存页,也就是65536字节左右。但这个是整个管道可缓冲的总量,不是说你可以一次性write进去6万字节而不阻塞。write一个超过缓冲区剩余空间的大块数据时,可能部分写入、部分等待,和缓冲区的大小以及写入方式都有关系。
同时要注意,FIFO的数据是字节流,没有消息边界。你写入两条消息"ABC"和"DEF",对端read出来的可能是"ABCDEF"粘在一起,也可能是"ABC"和"DEF"分开,取决于一次read读走了多少。所以业务上要自己约定帧格式,比如每条消息以换行符结尾,或者用固定长度包头。这就是数据传输程序里最常见的"粘包"问题,FIFO一样存在。
4.3 原子性:PIPE_BUF与写入边界
FIFO虽然不保证消息边界,但在一个特定条件下可以保证写入的原子性,那就是PIPE_BUF。
PIPE_BUF是POSIX定义的一个常量,在Linux上通常是4096字节。规则是这样的:如果一个进程write的数据量不超过PIPE_BUF,那么这次write是原子的,要么完整地写入缓冲区,要么写完才返回,多个进程并发写同一个FIFO时,它们的小块写入不会互相交错。
举个例子,进程1写"AAAA",进程2写"BBBB",两个进程同时往同一个FIFO写而且都在PIPE_BUF以内,读端读到的不管是"AAAABBBB"还是"BBBBAAAA",一定是完整的两块数据,绝不会出现"AABBAA"这种交错碎片。
但如果write的数据量超过PIPE_BUF,内核可能把它拆成多次写入,这时候两个进程的写入就会交叉,对端读到的字节流顺序会乱。所以在多写者并发场景下,务必控制单次write不超过4096字节。
这个知识点在写日志类应用时非常关键。多个采集进程写同一个FIFO,每条日志凑成一行不超过4KB,读端按行切分,就基本不会遇到日志串行的问题。
4.4 边界行为:EOF、SIGPIPE与read返回0的语义
命名管道有几个典型的边界行为,必须刻在脑子里。
第一个是EOF。当FIFO的所有写端都被关闭之后,读端调用read会返回0,相当于读到了文件末尾。这是唯一一种"管道里没有数据但read主动返回"的情况。前面示例代码里的监控端就是靠这个退出循环的。如果你在读端关闭之后还坚持往FIFO里write,内核会向写进程发送SIGPIPE信号,默认行为是终止进程——这正是很多程序"莫名其妙被kill"的元凶。
第二个是缓冲区满。当写端写入速度大于读端的消费速度,缓冲区填满之后,write会阻塞。这是一个天然的反压机制:生产者的节奏被消费者的处理速度拖住。不懂这一点的人会在生产端加一堆线程试图"提高吞吐",结果线程全卡在write上。这时候正确的做法是加速消费端,或者增加中间缓冲层。
第三是并发打开。FIFO支持多个读端和多个写端同时打开。多个读者同时读,每条数据只能被其中一个读者拿走,不像TCP广播之类的概念。如果有多个消费进程挂在同一个FIFO上,它们会争抢数据,这是设计上要避免的。一般是一个FIFO只服务一组通信对端。
5. 常见问题与排查技巧实录
5.1 open一直卡住不动,进程hang死
这是新手遇到最多的坑。程序打开FIFO时阻塞,显得像死锁。
排查思路分三步:先确认FIFO文件本身存在;再确认是否真的启动了配对进程;最后看配对进程是否成功调用了open。
如果配对的进程迟迟没启动,解决办法是明确服务的启动顺序,或者在写入端用非阻塞模式打开,让open直接返回,再用后面的write来探测真正的连通性。
我个人习惯在真实项目里用一个技巧:open(FIFO, O_WRONLY | O_NONBLOCK),如果返回值是-1且errno是ENXIO,说明当前没有读者,可以明确地给出报错或进入重试逻辑,而不是无限期等下去。
5.2 数据"丢"了?读端时序与缓冲区清空
有人遇到这样的现象:采集进程先写了几条数据,监控进程后启动,结果前面的数据没读到。
原因很简单:如果第一个进程写完了所有数据然后write关闭,第二个进程才开始打开FIFO,那在open之前,先打开的那个写端可能已经写完并且关闭了。FIFO缓冲区的数据在最后一个写端关闭后并不会保留。这不像一个持久化队列,它只是一个临时管道,两头没同时接好,数据流就不成立。
正确的"不丢数据"姿势是保证读端先于写端打开,或者至少让写端在open成功之后才生产数据。说到这,前面阻塞式open的价值就体现出来了:如果你两个进程都用默认的阻塞模式,并且约定读端先启动,那第一批数据从写端open成功那一刻才开始产生,天然不会因为时序丢数据。
5.3 FIFO文件删除后还能用吗?清理与回收
FIFO文件是显式存在于文件系统中的,它不会因为进程退出而自动消失。你启动100次程序,如果没有unlink,/tmp下就会留下一个陈旧的FIFO文件。
第二次运行程序时,如果代码里有mkfifo,会因为EEXIST失败。很多程序图省事,创建前先unlink再mkfifo,但这样做有个隐患:如果另一个进程正打开着这个FIFO,你unlink掉路径名,那个进程手里的文件描述符仍然指向这个管道,数据照样能传,但新的进程已经无法通过路径打开它。这种情况下,旧进程和新进程等于被"路名"割裂了,排查起来很迷惑。
我建议的处理方式是:启动时用access检查,存在就复用,不存在就创建,不要盲目unlink重建。程序退出时,可以主动unlink掉自己创建的FIFO文件,但要确保所有使用者都已经释放了文件描述符。
5.4 双向通信怎么写?一个FIFO可不够
命名管道是半双工的,数据只能单向流动。如果进程A要发命令给进程B,进程B又要回状态给进程A,一个FIFO搞不定。
标准的做法是创建两个FIFO,一个管A到B,一个管B到A。命名上建议直接体现方向,比如:
- /tmp/fifo_request,A写、B读
- /tmp/fifo_response,B写、A读
两边各自的逻辑都要小心:A进程不要用同一个fd去读写两个FIFO,最好保持两个独立的fd,读归读、写归写。否则极容易在select/epoll的回调里搞混数据来源。
另一种做法是用open(FIFO, O_RDWR)在一个描述符上双向读写。但前面提过,这会破坏EOF语义,read永远等不到0,且一个管道里的数据流方向无法区分。在简单场景里可以用,涉及多消息负载、请求响应匹配时非常容易乱。推荐直接用双FIFO方案,清晰可靠。
5.5 非阻塞模式:什么时候用,什么时候别用
非阻塞打开要用O_NONBLOCK标志。它的行为与阻塞模式有明显的差异:
| 打开方式 | 没有读端时 |
|---|---|
| open(FIFO, O_WRONLY) | 阻塞等待读端出现 |
| open(FIFO, O_WRONLY | O_NONBLOCK) | 立即失败,返回-1,errno为ENXIO |
| 场景 | 推荐方式 |
|---|---|
| 简单的单向日志传输,双方独立启动 | 阻塞模式最省心,open的阻塞恰好可以保证两端配对 |
| 写端需要尽早返回、自行处理错误 | 写端用非阻塞+ENXIO判断 |
| 单线程程序要同时监听多个FIFO | 非阻塞+select/poll/epoll |
| 需要一个永不退出、持续服务的守护进程 | 配合信号处理或独立事件循环,不要靠read返回0来退出 |
非阻塞模式下,read和write在没有数据或缓冲区满时会立即返回-1,errno是EAGAIN。这需要配合事件循环使用,否则就变成忙轮询,CPU空转得厉害。常见的做法是用poll或epoll监视这个fd的可读/可写事件,有事件再操作,效率最优。
6. 我的一点点经验总结
命名管道这个东西,API倒背如流容易,真正在项目里用它不踩坑却需要一点积累。我自己的体会是,它适合那些"结构简单、方向明确、对延迟不敏感"的进程间通信。比如日志汇聚、简单的任务分发、配置下发,一个FIFO解决得很干净,比套socket轻量太多,比共享内存也不需要锁和同步机制。
但它不适合的场景也要心里有数:数据量大且突发性强、需要请求响应配对、多对多广播——这些场景要么会被管道缓冲和反压机制拖累,要么会被半双工和粘包问题缠住。真遇到这些需求,老老实实考虑消息队列或者socket。
最后分享一个小技巧:调试FIFO相关程序时,可以随时用cat /proc/<pid>/fdinfo/<fd>查看某个进程的文件描述符信息,配合lsof /tmp/ipc_fifo_demo查看当前谁打开了这个FIFO。这两个命令能把"谁在读写、谁卡在open、谁已经关闭"看得明明白白,比瞎猜快得多。等下一篇我们再展开非阻塞IO和多路复用的玩法。