1. 有名管道到底"有名"在哪:从两个进程传数据的纠结说起
两个独立进程之间要传一段数据,第一反应往往是上 socket,稍微懂点的会扯共享内存或者消息队列;但如果只是让一个采集端把日志丢给一个处理端,或者让一个控制脚本去唤醒一个常驻服务,把 socket 那套 listen/accept/三次握手搬出来就有点杀鸡用牛刀了。我最早接触有名管道,就是在这么一个"重方案用不上、又不想让两个进程硬耦合"的场景里——一个进程要通知另一个进程"该干活了",还顺带把几 KB 的参数递过去。当时试过写临时文件轮询,CPU 空转和文件残留两个毛病都很难看;试过用信号,结果只能传一个整数,还得处理信号丢失。最后落在有名管道上:它就是一个存在于文件系统里的文件,但读写走的是内核缓冲区,两个毫无血缘关系的进程打开同一个路径,就能像插了根管子一样对传数据。
这个标题里三个关键词——创建、基本读写、进程间通信——恰好是有名管道从"能看见"到"能用起来"的完整链条。创建解决的是"这东西在哪儿、叫什么";基本读写解决的是"打开它之后行为是什么样的、什么时候会卡住";进程间通信解决的是"两个进程怎么配合、谁先开谁后开、怎么收场"。我见过不少人在第一步就卡住:echo hi > /tmp/fifo敲下去,终端一动不动,以为程序挂了,其实是被内核按在那儿等读端。也见过有人在第三步翻车:两个进程互相等着对方先打开,造成死锁。这篇就把这条链从头到尾走一遍,顺带把那些文档里一句"blocking"带过、实际能把人关在门外半小时的细节全拆开。
1.1 匿名管道的那道坎:亲缘关系
在讲有名管道之前,得先把匿名管道的问题说清楚,不然你体会不到前者存在的必要性。pipe(int fd[2])创建出的管道,本质是一块内核缓冲区加两个文件描述符,数据从fd[1]进去、从fd[0]出来。问题在于这两个 fd 只能通过 fork 继承给子进程,父进程和子进程天然共享这对 fd,通信很顺畅;可你要跨到一个跟它没任何关系的第三方进程,就没法把它们"递"过去,除非用 UNIX 域套接字做 fd 传递,那就绕远了。
有名管道绕开这道坎的办法非常粗暴:把管道的入口挂在文件系统里。/tmp/myfifo这个路径就是一个公开的门牌号,任何进程只要知道这个路径、有权限打开它,就能接上这根管子。用stat看一眼会发现它是一个特殊的文件类型(S_IFIFO),ls -l的第一个字符是p,大小永远是 0——因为往里写的数据根本不落盘,全在内核的那块缓冲区里打转。
这个设计带来的直接好处是"解耦"。发数据的进程不需要知道接收方是谁、在不在线(只要不是严格同步模式),它只管往路径里写;接收方也不需要知道数据是谁发的。两者之间唯一的契约就是那个路径名。这在做工具链串联、做进程编排的时候特别省事,你完全可以让一个 Python 采集脚本往 FIFO 里吐数据,另一个 C 写的处理程序从里面读,语言、框架、启动顺序都不用对齐。
1.2 FIFO在文件系统里留下的那个inode
一个容易被忽略的事实是:有名管道在磁盘上确实占了 inode,但它不占数据块。mkfifo创建完之后du一下是 0,stat的st_size也是 0,你往里写 100 MB,它还是 0。这点跟普通文件完全不同,它的意义在于:内核只需要为它记录一个"管道对象"的引用,真正的数据通道是在open之后才建立的。
理解了这一点,很多事情就顺了。比如你在tmpfs上创建 FIFO,重启就没了;在 ext4 的普通目录上创建,重启之后文件还在,里面残留的数据却没了(数据本来就在内存里)。这也解释了为什么有些老系统上会出现"管道文件存在但读不到任何东西"的怪现象——文件是上次运行留下的,管道对象早销毁了,新开的进程接上去就是一根空的管子。所以我个人的做法是:启动时用mkfifo失败返回EEXIST就直接复用,但绝不复用里面可能残留的语义,应用层自己做好握手。
还有一点值得提:FIFO 的路径不能跨文件系统共享,它不像 socket 可以指向远端。它就是个本地节点,跨机器传输这件事从设计上就没考虑过。
1.3 一张表看清FIFO与匿名管道、普通文件、socket的边界
很多人选型时会在几者之间犹豫,我整理了一张对照表,基本覆盖日常判断的大部分情况:
| 维度 | 有名管道 FIFO | 匿名管道 pipe | 普通文件 | UNIX域socket |
|---|---|---|---|---|
| 是否需要亲缘关系 | 否 | 是 | 否 | 否 |
| 数据是否落盘 | 否 | 否 | 是 | 否 |
| 消息边界 | 无(字节流) | 无 | 无 | 有(可选) |
| 支持双向 | 否(单向) | 否 | 是 | 是 |
| 支持多写端 | 是(需注意原子性) | 是 | 是 | 是 |
| 典型延迟 | 极低 | 极低 | 较高(磁盘) | 低 |
| 需要清理 | 需要 unlink | 不需要 | 需要删除 | 需要 unlink |
从表里能看出 FIFO 的定位:单向、无边界、低延迟、零配置的本地字节流通道。它不适合传结构化消息(没有边界,读端可能读到半个包),也不适合需要回传应答的场景(单向)。但只要你的需求是"一方推、一方收、不求应答",它就是最轻的那把刀。真需要双向,就开两条 FIFO,一条上行一条下行,这也是很多守护进程的经典做法。
2. 创建FIFO的三种姿势与权限细节
2.1 mkfifo命令:调试脚本时最快的一把刀
命令行里的mkfifo是验证思路最快的方式。最基本的用法就一行:
mkfifo /tmp/demo.fifo ls -l /tmp/demo.fifo第二条命令的输出大概长这样:
prw-r--r-- 1 user group 0 Oct 10 10:00 /tmp/demo.fifo开头那个p就是它的身份标识。要指定权限,-m参数可以直接给八进制:
mkfifo -m 600 /tmp/secret.fifo这里有个细节值得说清楚:-m 600给的是"最终权限"还是"要经过 umask 过滤的权限"?实测答案是最终权限,mkfifo命令内部会先umask(0)再设权限,所以-m 600出来就是600。但如果你是自己在 C 代码里调mkfifo()系统调用,传的那个mode参数是要跟 umask 做与运算的。这个差异坑过不少人——命令行测试好好的,换到代码里权限就对不上,八成就是这个原因。
验证 FIFO 能不能用时,标准的双终端操作是这样的:终端 A 执行cat /tmp/demo.fifo,会立刻卡住(因为读端在等写端);终端 B 执行echo "hello" > /tmp/demo.fifo,终端 A 立刻打印hello,然后两条命令都返回。这个流程看起来简单,但它把 FIFO 最核心的两个行为都演了一遍:open 的配对阻塞和 write 到 read 的即时传递。
2.2 mkfifo()系统调用:C代码里的创建逻辑
系统调用原型在<sys/stat.h>里:
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);返回值 0 表示成功,-1 表示失败,具体原因看errno。最常见的两个错误码是EEXIST(路径已存在)和EACCES(父目录没写权限)或ENAMETOOLONG(路径太长)。实际工程里我一般这么写创建逻辑:
#include <stdio.h> #include <errno.h> #include <string.h> #include <sys/stat.h> #include <sys/types.h> int ensure_fifo(const char *path, mode_t mode) { struct stat st; if (mkfifo(path, mode) == 0) { return 0; /* 新建成功 */ } if (errno == EEXIST) { /* 已存在:必须确认它确实是 FIFO,而不是同名普通文件 */ if (stat(path, &st) == -1) { perror("stat"); return -1; } if (!S_ISFIFO(st.st_mode)) { fprintf(stderr, "%s 已存在但不是 FIFO\n", path); return -1; } return 0; /* 复用已有 FIFO */ } perror("mkfifo"); return -1; }这段代码里最关键的是那个S_ISFIFO检查。我见过一次线上事故,就是启动脚本mkfifo失败后直接忽略错误,结果路径上恰好是个上次留下的普通日志文件,程序往里面写数据,全落到磁盘上去了,读端自然一个字节也收不到,排查了两个小时才发现路径被占。忽略EEXIST而不校验类型,是个非常典型的懒写法。
2.3 mknod与权限位:为什么我看到的是0644减去umask
除了mkfifo,还有一个更低层的系统调用mknod,也能创建设备节点式的特殊文件:
mknod(path, S_IFIFO | 0666, 0);第三个参数是设备号,对 FIFO 来说恒为 0。功能上跟mkfifo(path, 0666)等价,但mknod能创建字符设备和块设备,权限也更危险,所以日常场景直接用mkfifo就好,mknod知道有这么回事即可。
权限这块再补一个实操点。mkfifo命令默认给的是0666,被 umask(通常是022)过滤后变成0644。也就是说默认情况下只有所有者能写,其他用户只读。如果你的通信双方是不同用户跑的进程,写端就会收到EACCES,报错信息也还算清晰(Permission denied),但如果你在容器里跑、umask 被设成077,那就变成0600,跨用户彻底玩不转。所以涉及跨用户的场景,我一般显式写明权限:
mkfifo -m 666 /var/run/app/channel.fifo用-m给最终权限,比去改 umask 更稳。当然666在生产上要慎用,配合目录权限做隔离更合适——FIFO 的访问控制主要靠父目录的权限,把目录设成750比给文件666更安全。
2.4 已经存在怎么办:EEXIST的正确处理姿势
服务端程序通常需要"启动即就绪",所以创建逻辑要幂等。上面那段ensure_fifo已经覆盖了大部分情况,但还有两个边角值得提。
第一,残留 FIFO 的"脏状态"问题。上次进程异常退出时,FIFO 文件留在了文件系统上。新进程复用它是没问题的,因为open之后会创建全新的管道对象,里面的旧数据本来也不存在(数据在内存里,进程死了就没了)。但如果有另一个进程还持有旧的 fd,你unlink再重建就会出现"两个不同管道共用同一个路径"的诡异情况——老进程还在读老管道,新进程读新管道,互相都看不见对方。所以清理逻辑要跟生命周期绑定,不能随手删。
第二,启动时到底该不该先 unlink。我倾向于不主动删,而是用EEXIST分支复用,同时约定清理责任归服务端:服务端启动时如果需要确保干净,先检查有没有进程在用(比如fuser或者自己的 pid 文件),确认没有才删。图省事直接unlink再mkfifo,在多实例并存或者重启窗口很短的场景里,很容易把正在收数据的客户端踢掉。
3. 读写行为全解:阻塞点到底卡在哪里
3.1 open()为什么会被挂住:内核那根"两端到齐"的标尺
FIFO 最容易让人懵的地方就是open。普通文件open立刻返回,FIFO 的open可能永远不返回。原因在于内核对 FIFO 的打开设了一条规则:读端的 open 要等到至少有一个写端打开才返回,写端的 open 要等到至少有一个读端打开才返回。两边都到齐了,两个open同时放行。
这条规则的设计意图挺好理解——它天然实现了一种同步。生产者不会在消费者还没起来的时候白写数据,消费者也不会在生产者没起来的时候读到 EOF 就直接退出。但用起来就是个大坑,因为你没法"先开一端看看情况"。
绕开它的办法有两个,都很实用。
办法一:用O_NONBLOCK打开读端。这样即使没有任何写端,open也会立刻成功返回,只是read会立刻返回-1并设置errno = EAGAIN。但注意,写端的O_NONBLOCK行为不一样:如果没有任何读端,open直接失败返回-1,errno = ENXIO。这就是两者不对称的地方,很多人只记住了一半。
办法二:先O_RDONLY | O_NONBLOCK打开,再用fcntl清掉O_NONBLOCK。这样open不阻塞,之后的read又是阻塞语义,兼顾了两边。这是很多服务端的标准写法:
int fd = open(path, O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return -1; } int flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); /* 恢复阻塞读 */还有个偏门写法是open(path, O_RDWR)。Linux 下它确实能成功且不阻塞,因为进程自己既是读端又是写端,"两端都到齐"的条件瞬间满足。但这是 Linux 的扩展行为,POSIX 没有定义,而且副作用很难受:因为写端永远存在(就是它自己),这个 fd 永远读不到 EOF,很多依赖 EOF 判断结束的逻辑会挂死。所以这个用法我建议只在明确的调试场景用,生产代码别碰。
3.2 read()的三种返回:数据、0和-1
read在 FIFO 上的返回值要分成三种情况看,这个分类是理解整个读写模型的钥匙。
返回值大于 0:读到了数据。注意 FIFO 是字节流,一次read读到的量完全取决于当时缓冲区里有多少。写端写了 100 字节,读端缓冲区给 4096,那一次读就是 100 字节;写端分两次写 50 字节,读端可能一次读到 100 字节,也可能分两次读——没有任何消息边界保证。这条是新手最容易误解的地方,以为"一次 write 对应一次 read",实际完全不是。要传结构化数据,应用层必须自己做长度前缀或者分隔符。
返回值等于 0:表示 EOF。触发条件很严格——所有持有写端 fd 的进程都关闭了写端,并且缓冲区里没有剩余数据。这两个条件缺一不可。缓冲区里还有数据的时候,即使写端全关了,read也会先把数据吐完,再返回 0。
返回值等于 -1:出错,看errno。阻塞模式下常见的是被信号打断(EINTR),非阻塞模式下是EAGAIN(暂时没数据)。EINTR这个要特别注意,如果你程序里有SIGALRM之类的定时器,read被反复打断是常态,不处理EINTR就会出现"读不到数据"的假象。
这里插一个真实踩过的坑:一个进程用fork起了子进程去处理数据,父进程负责写。结果父进程关了写端,读端却一直不返回 EOF。查了半天才发现,子进程 fork 的时候继承了父进程的写端 fd,虽然子进程根本不用它,但 fd 还开着,内核就认为"写端还有人在",EOF 自然不来。fork 之后一定要把不用的那一端显式关掉,这是使用管道类 IPC 的通用铁律。
3.3 write()的原子性与PIPE_BUF的4KB线
write这边最需要记住的是一个常量:PIPE_BUF。在 Linux 上它是 4096 字节(可以从<limits.h>里拿到,也可以通过fpathconf(fd, _PC_PIPE_BUF)查询)。
这条线划出了两种行为:
| 写入长度 | 缓冲区空间足够 | 缓冲区空间不足 |
|---|---|---|
| ≤ PIPE_BUF(阻塞) | 一次性写入,原子 | 阻塞等待足够空间 |
| ≤ PIPE_BUF(非阻塞) | 一次性写入,原子 | 返回 -1,errno=EAGAIN |
| > PIPE_BUF(阻塞) | 可能分多次写 | 阻塞直到全部写完 |
| > PIPE_BUF(非阻塞) | 返回实际写入字节数,可能不足 | 返回实际写入字节数,可能为 0 或部分 |
所谓"原子",指的是多个写端并发时,一次 ≤ PIPE_BUF 的写入不会被别的写端的数据插进来,要么整段在前,要么整段在后。一旦超过这条线,内核可能会把这次写拆成若干段,中间被其他写端插入数据,读端就会读到交错的内容。
所以如果你的场景是多生产者写同一个 FIFO,要么把单次写入控制在 4096 字节以内,要么自己在应用层加锁或者给每个生产者单独开一个 FIFO。我一般倾向后者:多生产者场景,与其纠结原子性,不如一人一根管子,读端用poll同时监听多个 fd,逻辑反而更干净。
还有一个容量问题:FIFO 的内核缓冲区默认是 64 KB(16 个内存页),可以通过fcntl(fd, F_SETPIPE_SZ, size)调整,上限由/proc/sys/fs/pipe-max-size决定,默认 1 MB。这个参数决定了"写端最多能领先读端多少"。缓冲区满了之后,阻塞模式的write就会卡住,直到读端消费出空间。如果你的生产者是突发型的,调大这个值能减少卡顿;但别指望靠它做持久化,进程一死,里面的数据全丢。
3.4 O_NONBLOCK下行为矩阵:一张表覆盖大部分疑问
非阻塞模式下的行为特别碎,我用一张表把常见组合收拢一下,排查问题的时候对着看,比翻文档快:
| 操作 | 状态 | 阻塞模式 | 非阻塞模式 |
|---|---|---|---|
| open 读 | 无写端 | 阻塞等待 | 立即成功 |
| open 写 | 无读端 | 阻塞等待 | 失败,errno=ENXIO |
| read | 有数据 | 返回数据 | 返回数据 |
| read | 无数据,写端在 | 阻塞等待 | 失败,errno=EAGAIN |
| read | 无数据,写端全关 | 返回 0(EOF) | 返回 0(EOF) |
| write | 有空间 | 写入 | 写入 |
| write | 无空间,读端在 | 阻塞等待 | 失败,errno=EAGAIN |
| write | 读端全关 | 触发 SIGPIPE | 触发 SIGPIPE |
最后一行那个 SIGPIPE 值得单独说。读端全部关闭之后,写端再write,内核会给进程发SIGPIPE信号,这个信号的默认动作是终止进程。也就是说你的程序会毫无征兆地死掉,退出码是 141(128 + 13)。如果不想被杀,有两个办法:全局signal(SIGPIPE, SIG_IGN)忽略它,这样write会返回-1并设置errno = EPIPE,你可以正常处理;或者用send的MSG_NOSIGNAL(但 FIFO 用不上,那是 socket 的)。我写任何涉及管道的代码,都会在main开头加一句忽略 SIGPIPE,这是成本最低的保险。
4. 两个无亲缘关系进程的完整通信链路
4.1 服务端与客户端的最小可跑Demo
光看理论容易虚,上一份可以直接编译跑通的代码。场景是:一个"采集端"进程不断把时间戳写进 FIFO,一个"处理端"进程读取并打印。两者毫无亲缘关系,分别启动。
先看写端:
/* writer.c —— 编译:gcc -o writer writer.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <time.h> #include <errno.h> #include <signal.h> #define FIFO_PATH "/tmp/demo_channel.fifo" int main(void) { signal(SIGPIPE, SIG_IGN); /* 防止读端退出后把自己写死 */ int fd = open(FIFO_PATH, O_WRONLY); /* 没有读端时会阻塞在这里 */ if (fd < 0) { perror("open for write"); return 1; } char buf[128]; for (int i = 0; i < 5; i++) { time_t now = time(NULL); int n = snprintf(buf, sizeof(buf), "tick %d at %ld\n", i, (long)now); if (write(fd, buf, n) != n) { perror("write"); break; } sleep(1); } close(fd); return 0; }读端:
/* reader.c —— 编译:gcc -o reader reader.c */ #include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <sys/stat.h> #define FIFO_PATH "/tmp/demo_channel.fifo" int main(void) { if (mkfifo(FIFO_PATH, 0666) == -1 && errno != EEXIST) { perror("mkfifo"); return 1; } int fd = open(FIFO_PATH, O_RDONLY); /* 没有写端时会阻塞 */ if (fd < 0) { perror("open for read"); return 1; } char buf[256]; ssize_t n; while ((n = read(fd, buf, sizeof(buf) - 1)) > 0) { buf[n] = '\0'; printf("[reader] %s", buf); fflush(stdout); } if (n < 0) perror("read"); printf("[reader] 写端已关闭,收到 EOF\n"); close(fd); unlink(FIFO_PATH); /* 清理责任放在读端 */ return 0; }跑的顺序是:先启动reader,它会因为open卡住;再启动writer,两边的open同时放行,数据开始流动;writer写完 5 条退出,关闭写端,reader读到 EOF 后退出并删掉 FIFO 文件。
这段代码刻意保留了阻塞式的open,因为它最贴近"两个进程天然同步"的直觉。但实际工程里我更推荐读端用O_NONBLOCK打开再切回阻塞,理由前面说过:如果客户端起得比服务端早,阻塞式open会让服务端启动时直接卡死,管理脚本会以为它挂了。
4.2 双向通信为什么必须两条FIFO
FIFO 是严格单向的。同一个 fd 上,O_RDONLY打开就只能读,O_WRONLY打开就只能写。想双向,标准做法是开两条 FIFO,一条上行一条下行:
#define FIFO_UP "/tmp/chan_up.fifo" /* 客户端 -> 服务端 */ #define FIFO_DOWN "/tmp/chan_down.fifo" /* 服务端 -> 客户端 */服务端打开顺序要注意:open(FIFO_UP, O_RDONLY)和open(FIFO_DOWN, O_WRONLY)。客户端则是反的:open(FIFO_UP, O_WRONLY)和open(FIFO_DOWN, O_RDONLY)。
但如果两边都用阻塞open且顺序一致,就会出现经典的打开死锁:服务端先阻塞在open(UP, O_RDONLY)等客户端写端,客户端先阻塞在open(UP, O_WRONLY)等服务端读端——这俩其实是配对的,会通过;然后服务端走到open(DOWN, O_WRONLY)等客户端的读端,客户端走到open(DOWN, O_RDONLY)等服务端的写端,恰好又配上了。所以顺序一致反而能过。
真正会死锁的是两边打开顺序相反的情况,或者一方用了非阻塞而另一方用了阻塞且没有协调好。最稳妥的方案是:双向场景下,两端统一先打开"上行"再打开"下行",或者干脆全部用O_NONBLOCK打开再逐个fcntl清标志,把配对的事情交给应用层的握手协议,别依赖open的阻塞语义。我后来所有的双向实现都改成后者,因为一旦加进第三个进程或者想动态重连,阻塞open的配对关系就彻底失控了。
4.3 退出与清理:谁负责unlink
FIFO 文件不会因为进程退出就自动消失,它一直躺在文件系统上。所以必须有一个明确的责任人负责unlink。我的经验是把它绑在生命周期最长的那一端——通常是服务端:服务端启动时创建,退出前删除。客户端只负责打开,不创建也不删除。
这么做有个好处:客户端可以很早就启动,发现 FIFO 不存在就重试(或者等着),而服务端的创建/清理时机可控。如果让客户端负责创建,多个客户端并发创建会有一堆EEXIST需要处理,还得小心 A 客户端把 B 客户端正在用的 FIFO 删了。
清理的时机也有讲究。如果服务端要重启,unlink之后立刻mkfifo,中间有个极短的时间窗,此时客户端可能打开失败。所以更好的做法是用信号处理 + 优雅退出:收到SIGTERM之后先关闭 fd,等到确认没有客户端在用(或者干脆等一小段时间),再unlink。粗暴地在atexit里unlink会导致程序崩溃时来不及清理,残留文件留给下次启动的EEXIST分支处理,这也没什么问题。
4.4 用select/poll把单条FIFO接进事件循环
单条 FIFO 用阻塞read就够了,但一旦程序里还要处理别的 fd(比如另一个 socket、一个定时器 fd),就必须把 FIFO 塞进poll或者epoll里。FIFO 是完全支持poll的,这一点很多人不知道,以为只有 socket 能 poll。
#include <poll.h> struct pollfd pfd = { .fd = fifo_fd, .events = POLLIN, .revents = 0 }; int ret = poll(&pfd, 1, 1000); /* 1 秒超时 */ if (ret > 0) { if (pfd.revents & POLLIN) { /* 有数据可读,或者对端全部关闭(此时 read 会返回 0) */ } if (pfd.revents & POLLHUP) { /* 写端全部关闭 */ } }这里的关键是:POLLIN在有数据可读时置位,在写端全关时也会置位,此时read返回 0。如果代码只判断POLLIN就无脑read,遇到 EOF 会陷入"读了 0 字节,poll 又立刻返回就绪"的忙循环,CPU 直接飙到 100%。所以必须配合POLLHUP判断或者检查read返回 0 时主动移除 fd。这个忙循环坑我踩过至少两次,症状是进程 CPU 拉满但日志里一条数据都没有,第一次查了半天以为是数据源的问题。
5. 踩坑实录:我在FIFO上栽过的几次
5.1 打开顺序不对导致的双向死锁
最早做双向通信时,我沿用了"服务端先开读、客户端先开写"的直觉,结果两边都卡住了。用strace一看,两个进程都停在open系统调用上,谁也没往前走。原因就是我前面说的配对关系没对上:服务端在open(DOWN, O_WRONLY)等读端,客户端在open(UP, O_WRONLY)等读端,两个都在等,谁也不给。
排查这类死锁最快的办法是strace -p <pid>或者cat /proc/<pid>/stack,一眼就能看到卡在哪个open上。修复方案我最后选了"全部非阻塞打开 + 应用层握手":
/* 统一流程:非阻塞打开两个 fifo,再恢复阻塞 */ int up = open(FIFO_UP, O_RDONLY | O_NONBLOCK); int down = open(FIFO_DOWN, O_WRONLY | O_NONBLOCK);注意O_WRONLY | O_NONBLOCK在没有读端时会直接返回ENXIO,所以写端的打开要带重试:
int open_writer_retry(const char *path, int max_retry) { for (int i = 0; i < max_retry; i++) { int fd = open(path, O_WRONLY | O_NONBLOCK); if (fd >= 0) { int flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); return fd; } if (errno != ENXIO) return -1; usleep(100 * 1000); } return -1; }这段重试逻辑看着笨,但它把"对端可能还没起来"这个现实问题显式化了,比依赖内核的阻塞语义靠谱得多。
5.2 写端全关但读端读不到EOF的怪异现象
这个坑前面提过一次,值得展开。现象是:写端进程明明退出了,读端的read却一直不返回 0,要么阻塞着,要么读到一阵空。用lsof /tmp/xxx.fifo一查,会发现有第三个进程持有写端 fd。
最常见的三个来源:多线程程序里某个线程 fork 出来没关 fd、程序自己复制了 fd 忘记关(dup之后要关两份)、systemd 或 shell 把 fd 传递给了子进程。尤其是最后一种,在写启动脚本的时候特别容易中招——你用&后台启动一个进程,shell 会把一些 fd 继承下去。
排查手法很直接:ls -l /proc/*/fd/ 2>/dev/null | grep fifo,把所有持有该 FIFO 的进程列出来,逐个看是谁。定位到之后,在对应位置加close(),或者在fork之后立刻把不需要的 fd 关掉。我现在写管道的代码,fork之后的第一条语句永远是关闭本进程不需要的那一端,这是肌肉记忆了。
5.3 残留数据与旧管道文件导致的"幽灵消息"
有一回测试环境出现"读到重复消息"的现象。查下来是两个原因叠加:其一,测试脚本里mkfifo失败被忽略,路径上是个上次留下的 FIFO;其二,上次的进程还没完全退出,还持有旧管道的 fd,新进程和它共用同一个路径名却处在两个不同的管道对象上。
这个问题的根子在于FIFO 文件的存在性和内核管道对象的生命周期是分离的。文件在,管道对象可能早没了;文件被删了再重建,旧 fd 依然指向旧对象。所以我的处理原则是:每次启动都要么复用一个确认干净的 FIFO,要么显式 unlink 并保证没有老进程在用。判断"有没有人在用"可以用fuser命令:
fuser /tmp/demo_channel.fifo有输出说明有进程持有,没输出才安全。这个检查加在启动脚本里,成本很低,能省掉一堆诡异的调试时间。另外,如果消息本身有粘性(比如状态同步),应用层一定要做去重或者带序号,别指望管道"传一次就一次"。
5.4 SIGPIPE:进程莫名其妙被杀掉
这个是新手最常遇到的"程序无声无息退出"。症状是:程序跑得好好的,读端一退出,写端进程直接消失,日志里什么都没留下,退出码 141。
原因就是前面说的,写端向已关闭读端的管道write会收到SIGPIPE,默认动作是终止进程。修复只需要一行:
#include <signal.h> signal(SIGPIPE, SIG_IGN);加完之后write会返回-1,errno = EPIPE,你可以打日志、可以重连、可以退出,总之可控了。这行代码我建议放在main函数的第一行,任何涉及管道、套接字的程序都值得加。有人担心忽略 SIGPIPE 会掩盖问题,其实不会——EPIPE是显式的错误码,比被信号杀掉清楚多了。
顺带一提,SIGPIPE在SIG_IGN状态下还能被子进程继承,但fork + exec之后就重置了,所以如果是启动子进程的场景,父进程忽略并不能保护子进程,得在子进程里再设一次,或者在exec之前恢复默认。这个细节大多数人不知道,我是在一次守护进程带子进程的场景里踩出来的。
6. 工程化使用建议:什么时候该用FIFO,什么时候别用
6.1 适合FIFO的三类场景
第一类是命令行工具串联。比如一个采集程序持续输出 JSON 行,一个消费程序逐行处理,中间用 FIFO 连起来,两个程序可以独立启动、独立重启,比写一个大的管道命令灵活得多。这种场景数据的产生是流式的,不需要严格的请求-应答。
第二类是进程唤醒与简单同步。往 FIFO 里写一个字节,读端读到就表示"有新任务了"。这种用法比信号可靠(不会丢),比条件变量跨进程简单(不需要共享内存)。我做定时任务触发器的时候常用这招,控制脚本往 FIFO 写一个字节,守护进程读到一个字节就扫一遍任务目录。
第三类是轻量级单向日志转发。程序把结构化日志写进 FIFO,一个独立的收集进程读取并落盘或转发。好处是收集进程挂掉不会阻塞业务(配合O_NONBLOCK和丢弃策略),业务代码里也不需要考虑网络重连这些破事。但要注意缓冲区满了之后写入会阻塞或者失败,所以这种场景下写端一定要用非阻塞模式并在失败时丢弃,不能因为日志把主业务卡住。
6.2 FIFO撑不住的场景与替代方案
FIFO 的短板也很明确,我列几个明确不该用的场景。
需要消息边界和请求应答的。FIFO 是字节流,没有边界,也不方便做多路复用应答。这种场景直接上 UNIX 域套接字(AF_UNIX),它支持SOCK_SEQPACKET保边界,也支持双向,还能传 fd。代码量增加有限,但语义清楚太多。
多生产者高频并发的。虽然 ≤PIPE_BUF的写是原子的,但一旦有并发写、数据量大,就得自己处理交错和背压。这种场景要么一人一根 FIFO,要么换成消息队列。
需要跨机器或者需要持久化的。FIFO 只在本机文件系统上,数据只在内存里,进程一死全丢。需要跨机器就得走网络,需要持久化就得落盘或者用带持久化的队列。
对端可能长时间不在线的。阻塞式open会让你的进程卡死,非阻塞式的重试逻辑写起来也不轻松。这种异步场景用 socket 配合epoll反而更自然。
一个粗略的判断原则:FIFO 适合"两端都在、单向、流式、不求应答"的短路径通信。只要需求偏离这几点,就值得重新考虑方案。
6.3 一组可以直接抄的封装骨架
反复写过几次之后,我把常用逻辑收敛成了一个小封装,这里给出关键接口设计,你可以照着改成自己项目里的样子:
| 函数 | 作用 | 关键点 |
|---|---|---|
fifo_create(path, mode) | 幂等创建 | 处理 EEXIST,校验 S_ISFIFO |
fifo_open_reader(path, nonblock) | 打开读端 | 非阻塞打开后可选切回阻塞 |
fifo_open_writer(path, timeout_ms) | 打开写端 | 带重试,处理 ENXIO |
fifo_read_full(fd, buf, len) | 按需读满 | 处理 EINTR 和部分读 |
fifo_writer_safe(fd, buf, len) | 安全写 | 处理 EPIPE、部分写 |
fifo_cleanup(path) | 清理 | 检查无使用者后再 unlink |
写端打开那一条最值得封装,因为ENXIO重试逻辑散落在业务代码里特别难看。读端的fifo_read_full也要写好循环:
ssize_t fifo_read_full(int fd, void *buf, size_t len) { size_t got = 0; char *p = buf; while (got < len) { ssize_t n = read(fd, p + got, len - got); if (n < 0) { if (errno == EINTR) continue; if (errno == EAGAIN) break; /* 非阻塞模式,有多少给多少 */ return -1; } if (n == 0) break; /* EOF */ got += n; } return (ssize_t)got; }注意这个函数在非阻塞模式下会"读多少算多少",而不是死等凑满,这是因为管道本身不保证消息边界,非要凑满反而可能永远凑不满。真要按固定长度解析,得自己在应用层维护一个缓冲区,把读到的碎片拼起来,这是另一个话题了。
6.4 性能与调试:strace、/proc下的观察点
调 FIFO 问题,几个工具基本能覆盖九成场景。
strace -e trace=open,openat,read,write,close -p <pid>能看到进程在哪个调用上卡住,是open阻塞还是read没数据,一目了然。注意strace会拖慢程序,生产环境慎用,测试环境随便玩。
ls -l /proc/<pid>/fd/能看到进程打开了哪些 fd,配合readlink能确认某个 fd 指向哪个 FIFO。查"谁还持有写端"的时候,把这条命令在可疑进程上跑一遍,比猜快得多。
cat /proc/sys/fs/pipe-max-size看缓冲区上限,fcntl(fd, F_GETPIPE_SZ)看当前缓冲区实际大小。如果是写入频繁卡顿的场景,先看这两个值,很多时候调大缓冲区就顺手了。
性能上,FIFO 的延迟确实低,一次write+ 一次read的往返基本就是两次系统调用的开销,几十微秒量级。但它只在单机有效,而且每次传输都要过内核缓冲区拷贝一次,数据量特别大的时候(比如几百 MB 的文件传输)就不是它的用武之地了,那种场景应该考虑共享内存或者直接走文件。我的经验是:单条消息在几十字节到几 KB 之间、QPS 在几千以内,FIFO 完全够用;再往上就得测一测了。
最后分享一个我自己的使用习惯:所有涉及 FIFO 的代码,我都会在启动时打三行日志——FIFO 路径、当前进程的 pid、以及是读端还是写端。这三行看似废话,但在多方联调、进程重启、清理残留的时候,能省掉大量"到底是谁先起来的"这类扯皮。管道这种老东西,功能几十年没变过,坑也几十年没变过,把这几条守住,基本就不会出大事。