ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux命名管道FIFO:从open阻塞到PIPE_BUF原子性的工程实践

Linux命名管道FIFO:从open阻塞到PIPE_BUF原子性的工程实践 接触过匿名管道pipe的都知道它最大的限制就是只能用在父子进程之间fork 之后子进程把父进程的文件描述符表复制一份这条管道的两端自然跟着传下去别人想插都插不进来。但现实中的进程间通信场景大量根本不是父子关系——两个独立启动的守护进程、一个 Web 服务和日志采集器、监控程序和业务进程它们之间没有继承关系怎么把数据从 A 安全地交到 B 手上命名管道FIFO就是 Linux 专门为解决这个需求设计的入口。这是命名管道系列的第 2 篇。上一篇已经把匿名管道的读写行为、缓冲区边界讲透了这一篇聚焦命名管道本身的特性它在文件系统里到底是什么、open 阶段的阻塞机制、读写原子性、以及我在生产环境里踩过的具体坑。适合已经知道pipe()是干什么的、但还没认真用过 FIFO 的读者也适合想搞清楚命名管道和普通文件到底差在哪的运维和嵌入式开发者。1. 命名管道到底解决了匿名管道解决不了的问题1.1 匿名管道的根子问题匿名管道的核心依赖是 fork 之后的文件描述符继承。我们写pipe(fd)之后内核返回两个 fdfd[0]是读端fd[1]是写端。fork 的时候子进程会把父进程的打开文件表完整复制一份于是两边天然共享同一个管道内核对象。代码长这样int fd[2]; pipe(fd); if (fork() 0) { /* 子进程用 fd[0] 读 */ close(fd[1]); read(fd[0], buf, sizeof(buf)); } else { /* 父进程用 fd[1] 写 */ close(fd[0]); write(fd[1], hello, 6); }问题就藏在这个设计里这条管道没有在文件系统里登记任何名字它只能通过 fd 引用。两个毫不相干的进程各自手里拿到的 fd 数字没有共享上下文内核也没法凭路径找过去所以匿名管道天然只服务有亲缘关系的进程。很多刚入门的人会想既然pipe(fd)里写的是 fd那我能不能把这个 fd 数字直接传给另一个进程答案是不行。fd 只是进程私有文件描述符表的下标数值相同不代表指向同一个内核对象。跨进程共享 fd 必须依赖 fork 继承或 unix socket 的SCM_RIGHTS这已经是很后面的话题了。1.2 命名管道引入的新机制命名管道FIFO换了一条路它把内核对象的引用从文件描述符继承变成了文件系统路径查找。创建的时候文件系统里会出现一个固定路径的节点比如/tmp/log_pipe。任何进程只要知道这个路径都可以用标准的open()打开它然后当普通文件句柄去read()/write()。我在第一次接触 FIFO 时其实有个误解以为它和普通文件的语义差不多往里面写数据就可能写到磁盘。后来才发现完全不是一回事。用ls -l看 FIFO 节点第一个字符是p表示 pipe文件大小恒为 0。这是因为 FIFO 节点本身不承载数据数据全部存在内核内存的管道缓冲区里文件系统里只有一具挂名的空壳。两者对比如下对比项匿名管道命名管道创建方式pipe(fd)mkfifo(path, mode)通信范围有亲缘关系的进程任意知道路径的进程生命周期最后一个 fd 关闭即销毁mkfifo 后一直存在直到 unlink怎么引用文件描述符文件系统路径是否双向半双工半双工1.3 一句话理解电话线和固定电话想记牢这个区别可以用一个生活类比。匿名管道像座机内部的分机线两部话机本来就在同一个交换机下拨一个短号就能通但换一个办公楼它们就毫无联系。命名管道像固定电话的号码只要你知道对方号码在全球任何一个地方拨号都能找到它前提是你得先把这个号码记在通讯录上——对应到 Linux 里就是文件系统路径。这个类比还顺带提醒了你命名管道的一个代价路径是公共的意味着权限控制、同名冲突、残留清理这些普通文件才有的麻烦命名管道一个都躲不掉。2. 创建一条命名管道命令、API以及文件系统上的表象2.1 最快验证终端三行命令先用 shell 感受一下命名管道在文件系统里的样子mkfifo /tmp/myfifo ls -l /tmp/myfifo rm /tmp/myfifols的输出大概是prw-r--r-- 1 user user 0 Jul 12 10:00 /tmp/myfifo注意两个细节第一个字符是p而不是-说明这是个 FIFO 特殊文件大小是 0因为管道不落盘。rm之后节点消失任何已经打开管道的进程不再受影响因为真正通信的内核对象还在只是路径被断开了。2.2 C 语言里的 mkfifo 调用mkfifo命令是 glibc 库函数mkfifo()的壳。函数原型#include sys/types.h #include sys/stat.h int mkfifo(const char *pathname, mode_t mode);mode和 open 的权限位一样但会受进程 umask 影响。比如 umask 是 022 时传0644实际生效的权限是0644 ~0222 0644。如果想确保没被 umask 削弱可以先umask(0)或者创建后用chmod()纠正。写代码的时候要注意EEXIST不是错误因为生产环境里程序重启很常见FIFO 节点可能已经存在了#include sys/types.h #include sys/stat.h #include stdio.h #include stdlib.h #include errno.h #include string.h int main(void) { const char *path /tmp/myfifo; if (mkfifo(path, 0644) -1) { if (errno EEXIST) { printf(pipe already exists, reuse it\n); } else { perror(mkfifo); exit(EXIT_FAILURE); } } return 0; }另一种创建方式是mknod(path, S_IFIFO | mode, 0)这是比较老派的写法现在完全可以用mkfifo()替代没必要碰mknod因为后者涉及到更细的能力检查可移植性也差一些。2.3 同样是文件为什么它不占磁盘块这个问题我当年想了一阵。普通文件写入时内核会分配数据块把内容写到块设备上。FIFO 节点创建以后stat显示的大小是 0而且永远不会因为写入数据变大。原因是FIFO 的 inode 存在但数据部分就是一个内存对象。打开 FIFO 并写入时内核在内存里维护一个管道缓冲区队列。在 Linux 上这个缓冲区默认大小一般是 64KiB65536 字节可以通过fcntl(fd, F_SETPIPE_SZ, size)调整。数据只要进了缓冲区等待对端读走就完事进程重启、机器断电数据也跟着没。所以命名管道不是一个持久化方案不要指望我们可以先写进去过几天再读。这也是命名管道经常被误解的地方它长得像文件行为却是流。文件可以lseek回退重读管道不行文件里没有读者数据也在管道里没人读写端迟早被阻塞或者收到EPIPE。2.4 生命周期里的残留坑因为mkfifo创建的节点会一直留在文件系统里直到被rm或unlink()删除所以生产环境里最常见的隐性故障是程序崩溃后 FIFO 节点还在新进程启动时mkfifo会报EEXIST有人图省事直接忽略结果打开了一个权限和属主都不对的旧节点。我个人的习惯是程序启动时一定要stat()检查已有路径的类型确认它确实是 FIFO而不是某个手滑创建的普通文件或符号链接。如果是普通文件占着这个名字先 unlink 再 mkfifo或者直接报错退出不要让程序在一个错误的通信对象上运行。3. 最容易卡住新手的一步open 阶段的握手与阻塞3.1 读端先开没有写者open 直接卡住新手第一次写命名管道代码十有八九会卡在这个地方。比如你写一个读端程序逻辑是打开/tmp/myfifo然后read()等数据。你满心期待地运行./reader然后屏幕就定住了。你以为卡在read()上其实卡在open()上。用 CtrlC 杀掉再跑也不行代码看起来没问题可它就是不动。这是因为 FIFO 的open()语义和普通文件完全不同。普通文件 open 就是获取一个句柄数据在不在无所谓。FIFO 的 open 是一个握手动作读端要等一个写端出现才返回写端要等一个读端出现才返回。这个设计的关键在于保证管道两端都在线时才开始传数据避免数据写了没人读、或者有人等着读却没人写的半吊子状态。拿 shell 验证最快。终端 A 执行cat /tmp/myfifo它会一直挂着。然后终端 B 执行ls -l /tmp/myfifoCtrlC 之后再看终端 Acat已经输出了ls -l的结果并退出。这就是 open 握手完成的瞬间。3.2 内核为什么要把握手放进 open我一度觉得这个设计很反直觉直到理解了管道的模型管道是一条承上启下的水流通道水流必须从水龙头流进水槽。如果没有接水槽就把水龙头打开水会流得满地都是如果没有打开水龙头就等着水槽接水等一整晚也没有水来。内核的职责是保证不会出现这两种浪费于是把确认对方已经就位作为 open 返回的前提。这个设计代价是如果其中一端迟迟不启动另一端会挂死在 open 上很容易被误判成死锁。但它的收益远大于代价尤其是做守护进程之间的通信时你可以依赖这个行为实现启动顺序无关两个进程谁先启动都无所谓后启动的一方会自动完成握手。3.3 打开组合的完整行为表为了方便查阅我把open()在阻塞和非阻塞模式下的行为整理成一张表open 方向对端状态阻塞模式O_NONBLOCK读端 O_RDONLY有写端已打开立即返回立即返回读端 O_RDONLY无写端阻塞等待写端立即返回成功可以后续再等写端写端 O_WRONLY有读端已打开立即返回立即返回写端 O_WRONLY无读端阻塞等待读端返回 -1errno 置 ENXIO注意最后一行非阻塞模式下写端在没有读者时直接失败错误码是ENXIO。这个错误码看着陌生它其实就是设备或地址不存在的扩展在这里的意思是你要连接的对端读者不存在。3.4 调试技巧先怀疑 open而不是 read/write一旦程序看起来卡死我现在的第一反应就是strace跟着看它比任何猜测都快strace -e traceopen,openat,read,write ./reader如果输出停在openat(AT_FDCWD, /tmp/myfifo, O_RDONLY) ...那一行不动基本就是 open 阶段在等对端。这时候去检查对端进程是不是启动了或者对端是不是已经打开了 FIFO 但没写数据。配合lsof /tmp/myfifo可以快速列出当前谁打开了这个 FIFO 的读端和写端判断到底缺哪一端。4. 一份可编译通过的 FIFO 实战代码4.1 场景设定光说理论没用我直接给一套能跑通的示例。场景是一个采集进程writer持续产生状态日志另一个消费进程reader把这些日志实时落盘。这两个进程彼此独立启动唯一的联系方式就是/tmp/stats_pipe这条命名管道。这里我用两个 C 文件演示编译方法很简单gcc -o writer writer.c gcc -o reader reader.c先启动哪个都行。如果先启动 reader它会在 open 时等待 writer如果先启动 writer它会在 open 时等待 reader。等到另一端启动两边立刻建立连接开始传输数据。4.2 写端 writer.c#define _GNU_SOURCE #include fcntl.h #include stdio.h #include string.h #include stdlib.h #include unistd.h #include errno.h #include time.h int main(void) { const char *path /tmp/stats_pipe; if (mkfifo(path, 0644) -1 errno ! EEXIST) { perror(mkfifo); exit(EXIT_FAILURE); } int fd open(path, O_WRONLY); if (fd -1) { perror(open); exit(EXIT_FAILURE); } char buf[128]; int seq 0; while (1) { int len snprintf(buf, sizeof(buf), seq%d time%ld\n, seq, (long)time(NULL)); if (write(fd, buf, len) -1) { if (errno EPIPE) { fprintf(stderr, reader gone, stop.\n); break; } perror(write); break; } sleep(1); } close(fd); return 0; }4.3 读端 reader.c#include fcntl.h #include stdio.h #include stdlib.h #include unistd.h int main(void) { int fd open(/tmp/stats_pipe, O_RDONLY); if (fd -1) { perror(open); exit(EXIT_FAILURE); } FILE *out fopen(/tmp/stats.log, a); if (!out) { perror(fopen); exit(EXIT_FAILURE); } char buf[256]; ssize_t n; while ((n read(fd, buf, sizeof(buf) - 1)) 0) { buf[n] \0; fputs(buf, out); fflush(out); } fprintf(stderr, read ended, n%zd\n, n); fclose(out); close(fd); return 0; }4.4 运行结果与行为解释我在两台机器上都测过运行模式是开一个终端跑./writer另一个终端跑./reader。reader 那边会看到/tmp/stats.log每隔一秒多出一行seq0 time1720742400 seq1 time1720742401 seq2 time1720742402这个例子里有几个关键细节要特别注意。第一write()的次数和read()的次数并不保证一一对应。管道是字节流没有消息边界写端一次写入 40 字节读端可能一次read拿到 40 字节也可能在极端调度下拿到 2020 字节具体看内核缓冲区状态和调度时机。设计协议时不要假设一次 write 对应一次 read。第二如果 reader 被 CtrlC 杀掉writer 的下一次write()会收到SIGPIPE信号默认行为是直接终止进程。我在代码里没有屏蔽SIGPIPE而是通过打印EPIPE让写端优雅退出如果不想让进程被信号打死可以在开头signal(SIGPIPE, SIG_IGN)然后write就会返回 -1 且errno EPIPE。第三如果两边都结束FIFO 节点还在/tmp/stats_pipe躺着。下次重启程序时mkfifo会得到EEXIST我的代码里直接放行了但没有校验类型严谨的做法是stat()检查S_ISFIFO这就是前面 2.4 节说的残留坑。4.5 双向通信一个 FIFO 不够命名管道和匿名管道一样是半双工的单向通道。如果有两个进程需要互相发送消息只建一条 FIFO 是行不通的——数据只能往一个方向流。正确做法是建两条比如/tmp/pipe_a_to_b和/tmp/pipe_b_to_aA 往第一条写、B 从第一条读B 往第二条写、A 从第二条读。我做双进程交互的时候还会额外加一个约定每条消息用\n结尾或者每包数据固定头部长度。因为管道是流式接口没有消息边界你不主动定义帧的边界通信双方迟早会踩半包和粘包的坑。这一点和 TCP socket 编程非常像凡是用过 socket 的应该都有共鸣。5. 阻塞、非阻塞与原子写入PIPE_BUF 才是安全边界5.1 read 不会按消息切分只会按缓冲区切分很多用 FIFO 的人天然以为 write 端写一段read 端就能原样收到一段。这是错误想法。管道内部就是一串字节流read()可以读到可用数据的任意长度只要不超过你传入的缓冲区大小。写端写入的所有字节都在同一个流里排着队没有任何隐式的分包。如果确实需要消息边界标准做法是自造协议定长数据帧每帧固定字节数或者长度前缀前 4 字节存消息长度后面跟消息体再或者用分隔符比如\n。这三种方法没有绝对优劣看数据特征和性能要求但绝不能裸裸地依赖 write/read 次数对应。5.2 PIPE_BUF 为什么重要write 原子性这是命名管道里含金量最高的一条规则。当一个进程执行write(fd, buf, n)时如果n PIPE_BUF内核保证这次写入是原子的。意思是多个写进程同时往同一条 FIFO 写数据时每个不大于 PIPE_BUF 的写操作不会和别人的数据交叠要么整段数据排在前面要么整段排在后面绝不会有 A 的前半段、B 的所有、A 的后半段这种交错。在 Linux 上PIPE_BUF的值通常在/usr/include/linux/limits.h里定义一般是 4096 字节。可以用一段代码验证#include limits.h #include stdio.h int main(void) { printf(PIPE_BUF %d\n, PIPE_BUF); return 0; }运行时可以和另一个写者同时往同一个 FIFO 里写大块数据比如 10KiB观察读端收到的数据是否交错。实测下来超了PIPE_BUF之后交错是大概率事件因为内核会把一个超过缓冲限制的写操作拆成多次内部拷贝中间完全可能插入其它进程的数据。这里的工程结论很直接如果你有多个写者每条消息务必控制在 PIPE_BUF 以内。这样即使多个写者同时写读者看到的每条数据都是完整的不需要额外的拼接逻辑。如果你要传的东西超过 4KiB就自己在应用层做好切片和重组或者换 unix socket 走流式消息。5.3 非阻塞模式 O_NONBLOCK 的细节O_NONBLOCK不只影响 open 阶段的握手见 3.3 表格也影响读写阶段。读端非阻塞时缓冲区里没有数据read()直接返回 -1errno是EAGAIN不会等。写端非阻塞时如果管道缓冲区满了write()直接返回 -1errno是EAGAIN不会等。这时候就可以上select/poll/epoll做多路复用。FIFO 的 fd 本质上就是一个可读可写事件源可以像 socket 一样被监听。下面是一个用 poll 同时监听一条 FIFO 和标准输入的可读事件的简版框架#include poll.h #include fcntl.h #include stdio.h #include unistd.h int main(void) { int fd open(/tmp/stats_pipe, O_RDONLY | O_NONBLOCK); if (fd -1) { perror(open); return 1; } struct pollfd pfds[2]; pfds[0].fd fd; pfds[0].events POLLIN; pfds[1].fd STDIN_FILENO; pfds[1].events POLLIN; char buf[256]; while (1) { int ret poll(pfds, 2, -1); if (ret 0) { perror(poll); break; } if (pfds[0].revents POLLIN) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { write(STDOUT_FILENO, buf, n); } else if (n 0) { /* 对端全部关闭 */ break; } } if (pfds[1].revents POLLIN) { ssize_t n read(STDIN_FILENO, buf, sizeof(buf)); if (n 0) { write(STDOUT_FILENO, buf, n); } } } close(fd); return 0; }5.4 我的默认选择如果你的场景不需要同时监听几十个文件描述符我建议优先用阻塞模式并且把所有写入控制在 PIPE_BUF 以内。原因很简单非阻塞模式的核心优势是不等待但代价是代码复杂度立刻上去了要处理 EAGAIN、要维护事件循环。而阻塞模式下一个写进程一行write()就完成了同步读者自然等数据来了再read()逻辑最贴近直觉。性能上也不用担心管道的吞吐在单机进程间通信里已经足够快瓶颈通常不在内核管道而在应用层协议和磁盘 IO。等到你真的需要同时服务大量 FIFO 连接再切换到 epoll不要一开始就上重型武器。6. FIFO 生产环境中的几个典型坑与完整排查链路6.1 遗留的 FIFO 节点类型校验不能省程序崩溃后没来得及unlinkFIFO 节点会一直留在/tmp里。新进程启动再执行mkfifo就会得到EEXIST。直接忽略这个错误看似没问题但如果这个路径被一个普通文件占了你后续的open就打开了一个普通文件往里面写的所有数据都进了磁盘读端永远等不到管道数据。这种故障极其隐蔽因为进程看起来还在运行write 也没报错只是数据没流到该去的地方。我踩过一次当时排查了一整个下午。后来固定下来一段启动代码每次mkfifo遇到EEXIST时都做一次stat校验#include sys/stat.h static int ensure_fifo(const char *path, mode_t mode) { struct stat st; if (mkfifo(path, mode) 0) { return 0; } if (errno ! EEXIST) { return -1; } if (stat(path, st) -1) { return -1; } if (S_ISFIFO(st.st_mode)) { return 0; } /* 路径被非 FIFO 文件占用先清掉再重建 */ if (unlink(path) -1) { return -1; } return mkfifo(path, mode); }这段代码逻辑不复杂已有节点必须是 FIFO否则宁可删掉重建也不要让程序稀里糊涂打开一个普通文件。6.2 对端消失时的 SIGPIPE 处理写端写入时如果读端已经全部关闭内核会向写进程发送SIGPIPE。信号默认行为是终止进程这导致很多写进程莫名其妙就死了日志里甚至没有一条像样的错误。解决思路是两种写进程里做signal(SIGPIPE, SIG_IGN)忽略信号然后write()返回 -1 且errno EPIPE自己控制退出和重连逻辑。或者完全接受默认行为依赖退出码定位问题但这种方式不利于生产环境里的优雅降级。有一点要特别提醒SIGPIPE在网络上同样重要TCP 发送数据给一个已经 RST 的对端也会触发它。所以如果你同时写着 socket 程序和 FIFO 程序不要图省事在程序里全局忽略SIGPIPE否则 socket 相关的错误也会被吞掉一半排查起来更痛苦。更稳妥的是局部处理或者只在明确知道收不到SIGPIPE的线程里设置。6.3 系统调用被信号打断的 EINTR在带信号的环境里open()、read()、write()都可能被信号打断并返回 -1errno为EINTR。很多人第一次遇到会以为是对端出了问题实际上只是系统调用被中断了重试一遍就能继续。我通常在读写代码里加一个简单的重试包装static ssize_t robust_read(int fd, void *buf, size_t len) { ssize_t n; do { n read(fd, buf, len); } while (n -1 errno EINTR); return n; }同样逻辑适用于open和write。尤其是运行的机器上挂了SIGALRM定时器或者有频繁的信号投递时哪怕你代码本身没显式注册信号处理函数第三方库也可能给进程发信号EINTR出现的概率一点都不低。6.4 完整定位链路从进程没反应到根因确定如果线上进程疑似卡在 FIFO 上我的排查顺序是固定的ps -ef | grep 进程名看进程状态如果是大写的 Ssleep 可中断且 CPU 占用为 0嫌疑很大。strace -p PID附加到进程卡住时看输出停在哪个系统调用。如果是read()停在等待可能是没有写端如果是open()停在等待可能是对端没启动。lsof /tmp/stats_pipe查看谁打开了这个 FIFO读端和写端各有多少个 fd 打开。lsof输出里能看到每个进程的打开方式FD列里的r和w就能告诉你哪端缺席。查看/proc/PID/fd目录确认进程持有的 fd 状态判断是否存在 fd 泄漏。这条链路不需要猜测几次实战下来基本都能在几分钟内把根因锁定。最怕的就是不看工具靠猜然后反复重启进程。6.5 多读者场景数据会不会广播一个常见误解是多个进程同时open同一个 FIFO 的读端数据是不是像广播一样每个进程都收一份不是。管道只是字节流数据从写端进来只会被某一个读者读走先到先得。如果两个读者同时在等数据到达后的归属取决于内核唤醒哪个进程没有公平策略另一个读者可能一直饿着。所以命名管道天然适合单生产者、单消费者或多生产者、单消费者的模型不适合做发布订阅。如果业务上确实需要一份数据广播给多个消费者要么每个消费者一条独立 FIFO要么直接换成消息队列、共享内存外加同步机制不要在一条 FIFO 上硬撑。7. 什么时候别用命名管道7.1 需要动态重连和复杂协议时命名管道是极其轻量的通信手段但它没有连接管理、没有状态机、没有错误恢复。如果两个进程经常需要断开重连或者频繁有进程重启代码里就得自己处理各种 EPIPE、EINTR、莫名其妙的 open 阻塞这些逻辑写多了就是在手搓一个半成品的网络协议。我自己的经验是一旦出现超过两三处的重连和错误恢复逻辑就该切到 unix socket。unix socket 有真正的连接概念可以 accept、可以检测对端断开还支持消息边界和发送 fd几乎就是为这种需求准备的。7.2 需要请求/响应模型时FIFO 是单向流做请求/响应要建两条管道然后自己维护 request id、应答匹配、超时。这类模型用 unix socket 或者普通 TCP 是更加成熟的方案因为你有现成的recv/send边界的控制也可以用MSG_PEEK预读数据协议层省不少事。7.3 数据量特别大、延迟要求高时管道的设计目标不是高吞吐缓存。虽然内核缓冲区能到 64KiB 甚至调整到更大但每个 read/write 都要经过内核拷贝能从 A 进程内存复制到内核再从内核复制到 B 进程内存。如果对性能极其敏感比如实时音视频传输、高频交易数据通路共享内存加锁或者直接用vmsplice之类的用户态零拷贝技术会是更好的方向。命名管道适合的是中小数据量、低频事件、日志汇聚这类场景它赢在简单可靠不赢在性能上限。7.4 个人使用建议的总结我实际项目里最常用的场景是日志采集进程把半结构化文本从业务进程送到日志处理进程、监控脚本之间传状态事件、以及 C 程序给 Python 脚本递数据。这些都是典型的中小负载单向通信一条 FIFO 就解决代码量少、可读性好、部署也简单。最后再分享一个调试小技巧测试命名管道的时候请务必同时开两个终端一边跑读端、一边跑写端。如果你只启动了一个进程然后看不见输出多半不是程序死锁而是那次 open 在对端手里等着握手。把对面的进程拉起来一切就通了。这一段我在初学阶段重复踩了好几次希望你一次就过。
返回列表