前阵子在调试一个内部采集工具时,遇到一个很典型的通信需求:进程 A 负责从硬件读取状态数据,进程 B 需要拿到这些数据去做解析和入库。两个进程之间没有父子关系,它们由不同的服务拉起,不能继承文件描述符。一开始我自然想到用普通管道,但很快发现这条路走不通。后来换成 FIFO(命名管道),问题才真正解决。
和其他“看起来更现代”的 IPC 方式相比,FIFO 显得有点老、有点朴素。但正因为朴素,它的行为反而很好理解:先在文件系统里创建一个名字,再让两个完全无关的进程通过这个名字见面。如果你在写 Linux 下的多进程程序,尤其是想避开 Socket、不想引入消息队列和共享内存那套复杂机制时,FIFO 往往是性价比最高的第一选择。它真正解决的,不是“更快地传数据”,而是“没有血缘关系的进程,如何稳定地碰头”。
这篇文章不会只讲函数原型。我会先用一张图把数据流讲清楚,再给出可直接编译的最小示例,然后从源码层面拆解 mkfifo、open、read、write 的联动逻辑,最后用一段真实的日志透传场景总结出常见坑和排查路径。希望读完之后,你对 FIFO 的理解不再停留在“多了一个 p 类型文件”的层面。
1. 没有名字的管道,跨不过父子进程这道墙
1.1 普通管道能用,但有个隐藏前提
很多人第一次接触进程间通信,是用 Shell 里的竖线:
ps aux | grep nginx这条命令能起作用,是因为ps和grep是由同一个 Shell 进程 fork 出来的子进程,它们共享了启动时创建的文件描述符。管道的读写两端,在 fork 之前就准备好了,子进程带着继承下来的描述符开始干活。
这段关系里藏着一个很容易被忽略的前提:两个进程必须有血缘关系,或者至少处于同一个祖先进程的继承链上。一旦程序变成了守护进程,或者由不同的服务管理器拉起来,双方便没有共同的父进程,也没有办法在 fork 前提前创建管道。这时候普通管道就失效了。
很多入门资料会告诉你 “pipe 系统调用用于进程间通信”,但没强调这个血缘限制。到了真实业务里,需要通信的两个模块往往互不相识:一个埋在硬件采集层,一个在业务控制层,中间隔着 systemd、脚本和一堆资源回收逻辑。想在它们之间拉一条匿名管道,甚至不知道该在哪个进程里先执行pipe()。
1.2 FIFO 把“继承”换成了“路径访问”
FIFO 解决这个问题的方式很直接:不再依赖 fork 时的描述符继承,而是把管道挂到文件系统里。它有一个路径名,比如/tmp/my_fifo,任何进程只要对这个路径有足够的访问权限,就可以通过open()打开它,像打开一个普通文件那样开始读写。
关键点是,这个“文件”并不是磁盘上的真实数据存储,而是一个内核缓冲区。写入 FIFO 的数据由内核维护,读取进程拿走之后,缓冲区便被清空。它看起来是一个文件节点,实际是一条数据通道。
所以,FIFO 的第一个价值不是性能,而是“命名”。它把进程间通信从“我们来自同一个家族”变成了“我们认识同一个路径”。这正是它在大量工程场景里到今天仍然没有被淘汰的原因。
2. 一张图看懂 FIFO:文件系统里的单向数据通道
2.1 先建立整体数据流认知
在写代码之前,先建立一个整体画面。一个 FIFO 通道的典型结构长这样:
+-------------+ +----------------------------+ +-------------+ | 写端进程 | ---> | FIFO 文件节点 /tmp/my_fifo | ---> | 读端进程 | | open(O_WRONLY) | write | 内核缓冲区 | read | open(O_RDONLY) | +-------------+ +----------------------------+ +-------------+这里的核心特征是:数据是单向流动的。写端写进去,读端读出来,像一根水管。如果你想实现双向通信,需要创建两个 FIFO,各自负责一个方向。
另外要注意,那个“FIFO 文件节点”在ls -l下,文件类型会显示为p,而不是普通的-或者目录的d。它不是正则文件,你无法cat一个 FIFO 的文件内容,因为cat会尝试打开它并阻塞在读取上。
2.2 关键区别在于打开方式
普通文件打开后,读写是自由的。FIFO 不同,它的打开行为受到另一种规则的约束:
- 以只读方式打开 FIFO 时,如果当前没有写端打开这个 FIFO,
open默认会阻塞,直到一个写端出现。 - 以只写方式打开 FIFO 时,如果当前没有读端打开这个 FIFO,
open默认也会阻塞,直到一个读端出现。
这个行为和普通管道非常像:管道必须有读端和写端,数据才能流动。只开一端,另一端不存在,操作就无法继续。这个特性是 FIFO 最容易让新手困惑的地方,也是后面排查问题时最重要的一条线索。
3. 动手写最小示例:先跑通一次写入和读取
3.1 命令行验证:mkfifo 和 cat 的配合
不需要先写 C 代码,用命令就能理解 FIFO 的行为。打开两个终端。
在终端一里创建 FIFO:
mkfifo /tmp/demo_fifo ls -l /tmp/demo_fifo如果系统创建成功,你会看到类型是p:
prw-r--r-- 1 user user 0 ... /tmp/demo_fifo在终端一里执行:
cat /tmp/demo_fifo这个终端会一直挂着,因为内核知道目前没有写端打开这个 FIFO,cat的open会被阻塞。
在终端二里写入:
echo "hello fifo" > /tmp/demo_fifo此时,终端一里会立即打印出hello fifo。而终端二的命令结束,因为管道另一端的读端已经关闭,写操作的完成条件被满足了。
这个演示可以让我们直观体会到open的阻塞规则。同时也暴露了一个问题:FIFO 不能像普通文件一样被反复打开、写一次就完了。它的生命周期由读端和写端的“配对”决定。
3.2 C 语言版本:写端与读端最小实现
命令行演示只能覆盖最简单的场景。真正要在工程里用,还是得回到 C 代码。下面给出一个最小但完整的示例,包含写端和读端。
先看 write_b.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <sys/stat.h> #include <unistd.h> #include <errno.h> #define FIFO_PATH "/tmp/demo_fifo" int main(void) { int fd; char *msg = "hello from writer"; // 如果 FIFO 不存在则创建;如果已存在则忽略失败 if (mkfifo(FIFO_PATH, 0666) < 0 && errno != EEXIST) { perror("mkfifo"); exit(EXIT_FAILURE); } // 以只写方式打开,可能阻塞,直到有读端打开 fd = open(FIFO_PATH, O_WRONLY); if (fd < 0) { perror("open"); exit(EXIT_FAILURE); } write(fd, msg, strlen(msg) + 1); close(fd); return 0; }再看 read_b.c:
#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <sys/stat.h> #include <unistd.h> #include <errno.h> #define FIFO_PATH "/tmp/demo_fifo" int main(void) { int fd; char buf[256] = {0}; ssize_t n; if (mkfifo(FIFO_PATH, 0666) < 0 && errno != EEXIST) { perror("mkfifo"); exit(EXIT_FAILURE); } // 以只读方式打开,可能阻塞,直到有写端打开 fd = open(FIFO_PATH, O_RDONLY); if (fd < 0) { perror("open"); exit(EXIT_FAILURE); } n = read(fd, buf, sizeof(buf)); if (n > 0) { printf("read %zd bytes: %s\n", n, buf); } close(fd); return 0; }编译后先运行 read_b,再运行 write_b,你会看到读端从 FIFO 里拿到了写端发来的字符串。如果顺序反过来,先运行 write_b,它也会阻塞在open上,直到读端出现。
3.3 三种阻塞行为,必须亲眼确认
源码本身不难,难在对阻塞行为的理解。实际运行过程中,建议把下面三种情况都试一遍:
- 只有读端打开,没有写端,看看
open卡在哪里。 - 只有写端打开,没有读端,看看
write或者open的行为。 - 两端都打开,读端先 close,写端再 write,看看会不会收到
SIGPIPE。
第 3 种情况最常见。一个健壮的程序通常要处理SIGPIPE,或者用send/write时检查错误码为EPIPE。否则,当读端意外退出时,写端可能默默被杀掉,甚至没有任何日志。
4. 源码背后:mkfifo、open、read/write 的联动机制
4.1 mkfifo 不是普通文件,它创建了一个 IPC 入口
先看mkfifo这个函数。它的原型在<sys/stat.h>中:
int mkfifo(const char *pathname, mode_t mode);功能是创建一个 FIFO 特殊文件。mode参数指定权限,例如0666表示读写权限开放给所有者、组和其他用户。但要注意,创建时实际的权限还会受到umask的影响。换句话说,0666只是请求权限,不是最终权限。
FIFO 文件一旦创建,就会一直存在,除非你手动删除。这就是它和匿名管道最大的不同。匿名管道是进程退出后自动消亡,FIFO 则是一个文件系统对象,需要你显式管理生命周期。很多服务运行几个月后,/tmp里躺着一堆没人清理的 FIFO 文件,就是因为在代码里只做了创建,没有做退出清理。
4.2 O_NONBLOCK 开关决定了 open 和 read 的边界行为
open的默认行为是阻塞的。但你可以传入O_NONBLOCK,让打开和读写都变成非阻塞模式。此时规则会发生变化:
- 只读方向打开 FIFO 时,即使没有写端,
open也会立即成功。 - 只写方向打开 FIFO 时,如果没有读端,
open会立即失败,并返回ENXIO错误。
这个不对称很容易把人绕晕。为什么只读可以立即成功,只写却不行?原因是,写数据到没有读者的管道里没有意义,内核干脆不让你打开;而一开始没有写者时,读者可以空等,所以允许打开。
O_NONBLOCK带来的另一个变化是read和write不再长时间阻塞。比如读端以非阻塞方式打开 FIFO 后,如果缓冲区里没有数据,read会立即返回-1,并设置errno为EAGAIN。这就让 FIFO 有可能被放进多路复用循环里,比如用select或poll监控它的可读事件。
4.3 原子写限制:超过 PIPE_BUF 就可能有串包风险
管道和 FIFO 的内核缓冲区有大小限制,在 Linux 里通常可以用:
ulimit -a或者其他更底层的接口查看。但更关键的是PIPE_BUF,它定义了一次写入操作是否具备原子性:
- 如果写入的字节数不超过
PIPE_BUF,内核保证这次写操作是原子的,不会和其他进程的写操作交错。 - 如果超过
PIPE_BUF,那么多个写进程同时往 FIFO 里写时,数据可能被拆散、交叉,像多个并发线程写同一个文件一样。
所以,如果程序里有多个写端同时写入,最好的方式是把每次消息控制在PIPE_BUF以内,同时约定消息边界。不要以为 FIFO 会像 socket 长连接一样自动帮你拆包,它本质上是字节流,不维护消息边界。
5. 实战场景:用 FIFO 搭建一个轻量日志透传通道
5.1 为什么适合日志流,不适合复杂请求响应
我实际用 FIFO 处理过一个相对简单的需求:业务进程不停输出结构化状态数据,另一个后台进程需要消费这些数据并做本地聚合。这个场景有两个特点:
- 数据是单向的:业务进程只写,消费进程只读。
- 消费频率低于生产频率:需要缓冲区,允许短暂堆积。
FIFO 天然适合。它不需要像 Socket 那样处理连接建立、断线重连、半开连接,也不需要像消息队列那样引入额外的守护进程。两个进程只要约定好同一个路径名,就能把一条流接起来。
但它不适合复杂的请求响应式通信。因为 FIFO 是单向且无连接语义的,如果你要发送一条请求后等待对方回答,就需要额外创建第二条 FIFO,还要自己处理请求 ID 的对应关系。这比直接使用 Unix 域套接字麻烦得多。
5.2 一种可落地的行协议设计
要让 FIFO 真正可用,数据格式必须提前约定。我建议在早期就引入“行协议”,也就是每条消息以换行符结束。这样接收方每次用read读取数据后,再按\n切分成若干条消息,放入缓冲区继续处理。
一个简单的消费示例可以这样写:
// 伪代码,演示按行处理 char tmp[4096]; while (1) { ssize_t n = read(fd, tmp, sizeof(tmp)); if (n > 0) { // 处理字节流,按 '\n' 分割出完整消息 } else if (n < 0 && errno != EAGAIN) { break; } }这里的重点是:不要假设一次read就是一条完整消息。FIFO 是字节流,可能一次读到了半条消息,也可能一次读到了多条消息。处理方式和 Socket 编程很相似,需要自己维护消息缓冲区和边界解析。
5.3 进程退出和文件清理的工程细节
生产环境里,FIFO 的清理往往比创建更重要。建议在程序启动时做三件事:
mkfifo返回EEXIST时,先确认路径对应的文件类型确实是 FIFO,而不是普通文件或目录。- 程序退出时显式调用
unlink(FIFO_PATH),避免残留文件被其他进程误打开。 - 对打开后的文件描述符设置
FD_CLOEXEC,避免exec后描述符意外泄漏给子进程。
很多人会忽略第一点。如果之前一次运行不小心创建了普通文件,后续再启动时open就变成了打开一个普通文件,行为会和 FIFO 完全不一样,排查起来非常隐蔽。
6. 常见故障与排查链路:FIFO 用起来挂住怎么办
6.1 先分现象:是 open 挂起,还是 read 挂起,还是写端被杀
遇到问题不要急着改代码,先确定卡在哪一个环节。常见现象有三种:
- 程序启动后就停着不动,大概率是
open在等待对端。 - 程序能启动,但一直收不到数据,可能是
open成功了,但read端没人写,或者写端被阻塞。 - 写端进程直接消失,而且没有任何输出,可能是读端关闭后写端触发
SIGPIPE。
用strace跟着系统调用看一遍,是最直接的定位方式。例如:
strace -f -e trace=open,read,write ./read_b你能看到open是否卡住,以及read是否返回了EAGAIN或0。
6.2 定位手段:ls、strace、lsof 的组合使用
先把文件类型和权限确认一遍:
ls -l /tmp/demo_fifo输出应以p开头。如果看不到p,说明路径上是其他文件,open行为已经改变。
再查一下谁打开了这个 FIFO。lsof通常能显示:
lsof /tmp/demo_fifo如果你能看到读端和写端的进程,那么阻塞大概率发生在read或write阶段,而不是open阶段。如果只能看到一端,说明对端还没有成功打开。
6.3 按输入、阻塞、权限、清理四个方向排查
建议按下面的顺序检查:
- 输入是否正确:
FIFO_PATH是否一致,有没有多写或少写一个/。 - 阻塞原因:是否默认阻塞模式,对端有没有正常打开;如果期望非阻塞,是否设置了
O_NONBLOCK。 - 权限问题:用户能否访问该路径,
umask有没有把创建权限压掉。 - 清理问题:上一次运行有没有留下退出未清理的 FIFO,或者意外创建成普通文件。
这四个方向基本能覆盖 90% 的“FIFO 莫名挂住”问题。如果都检查过了,再用perf或内核事件来查,但那种情况已经非常少见了。
7. FIFO 不是银弹:什么时候应该换成 Unix 域套接字
7.1 用对比表看清边界
FIFO 的最大优势是简单、直接,但简单也意味着它在能力边界上非常清晰。下面这张表是我做选型时常用的对比:
| 维度 | FIFO | Unix 域套接字 | 共享内存 |
|---|---|---|---|
| 是否需要文件系统节点 | 是 | 是 | 否,但需要额外同步机制 |
| 是否支持双向通信 | 否,需要两条 FIFO | 是,一条 socket 即可 | 手动实现 |
| 消息边界 | 字节流,需自己定义协议 | 默认字节流,可用 SOCK_SEQPACKET 保留边界 | 内存布局自己负责 |
| 连接管理 | 无,两端 open 即可 | 有 connect/bind/listen 流程,语义更完整 | 无连接概念 |
| 并发多客户端 | 不太适合,靠多进程读会有竞争 | 非常合适,天然面向连接 | 多进程访问需要锁 |
| 复杂度 | 低 | 中 | 高 |
从这张表可以看到,FIFO 适合“极简的单向管道”,Unix 域套接字适合“需要双向交互、多客户端、连接管理”的场景。
7.2 三种替代方案适合的场景
如果遇到下面几类需求,建议直接换方案:
- 需要请求 / 响应模型:用 Unix 域套接字的
SOCK_STREAM或SOCK_SEQPACKET,语义更自然。 - 需要高性能大块数据交换:用共享内存配合信号量或原子操作。
- 需要跨网络通信:FIFO 只能本机,此时只能考虑 TCP 或 Unix 域套接字转发。
FIFO 的真正主场是:本地、单向、低频、少量数据、进程之间没有复杂协作关系。比如配置下发、日志透传、状态同步、简单命令通知。
7.3 是否需要“双向”“多客户端”“消息边界”是关键判断
我在选型时会问自己三个问题:
- 双方需要相互对话吗?如果需要,FIFO 会让我为双向通信搭建两套路径,还不如直接上 socket。
- 会有多个写者或读者吗?如果会,FIFO 的原子写限制和字节流特性会带来额外协议成本。
- 消息边界重要吗?如果想保留“一条条完整消息”,
SOCK_SEQPACKET或消息队列比 FIFO 更容易实现。
如果这三个问题答案都是否,用 FIFO 就是合理的;只要有一个答案是是,就应该继续想想其他方案。
8. 沉淀一套自己的 IPC 选型框架
8.1 从五个问题判断是否选 FIFO
每次做多进程通信设计时,我都会先过一遍这张问题清单:
- 两个进程是否本机,且不跨网络?
- 数据流是否基本单向?
- 消息是否足够小,能控制在
PIPE_BUF以内? - 是否需要长期连接管理、断线重连、多客户端?
- 能否在代码里严格管理文件生命周期和权限?
如果前三个是“是”,后两个是“否”,FIFO 就是一个很稳的选择。否则,请谨慎。
8.2 先跑通,再考虑异常、权限和生命周期
工程上我通常会采取“三步走”:
- 先用命令行
mkfifo配合cat验证路径、权限和基本流程。 - 再用 C 代码写最小读写端,把阻塞行为摸清。
- 最后才把异常处理、
unlink、O_NONBLOCK、信号处理补上。
不要一上来就写一个复杂的“FIFO 服务类”。先把最小流程跑通,你才能真正理解哪一步是阻塞的、哪一步是异步的、哪一步会退出。过早抽象只会让问题更难排查。
8.3 一次踩坑后的经验清单
如果你准备把 FIFO 放进真实项目,请把下面几条记在笔记里:
mkfifo之后先检查返回值,EEXIST不代表路径一定可用。- 默认阻塞模式下,
open可能一直等对端,最好在日志里打一句“before open”和“after open”。 - 每次
read不等于一条消息,必须自己维护缓冲区。 - 写端如果直接忽略
SIGPIPE,很容易出现“进程突然消失但没日志”的情况。 - FIFO 文件不会自己消失,程序退出前必须
unlink。
老工具不意味着过时。FIFO 在 Linux 的 IPC 工具箱里,就像一把精度不高但永远锋利的小刀。它没法帮你解决复杂架构问题,却能在不需要重型机制时给你一条干净利落的通道。下一次在进程协作上卡住,先别急着把 Socket 拉进来,想想这条命名管道,也许答案比你想象得更简单。