1. 为什么进程间通信绕来绕去还是要回到有名管道
先说清楚这篇内容讲什么:有名管道(named pipe,也叫 FIFO),Linux 和类 Unix 系统里最古老、最朴素、也最容易被低估的一种进程间通信手段。它能让两个毫无亲缘关系的进程——不是父子、不共享内存、甚至不知道对方 PID——通过文件系统上的一个路径名建立一条单向数据通道。会用到它的场景非常实在:本地两个服务之间传一段配置、一个采集程序把文本流喂给另一个处理程序、脚本和后台守护进程之间搭一条最简单的命令通道。适合已经会写 C 或熟悉 Shell、被read卡死过、被SIGPIPE打断过、想搞明白"为什么我open了就停住了"的人看。
我最早接触它不是为了写代码,而是为了排查一个卡死现场。某个数据采集进程和它的下游处理进程都跑得好好的,日志停在某个位置不动,ps看两个进程状态都是S(可中断睡眠),CPU 占用为零,也不报错。strace挂上去一看,采集端停在open("/tmp/xxx", O_WRONLY)上,处理端停在别的地方。查了半小时才发现处理端根本没起来——采集端那个open因为找不到对端读进程,就那么一直阻塞着,一动不动。这个问题在管道、有名管道、流式套接字里都会出现,但有名管道是最容易让人困惑的一种,因为它在文件系统里长着普通文件的样子,ls能列出来,cat也能读,偏偏行为完全不像文件。我把这套东西彻底捋明白之后,同类问题基本能在五分钟内定位,所以下面把创建、读写、阻塞语义和踩坑记录一次讲透。
1.1 先把它和"亲戚们"放在一张表里比较
进程间通信的手段不少,选型选错的话,后面调试的时间成本会成倍增加。有名管道的特点可以一句话概括:走文件系统路径,单向,基于字节流,内核缓冲区,生命周期与进程无关(跟着文件系统里的那个特殊文件走,而不是跟着创建它的进程走)。这一点和匿名管道正相反——匿名管道的生命周期完全绑定在创建它的进程及其子进程上,父进程一退出,管道就没了。
| 方式 | 是否有名字 | 通信方向 | 能否跨无亲缘进程 | 数据边界 | 典型问题 |
|---|---|---|---|---|---|
匿名管道pipe() | 无 | 单向 | 不能(需 fork 继承) | 字节流 | 父子进程绑定,难解耦 |
| 有名管道 FIFO | 有(路径) | 单向(单个 FIFO) | 能 | 字节流 | open阻塞、写端退出信号 |
| 本地域套接字 | 有(路径) | 双向 | 能 | 字节流或数据报 | 需要 accept 循环,代码量大 |
| System V / POSIX 消息队列 | 有(键或名称) | 双向 | 能 | 有消息边界 | 需要管理队列生命周期 |
| 共享内存 | 有(键或名称) | 双向 | 能 | 无(靠自己做同步) | 必须配信号量,同步复杂 |
从这张表能看出一条清晰的选型逻辑:如果只是本地两个进程传一串字节、要的是最简单能跑起来、而且希望发送方和接收方在启动顺序上尽量解耦,有名管道是最省事的一档。它不需要网络栈,不需要端口,不需要握手协议,甚至可以用echo > /tmp/xxx这样的命令直接往里灌数据。一旦你需要双向对话、需要消息边界、需要多客户端并发,就该往套接字或消息队列上走了。我个人的判断标准很粗暴:能用一条mkfifo说清楚的事情,就不要再写一百行 socket 代码。
1.2 单向下行链路,才是它的舒适区
有个容易被忽略的点:一个 FIFO 是单向的。如果你想双向通信,得创建两个 FIFO,一收一发。很多人第一次用的时候会想当然地在一个管道上两边都写,结果数据混在一起互相干扰,还以为是内核 bug。这个限制听起来很笨,但在"采集端 → 处理端 → 落盘"这类流水线结构里恰恰够用:每一级只需要往后传,天然单向,一级一个 FIFO,链路清清爽爽,每一级的进程都可以独立重启,只要 FIFO 文件还在,重启后的进程照样能接上。
这一点是它相比匿名管道最大的价值。匿名管道必须fork出来,父子进程的启动顺序和生命周期被死死绑在一起;有名管道只认路径不认进程,谁先起来都行(前提是打开方式选对,见第 4 节),谁挂了也不影响另一个继续跑——对端挂掉之后的表现(read返回 0 或者write收到EPIPE)本身就是一种天然的"断线检测"信号,这一点在流式管道里非常好用。
2. 有名管道在内核里到底是什么
2.1 文件系统里是空壳,内核里是一条传送带
mkfifo创建出来的东西,ls -l会显示成p开头(prw-r--r--),文件大小显示为 0,ls -l的权限和普通文件没区别,stat出来类型是 FIFO。你可以用cat读它,用echo写它,用rm删它,一切看起来都像文件。
但它的实际存储机制完全不同。这个路径名在磁盘上只占一个 inode,不占任何数据块(这就是为什么大小永远是 0)。真正存放数据的地方是内核里的一块内存缓冲区,典型大小是 64KB(也就是 16 个页,具体可以通过fpathconf(fd, _PC_PIPE_BUF)查到原子写大小,缓冲区总量在常见内核上远大于 4KB)。数据从写端进程的write()进内核缓冲区,读端进程的read()从缓冲区取走,全程不落地磁盘。所以有两个常见误解要澄清:第一,它不是磁盘文件,写进去的东西掉电就没了,进程退出也不会残留;第二,它的容量是有限的,写满了写端就会阻塞或者返回EAGAIN。
把这层理解成一条传送带很贴切:文件系统里的路径名只是"这条传送带的入口在哪"的标签,物料本身在带子上跑,跑完就没了,不会堆在仓库里。这个心智模型建立起来之后,后面所有的阻塞、满、空、EOF 行为都能自然推导出来,不用死记硬背。
2.2 和普通文件的三个关键差异
第一个差异是容量不是无限的。往普通文件写 10MB,内核会慢悠悠地刷到磁盘,写完就返回,你几乎不会因为"文件满了"而被卡住。往 FIFO 写 10MB,如果读端不配合取走,写到 64KB 左右就会阻塞住,进程挂在那里等你。这既是保护机制(防止内存被无限制吃掉),也是流控机制(backpressure),下游慢下来时上游会被自然压制——这是好事,在数据采集链路上比"上游狂写到内存爆掉"要安全得多。
第二个差异是读写必须两端都到齐。普通文件你一个人就能读能写,不需要另一个进程配合。FIFO 不行:只有写端没读端,写端的open会一直等;只有读端没写端,读端的open会一直等。这个"会合"机制(rendezvous)是 FIFO 最核心的行为特征,也是所有卡死问题的源头。
第三个差异是数据没有边界。你write了三次,每次 10 字节,对端read一次收到 30 字节是完全正常的。FIFO 是字节流,不是消息队列,不会帮你保留报文边界。如果你需要"一条一条消息"的语义,得自己在应用层加分隔符或者定长头。这一点和 TCP 完全一样,熟悉网络编程的人不会有障碍,但只写过文件读写的人很容易在这里翻车。
2.3 生命周期:谁创建它,谁负责删
FIFO 文件的生存期完全由文件系统决定,和创建它的进程没有任何关系。mkfifo的那个进程退出了,FIFO 还在;所有读写进程都退出了,FIFO 还在;只有unlink或者rm才真正让它消失。这个特性和匿名管道完全不同,也是它的优势——重启任意一端都不需要重建通道。
但反过来说,它没人自动清理。临时目录里堆一堆几个月前留下的xxx_fifo是常见现象,更要命的是残留的 FIFO 可能带着上次的权限,或者被别的用户占用着路径,导致新版本程序mkfifo失败(EEXIST)。我的习惯写法是:创建时判断errno == EEXIST就当作成功处理,程序退出时用一个atexit或者信号处理里做unlink——但要注意,如果别的进程还挂在上面,unlink之后它们仍然能继续通信(已经open的 fd 不受影响),只是新进程无法再通过这个路径接入了。这个细节在热升级场景里其实很有用。
3. 创建有名管道的三种方式与权限细节
3.1 命令行 mkfifo:最快验证手段
想在二十秒内验证一个想法,命令行是最快的:
mkfifo /tmp/demo_fifo ls -l /tmp/demo_fifo # prw-r--r-- 1 user user 0 Nov 1 10:00 /tmp/demo_fifo然后开两个终端,一个执行cat /tmp/demo_fifo(会卡住,这是对的),另一个执行echo "hello" > /tmp/demo_fifo。第一个终端立刻打印出 hello 然后退出(因为写端关闭,cat读到 EOF)。
注意一个现象:echo > fifo这条命令执行完就退出了,写端关闭,所以cat读到 EOF 就结束了。如果你想让cat一直活着接数据,得让写端持续打开着,比如while true; do echo x; sleep 1; done > /tmp/demo_fifo。这个"写端关闭导致读端 EOF"的行为是理解 FIFO 的第一课,几乎所有初学者都会被cat一闪而过搞懵一次。
还有一个很实用的小技巧:mkfifo加上-m参数可以直接指定权限,mkfifo -m 0660 /tmp/demo_fifo,避免还要额外chmod一次。在需要用组权限做访问控制的场合,这个参数能省一步。
3.2 mkfifo() 函数调用:程序里的标准做法
程序里创建 FIFO 用的是mkfifo(),签名是int mkfifo(const char *pathname, mode_t mode),定义在<sys/stat.h>里。返回 0 表示成功,返回 -1 需要看errno:EEXIST表示路径已存在(多数情况下可以忽略),EACCES是父目录没有写权限,ENAMETOOLONG是路径太长,ENOSPC或EROFS是文件系统问题。
一段能直接抄的创建代码长这样:
#include <sys/stat.h> #include <sys/types.h> #include <errno.h> #include <stdio.h> #include <string.h> int ensure_fifo(const char *path, mode_t mode) { if (mkfifo(path, mode) == 0) { return 0; /* 新建成功 */ } if (errno == EEXIST) { struct stat st; if (stat(path, &st) == 0 && S_ISFIFO(st.st_mode)) { return 0; /* 已存在且确实是 FIFO,放行 */ } fprintf(stderr, "%s 已存在但不是 FIFO\n", path); return -1; } fprintf(stderr, "mkfifo(%s) 失败: %s\n", path, strerror(errno)); return -1; }这段代码里最关键的是EEXIST分支:不要简单地无视它,而是要stat一次确认它真的还是 FIFO。我踩过一次坑——调试时手滑把路径写成了一个普通文件名,程序里只判断了EEXIST就直接放行,结果后续open成功、read立刻返回 EOF、逻辑全乱,查了两小时才发现那个"管道"其实是个空文件。多写三行S_ISFIFO判断,能省掉一次通宵。
3.3 权限位与 umask 的那点误会
mkfifo(path, 0666)创建出来的文件很多时候是644而不是666,原因是被umask按位与掉了。假设umask是022,0666 & ~022 = 0644。这不是 bug,是全部文件创建 syscall 的统一行为。
如果确实需要精确的权限(比如用组权限让同组进程互访),有两条路:一是umask(0)之后再创建、创建完恢复umask——但这在多线程程序里是危险操作,因为umask是进程级全局状态,会误伤同时创建的其他文件;二是创建之后直接chmod()一次:
umask(0); /* 单线程程序里可以这么干 */ mkfifo(path, 0666); umask(old_umask); chmod(path, 0660); /* 更稳妥:创建后显式修正 */我更推荐第二种,因为它不做全局状态临时修改,多线程下也安全。另外还要提醒一句:FIFO 文件的权限里,读权限管的是"能不能打开来读",写权限管的是"能不能打开来写",和普通文件一致;但已经打开的 fd,在chmod之后不受影响,这一点两种文件也一样。
3.4 清理时机:unlink 到底放在哪
清理策略取决于用途。如果是常驻服务之间的固定通道,FIFO 应该长期存在,清理工作交给部署脚本或者包管理器,程序内部不需要unlink。如果是临时性的单次任务(比如一个批处理脚本内部使用),处理完就删,避免污染目录。
有个细节值得记一下:unlink一个正在被使用的 FIFO 不会立即"断开"已有连接。已经open成功的两个进程仍能通过各自 fd 继续收发,因为内核对象引用计数不为零;只是新进程无法再用这个路径接入。这个行为在需要"优雅替换"的场景里很有价值——先unlink旧路径,再mkfifo新路径,老连接自然跑完退出,新连接走新文件,不需要停机。
4. 读写的真实语义:阻塞、缓冲区与原子性
4.1 open 的阻塞行为,几乎所有人的第一个坑
这是有名管道最反直觉的地方,也是绝大多数"程序卡死"的根因。规则如下:
- 以
O_RDONLY打开:阻塞,直到有一个进程以写方式打开同一个 FIFO。 - 以
O_WRONLY打开:阻塞,直到有一个进程以读方式打开同一个 FIFO。 - 以
O_RDONLY | O_NONBLOCK打开:立即成功返回,即使没有写端。 - 以
O_WRONLY | O_NONBLOCK打开:如果没有读端,立即失败,errno = ENXIO。
用大白话说:默认情况下,读写两端必须在open这一步"握手",谁先到谁等。注意这个等待发生在open,不是read或write,所以你在open前后打的日志会呈现出"前一条有,后一条没有"的形态,非常典型。
这个设计其实挺合理——它保证了open返回之后read一定能读到有效数据或 EOF,不用处理"读到一半对端从未存在过"的歧义。但它对启动顺序提出了隐性要求:如果读端是常驻服务、写端是客户端,客户端先起来就会卡在open上。解决方案有三个:一是读端用O_RDONLY | O_NONBLOCK打开(这样它永远不阻塞,但后续read也会变成非阻塞,需要处理EAGAIN);二是启动顺序上保证读端先起;三是读写两端都用非阻塞加轮询/多路复用。
有个非常实用的小技巧:读端如果想"永远不阻塞地打开"又想在后续read上阻塞,可以先用O_RDONLY | O_NONBLOCK打开拿到 fd,然后用fcntl把O_NONBLOCK清掉。这两步之间建立起的 fd 不会因为清标志而断开。
int fd = open(path, O_RDONLY | O_NONBLOCK); int flags = fcntl(fd, F_GETFL); fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); /* 恢复阻塞读 */4.2 读规则与写规则逐条对照
open之后的行为,值得做成一张表贴在脑子里。下面这张是我自己总结的,覆盖了实际会遇到的全部情况:
| 场景 | 具体条件 | 行为 |
|---|---|---|
| 读端 open | O_RDONLY | 阻塞至有写端 |
| 读端 open | O_RDONLY | O_NONBLOCK | 立即成功 |
| 写端 open | O_WRONLY | 阻塞至有读端 |
| 写端 open | O_WRONLY | O_NONBLOCK | 无读端则-1 / ENXIO |
| read | 管道空、写端存在、阻塞模式 | 阻塞等待 |
| read | 管道空、写端存在、非阻塞 | -1 / EAGAIN |
| read | 写端已全部关闭 | 返回 0(EOF) |
| read | 有 n 字节、请求 m 字节 | 返回min(n, m),不会为凑满而等 |
| write | 请求 ≤PIPE_BUF | 原子写入,保证不与其他写端交叉 |
| write | 请求 >PIPE_BUF、缓冲区空间不足 | 阻塞(阻塞模式)或部分写入(非阻塞) |
| write | 无任何读端 | 收到SIGPIPE(默认终止进程),或-1 / EPIPE |
表里最需要注意的两行是"read 写端全关返回 0"和"write 无读端发SIGPIPE"。前者的坑在于:如果读端是循环read,一旦所有写端退出就会立刻拿到 0,如果代码里没判断这个 0,直接拿去当有效长度用,就会进入空转死循环,CPU 瞬间拉满。后者的坑在于:默认SIGPIPE会直接杀掉进程,你连errno都看不到,日志里只有一句莫名其妙的 terminate。
4.3 PIPE_BUF 与原子写入
PIPE_BUF是"单次写入的原子边界",POSIX 规定它至少 512 字节,Linux 上一般是 4096。含义是:如果你一次write的字节数不超过PIPE_BUF,并且管道剩余空间够,那么这次写入对任何其他写端来说都是不可分割的——要么全进去,要么一点都没进。超过这个大小,内核可能分多次搬,多个写端的数据就可能交错。
这个特性在单写端场景里没有意义,但在多写端场景里是救命稻草。比如三个进程往同一个 FIFO 写日志,每条日志不超过 4KB 时,每条日志都是完整的一行,不会出现"A 的前半句 + B 的前半句"。一旦某条日志超过 4KB,交错的概率就直线上升。
所以我在多写端场景下的固定做法是:每条消息控制在PIPE_BUF以内,超出就自己在应用层分片,写端之间靠一条约定好的协议还原。查询方式很简单:
long pipe_buf = fpathconf(fd, _PC_PIPE_BUF); /* 通常返回 4096 */顺便提一句,别把PIPE_BUF和管道总容量搞混了。总容量是缓冲区大小,Linux 上默认 64KB,可以通过fcntl(fd, F_SETPIPE_SZ, size)调整(有上限,通常在/proc/sys/fs/pipe-max-size里)。前者管的是"单次写入的原子性",后者管的是"最多能囤多少数据"。
4.4 缓冲区的实际意义:背压与流控
64KB 听起来不多,但它在链路里扮演的角色非常关键。它相当于一个蓄水池:写入速度略快于读取速度时,多出来的部分先存在池子里,两边都能平稳运行;一旦池子满了,写端就被迫慢下来,等读端消费。这套机制不需要写一行代码,天然就是流控。
我在一次批量导入的任务里吃过这个设计的甜头。上游是一个目录遍历程序,扫描速度极快,随便就是几百 MB 的路径列表;下游是一个处理程序,每条记录要做一次磁盘操作,慢得多。原本用共享内存加队列,结果上游把内存吃干,被系统 OOM 掉了。改成 FIFO 之后,上游写入几百 KB 就被内核按住了,速度和下游自然对齐,内存占用始终稳定在几百 KB。有时候最好的限流器就是最笨的那个。
代价当然也有:如果下游彻底挂掉(不是关闭,而是卡住),上游会在write上永远阻塞,无法感知对端死活。这种场景下就得配O_NONBLOCK加超时,或者用select/poll监听可写性,别一条路走到黑。
5. 完整实操:两个无亲缘进程跑通一次传递
5.1 读端代码与逐行说明
下面这段读端代码我按"能直接编译运行、并且能正确处理各种边界"的标准写,每一行注释都是踩过坑之后补上去的。
/* reader.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <fcntl.h> #include <unistd.h> #include <sys/stat.h> #include <sys/types.h> #define FIFO_PATH "/tmp/demo_fifo" #define BUF_SIZE 4096 int ensure_fifo(const char *path, mode_t mode) { if (mkfifo(path, mode) == 0) return 0; if (errno == EEXIST) { struct stat st; if (stat(path, &st) == 0 && S_ISFIFO(st.st_mode)) return 0; fprintf(stderr, "%s 存在但不是 FIFO\n", path); return -1; } perror("mkfifo"); return -1; } int main(void) { if (ensure_fifo(FIFO_PATH, 0666) < 0) return 1; /* 非阻塞打开,避免在写端没起来时卡死在 open 上 */ int fd = open(FIFO_PATH, O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return 1; } /* 拿掉 O_NONBLOCK,后续 read 恢复阻塞语义 */ int flags = fcntl(fd, F_GETFL); if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) { perror("fcntl"); close(fd); return 1; } /* 阻塞等待数据;写端全关时 read 返回 0 */ char buf[BUF_SIZE]; for (;;) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { fwrite(buf, 1, (size_t)n, stdout); fflush(stdout); } else if (n == 0) { fprintf(stderr, "[reader] 所有写端已关闭,退出\n"); break; } else { if (errno == EINTR) continue; /* 被信号打断,重试 */ perror("read"); break; } } close(fd); return 0; }需要重点解释三处。第一处是O_NONBLOCK打开再清标志的组合技,前面说过,它解决的是"读端先起、写端后起"的启动顺序问题。第二处是read返回 0 的判断,这是所有写端都关闭的信号,不处理的话循环会空转,把 CPU 打满。第三处是EINTR重试,很多人写代码时忽略它,但只要程序里装了任何信号处理器(哪怕只是SIGCHLD),read就会时不时被打断返回 -1,不重试就会出现"偶发丢数据"的诡异现象,而且极难复现。
5.2 写端代码与异常处理
写端比读端坑更多,主要是SIGPIPE和部分写这两个问题。
/* writer.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <fcntl.h> #include <signal.h> #include <unistd.h> #define FIFO_PATH "/tmp/demo_fifo" int main(int argc, char **argv) { /* 关键:忽略 SIGPIPE,否则对端退出会直接杀掉本进程 */ signal(SIGPIPE, SIG_IGN); int fd = open(FIFO_PATH, O_WRONLY); if (fd < 0) { perror("open"); return 1; } const char *msg = (argc > 1) ? argv[1] : "hello from writer\n"; size_t total = strlen(msg), off = 0; /* 循环写到全部写完,处理部分写的情况 */ while (off < total) { ssize_t n = write(fd, msg + off, total - off); if (n < 0) { if (errno == EINTR) continue; if (errno == EPIPE) { fprintf(stderr, "[writer] 读端已关闭\n"); break; } perror("write"); break; } off += (size_t)n; } close(fd); return 0; }signal(SIGPIPE, SIG_IGN)这一行是必须的。默认行为下,往一个没有读端的 FIFO 写数据,内核会先给进程发SIGPIPE,进程直接死掉,你连write的返回值都拿不到,日志里什么都留不下。忽略之后,write会返回 -1 且errno == EPIPE,你就能优雅地打日志、清理资源、退出。
第二处是while (off < total)的循环。在阻塞模式下,写端单次write通常会把全部数据写完(除非被信号打断),但在非阻塞模式下,或者写入量很大、中途读端关闭,就可能只写进去一部分。不循环处理部分写,就会出现"我以为全发出去了其实只发了一半"的情况,这种 bug 在流量小的时候完全测不出来,一上量就暴露。
5.3 编译运行与验证步骤
编译没什么花头:gcc -Wall -Wextra -O2 -o reader reader.c和gcc -Wall -Wextra -O2 -o writer writer.c。验证顺序建议这样:
# 终端 1:先起读端,它会阻塞在 read 上(注意 open 不阻塞了) ./reader # 终端 2:起写端发一条消息 ./writer "first message" # 终端 2:再发一条 ./writer "second message" # 终端 1 会依次打印两行。此时写端两次都退出了,读端收到 EOF 自行结束。如果要观察"写端保持打开、连续发多条"的行为,可以在写端里加一个sleep循环,或者在 shell 里这么验证:
# 终端 1 cat /tmp/demo_fifo # 终端 2 exec 3>/tmp/demo_fifo echo "line 1" >&3 sleep 3 echo "line 2" >&3 exec 3>&- # 关闭写端,终端 1 的 cat 立刻退出这里exec 3>/tmp/demo_fifo把 fd 3 绑定到 FIFO 上并保持打开,中间跨了 3 秒,读端cat一直活着。最后exec 3>&-关闭写端,cat立刻收到 EOF 退出。这个实验能让你直观感受到"写端存在与否"对读端的影响,比看十遍文档都管用。
5.4 用 tee 和 split 做链路验证
在正式写代码之前,我一般先用 shell 工具把链路打通,确认数据流方向、数据量、以及阻塞点,然后再去写真正的程序。这个习惯能省掉很多"代码里调半天其实链路设计就错了"的时间。
mkfifo /tmp/in_fifo /tmp/out_fifo # 下游:从 out_fifo 读,落盘并统计 ( cat /tmp/out_fifo | tee -a /tmp/recv.log | wc -c ) & # 中间处理:从 in_fifo 读,转换后写 out_fifo ( tr 'a-z' 'A-Z' < /tmp/in_fifo > /tmp/out_fifo ) & # 上游:持续注入 for i in $(seq 1 5); do echo "item $i"; sleep 0.2; done > /tmp/in_fifo wait wc -l /tmp/recv.log # 应该是 5 行,且全部大写这个三段式管道跑通,就说明 FIFO 的打开顺序、EOF 传播、数据量大致的节奏都没问题,后面只是把 shell 换成 C 而已。tr那条命令里>会在打开/tmp/out_fifo时阻塞,直到cat端打开——这个阻塞是自动的,不用你看管,正好体现了 FIFO 的会合语义。
6. 常见问题与排查实录
6.1 进程停在 open 上不动
这是最高频的问题,十次里占八次。现象:进程状态S,CPU 为 0,日志停在open之前的那条,strace显示卡在open系统调用。
排查路径按顺序走:先确认对端是不是真的起来了(ps找一下);再确认两端用的是不是同一个路径(打印绝对路径,别用相对路径,chdir之后相对路径会变);然后确认打开方式是不是匹配的(一端O_RDONLY一端也O_RDONLY,那两边都会等,永远等不到——这个错误看起来蠢,但真有人犯,尤其是一端在 shell 里用<另一端也用<);最后确认是不是有权限问题被误判成阻塞,ls -l看一眼权限位。
有个特别好用的命令组合:ls -l /proc/<pid>/fd能直接看到进程当前打开了哪些 fd、指向哪个文件。如果那个 FIFO 在列表里,说明open已经成功了,问题在后面的read/write;如果不在,说明卡在open。这一条能瞬间把问题范围缩小一半。
6.2 写端莫名被杀,日志全无
现象:写进程跑着跑着直接消失,退出码 141(128 + 13,13 就是SIGPIPE),日志里没有任何错误。这是往无读端的 FIFO 写数据的标准表现。
处理办法前面说过,signal(SIGPIPE, SIG_IGN)。但更值得反思的是:为什么读端会消失?常见原因是读端有 bug 提前退出了,或者是读端被上游的超时逻辑干掉了。所以除了忽略信号,还应该在write返回EPIPE时打一条明确的日志,把时间点和已发送量记下来,方便回溯。
顺带说一个容易忽略的点:如果你用了O_NONBLOCK打开写端,但打开时没有读端,open直接就返回ENXIO失败,根本走不到write。所以ENXIO也是一个需要单独处理的错误码,别把它和路径不存在混在一起报错。
6.3 数据看起来丢了或者粘在一起
分两种情况。第一种是"丢了",实际是部分写没处理——非阻塞模式下write返回了一个小于请求长度的值,代码没检查返回值就认为写完了。第二种是"粘了",实际是字节流没有边界——读端一次read返回了两条消息的拼接。
对应的解法也不同。部分写要用while循环直到写完(见 5.2 的代码)。消息边界要靠应用层协议,最简单的是换行分隔,读端自己按\n切分累积缓冲区;稍复杂一点是长度前缀,前 4 字节是长度,后面紧跟内容。这两种在 FIFO 场景下都够用,我一般优先选长度前缀,因为不用处理"半行"的中间状态,解析逻辑更干净。
还有一种假性的"丢数据":读端拿到 EOF 就退出了,但写端在关闭之前还有数据在缓冲区里没被读完。这里要特别说明一下,内核的规则是只要缓冲区里还有数据,即使写端全关,read也会先把数据读完再返回 0,所以正常情况下不会丢。真正会丢的是读端在收到 0 之前自己主动退出,或者用了close(fd)之后还有数据没处理。这个顺序一定要保证:读完、处理完、再关。
6.4 管道文件到底占不占磁盘
du -sh /tmp/demo_fifo是 0,ls -l大小也是 0,这是对的。但如果你往里灌了 100MB 数据而没人读,du依然显示 0,因为数据全在内核内存里,不在磁盘上。这也是为什么 FIFO 不适合做大文件传输——一方面缓冲区就那么大,会全程阻塞;另一方面数据完全不持久化,进程崩了就全没了。
如果确实需要"看起来像管道但能持久化"的效果,那就不该用 FIFO 了,直接用普通文件加tail -f,或者用一个真正的消息中间件。别硬拗。
6.5 多写端并发时的数据完整性
多个进程同时往一个 FIFO 写,只要每次写入不超过PIPE_BUF且缓冲区剩余空间足够,各条消息不会互相穿插。但这里有两个前提,缺一不可。空间不足时,即使请求小于PIPE_BUF,写入也会被阻塞,此时内核不会帮你切分,而是让你等,等有空间了再整体写入——所以原子性还是成立的,只是可能等很久。
真正会被打散的是超过PIPE_BUF的写入。这种情况下内核会分批搬运,中间如果被其他写端插队,数据就交错了。所以多写端场景下的铁律是:单条消息必须小于PIPE_BUF,超了就应用层自己切片加序号。
还有一个隐蔽的坑:多个进程各自open(O_WRONLY)同一个 FIFO,它们共用同一个内核缓冲区。这不像 socket 那样每个连接有独立缓冲,所以一个读端读到的数据来自所有写端,是混在一起的。如果业务上需要区分来源,就必须在消息头里带上写端标识,不能指望内核帮你分。
6.6 排查速查表
| 现象 | 最可能原因 | 快速验证 | 处理 |
|---|---|---|---|
open卡住不动 | 对端未启动或打开方式不匹配 | ls -l /proc/<pid>/fd | 改非阻塞打开或调整启动顺序 |
| 写进程退出码 141 | 无读端时写入触发SIGPIPE | echo $?看退出码 | signal(SIGPIPE, SIG_IGN) |
open返回ENXIO | 写端非阻塞打开但无读端 | errno打印 | 先起读端,或改阻塞打开 |
| 读端循环 CPU 100% | 未处理read返回 0 | top看进程 | 返回 0 时跳出循环 |
| 数据交错 | 单次写入超过PIPE_BUF | fpathconf查大小 | 应用层切片加序号 |
| 只收到部分数据 | 未处理部分写 | 打印write返回值 | while循环写到底 |
mkfifo报EEXIST | 路径残留 | ls -l看类型 | 校验S_ISFIFO后复用 |
| 权限不足打不开 | umask削掉了写位 | ls -l | 创建后显式chmod |
7. 从"能跑"到"好用"的几个进阶套路
7.1 非阻塞 + 多路复用,一次管多个 FIFO
单一 FIFO 用阻塞模式最省事,但一旦要同时监听多个通道(比如三个上游都在往不同 FIFO 写,一个下游统一汇聚),阻塞式read就应付不来了。这时候标准做法是全部用O_RDONLY | O_NONBLOCK打开,然后交给poll统一等待:
struct pollfd pfds[3]; pfds[0].fd = fd_a; pfds[0].events = POLLIN; pfds[1].fd = fd_b; pfds[1].events = POLLIN; pfds[2].fd = fd_c; pfds[2].events = POLLIN; for (;;) { int r = poll(pfds, 3, -1); if (r < 0) { if (errno == EINTR) continue; break; } for (int i = 0; i < 3; i++) { if (pfds[i].revents & POLLIN) { /* 循环读到 EAGAIN 为止,别只读一次 */ } if (pfds[i].revents & POLLHUP) { /* 所有写端关闭;注意 POLLHUP 时仍可能有残留数据 */ } } }两个注意点。第一,POLLIN触发后要循环读直到返回EAGAIN,只读一次的话缓冲区里剩余的数据会延迟到下一轮才处理,吞吐会明显下降。第二,POLLHUP触发时缓冲区里可能还有数据没读完,不能直接关掉,要先排空再处理 EOF。这两点我在第一版代码里都写错过,表现是"最后几条数据莫名其妙不见了",查了很久。
7.2 双向通信的正确做法
一个 FIFO 只能单向,要做双向就得两个:一个上行、一个下行。命名上建议用后缀区分,比如xxx_req和xxx_rep,请求走 req,响应走 rep。两个 FIFO 要分别open,注意顺序——如果两边都先openreq 再openrep,且都用阻塞模式,就可能死锁:A 打开了 req 的读端在等写端,B 打开了 req 的写端成功了(因为 A 在读),然后 B 去打开 rep 的读端,A 去打开 rep 的写端……这种"交叉等待"在设计协议时要提前想清楚。
稳妥的做法是约定好角色:客户端先打开"请求写端",服务端先打开"请求读端"和"响应写端",客户端收到第一次响应之后再打开"响应读端"。或者干脆两端都用非阻塞打开,配合poll处理,虽然代码复杂一点,但不会死锁。我个人的经验是,任何一个需要两端都open两个通道的设计,第一版必然出死锁,所以要么把通道减到一个,要么就老老实实上非阻塞。
7.3 什么时候该果断换掉它
FIFO 好用,但它有三条硬边界:单向、本地、无消息边界。一旦业务踩到其中任何一条,继续硬撑的代价会越来越高。需要跨机器传数据,换成流式套接字;需要多客户端并发且每条连接独立,换成套接字加accept循环;需要消息不丢、可重放、可持久化,换成消息队列;需要极高吞吐的本地大块数据传输,换成共享内存加信号量。
我遇到过一次典型的"该换没换"。一个本地图像处理流水线,用 FIFO 传帧数据,单帧几百 KB。跑小分辨率没事,一切到高分辨率,链路就开始卡——因为单次写入远超PIPE_BUF,经常要阻塞等缓冲区腾空间,而且部分写的处理有 bug,偶尔还会出现半帧数据。后来改成共享内存加环形缓冲,吞吐直接上了一个量级。所以判断标准很简单:传输单元超过 4KB 且对延迟敏感,就别用 FIFO 了,它不是为这个设计的。
7.4 我常用的两个调试手法
第一个是strace -e trace=openat,read,write,poll,select -p <pid>,能实时看到进程在哪个系统调用上停着,是卡在open还是卡在read,一眼就清楚。加上-T参数还能看到每个调用耗时,判断是"等了一下"还是"等到天荒地老"。
第二个是往链路里插一个"透明观察者":
# 不改动两端程序,只把中间那段换成 tee ( cat /tmp/a_fifo | tee /tmp/sniff.log | cat > /tmp/b_fifo )这样数据流经中间时被复制一份到日志里,能直接看到内容、时间点、有无丢包。虽然多一次拷贝会有一点开销,但排障阶段的这点开销完全可以接受。等定位完问题再把这个中间环节拿掉。
8. 最后说说我在实际使用中的体会
有名管道这个东西,功能上确实"老旧",但它在"本地两个进程传一串字节"这个场景里,至今仍然是最短的路径。我这些年用它的次数不算多,但每次用上都觉得省事——不用起服务、不用配端口、不用设计握手,一个路径名就是一条通道,mkfifo加上echo就能验证,出问题的时候strace一挂基本就知道卡在哪个系统调用上。
真正需要警惕的从来不是它本身,而是没搞清它的阻塞语义就上手写代码。open会合、read返回 0、write触发SIGPIPE、单次写入的原子边界——这四条如果提前弄明白,你写出来的代码基本不会出问题;如果没弄明白,大概率会在某个深夜对着一个 CPU 占用为零、状态S的进程发呆。
如果非要给一条最实用的建议,那就是:读端先起,写端后起,两端都别忽略返回值。这条规矩能挡掉我见过的大概八成 FIFO 相关的问题。剩下的两成,去查PIPE_BUF和EPIPE,基本也就解决了。