1. 管道的物理形态:内核里那块被两个文件描述符夹住的缓冲区
第一次把ls | grep log敲进终端的人,大概不会去想中间那根竖线到底是什么。可一旦开始写多进程程序,早晚会被read()卡住不返回、或者子进程一退出父进程就收到一个莫名信号这类问题绊一跤。这些现象的共同答案,都在同一个地方:管道并不是磁盘上的文件,也不是两个进程共享的一段用户态内存,它是内核里申请的一块环形缓冲区,被一对文件描述符夹在中间。理解了这句话,后面所有的阻塞、EOF、原子性问题都会变得顺理成章。
这篇内容面向的读者范围比较宽:写过fork()但没系统用过 IPC 的,做运维脚本想把两个独立程序串起来的,以及被管道死锁卡了半天还没找到原因的老手。核心关键词只有三个——管道、进程间通信、匿名管道与命名管道。我会把系统调用层面、缓冲区层面、踩坑排查层面都过一遍,代码全部可以复制下来直接编译跑,环境是常见的 Linux 发行版,内核版本 4.x 以上都适用。
1.1 从 file 结构体到 pipe_inode_info,管道究竟占谁的内存
很多人第一次接触管道时误以为它类似临时文件,读写完了数据就落在某个目录下。实际完全不是。pipe()调用发生时,内核做三件事:分配一个pipe_inode_info结构(里面包含环形缓冲区的页指针、读写指针、等待队列),创建两个struct file对象分别指向它,再把这两个 file 挂到当前进程的两个空闲文件描述符上。所以管道的数据只活在内核态,进程退出、两个 fd 都关闭之后,这块缓冲区立即被回收,磁盘上不会留任何痕迹。
这个设计直接决定了管道最鲜明的两个特征。第一是生命周期由 fd 决定:只要还有一个进程持有管道某一端的 fd,管道就还活着,缓冲区里的数据就还能被读走。第二是数据流过即消失,只能读一次,没有lseek这回事,lseek对管道返回ESPIPE。我见过有人在管道上试图pread或者回退读取,这从机制上就不成立。
还有个细节值得说:管道缓冲区是按页分配的,早期内核只有一页(4KB),后来改成可扩展的环形缓冲,默认 16 页即 64KB。它不是"写进去立刻能读",而是走了一遍完整的用户态到内核态拷贝、再从内核态到用户态拷贝,两次内存拷贝。这也是它比共享内存慢的原因,但也正是这两次拷贝换来了天然同步,不需要自己加锁。
1.2 半双工这条限制约束的到底是什么
POSIX 定义管道是单向的,也就是数据只能从写端流向读端。虽然 Linux 在 2.6.16 之后给pipe()加过O_DIRECT之类的实验性开关,但标准接口下它就是半双工。这个限制不是性能问题,是设计约定:fd[0]固定为读端,fd[1]固定为写端,你在写端调用read()会得到EBADF。
要双向通信怎么办?开两条管道,一条负责 A 到 B,一条负责 B 到 A。我做过一个本地进程间的请求应答模型,就是父进程持p2c[1]和c2p[0],子进程持p2c[0]和c2p[1],各自把不用的两端关掉。虽然代码多几行,但语义清楚,也不会出现双方同时往一个 fd 写导致数据方向混乱的问题。
有同学会问,那能不能干脆socketpair()一把梭?可以,socketpair(AF_UNIX, SOCK_STREAM, 0, fd)得到的就是全双工的一对 fd,接口也兼容read/write。但如果你只是想让父子进程单向传个配置或输出,用管道就够了,不受 Unix 域套接字那些权限、命名、连接建立的复杂度影响。
1.3 匿名管道和命名管道的分水岭
匿名管道(也就是pipe()创建的那种)有一个致命短板:它没有名字,所以只有能"继承"到这两个 fd 的进程才能用它。继承靠的是fork(),子进程复制父进程的 fd 表,这两个 fd 就跟着过去了。所以匿名管道的典型场景是有亲缘关系的进程:父子、兄弟。如果两个进程毫无关系,一个是系统服务、一个是普通命令行工具,匿名管道就接不上。
命名管道(FIFO)补的正是这块。它先在文件系统里创建一个特殊文件节点(mkfifo),虽然这个节点也不存数据,但它是个"名字",两个没有亲缘关系的进程可以各自open()同一个路径,从而在内核里会合到同一个管道对象上。用一句话概括区别:匿名管道靠 fd 继承会合,命名管道靠路径名会合。
| 维度 | 匿名管道 pipe() | 命名管道 FIFO |
|---|---|---|
| 创建方式 | pipe(int fd[2]) | mkfifo(path, mode)后open() |
| 是否有文件系统节点 | 无 | 有(ls -l显示类型为p) |
| 使用前提 | 进程间存在亲缘关系 | 无亲缘关系也可,靠路径会合 |
| 生命周期 | 随 fd 全关而消失 | 节点常驻,需显式unlink |
| 跨机器 | 不可能 | 也不可能(仅本机) |
| 典型用途 | fork 后的父子通信、shell 管道 | 采集端与处理端解耦、跨进程日志汇聚 |
2. pipe() 那一行系统调用,藏着四种关闭组合
int fd[2]; pipe(fd);这两行看起来平淡无奇,但它是整篇文章里最值得反复咀嚼的部分。因为管道 90% 的诡异行为——读写卡死、读到 EOF 但进程不退出、对端没了却毫无反应——都能归结为"某一端没有按预期关闭"。我带的实习生里有不下三个人栽在同一个地方:fork 之后父子两边都保留了全部四个 fd,结果读端永远等不到 EOF。
2.1 读端写端的约定是用出来的,不是强制的
内核并不知道fd[0]就该读、fd[1]就该写,它只记录这个 fd 上挂着的是"管道读端"还是"管道写端"这个属性。pipe()返回的顺序是固定的,0 是读、1 是写,这是 man 手册明写的约定,不是内核检查出来的。所以你不小心对fd[0]调用write()会拿到EBADF,不是因为"读端不能写"这个概念,而是因为内核在创建时就把这个 file 对象的操作集设成了只读。
理解这一点有实际意义:一个 fd 在被dup()、被fork()复制之后,它们共享同一个struct file,也就共享同一个引用计数。只有当某一端的所有副本都被关闭,内核才认为这一端真的关了。这就引出了下面这个最常见的错误。
2.2 fork 之后父子各自到底该关哪一端
先看一段能跑的最小代码:
#include <unistd.h> #include <stdio.h> #include <string.h> #include <stdlib.h> #include <sys/wait.h> int main(void) { int fd[2]; if (pipe(fd) < 0) { perror("pipe"); exit(1); } pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { /* 子进程只负责写 */ close(fd[0]); const char *msg = "child payload"; ssize_t n = write(fd[1], msg, strlen(msg)); fprintf(stderr, "[child] wrote %zd bytes\n", n); close(fd[1]); _exit(0); } /* 父进程只负责读 */ close(fd[1]); char buf[256]; ssize_t n = read(fd[0], buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("[parent] got: %s\n", buf); } else if (n == 0) { printf("[parent] EOF, nothing to read\n"); } else { perror("[parent] read"); } close(fd[0]); waitpid(pid, NULL, 0); return 0; }编译:gcc -Wall -O2 -o pipe_demo pipe_demo.c。
关键在close()那几行。子进程close(fd[0])是因为它不读,留着这个读端会让读端引用计数永远大于零;父进程close(fd[1])同理。如果你把父进程那句close(fd[1])注释掉,会发生什么?程序还是能正常打印,然后read()在第一次返回后你如果再读一次,就永远卡住了。因为父进程自己还攥着写端,内核认为"还有人可能写",于是read()阻塞等待,而不是返回 0。
我一般的规范是:fork 之后立刻在各自分支的第一行关闭无关端,不要留到后面。别指望"我后面会关",出错路径一多,必然漏。
2.3 用 shell 里的竖线反推一遍
ls /var/log | grep systemd这条命令背后,shell 大概做了这些事:先pipe(fd),再 fork 出两个子进程。第一个子进程dup2(fd[1], STDOUT_FILENO)然后close(fd[0]); close(fd[1]),execve("/bin/ls", ...)。第二个子进程dup2(fd[0], STDIN_FILENO),同样关掉两个原始 fd,execve("/bin/grep", ...)。shell 自己把两个 fd 都关了,然后waitpid两次。
注意dup2之后为什么要关原 fd:dup2已经让标准输入输出指向管道了,多留一个原始 fd 在子进程里没有意义,而且会拖住引用计数。这个习惯我建议一直保留,哪怕是在自己写的程序里用dup2重定向。
还有一个容易被忽视的点:管道连接的两个命令如果一个是长跑任务,shell 的waitpid是等两个都结束。中间那个进程如果退出,另一个会收到SIGPIPE(下一章细说)。这就是为什么yes | head -n 1不会无限跑下去——head退出后yes写管道失败被信号杀掉。
3. 阻塞、容量与原子性:管道行为的三条硬边界
如果你只用过read读管道然后打印,可能觉得它就是个"先进先出队列"。但只要涉及到两个以上的写者、或者数据量超过缓冲区,不搞清楚这三条边界,写出来的程序就是薛定谔的:大多数时候对,压测一上来就错。
3.1 64KB 这个容量从哪来,能不能改
默认情况下,管道缓冲区大小是 65536 字节,也就是 16 个内存页。可以用fcntl(fd, F_GETPIPE_SZ)查,用F_SETPIPE_SZ改,但上限受/proc/sys/fs/pipe-max-size控制,很多发行版默认 1MB,普通用户不能超过这个值(root 可以突破)。
int cap = fcntl(fd[1], F_GETPIPE_SZ); /* 典型输出 65536 */ int newcap = fcntl(fd[1], F_SETPIPE_SZ, 4096 * 64); /* 尝试设为 256KB */容量决定了什么?决定了写端在缓冲区满时会阻塞。如果你写一个每秒产生 10MB 的日志生产者、配一个每秒处理 1MB 的消费者,缓冲区那 64KB 根本撑不住,写端会频繁阻塞,整个链路的吞吐就被最慢的一环卡死。这时候要么提高消费能力,要么用F_SETPIPE_SZ适度放大缓冲,要么干脆换成共享内存加信号量。我一般只在数据是"短脉冲"(突发 100KB 左右然后长时间空闲)时才考虑调大,长期不均衡的吞吐用调缓冲是治标。
顺带说一句,缓冲区大小不等于"一次 write 就能全进去"。内核的环形结构导致当缓冲区被写指针和读指针切成两段时,单次写入可能填不满一整块连续空间,虽然从上层看write()会帮你处理,但这也说明为什么要关注下面这个原子性问题。
3.2 读空、写满、对端关闭:返回值对照表
管道上所有的read/write行为,都可以用"缓冲区空/满"和"对端是否还开着"两个因素组合出来。O_NONBLOCK 是可选项,默认阻塞。
| 场景 | 阻塞模式下 | 非阻塞模式(O_NONBLOCK)下 |
|---|---|---|
| 读一个非空管道 | 返回读到的字节数 | 同左 |
| 读一个空管道,写端仍开着 | 一直阻塞,直到有数据或写端全关 | 立即返回 -1,errno = EAGAIN |
| 读一个空管道,写端已全关 | 立即返回 0(EOF) | 立即返回 0(EOF) |
| 写一个未满管道 | 写入并返回字节数 | 同左 |
| 写一个已满管道,读端仍开着 | 阻塞直到有空间 | 返回 -1,errno = EAGAIN |
| 写一个读端已全关的管道 | 收到SIGPIPE,默认终止进程 | 同左(信号与阻塞模式无关) |
这张表我建议贴在显示器边上。尤其是"读端已全关时无论阻塞与否都会收到 SIGPIPE"这一条,很多人以为设了非阻塞就安全了,其实不成立。
另外补充一个 FIFO 独有的规则:以O_WRONLY打开一个还没有任何读端打开的 FIFO 时,阻塞模式下open()本身就会阻塞,非阻塞模式下直接返回 -1 且errno = ENXIO。这点和匿名管道完全不一样,下一章展开。
3.3 PIPE_BUF 与多写者交错
PIPE_BUF在 Linux 上是 4096。它的含义是:当一次写入的字节数不超过 PIPE_BUF 时,内核保证这次写入是原子的,不会被其他写者的数据插在中间。超过这个值,就不保证,可能出现 A 的一半数据接着 B 的全部数据再接着 A 的另一半。
这个保证在单写者场景下毫无意义,但在多写者场景下就是救命稻草。比如多个子进程往同一个日志管道写结构化记录,每条记录如果你控制在 4KB 以内一次性write(),读端读到的每条记录就是完整的,不需要自己加锁或加分隔符校验。反过来,如果你先write长度头再write正文,两次调用之间就可能被别的进程插入,读端解析就会错乱。
我处理过的一个案例:四个采集进程往同一个 FIFO 写 JSON,每条 500 字节左右,本来一切正常,后来某条记录因为采集到的堆栈变长涨到了 6KB,就偶尔出现解析失败。排查了半天才定位到是跨过了 PIPE_BUF 边界。解决办法是从业务层限制单条记录大小,超长的拆成多条并带头部序号。
4. FIFO 实战:把两个互不相识的进程接到一起
匿名管道解决了亲缘进程,可现实里更常见的是"一个常驻服务 + 一个随手写的脚本"。比如你有个采集程序想把状态推给一个 Python 写的分析脚本,两者生命周期独立、互不 fork。这种时候命名管道是最省事的方案,比走本地套接字少写一大堆连接管理代码。
4.1 open 的阻塞规则是最容易翻车的地方
创建 FIFO 很简单:
mkfifo -m 0660 /tmp/collect.fifo ls -l /tmp/collect.fifo # prw-rw---- 1 user user 0 ... /tmp/collect.fifo注意那个p,它表示这是一个管道类型的特殊文件。stat查看时st_size永远是 0,不管里面缓存了多少数据。
坑在于open()。FIFO 的打开遵循一条铁律:读写两端必须都有人打开,open()才会返回。具体分成几种:
- 以
O_RDONLY打开:阻塞,直到有一个写端打开。 - 以
O_WRONLY打开:阻塞,直到有一个读端打开。 - 以
O_RDONLY | O_NONBLOCK打开:立即成功返回,即使没有写端。 - 以
O_WRONLY | O_NONBLOCK打开:如果没有读端,立即失败,errno = ENXIO。 - 以
O_RDWR打开:Linux 上立即成功(不推荐,语义不标准,且会让自己既当读端又当写端,导致 EOF 永远不来)。
我见过最典型的翻车是:写端程序先启动,日志卡在open()上一动不动,运维以为程序挂了。其实是没人在读,open()正老老实实阻塞着。这种"看起来启动成功但什么都不做"的情况特别迷惑人,所以我在写 FIFO 写端时,第一行日志一定打出 "waiting for reader on /tmp/xxx.fifo"。
4.2 一个可复现的采集端与处理端
写端(C 实现,每 500ms 推一条记录):
#include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <string.h> #include <time.h> int main(void) { const char *path = "/tmp/collect.fifo"; fprintf(stderr, "[producer] waiting for reader...\n"); int fd = open(path, O_WRONLY); /* 没有读端就卡在这里 */ if (fd < 0) { perror("open"); return 1; } fprintf(stderr, "[producer] reader attached\n"); for (int i = 1; i <= 20; i++) { char line[256]; time_t now = time(NULL); int len = snprintf(line, sizeof(line), "seq=%d ts=%ld value=%d\n", i, (long)now, i * 7 % 100); if (len <= 0 || len >= (int)sizeof(line)) break; if (write(fd, line, len) != len) { perror("write"); break; } usleep(500 * 1000); } close(fd); return 0; }读端(shell 一行就够,演示解耦):
cat /tmp/collect.fifo | while read -r line; do echo "[consumer] $(date +%T) <- $line" done先跑读端还是先跑写端都行,谁先启动谁等对方。这里有个小陷阱:cat结束后管道关闭,while循环自然退出。但如果你用mkfifo之后反复测试,记得每次写完检查一下读端是否真的收到了全部数据——写端close()之后数据还在缓冲区里,读者能读完再收到 EOF,这一点是内核保证的,不用担心丢数据。
如果读端中途退出(比如 Ctrl+C 杀了cat),写端的下一次write()会触发SIGPIPE,默认行为是进程直接终止。想让写端优雅处理,需要忽略信号并检查errno == EPIPE,见下一章。
4.3 多读者多写者接入时的数据如何切分
FIFO 是支持多个进程同时打开同一路径的,但它们共享同一个管道缓冲区。也就是说两个写者同时写,读端看到的数据是交错的;两个读者同时读,每条消息只会被其中一个读到,谁抢到算谁的。这点和消息队列的"广播"完全不同,别抱错期待。
这带来两个实践约束。第一,多写者时必须依靠 PIPE_BUF 原子写,保证单条记录的完整性,做法同上一章。第二,多读者时要做负载分摊,如果每条消息只能被处理一次,那正好;如果每个读者都需要全量,FIFO 就不合适,得用一个读端进程读出来后分发给多个下游(比如再写多个 FIFO,或者用更上层的机制)。
我在一个本地指标汇聚的场景里用过方案二:一个"扇出进程"独占读端,读到后按规则写到三四个不同的 FIFO,每个下游各读各的。虽然多了一层进程,但职责清晰,某个下游挂了也不影响其他下游,扇出进程只要检查write返回值决定是否丢弃即可。
5. 那些年踩过的管道坑:从 SIGPIPE 到缓冲假死
技术细节讲完,进入实操里最耗时间的环节。管道的坑有个共性:不会报错,只会"不动了"或者"莫名退出了"。没有异常堆栈,没有日志,只能靠对机制的理解反推。我把这几年踩过、也帮人排查过的几类问题整理成一条排查链路,遇到问题时按顺序走一遍,基本能定位。
5.1 写端消亡后的 SIGPIPE,为什么进程无声无息就没了
现象:一个往管道写数据的进程,跑着跑着就消失了,退出码是 141,日志里啥也没有,dmesg也干净。
141 就是128 + 13,13 是 SIGPIPE。原因很明确:读端全部关闭后,写端继续write(),内核向写进程发送 SIGPIPE,默认动作是终止进程,而且不产生 core dump。所以你看不到任何现场。
处理方式有两种,取决于业务语义。如果你希望"读端没了就当写完收工",那就在程序初始化时signal(SIGPIPE, SIG_IGN),之后write()会返回 -1 且errno = EPIPE,你自己决定是退出还是重连。如果你不忽略信号,那就得在每次写之前判断读端是否还在,但这几乎做不到(有竞态),所以实际工程里基本都是忽略 SIGPIPE + 检查 EPIPE。
#include <signal.h> signal(SIGPIPE, SIG_IGN); /* ... */ ssize_t n = write(fd, buf, len); if (n < 0) { if (errno == EPIPE) { fprintf(stderr, "reader gone, stop writing\n"); /* 关闭并准备重连,或直接退出 */ } else { perror("write"); } }这里有个容易忽略的细节:忽略 SIGPIPE 是进程级的设置,如果你的程序是多线程的,任何线程调用signal(SIGPIPE, SIG_IGN)都影响全进程。这在库代码里尤其危险——你作为一个库把 SIGPIPE 忽略了,宿主程序原来依赖默认终止行为的逻辑就被改变了。所以写库的时候更稳妥的做法是用send(fd, buf, len, MSG_NOSIGNAL),但那是套接字专用,管道没有等价物,只能改信号处置,或者在文档里明确声明。
5.2 输出重定向到管道后,日志突然不实时了
这个坑跟管道本身没关系,但百分之百会在用管道的场景里撞上,而且极难自查。
现象:程序直接在终端跑,日志一行行实时刷;一旦./app | grep xxx或者./app > /tmp/out.log,就变成攒一大块才出来一次,甚至程序结束才全部吐出。
原因是 C 标准库的缓冲策略:stdout连接终端(tty)时是行缓冲,遇到换行就 flush;连接管道或普通文件时是全缓冲,攒够 4KB(BUFSIZ)才 flush 一次。程序没变,只是 stdout 的目标变了,缓冲模式跟着变。
对应的解决办法有几种,按侵入性从小到大:
| 方法 | 做法 | 适用场景 |
|---|---|---|
| 改程序 | 关键日志后加fflush(stdout),或启动时setvbuf(stdout, NULL, _IOLBF, 0) | 自己能改代码 |
| 用工具包裹 | `stdbuf -oL ./app | grep xxx` |
| 写 stderr | fprintf(stderr, ...),stderr 默认无缓冲 | 调试输出、错误日志 |
| 伪终端 | 用script -c之类的方式给程序一个 tty | 特殊场景,代价高 |
我一般给团队的建议是:任何会被接到管道上的命令行工具,输出到 stdout 的进度类信息一律手动fflush,或者干脆写到 stderr。这样别人| grep、| head的时候行为一致,不会以为是程序卡住了。head -n 5之后程序还在傻跑几分钟,很多时候就是缓冲导致的错觉加上 SIGPIPE 处理不当的复合结果。
5.3 从"读到 EOF 不返回"到"两边互等"的完整排查链路
我把管道类问题按"卡住"和"异常退出"两类,整理成一条从现象到根因的路径。这套流程走下来,比盲目加日志快得多。
第一步:确认卡在哪个调用上。用strace -f -e trace=read,write,open,close -p <pid>挂上去,如果已经卡住了,用cat /proc/<pid>/stack(需要权限)或者gdb -p <pid>看调用栈。多数时候一眼就能看出是read还是open上停着。
第二步:看 fd 引用计数。用ls -l /proc/<pid>/fd列出该进程持有的所有 fd。如果它在读管道却还持有一个写端 fd,那基本就是"自己攥着写端导致读不到 EOF"这个经典问题。这一步我强烈建议养成习惯,ls -l /proc/*/fd 2>/dev/null | grep pipe能看出全系统谁在持有管道。
第三步:区分"空管道阻塞"和"对端消失"。用cat /proc/<pid>/wchan看内核等待点,管道读写通常显示pipe_read或pipe_write。再结合write端的进程是否还活着判断。两端都活着且都停在pipe_read/pipe_write上,那就是典型的死锁——双方都在等对方。
第四步:排查环形依赖。死锁的常见形态是:进程 A 写完等 B 的响应,B 在等 A 发完。用两条管道做请求应答时如果谁都不先关写端或者谁都不先读,就锁死了。解决办法是明确协议方向,或者用select/poll同时监听两个方向,避免"同步请求-同步应答"这种天然易锁的模式。
| 现象 | 高频根因 | 处理方式 |
|---|---|---|
read永远不返回,程序无输出 | 本进程仍持有写端 fd,未关闭 | 检查/proc/<pid>/fd,关闭无关端 |
| 写进程退出码 141,无日志 | 读端全关触发 SIGPIPE | 忽略信号并处理 EPIPE |
open()卡死不动 | FIFO 另一端无人打开 | 保证先启动读端,或用 `O_RDONLY |
| 输出攒批、日志不实时 | stdout 全缓冲 | fflush、stdbuf -oL或写 stderr |
| 多进程写同一 FIFO,读端解析错乱 | 单次写入超过PIPE_BUF | 限制单条记录 ≤4KB 或自行加锁 |
| 程序莫名其妙"卡"很久才退出 | 有子进程继承了 fd,waitpid等待中 | fork 后立即在子进程关闭无关 fd |
这套流程的核心思想是:管道问题几乎都能通过 fd 归属和引用计数解释清楚。只要你清楚每一个 fd 在哪个进程里、由谁关闭,就不会有想不通的卡死。
6. 选型判断:管道在 IPC 工具箱里的位置
管道用起来太顺手,顺手到容易过度使用。我在评审代码时见过把管道塞进数据量每秒几十 MB 的路径里,结果 CPU 一半耗在两次内存拷贝上;也见过用命名管道做跨用户权限控制,最后被文件权限和目录粘滞位搞晕。这一章就把管道的边界画清楚。
6.1 和共享内存、消息队列、套接字横向比一遍
| 机制 | 数据拷贝次数 | 同步方式 | 跨机器 | 适合的规模 | 主要限制 |
|---|---|---|---|---|---|
| 匿名管道 | 2 次 | 内核自带阻塞 | 否 | 小到中等,KB 级批次 | 需亲缘关系,单向 |
| 命名管道 FIFO | 2 次 | 内核自带阻塞 | 否 | 小到中等,多进程汇聚 | 共享缓冲区,无消息边界 |
| 共享内存 | 0(映射后直接访存) | 需自建(信号量/原子量) | 否 | 大块数据、高频访问 | 同步复杂,易出内存序问题 |
| 消息队列 | 2 次 | 内核队列,有消息边界 | 否(POSIX 可经网络文件系统) | 中等,需保留边界 | 系统级资源限制,配置麻烦 |
| Unix 域套接字 | 2 次 | 支持面向连接/数据报 | 否 | 中等到较大,双向 | 代码量比管道多 |
| TCP 套接字 | 2 次以上(协议栈) | 面向连接/数据报 | 是 | 大,跨机 | 开销最大,需处理连接状态 |
判断逻辑其实很朴素:如果数据量小、需要天然同步、只有本机、单向就够,管道就是最优解,代码最少、出错面最小。一旦出现下面几种信号,就该考虑换:数据块经常超过几百 KB(换共享内存)、需要保留消息边界(换消息队列或数据报套接字)、需要双向且协议复杂(换 Unix 域套接字)、需要跨机器(只能套接字)。
6.2 什么时候我明确不建议上管道
第一,高吞吐连续流。管道的两次拷贝在几十 MB/s 以上就明显吃力,而且阻塞模型会把生产者消费者绑定在一起,任何一方抖动都会放大成整体延迟。这种场景用共享内存环形队列,配合原子索引,性能差距是数量级的。
第二,需要消息边界或优先级。管道是字节流,没有"第几条消息"的概念。你可以在应用层加分隔符或长度头,但那就是自己造轮子。消息队列天生带边界,写起来反而更省心。
第三,权限和生命周期难控的场景。FIFO 依赖文件系统权限,要处理创建时机、残留文件、多用户访问、容器挂载路径等一系列问题。我见过因为容器重启后 FIFO 文件残留、open()行为和预期不一致导致启动卡住的案例。这种场景改用 Unix 域套接字(配SO_PEERCRED校验对端身份)或者直接走本地 TCP,可控性高得多。
第四,错误处理和重连要求高的场景。管道一旦读端消失,写端除了收 SIGPIPE 别无选择,没有"半关闭"、没有连接状态、没有重传。如果业务要求断线重连、消息不丢,管道这套模型根本承载不了,它本来就是设计给短生命周期、强耦合进程用的。
我个人在实际项目里的体会是:管道最好的位置是"胶水",不是"主动脉"。把sort、grep、自研的小工具串成一条流水线,把某个进程的输出喂给另一个进程做过滤和转发,这些场景用管道简直是量身定做,几行代码就顶别人几十行。但一旦它成了系统里的核心数据通道,承载着不能丢、量还大的数据,那就是给自己埋雷。两者的分界线不在于技术难度,而在于你是否能接受"对端一挂,这条链路就无解"这个前提。能接受,管道够用且优雅;不能接受,早点换个更结实的方案,别等出问题再改。