两个进程互不相识,一个是采集端,一个是入库端,各自独立启动、独立退出,中间要把数据递过去,这事儿在单机上最省事的做法就是有名管道。我在好几个采集类项目里都用它做过进程间通信的粘合层,尤其是那种“一方是别人写好的程序、我不想动它源码”的场景,有名管道几乎是成本最低的解法。这篇就把有名管道从创建、打开、读写到进程间通信的完整链路拆开讲,包括创建方式怎么选、open 为什么会让你的进程卡住不动、PIPE_BUF 到底影响什么、读端没了写端为什么会莫名其妙退出等问题。内容偏实战,适合已经会写基本 C 程序、想搞清楚 Linux 进程间通信到底怎么落地的人,也适合手头正被“两个独立程序没法传数据”卡住的同学直接抄作业。下面所有示例都在常见 Linux 环境下验证过,代码可以直接编译运行。
1. 两个互不相识的进程,怎么把数据递给对方
1.1 匿名管道卡在哪一步
先说清楚为什么不能直接用匿名管道。匿名管道是pipe()系统调用创建出来的,它返回两个文件描述符,一个读一个写,看起来挺方便。但问题在于,这两个描述符只对调用pipe()的那个进程以及它 fork 出来的子进程有效,因为子进程是继承父进程的文件描述符表的。也就是说,匿名管道天生只能用于有血缘关系的进程之间,父子、兄弟都行,隔了一层就不行了。
我自己遇到的具体场景是这样的:某项目里有一个第三方的采集程序,只负责把原始数据往标准输出打,源码拿不到也不让改;另外有一个入库程序,只从标准输入读。两个程序各自独立启动,谁也不认识谁。这种情况下pipe()完全用不上,因为你没法让两个毫无关系的进程共享同一组描述符。有人会说用 shell 的竖线|,producer | consumer,这确实能用,但一旦涉及三个以上的进程、或者需要中途插入处理逻辑、或者需要让两个程序以不同频率启停,竖线的表达能力就不够了。
还有一个更隐蔽的限制:匿名管道的数据生命周期完全绑定在进程上。父进程退出、所有持有描述符的进程退出,管道就没了,里面的数据也一起消失。有时候我恰恰需要“写端先跑完退出,读端过一会儿再来取数据”,匿名管道做不到这个,因为它根本没有名字,读端没有途径找到这条管道。
1.2 有名管道真正解决的是“寻址”问题
有名管道(FIFO)的关键改动其实非常朴素:它给管道起了一个名字,这个名字以特殊文件的形式存在于文件系统中。任何进程只要知道这个路径,就能open()它,从而接入同一条管道。寻址问题一解决,血缘限制自然就没了。
我用一个生活化的类比来解释。匿名管道像是两个人在同一个房间里用一根绳子传纸条,房间就是进程空间,绳子就是描述符,出了房间就没法用。有名管道则像是在小区门口设了一个公用信箱,信箱有个门牌号,也就是路径名,谁都能往里面投信,谁都能去取信,投信的人和取信的人完全不必认识。当然,这个信箱有个特点:容量有限,投满了就得等,取空了就得等,而且取走一封就少一封,不会留底。
这里有三个概念必须分清楚,否则后面很容易踩坑。第一,有名管道的名字在文件系统里,是一个真实的目录项,占用 inode;第二,有名管道的数据在内核的管道缓冲区里,不落在磁盘上,关掉电源就没了;第三,有名管道的生命周期跟着文件系统走,只要你不unlink()它,重启机器之后这个路径还在,但里面的数据早就没了。这三点混在一起想,是新手最常见的一类误解,比如有人以为往有名管道写数据相当于写文件,写完就能断电保存,这个想法错得很彻底。
1.3 它到底能干什么、不能干什么
能干的:单机范围内任意两个或多个进程之间的单向字节流传输;跨语言,C 写的程序和 Python 写的脚本能对接;跨权限,只要路径的读写权限配置对了,不同用户跑的程序也能通信;还能和 shell 命令无缝混用,echo往管道里写、cat从管道里读,都不需要写一行代码。
不能干的:跨机器不行,它只在本机内核里;保留消息边界不行,它是纯字节流,写三次可能被读端一次读走,也可能一次写的被读端分三次读走;双向通信不自然,它本质是半双工的,虽然可以用两个 FIFO 拼出双向通道;持久化不行,数据不落盘;大吞吐量场景也不合适,管道缓冲区就那么点大,典型的 64KB 量级,写满了就得阻塞等待。
注意:很多资料把有名管道说成“可以当文件用”,这个说法容易误导。它能被
stat()、能被ls看到、能被open(),但从语义上讲它是流,不支持lseek()随机定位,fseek之类的操作会直接失败。把它当普通文件去 seek,是新手很容易犯的错。
2. 有名管道的本质:一个落在文件系统里的特殊文件
2.1 用 ls 一眼认出它
创建完一个有名管道,最直观的验证方式就是ls -l。普通文件开头是-,目录是d,符号链接是l,而 FIFO 开头是p。这个p就是 pipe 的意思,非常直白。
mkfifo /tmp/demo_fifo ls -l /tmp/demo_fifo # prw-r--r-- 1 user user 0 Jan 1 12:00 /tmp/demo_fifo注意那个大小是0。有人第一次看到会以为创建失败了,其实这就是正常状态——FIFO 的大小永远是 0,因为它根本不存数据,数据都在内核缓冲区里。你可以往里写几个 G 的数据,ls看到的还是 0。这个特性在排查问题时很有用:如果你ls看到一个 FIFO 大小不是 0,那基本可以判断这个路径已经被别的什么东西覆盖了,不是真正的 FIFO。
程序里判断一个路径是不是 FIFO,用stat()加S_ISFIFO()宏,这是最可靠的方式。我一般会在打开之前先做一次这个检查,如果目标路径存在但不是 FIFO,直接报错退出,避免后面出现一堆莫名其妙的行为。比如有人不小心用>重定向在同一个路径上创建了一个普通文件,程序再去open就会把它当普通文件处理,读到的永远是空内容,这个 bug 查起来能耗掉半天。
#include <sys/stat.h> struct stat st; if (stat("/tmp/demo_fifo", &st) == 0 && !S_ISFIFO(st.st_mode)) { fprintf(stderr, "path exists but is not a FIFO\n"); exit(1); }2.2 两种创建方式:命令行和系统调用
创建有名管道有两条路,一条是 shell 里的mkfifo命令,一条是 C 里的mkfifo()函数。两者底层是同一个系统调用,只是入口不同。
命令行方式适合快速验证和脚本里用:
mkfifo /tmp/demo_fifo mkfifo -m 660 /tmp/demo_fifo # 直接指定权限-m参数指定的是最终的权限模式,它会绕过 umask 的影响,这一点和chmod的效果类似。在脚本里我一般会显式带上-m,因为不同环境的 umask 差别很大,不指定的话权限可能是 644 也可能是 664 甚至 600,跨机器部署时很容易出现“我这边能跑你那边不行”的情况。
系统调用方式的函数原型是int mkfifo(const char *pathname, mode_t mode),需要包含<sys/types.h>和<sys/stat.h>。返回 0 表示成功,返回 -1 表示失败,具体原因看 errno。这个函数最常遇到的错误是EEXIST,也就是路径已经存在了。
#include <sys/types.h> #include <sys/stat.h> #include <errno.h> if (mkfifo("/tmp/demo_fifo", 0666) == -1) { if (errno != EEXIST) { perror("mkfifo"); return -1; } /* 已存在,按正常情况继续 */ }这里有个非常重要的实战要点:必须处理EEXIST。因为有名管道是持久化的,程序第一次启动创建成功,第二次启动就会撞上EEXIST。新手常常写成“创建失败就退出”,结果程序只能跑一次,第二次启动直接报错,排查半天才发现是文件已经在了。正确的写法是把EEXIST当成正常情况放行,前面那段代码就是这个思路。
2.3 权限位和 umask 的坑
mkfifo()的第二个参数是 mode,但最终权限不是它说了算,而是它和 umask 做按位与之后的结果。比如你传0666,umask 是0022,实际权限就是0644,也就是只有所有者能写。如果你打算让不同用户跑的程序通过这条管道通信,写权限就必须给到对方,否则open(O_WRONLY)会直接返回EACCES。
我一般的处理方式是:在mkfifo之后显式调一次chmod(),把权限设成预期值,这样不受调用环境 umask 的影响。
mkfifo(path, 0666); chmod(path, 0666);这样做看起来有点啰嗦,但它能让行为和环境解耦。我在一个多用户部署的项目里就因为没做这一步,开发机上跑得好好的,到测试环境换了个 shell 就挂了,原因就是那个 shell 的 umask 是0077,创建出来的管道权限是0600,另一个用户根本打不开。这个坑不大,但排查成本很高。
还有一个容易忽略的点:目录的执行权限。管道能不能被打开,不光看管道文件本身的权限,还要看路径上每一级目录的搜索权限。如果管道放在一个0700的目录里,别人就算对管道文件有全权限也进不去。这个在容器化部署的时候尤其常见,因为容器里的用户 UID 常常和宿主机对不上。
2.4 什么时候该 unlink,什么时候不该
unlink()是删除有名管道的方式,和删除普通文件一样。关键问题是:该在什么时机删。
如果是长期存在的固定服务通道,比如一个常驻的采集端和一个常驻的入库端,那管道就不该删,创建时容错EEXIST就够。这种情况下管道相当于一个约定好的“通信地址”,删了反而会让等待中的进程出问题。
如果是短生命周期的临时通道,比如一个批处理任务跑完就结束,那一定要在退出前unlink(),否则/tmp目录下会积累一大堆垃圾 FIFO 文件。我见过一个项目跑了一个月,/tmp下堆了几千个 FIFO,ls都能卡住。更麻烦的是,如果程序用固定路径,第二次启动时可能因为残留文件而导致行为异常,比如上一个实例没清理干净,新的读端一打开就读到上一个实例的残留数据。
一个稳妥的模式是:用mkstemp类似的思路生成唯一路径,比如在路径里拼上 PID 或者时间戳,用完就删。
char path[64]; snprintf(path, sizeof(path), "/tmp/channel_%d", getpid()); mkfifo(path, 0600); /* ... 使用 ... */ unlink(path);这样做的好处是完全避免了残留文件冲突,缺点是通信双方必须事先约定路径,或者通过其他途径传递路径名。如果两个进程是父子关系,父进程创建完把路径通过命令行参数传给子进程就行;如果完全没有关系,那就得走固定的约定路径。
3. 打开与读写:那些必须理解的阻塞规则
3.1 open 会卡住,这不是 bug
有名管道最容易让人懵的行为就是open()会阻塞。很多人写完int fd = open(path, O_RDONLY);之后发现程序停在那里不动了,第一反应是程序卡死或者死锁了,实际上这是内核的正常行为。
规则是这样的:以只读方式打开 FIFO,会阻塞直到有进程以写方式打开它;以只写方式打开 FIFO,会阻塞直到有进程以读方式打开它。这是 POSIX 规定的语义,目的是保证管道的两端都就位之后才开始传输,避免一端刚写完数据发现没人在听。
我给你一个能直观感受到这个行为的实验。开两个终端,第一个终端执行:
mkfifo /tmp/t1 cat /tmp/t1光标会停在那里不动。再开第二个终端执行:
echo "hello" > /tmp/t1第一个终端立刻打印出hello,然后cat继续等待。这个现象背后的机制就是:第一个终端的cat在open(O_RDONLY)上阻塞,直到第二个终端的echo执行了open(O_WRONLY),两端同时就位,open才返回。
四种打开组合的行为我整理成一张表,这张表建议背下来,能省掉大量调试时间:
| 打开方式 | 是否有对端 | 行为 |
|---|---|---|
O_RDONLY | 无写端 | 阻塞,直到有写端打开 |
O_RDONLY | O_NONBLOCK | 无写端 | 立即返回成功,读操作返回 0(EOF) |
O_WRONLY | 无读端 | 阻塞,直到有读端打开 |
O_WRONLY | O_NONBLOCK | 无读端 | 立即返回 -1,errno 为ENXIO |
O_RDWR | 不需要 | Linux 上不阻塞,但语义不标准,不推荐 |
注意最后一行O_RDWR。在 Linux 上以读写方式打开 FIFO 不会阻塞,因为内核认为“自己既是读端又是写端”,对端总是存在的。有些老代码用它来规避阻塞问题,但我不建议这么干。原因有两个:一是 POSIX 没有明确规定这个行为,可移植性差;二是一旦自己持有读写两端,读端永远不会收到 EOF,因为你自己的写端一直开着,读循环没法自然结束。这个坑我在一个跨平台迁移的项目里踩过,代码在 Linux 上跑得好好的,换到另一个环境就出现读循环永不退出的问题。
3.2 读写语义和 PIPE_BUF 原子性
read()和write()在 FIFO 上的行为和普通文件有相似之处,但细节差别很大,必须搞清楚。
先说读。read(fd, buf, n)返回实际读到的字节数,这个数可能小于你请求的n,不要假设一次读满。当管道里没有数据时,默认阻塞等待;当所有写端都关闭且管道里也没有数据时,返回 0,表示 EOF。这个 0 非常关键,它是读循环的终止条件,很多程序写成while (1) { read(...); }忘了判断 0,结果写端退出之后读端还在空转。
再说写。write(fd, buf, n)在管道缓冲区有空间时会尽量写入,返回实际写入的字节数,也可能小于n。当缓冲区满了,默认阻塞等待。当所有读端都关闭时,写操作会触发SIGPIPE信号,如果这个信号没有被处理,进程会直接被杀掉;如果信号被忽略了,write返回 -1 且 errno 是EPIPE。
PIPE_BUF是这里最重要的常数。它的含义是:当单次write写入的字节数不超过PIPE_BUF时,这个写操作是原子的,不会被其他写者的数据穿插。在 Linux 上这个值是 4096,可以用fpathconf(fd, _PC_PIPE_BUF)动态获取,不要写死 4096,不同系统的值不一样。
原子性意味着什么?假设有两个进程同时往同一条 FIFO 写,A 写一个 1000 字节的消息,B 也写一个 1000 字节的消息。因为都小于 4096,所以最终管道里的内容一定是完整的 A 接完整的 B,或者完整的 B 接完整的 A,不会出现 A 的前半段 + B 的全部 + A 的后半段这种情况。但如果 A 要写 10000 字节,超过了PIPE_BUF,那内核就可能分多次写入,中间被 B 的数据插进来。
这个特性直接决定了一个设计选择:如果你需要消息边界,就把单条消息控制在 PIPE_BUF 以内。或者,自己加一层长度前缀的协议,由读端根据长度字段来切分。这两种方案我都用过,前者简单,后者灵活,具体选哪个看消息大小。
提示:
fcntl(fd, F_GETPIPE_SZ)可以查看管道缓冲区总容量,Linux 上默认通常是 65536 字节,F_SETPIPE_SZ可以调整,但有上限限制。调大缓冲区能缓解写入阻塞的频率,但治标不治本,真正的解法是让读写速率大致匹配。
3.3 非阻塞模式怎么用才对
O_NONBLOCK是把双刃剑。它能让open和读写立刻返回,避免程序卡住,但也意味着你必须自己处理EAGAIN、ENXIO这些错误码,代码复杂度会明显上升。
我一般这样判断要不要用:如果程序是单线程、只做一件事、可以接受等待,那就用阻塞模式,代码最简单,逻辑也最清晰。如果程序需要同时处理多路输入,或者需要在没有数据的时候做别的事情,那就用非阻塞模式配合poll()、select()、epoll()。
非阻塞模式的读写行为值得单独说清楚。读的时候,如果管道为空,read返回 -1,errno 是EAGAIN或者EWOULDBLOCK,这两个值在 Linux 上是相等的。如果所有写端已关闭,返回 0。写的时候,如果缓冲区满,write返回 -1,也是EAGAIN;如果没有读端,返回 -1,errno 是EPIPE同时伴随SIGPIPE。
这里有个细节很多人没注意到:用O_NONBLOCK以只读方式打开 FIFO 会立即成功返回,哪怕一个写端都没有。这意味着你不能用open是否成功来判断对端是否就绪。如果你需要知道有没有写者,得依赖read的返回值——返回 0 表示“有过写者但都关了”,返回EAGAIN表示“还没有写者”。这个区别在做状态机的时候非常有用。
int fd = open(path, O_RDONLY | O_NONBLOCK); if (fd < 0) { perror("open"); return -1; } char buf[1024]; ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { /* 有数据 */ } else if (n == 0) { /* 曾经连接过,现在写端全都关了 */ } else if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 暂时没数据,可以干别的 */ } else { perror("read"); }4. 动手实现一套完整的生产者-消费者通信
4.1 最小可用的单写单读版本
先写一个能跑起来的最小例子,两个程序,一个写,一个读,通过固定路径./data_fifo通信。写端负责创建管道(容错EEXIST),读端假设管道已经存在。
写端代码:
/* producer.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #define FIFO_PATH "./data_fifo" int main(void) { if (mkfifo(FIFO_PATH, 0666) == -1 && errno != EEXIST) { perror("mkfifo"); return 1; } int fd = open(FIFO_PATH, O_WRONLY); if (fd < 0) { perror("open"); return 1; } char line[256]; for (int i = 0; i < 5; i++) { int len = snprintf(line, sizeof(line), "msg-%03d\n", i); ssize_t w = write(fd, line, len); if (w < 0) { perror("write"); break; } printf("sent: %s", line); sleep(1); } close(fd); return 0; }读端代码:
/* consumer.c */ #include <stdio.h> #include <unistd.h> #include <fcntl.h> #define FIFO_PATH "./data_fifo" int main(void) { int fd = open(FIFO_PATH, O_RDONLY); if (fd < 0) { perror("open"); return 1; } char buf[4096]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { printf("recv %zd bytes: %.*s", n, (int)n, buf); } if (n < 0) perror("read"); else printf("writer closed\n"); close(fd); return 0; }编译运行:
gcc -o producer producer.c gcc -o consumer consumer.c ./consumer & # 先起读端,它会在 open 上阻塞 ./producer # 再起写端,两端就位,开始传输这个例子里有几个设计选择值得说明。为什么让写端负责创建?因为写端通常是主动方,它的启动时机更可控;读端被动等待,假设管道存在是合理的。如果读端先启动而写端还没准备好,读端在open上阻塞就行,不会浪费 CPU。为什么写端写完就close而不是unlink?因为管道是长期约定的通信地址,删掉会让后续的读端打开失败。如果确实是一次性任务,可以在最后加上unlink。
跑起来之后你会看到:读端打印五条消息,然后打印 “writer closed”。这个 “writer closed” 来自read返回 0,也就是写端close之后触发的 EOF。这个机制是读端能自然退出的关键。
4.2 多写者场景下的消息定界
单写单读很好办,多写者就麻烦了。假设有多个进程同时往一条 FIFO 写,读端怎么知道每一段数据是谁写的、从哪里到哪里?
前面提过PIPE_BUF的原子性,如果每条消息都小于 4096 字节,那至少能保证不穿插。但“不穿插”只解决了数据完整性问题,没有解决“边界在哪”的问题。因为管道是字节流,读端read(fd, buf, 4096)可能一次读到三条短消息拼在一起,也可能一条长消息分两次读到。
解决方案有两条路。
第一条是定长消息。所有消息都补齐到固定长度,比如 512 字节,读端每次读 512 字节就是一条完整的。实现最简单,缺点是浪费带宽,而且扩展性差,一旦消息变长就得改协议。
第二条是长度前缀。每条消息前放一个固定长度的头,里面写消息体的长度,读端先读头,再按长度读体。这个方案通用,也是我实际项目里用得最多的。
/* 发送方 */ struct msg_hdr { uint32_t body_len; }; void send_msg(int fd, const char *body, uint32_t len) { struct msg_hdr h = { htonl(len) }; write(fd, &h, sizeof(h)); write(fd, body, len); }注意这里我用了两次write,这在多写者场景下是有风险的:虽然每个write本身是原子的,但头和体之间可能被别的写者插进来。稳妥的做法是把头和体拼在一块缓冲区里,一次write出去,而且要保证总长度不超过PIPE_BUF。如果消息本身就超过PIPE_BUF,那就必须自己加锁,通常用文件锁(flock)来串行化写入。
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET }; fcntl(fd, F_SETLKW, &fl); write(fd, full_frame, frame_len); fl.l_type = F_UNLCK; fcntl(fd, F_SETLK, &fl);注意:
flock和fcntl的锁在 FIFO 上的行为要小心验证。我一般更倾向于用外部的一个锁文件来串行化,逻辑更清晰,也不依赖文件系统对 FIFO 加锁的实现细节。
4.3 处理 SIGPIPE:写端莫名其妙退出的元凶
这是我在实际项目里被坑得最惨的一个问题。现象是:写端进程在运行过程中突然消失了,没有任何错误输出,日志也没记,dmesg里也看不出什么。查了很久才发现,是因为读端先退出了,写端下一次write时内核发出了SIGPIPE,而进程没有处理这个信号,默认行为就是终止进程。
这个默认行为在交互式程序里还算合理,在后台服务里就是灾难。写端完全不知道自己是被信号杀的,因为信号是异步的,write根本没来得及返回。
处理方式有两种,选一种就行。第一种是全局忽略这个信号,然后靠write的返回值判断:
#include <signal.h> signal(SIGPIPE, SIG_IGN); /* 之后 write 失败会返回 -1,errno 为 EPIPE */ ssize_t w = write(fd, buf, len); if (w < 0 && errno == EPIPE) { /* 读端没了,做清理 */ }第二种是用sigaction设置一个处理器,在里面设置一个标志位,主循环检查标志位。第一种更简单,我基本都用第一种。sigaction的好处是能拿到更多信息,但一般情况下用不上。
这里还有个衍生问题:读端退出后写端知道了,然后呢?是重启读端、还是缓存数据、还是直接放弃?这个要看业务。我的做法是记录一条告警日志,然后进入重试循环,每隔几秒重新open一次管道,等新的读端启动。这个模式在采集类项目里很常见,因为读端可能是可重启的服务。
while (1) { int fd = open(FIFO_PATH, O_WRONLY); if (fd < 0) { sleep(1); continue; } ssize_t w = write(fd, buf, len); close(fd); if (w == len) break; if (w < 0 && errno == EPIPE) { sleep(1); continue; } break; }注意这里每次重试都重新open一次。为什么不复用旧的 fd?因为EPIPE之后那个 fd 基本废了,继续往里写还是错。重新打开是干净的做法。
5. 高频踩坑与排查清单
5.1 典型故障速查表
下面这些是我这些年遇到过或者帮别人排查过的典型问题,整理成表,遇到问题时可以对照着看。
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
open卡住不动 | 对端未就位,阻塞模式下正常 | ls -l看是p还是-;lsof看有没有其他进程持有 | 先启动对端;或改用O_NONBLOCK |
open返回ENXIO | 用了O_WRONLY | O_NONBLOCK但没读端 | 确认读端是否已运行 | 先起读端,或改阻塞模式 |
| 读到空内容就 EOF | 写端打开后立刻关了,或路径是普通文件 | stat检查S_ISFIFO | 检查写端逻辑;重建 FIFO |
| 写进程无故退出 | 收到SIGPIPE | dmesg、strace看信号 | signal(SIGPIPE, SIG_IGN) |
| 数据被截断或粘连 | 消息超过PIPE_BUF,或多写者并发 | 用fpathconf查PIPE_BUF;数消息边界 | 加长度前缀 + 文件锁 |
mkfifo报EEXIST | 上次运行残留 | ls看路径是否存在 | 容错处理,不要当错误 |
| 不同用户打不开 | 权限或 umask 问题 | ls -l看权限位;检查目录权限 | 显式chmod;检查每级目录 |
| 数据看起来“丢了” | 缓冲区满导致写阻塞,或读端消费慢 | 看写端是否卡在write | 提高消费速率或加缓冲策略 |
5.2 几个我踩过的具体坑
第一个坑是关于read的返回值。我早期写读端的时候,习惯性写成read(fd, buf, sizeof(buf))然后直接用buf,忘了read不会自动加\0。结果打印出来后面跟一堆乱码。管道读和文件读在这点上是一样的,都得自己处理字符串终止。
第二个坑是路径选在了/tmp下,然后用了固定文件名。某次两个项目撞了名字,互相干扰,一个把另一个的数据读走了,排查了半天才想到是路径冲突。后来我改成所有临时管道都带上项目前缀,/tmp/proj_a_channel这种,冲突概率就小多了。
第三个坑是关于容器的。在容器里跑的时候,如果管道是挂载进去的,宿主机的 FIFO 在容器里可能表现异常,尤其是涉及用户命名空间的时候,权限的映射关系很容易搞错。我一般的做法是让容器自己在自己的可写层里创建 FIFO,不要跨挂载点共享。
第四个坑和关闭顺序有关。读端在读循环结束之后就必须close,不能因为“反正程序要退出了”就不关。因为在多进程环境里,描述符没有关闭可能会被 fork 出来的子进程继承,导致写端那边永远看不到 EOF,读端永远等不到结束。这类问题最隐蔽,因为它不是每次都出现,取决于进程创建的时机。
/* 错误示范:读循环里没有处理 EOF,也没有关闭 */ while (1) { read(fd, buf, sizeof(buf)); process(buf); } /* 正确示范 */ ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { process(buf, n); } close(fd);提示:排查管道问题时,
strace -f -e trace=open,openat,read,write,close非常好用,能看到每个进程到底在哪个系统调用上卡住,比加一堆 printf 效率高得多。
5.3 一个容易被忽略的性能问题
管道本身性能不差,数据在内核缓冲区里拷来拷去,没有磁盘 IO。但它有个隐藏成本:每次write和read都是一次系统调用。如果你的消息很小,比如每条只有几十字节,而频率很高,比如每秒几万条,那系统调用的开销就会成为瓶颈。
我做过一个简单的对比:同样是传 1MB 数据,用 1 字节一次写需要一百万次系统调用,用 4096 字节一次写只需要 256 次。后者的耗时可能只有前者的几十分之一。所以如果吞吐量是关注点,一定要批量写、批量读,把消息攒一攒再发。
但攒批也有代价,会增加延迟。这就需要根据业务特点做权衡:实时性要求高的场景,小批量、高频次;吞吐量优先的场景,大批量、低频次。我一般会在写端加一个小的发送缓冲区,攒到一定大小或者超过一定时间就 flush,这个策略和网络编程里的 Nagle 算法思路类似。
还有一点,read的缓冲区大小也不是越大越好。设成 4096 或者 8192 比较合适,正好覆盖PIPE_BUF和常见的内存页大小。设成几 MB 没有意义,因为管道缓冲区本身就没那么大,一次也读不满。
6. 选型:什么时候该用它,什么时候别碰
6.1 和几种常见方案的横向对比
单机进程间通信的方案不少,有名管道只是其中一种。选型的时候我会拿这张表对照一下:
| 方案 | 跨进程血缘 | 消息边界 | 双向 | 跨机器 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|---|
| 匿名管道 | 必须 | 无 | 半双工 | 否 | 极低 | 父子进程简单传递 |
| 有名管道 | 不需要 | 无 | 半双工 | 否 | 低 | 独立程序对接、shell 混用 |
| Unix 域套接字 | 不需要 | 有(流/数据报) | 支持 | 否 | 中 | 需要双向、多客户端 |
| 共享内存 | 不需要 | 自行管理 | 支持 | 否 | 高 | 大数据量、低延迟 |
| 消息队列 | 不需要 | 有 | 支持 | 否 | 中 | 需要优先级、持久化 |
从这张表能看出来,有名管道的定位很清晰:简单、单向、本机、无边界。它最大的优势是简单,一个路径就搞定了,不需要任何额外的配置、不需要建连接、不需要管服务端客户端。它最大的劣势也是简单,需要消息边界的地方就得自己加协议,需要双向的地方就得开两条管道。
我的判断标准大致是这样:如果两个进程之间只是单向的、频率不高的数据流,有名管道足够;如果涉及多客户端接入、需要双向交互、或者需要明确的消息边界,直接上 Unix 域套接字,别在管道上硬堆协议;如果数据量特别大、对延迟特别敏感,共享内存更合适。这个判断不需要纠结太久,因为从管道迁移到其他方案的改造成本通常不高,先跑起来比什么都重要。
6.2 几个实际用得上的典型场景
第一个是日志转发。某些程序只会往标准输出打印日志,你希望把这些日志送给一个统一的收集进程。做法是用有名管道做中转,写端是producer > /path/fifo,读端是收集程序。这种方式不需要改任何一方的代码,成本极低。
第二个是流式压缩或打包。需要把一个大目录打包压缩,但不想先落地一个中间文件再压缩,可以用管道串起来,避免中间文件占磁盘。这类用法在命令行里很常见,本质上就是利用了管道的流式特性。
第三个是配置热更新。主进程监听一条 FIFO,运维人员往里面echo一条命令,主进程读到之后执行对应动作,比如 reload 配置、切换日志级别、清理缓存。这个用法我在运维脚本里见过很多次,比发信号灵活,因为可以传参数,也比开个 HTTP 接口轻量得多。
第四个是测试替身。在做集成测试的时候,有时候需要模拟一个外部服务的行为,用 FIFO 可以很轻松地搭一个假的服务端,往里面写预设的响应,被测程序照常从管道读,完全感知不到差别。
6.3 给准备上手的你几条实在建议
第一,一定要处理EEXIST和SIGPIPE。这两个是使用有名管道时最高频的错误来源,前者会导致程序只能跑一次,后者会导致进程无声退出。在写第一行代码的时候就加上,比事后排查省事得多。
第二,先想清楚要不要消息边界。如果需要,就在协议里加长度前缀,别指望管道帮你保边界。这个问题早期不做决定,后期改造成本很高,因为上下游都要跟着改。
第三,路径设计要带上唯一性。尤其是放在/tmp下的管道,加上项目标识或者 PID,能避免大量莫名其妙的冲突。同时要有清理机制,程序退出了就把临时管道删掉。
第四,读端要有自然的退出条件。靠read返回 0 来结束循环,不要写while(1)。写端关闭会触发 EOF,这是管道设计里很优雅的一点,用好它能让进程生命周期管理变得清晰。
第五,排查问题先看ls -l和strace。看一眼文件类型是不是p,能排除掉一半问题;strace看一下open、read、write停在哪个调用上,能定位掉另一半。
第六,别在管道上做太复杂的事。它就是个简单的字节流通道,能承担的责任有限。一旦你发现自己在管道之上实现重连、心跳、分片、重传这些东西,就该停下来重新考虑方案了。工具选型的第一原则是匹配复杂度,用简单的工具做简单的事,用合适的工具做复杂的事,别硬扛。