前两天整理 Linux 笔记时,翻到一年前写的匿名管道 demo,标题写着“Linux-C 匿名管道(PIPE) 3.19”。那时候我正好在一台内核 3.19 的嵌入式板子上练手,把 pipe 这套东西从 API 到内核实现全过了一遍。今天就把这次实践完整展开,讲清楚 pipe() 到底做了什么、父子进程是怎么通过一段内核缓冲区传数据的,以及我踩过的那些坑。
如果你在 Linux 下写 C 代码,迟早会碰到进程间通信。匿名管道就是最简单的那种:一个 pipe() 调用,两个文件描述符,父子进程就能传数据。不要小看它,很多项目里的数据流处理、输出重定向、同步机制,底层都是这套逻辑。标题里的 3.19 指的是我实验环境的内核版本,管道 API 从那时到现在基本没变,所以这篇文章就算放到今天的发行版上,依然能直接照着敲。
这篇文章适合刚入门 Linux 系统编程的 C 语言开发者,也适合那些已经写过 shell 管道、但不知道背后的系统调用是怎么工作的人。我会从原理、代码、问题排查、内核细节四个层面,把这个项目完整复现一遍。
1. 项目定位:匿名管道到底解决了什么问题
1.1 进程隔离带来的通信需求
操作系统为了让程序之间不互相搞乱内存,默认给每个进程开了独立的地址空间。父进程改了变量,子进程看不到,这既安全又省心。但实际任务往往需要进程之间配合:比如父进程想拿到子进程的计算结果,或者一个程序处理完的数据要交给另一个程序继续处理。
怎么传?最粗暴的做法是写文件。但文件在磁盘上,进程间频繁通信就得反复刷盘,慢还不是最麻烦的,更大的问题是文件名冲突——两个进程同时写同一个临时文件,数据就乱了。更优雅的方案是在内核里留一块内存缓冲区,A 进程往里写,B 进程从里读,读写过程对进程来说就像操作普通文件描述符。这块内核缓冲区,加上两端关联的文件描述符,就是“管道”。
管道分为匿名管道和命名管道。匿名管道没有文件系统中的名字,只在创建它的进程里存在,一般通过 fork 传给子进程使用。命名管道(FIFO)有路径名,可以让没有亲缘关系的进程通过文件名打开。我这次练手做的是匿名管道,因为它的系统调用少,最适合作为理解 IPC 的起点。
1.2 为什么选 C 和 PIPE 做这个练习
用 C 写管道,最大的好处是贴近系统调用本身。你直接调用 pipe(),传一个 int[2] 数组,内核返回两个文件描述符,错误时返回 -1、设置 errno。整个过程没有任何语言层面的包装,动手敲一遍,你对文件描述符、进程、阻塞这些概念的体会,比看十篇博客都深。
匿名管道本身也是一个非常纯粹的实验对象。它只涉及进程、文件描述符、内核缓冲区,不牵扯 socket、网络协议栈那些额外概念。只要你掌握了 fork() 和 read()/write(),剩下的事情就是数据流的流动方向。我当时特意选了内核 3.19 的板子,一是想看老版本内核里管道的页缓冲实现,二是想在嵌入式环境里验证管道在这种资源受限的场景下是否顺畅。
1.3 项目适用的读者和前置知识
如果你是完全没有 Linux 编程经验的新手,我不建议直接从管道入手。你需要先知道几个基础概念:进程是什么,fork() 会把当前进程复制一份,子进程和父进程各自有一份文件描述符表,以及 read/write 是阻塞式的系统调用。
如果你已经能写简单的 C 程序,知道指针和数组,用过 fopen/fread 这类 stdio 函数,那这个项目就是你从文件操作跨到进程通信的第一座桥。匿名管道不看 IP 地址,不看端口,只关心“谁持有文件描述符、谁往里面写、谁从里面读”。整个项目代码量不大,一百行左右就能跑通。
2. 匿名管道的核心机制拆解
2.1 pipe() 到底创建了什么
先看函数原型:
#include <unistd.h> int pipe(int pipefd[2]);成功时返回 0,pipefd[0] 是读端,pipefd[1] 是写端。失败返回 -1,并设置 errno,常见错误是 EMFILE(进程文件描述符耗尽)或 ENFILE(系统文件表满)。
注意,管道不是文件系统里的文件。它没有目录项,没有文件名,你不能用 open() 打开一个管道。你可以把它理解为“一段内核缓冲区 + 两个文件描述符引用”。pipefd[1] 负责把数据写进缓冲区,pipefd[0] 负责把数据从缓冲区读出来。数据在管道里是单向流动的,你没法用 pipefd[0] 写、再用 pipefd[1] 读,方向反了就直接报错。
管道缓冲区的大小是有限制的。在 3.19 内核里,默认管道容量是 16 个页,每页 4KB,也就是 64KB。这个容量可以通过 fcntl() 的 F_GETPIPE_SZ 查看,也可以用 F_SETPIPE_SZ 调整,但最大不能超过 /proc/sys/fs/pipe-max-size 设定的值,默认通常是 1MB。缓冲区满了,写端就会被阻塞,这是理解后面各种坑的关键。
2.2 fork 之后的文件描述符关系
管道创建完,接下来一般是 fork()。fork 会复制进程地址空间,同时也会复制文件描述符表,所以子进程里也有 pipefd[0] 和 pipefd[1]。问题来了:管道原本是单向的,如果父子进程各自都持有两个端,那就没人知道哪个端该关。
正确的做法是:用完自己不需要的那一端后立刻 close。比如父进程负责读、子进程负责写,那么父进程要关闭 pipefd[1],子进程要关闭 pipefd[0]。为什么要这么较真?因为管道的读写规则和“写端是否完全关闭”强相关。只要系统里还存在哪怕一个写端没有关闭,读端调用 read() 就会一直阻塞,等不到 EOF,程序就像卡死了一样。
我见过很多新手在这上面翻车:代码逻辑没问题,但忘了 close 多余的文件描述符,结果父子进程互相等待,整个程序挂死。这种现象在系统编程里有个专门的说法,叫“死锁”。解决的方法只有一个,就是严格遵守“用哪个端,留哪个端,其余全部 close”的原则。
2.3 管道的读、写与阻塞规则
管道的阻塞规则是整套机制的灵魂。先说读端:如果管道缓冲区里有数据,read() 会立刻返回并拿走数据;如果缓冲区空,但系统里至少还有一个写端打开着,read() 会阻塞在这种状态,直到有数据可读;如果缓冲区空,并且所有写端都已经关闭,read() 不会阻塞,而是立即返回 0,表示读到 EOF。
再看写端:如果缓冲区没满,write() 会直接写入并返回写入的字节数;如果缓冲区满了,write() 会阻塞,直到内核腾出空间,把数据写入后返回。如果读端全部关闭,写端继续 write(),内核会向进程发送 SIGPIPE 信号,这个信号的默认动作是终止进程。命令行里经常能看到的 “Broken pipe” 报错,就是这种情况。
还有一个重要概念叫原子写。POSIX 规定,一次写入的字节数不大于 PIPE_BUF 时,write() 必须是原子的。在 Linux 上 PIPE_BUF 是 4096 字节。也就是说,只要一次写入不超过 4KB,内核保证这次写入的数据不会被其他进程的写入操作交错;如果超过 4KB,内核分多次写,多个写者同时往管道写的时候,数据就可能交错,读端拿到的东西就不是你想的那样了。这一点在处理多进程写入同一个管道时尤其要注意。
2.4 管道的生命周期
管道的生命周期完全由文件描述符决定。内核在最后一个引用它的 fd 被关闭时,才会释放缓冲区。所以,只要有一个写端还没关,读端就会认为“管道还没结束”;同理,只要有一个读端还开着,写端理论上就还能找到接收方。
在实际编程中,你需要在合适的时机关闭 fd。比如子进程写完数据后,应当关闭自己的写端;父进程读完全部数据后,也应当关闭读端。程序退出时,操作系统会自动回收所有 fd,但如果进程常驻,忘记 close 会导致 fd 泄漏,长期运行后文件描述符被耗光,后续的 open/pipe 都会失败。
理解这个生命周期,你就知道为什么管道可以用于进程间同步:读端阻塞等待数据,相当于把“子进程产生了数据”这个事件天然地转化成了“read 返回”这个动作。这是后面做进程池、事件驱动的基础。
3. 实操:用 C 在 3.19 内核上实现管道通信
3.1 环境准备
我的实验环境是一块运行 Linux 3.19 内核的 ARM 板子,上面装了 gcc 4.9 和基本的 build 工具。其实你在自己的电脑上用虚拟机跑任意一个发行版都行,匿名管道接口是稳定的,不用刻意复刻内核版本。编译命令很简单:
gcc -o pipe_demo pipe_demo.c -Wall-Wall 一定要加。管道程序最常见的错误就是“关闭了错误的 fd”或者“忘了 printf 的换行符”,这些警告选项能帮你提前看到很多问题。
如果你的系统没有 gcc,先安装 build-essential 或者对应发行版的开发工具包。另外建议装一下 strace,后面排查阻塞问题会非常有用:
strace -f ./pipe_demo-f选项会跟踪 fork 之后的子进程系统调用,你能清楚地看到每个进程在 read、write、close 上停留了多久,是排查问题的一双透视眼。
3.2 第一个 demo:父读子写
这是最基础的管道用法:子进程往管道写字符串,父进程从管道读出来。完整代码如下:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> #include <string.h> int main(void) { int pipefd[2]; pid_t pid; char buf[128]; const char *msg = "hello from child"; if (pipe(pipefd) == -1) { perror("pipe"); exit(EXIT_FAILURE); } pid = fork(); if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { // 子进程:关闭读端,写数据后关闭写端 close(pipefd[0]); write(pipefd[1], msg, strlen(msg) + 1); close(pipefd[1]); exit(EXIT_SUCCESS); } else { // 父进程:关闭写端,从管道读取子进程的数据 close(pipefd[1]); ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1); if (n < 0) { perror("read"); exit(EXIT_FAILURE); } buf[n] = '\0'; printf("parent got %zd bytes: %s\n", n, buf); close(pipefd[0]); wait(NULL); } return 0; }这段代码有几个细节值得展开。第一,在 fork 之前先 pipe(),这样父子进程才会共享同一组管道 fd。如果你在 fork 之后才调用 pipe(),两个进程各自创建各自的管道,完全不相通。第二,子进程里第一个操作就是 close(pipefd[0]),父进程里第一个操作是 close(pipefd[1]),这样能保证管道的“单向性”不被破坏。第三,子进程写完数据后主动 close 写端,父进程的 read() 才能读到 EOF,这里虽然只写了一次数据,read 也能正常返回,但养成关闭 fd 的习惯很重要。
编译运行后,正常输出类似:
parent got 18 bytes: hello from child3.3 第二个 demo:用 dup2 把标准输出接到管道
管道最常见的使用场景是“把子进程的标准输出接过来”。这个机制类似 shell 里ls | grep xxx管道符的底层实现,但 shell 帮我们封装好了,C 里需要我们手动完成。核心就是 dup2() 函数。
完整代码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(void) { int pipefd[2]; pid_t pid; if (pipe(pipefd) == -1) { perror("pipe"); exit(EXIT_FAILURE); } pid = fork(); if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); } if (pid == 0) { // 子进程:把 stdout 重定向到写端,然后 exec dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execlp("ls", "ls", NULL); perror("execlp"); exit(EXIT_FAILURE); } else { // 父进程:关闭写端,读取子进程标准输出 close(pipefd[1]); char buf[4096]; ssize_t n; while ((n = read(pipefd[0], buf, sizeof(buf))) > 0) { fwrite(buf, 1, n, stdout); } close(pipefd[0]); wait(NULL); } return 0; }这段代码的重点在于 exec 之前先 dup2。dup2(pipefd[1], STDOUT_FILENO) 的意思是把标准输出这个 fd(值为 1)重定向到管道写端上,也就是“以后写到 stdout 的数据,全部进管道”。这里有个新手容易忽略的问题:为什么 dup2 之后还要 close(pipefd[1])?因为 dup2 复制了一个新的 fd,原来的 pipefd[1] 还开着,如果不关闭,标准输出 fd 和 pipefd[1] 同时指向同一个管道写端。虽然功能上看起来没问题,但会造成 fd 泄漏,而且一旦 exec 执行其他程序,被 dup2 重定向的 fd 依然会保留。正确的做法就是 dup2 之后立刻 close 原始的 fd,让系统里只留下标准输出这一个写端。
父进程的 while 循环会持续 read,直到子进程的写端全部关闭、read 返回 0。这里不能用只读一次的方式,因为子进程的 ls 可能输出很多内容,超过管道缓冲区导致写端阻塞,如果父进程只 read 一次就 continue,两个进程会卡死。所以必须循环读完所有数据。运行效果等价于直接在 shell 里执行 ls 然后通过管道输出给父进程。
3.4 返回值检查和 EINTR 处理
系统调用和普通函数不同,它可能被信号打断。比如进程收到 SIGCHLD 时,正在阻塞的 read() 可能被唤醒并返回 -1,errno 被设置为 EINTR。严格来说,这时候不应该认为是错误,而应该重新发起调用。
我自己在嵌入式板子上调试时就遇到过:一个管道读进程,偶尔莫名其妙读到 -1,日志里显示 Interrupted system call,程序却还活着。排查了半天,发现是其他线程的信号处理把它打断了。真实项目里建议封装一个读取函数:
ssize_t read_retry(int fd, void *buf, size_t count) { ssize_t n; do { n = read(fd, buf, count); } while (n < 0 && errno == EINTR); return n; }写数据也一样,write() 返回 -1 且 errno 为 EINTR 时,需要重试。这个细节在写长时间运行的守护进程时几乎是必踩的坑。
4. 常见问题与排查技巧实录
4.1 管道卡在 read 上,永远不返回
这是匿名管道最容易翻车的问题。现象:程序运行后停在 read 那里,既不打印数据,也不退出。原因九成是“该关闭的写端没有关闭”。最常见的场景是父进程忘了关闭写端,然后父进程自己读,结果系统里还有一个写端开着,read 认为“管道还没有结束”,就一直等数据。
排查方法分两步。第一步,用 strace 跟踪系统调用,看到底卡在哪个 read 上:
strace -f -o trace.log ./pipe_demo第二步,看 trace.log 里 fork 前后的 fd 状态。如果发现父进程在 read 之前没有 close 管道写端,问题就定位了。还有一种情况是 fork 之前创建了两个管道,代码里复用了同样的 fd 变量,子进程关闭了 A 管道,但 B 管道的写端还开着,也会导致读端等不到 EOF。
解决办法只有一个:在 fork 之后立刻关闭不需要的那一端。建议在代码里把每个 fd 的关闭动作写清楚,尤其是父进程分支和子进程分支,不要共用一套 close 逻辑。
4.2 子进程一写数据就崩溃(Broken pipe)
另一个高频问题:子进程向管道写数据时突然退出,终端显示 “Broken pipe”。根本原因是读端已经被关闭,写端继续写了。内核收到“读端已经没了”的信号后,向写进程发送 SIGPIPE,这个信号的默认行为是结束进程。
常见的触发场景:父进程提前退出,或者父进程在读取逻辑中出错提前 close 了管道读端,而子进程还在不停写。如果子进程不想被 SIGPIPE 干掉,可以选择忽略这个信号:
#include <signal.h> signal(SIGPIPE, SIG_IGN);忽略之后,write() 会返回 -1,errno 设为 EPIPE。这时候你可以自行处理,比如记录日志再退出,而不是被信号打得措手不及。注意,SIGPIPE 是进程级信号,一般在 main 函数最开始设置一次即可。
我自己写管道工具时,统一策略是:凡是作为写端的进程,都在入口处 SIG_IGN SIGPIPE,然后根据 write 的返回值判断对端是否已经关闭。这样既能避免进程被信号突然终止,又能拿到明确的错误码。
4.3 写入数据超过管道容量导致阻塞
管道的缓冲区是有限的,默认 64KB。如果你一次性 write 1MB 数据,而且对端没有及时读取,write 调用就会在写到缓冲区满的时候阻塞,直到对端读走一部分数据,才能继续写入。这不是 bug,而是管道的天然行为,但在某些场景下会变成 bug:比如写端和读端互相等待对方先操作,就会死锁。
实测管道容量可以用 fcntl:
#include <fcntl.h> int cap = fcntl(pipefd[1], F_GETPIPE_SZ); printf("pipe capacity: %d\n", cap);如果确实需要更大的缓冲区,可以用 F_SETPIPE_SZ 调整,但注意不要超过系统限制。生产环境里,更可靠的方式是让读端持续读取,不要等到把所有数据攒齐了才去读。比如前面说的 while 循环,就是为了保证读端持续消费,写端才不会被阻塞。
4.4 多进程同时写入导致数据交错
如果一个管道同时有多个写端,每个写端分别 write 几百字节,读端收到的数据可能不是你预期的顺序。POSIX 只保证不超过 PIPE_BUF(4096 字节)的写入是原子的,也就是说,每个写端即使一次写 100 字节,这 100 字节在管道里是连续的,不会跟另一个进程的 100 字节混在一起。但它们之间的顺序不确定:A 进程写入的 100 字节可能排在 B 进程之前,也可能之后。
如果业务要求严格的消息边界,你需要在应用层自己加协议。常见的做法是每条消息前面加一个长度字段,读端先读长度,再按长度读取消息体。管道本身不提供消息分区,这是它和消息队列(POSIX mq)最大的区别。
4.5 忘记回收子进程,产生僵尸进程
管道 demo 写完后,经常有人发现程序退出后系统里还残留一个defunct进程。这就是僵尸进程,原因是子进程已经退出,但父进程没有调用 wait() 或 waitpid() 回收它的退出状态。在父进程的代码块里加上:
wait(NULL);问题就解决了。更复杂的场景下,如果父进程需要同时处理多个子进程,可以用 waitpid 配合 WNOHANG 在一个循环里回收所有退出的子进程。僵尸进程虽然不占 CPU,但会占用进程表项,长期运行累积多了,系统会无法创建新进程。
4.6 用表格快速定位问题
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| read 卡住不返回 | 还有写端未关闭 | strace 查看 fd |
| Broken pipe | 读端已关闭,写端继续写 | 忽略 SIGPIPE,检查读端逻辑 |
| write 阻塞 | 管道缓冲区满,没人读 | 确认读端是否在持续读取 |
| 数据顺序混乱 | 多写端交错,超过 PIPE_BUF | 应用层加长度字段/协议 |
| 僵尸进程 | 父进程未 wait | 使用 wait or waitpid |
| 读数据半截 | 一次读没读完,对端还在写 | 循环读取直到返回 0 |
5. 深入内核:3.19 的管道实现细节
5.1 管道缓冲区并不是一块连续内存
很多人以为管道就是一个循环队列,数据像排队一样在内存里排好。实际上,Linux 的管道实现更巧妙:它由若干个页组成,每个页对应一个pipe_buffer结构。在内核 3.19 中,struct pipe_inode_info里维护了一个pipe_buffer数组,默认长度是 16,所以默认容量就是 16 个页,64KB。
每次 write() 时,内核把数据复制到当前可用的页中;每次 read() 时,读取当前页的数据,读完后释放或重用这个页。如果数据跨越页边界,内核会把一个页的读取指针和写入指针分别管理,这样就避免了先把所有数据拷贝到连续内存里的开销。
这个设计带来的好处是,零拷贝场景下,管道页可以直接通过 splice() 从一个文件描述符转移到另一个,中间不经过用户态缓冲区。这个特性在高性能日志转发里很常用,但这是后话了。理解管道的页结构,至少能解释为什么F_SETPIPE_SZ是按页对齐的:你设置一个不是页倍数的大小,内核会向上取整到页的倍数。
5.2 pipe2 和 O_CLOEXEC
在 Linux 2.6.27 之后,系统提供了 pipe2():
#define _GNU_SOURCE #include <fcntl.h> #include <unistd.h> int pipe2(int pipefd[2], int flags);flags 可以传 0,效果和 pipe() 一样;也可以传 O_NONBLOCK 或 O_CLOEXEC,或者两者位或。O_CLOEXEC 是一个很容易被忽略但很重要的标志:它告诉内核,在 exec 新程序时自动关闭这个 fd,避免新程序继承到旧的管道 fd。
举个例子,如果你 fork 子进程后,马上用 execlp 执行一个外部程序,而这个外部程序完全不知道你的管道存在,却仍然继承了管道写端的 fd。一旦这个外部程序后续 fork 了其他进程,管道写端就被意外传递得更远,读端永远得不到 EOF。使用 pipe2(pipefd, O_CLOEXEC) 可以彻底避免这个隐患。
3.19 内核已经完全支持 pipe2,所以新代码建议直接使用 pipe2 而不是 pipe。如果你的代码需要考虑老内核的兼容性,再退回 pipe() 也不迟。
5.3 匿名管道 vs 命名管道
匿名管道的限制是“必须由亲缘关系进程共享”,因为它是通过 fork 继承文件描述符的。没有亲缘关系的两个进程,怎么共享同一个管道?答案是命名管道,或者叫 FIFO。它有一个文件系统里的路径名,任何进程都可以用 open() 打开同一个路径,从而获得同一管道。
| 对比项 | 匿名管道 | 命名管道 FIFO |
|---|---|---|
| 创建方式 | pipe() | mkfifo() |
| 文件名 | 无 | 有路径 |
| 进程关系 | 亲缘(fork) | 任意进程 |
| 打开方式 | 直接得到 fd | open() |
| 生命周期 | fd 全关闭即消失 | 路径存在直到删除 |
| 典型场景 | 进程内父子通信 | 服务端与客户端、Shell 多命令协作 |
命名管道在 open 时默认会阻塞:以只读方式打开时,要等到有写者打开;以只写方式打开时,要等到有读者打开。这也是一个常见的坑,但如果你想做的项目只涉及父子进程,匿名管道已经足够,不需要引入 FIFO 的额外复杂度。
6. 项目扩展:从这个练习还能走向哪里
6.1 用管道做简单进程池
我做完这个基础 demo 后,第一个扩展就是把管道用在了一个小型任务队列里。父进程创建 N 个管道,fork N 个子进程,每个子进程持有自己的管道读端。父进程根据负载把任务写入对应子进程的管道写端,子进程从管道读到任务描述后执行,执行完把结果写回另一条管道。
注意,这里需要每个子进程两条管道:一条父到子传任务,一条子到父传结果。因为匿名管道单向,双向通信必须建两条。这个模型虽然简单,但已经能覆盖很多嵌入式场景下的主从协作。如果你希望一个父进程同时监听多个子进程的“结果到达”事件,就要引入 poll 或者 epoll。
6.2 把管道读端加入 epoll
管道默认是阻塞式的,但配合 O_NONBLOCK 和 epoll,可以做得非常高效。把所有子进程返回结果的管道读端加入 epoll,父进程就能在一个线程里同时等待多个子进程的数据。这个模式和用 socket 做网络事件循环非常相似,只是通信载体从网络换成了管道。
写法上,先用 pipe2(fds, O_NONBLOCK) 创建管道,再把读端 fd 加入 epoll 事件集。一旦 epoll 报告可读,就调用 read()。因为 fd 是非阻塞的,即使没数据,read 也会立即返回 -1 和 EAGAIN,不会卡住事件循环。这种模型在嵌入式里常见于多传感器数据采集:每个传感器采集子进程通过管道返回数据,主进程用 epoll 统一调度。
6.3 注意管道在 fork + exec 场景下的方向
如果你经常写 shell 管道,你可能会遇到“为什么子进程 exec 之后管道还是没数据”的问题。根本原因往往在于:你 fork 之前创建的管道,子进程里同时有读写两端,你虽然 close 了不需要的那一端,但 exec 会保留所有没加 O_CLOEXEC 的 fd。如果 exec 启动的程序和你预想的管道方向不一致,比如它往 stdout 写,而你没有提前把 stdout 重定向到管道写端,那数据就去不了管道。
正确的顺序是:pipe() -> fork() -> 子进程 dup2 重定向 -> close 原 fd -> exec。每一步都要确认 fd 状态。这些细节在实现一个简单的popen()时特别容易踩。实际上,popen()函数就是封装了“创建管道 + fork + 重定向 + exec”整个过程,你可以去查看 glibc 的实现,作为管道编程的进阶学习材料。
6.4 内核版本差异带来的行为变化
虽然 pipe API 稳定,但管道容量的默认值在不同内核版本里并不完全一样。3.19 里默认 16 页,64KB;比较新的内核里,默认容量也是基于PIPE_DEF_BUFFERS,通常还是 16 页。最大容量限制/proc/sys/fs/pipe-max-size默认为 1MB,普通用户可以调的默认上限是/proc/sys/fs/pipe-user-pages-soft所限制的。如果你的程序依赖管道容量来避免阻塞,建议运行时用 F_GETPIPE_SZ 查一次实际值,不要写死。
我个人的习惯是,在程序启动时打印一次:
int cap = fcntl(fd, F_GETPIPE_SZ); printf("pipe capacity = %d bytes\n", cap);这样可以避免在不同内核版本上调试时产生困惑。你辛辛苦苦调好的参数,到了别的主机上效果完全不同,这种事很常见。
最后的几句话
我做完这套匿名管道练习,最大的收获不是记住了 pipe() 怎么用,而是彻底理解了“文件描述符是进程间通信的门把手”这句话。管道的本质是一块内核缓冲区,但你能操作它的唯一入口就是那两个 int 值。谁拿着哪个端、什么时候关闭哪个端,决定了数据怎么流、流到什么时候停。
如果你正准备学 Linux 系统编程,我建议你把这个 demo 亲手敲一遍,不要直接复制。改一改数据大小,看看超过 64KB 时 write 的阻塞现象;开两个终端用 strace 跟踪一下进程状态;再把 demo 改成双向两条管道,看看通信怎么设计。这些东西只有手摸一遍才会真正变成自己的经验。
最后分享一个小技巧:在所有涉及管道的程序入口处,加上signal(SIGPIPE, SIG_IGN)。这个习惯我后来用在了所有 socket 和管道程序里,再没遇到过进程被莫名信号杀掉的情况。写系统程序,安全边界防范得越早,后面踩坑越少。