恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
【Linux指南】进程通信系列(二):匿名管道 —— pipe 的本质、fork 继承与四种读写规则
首页
资讯中心
/
【Linux指南】进程通信系列(二):匿名管道 —— pipe 的本质、fork 继承与四种读写规则
【Linux指南】进程通信系列(二):匿名管道 —— pipe 的本质、fork 继承与四种读写规则
发布时间:2026/10/15 10:50:59
上一篇我们建立了 IPC 的核心认知进程通信的本质是让互相通信的进程看到同一份由操作系统提供的公共资源。从这一篇开始我们正式学习第一种、也是 Unix 历史上最古老的 IPC 方式 ——管道pipe。你每天在 Shell 里敲的ls | grep txt、ps aux | wc -l底层走的全是它。这一篇我们不满足于 “会用”而是要回答三个问题管道在内核里长什么样两个进程是怎么 “看到” 同一条管道的管道的读写行为为什么是这样的把这些搞懂你会发现管道的一切 “奇怪” 行为都是自然而然的。文章目录一、先搞懂为什么不能用普通文件通信二、管道是什么连接两个进程的一条数据流三、pipe 接口创建一条管道3.1 函数原型3.2 为什么返回的是文件描述符3.3 fd 编号的规律四、内核视角管道是 披着文件外皮的内存4.1 第一步pipe() 在内核里建了什么4.2 第二步fork 让子进程 继承 了管道4.3 第三步关掉不用的端形成单向通道4.4 第四步为什么匿名管道只能用于亲缘进程五、动手实验父子进程通信5.1 简版五句话完成一次通信5.2 完整版双向验证与进程回收六、四种读写规则逐个实验验证6.1 情况一管道为空 读端正常 → 读阻塞6.2 情况二管道写满 读端正常 → 写阻塞6.3 情况三写端全部关闭 读端正常 → read 返回 0EOF6.4 情况四读端全部关闭 写端正常 → SIGPIPE 杀死写进程6.5 补充非阻塞模式七、管道的大小与写入原子性7.1 管道容量有多大7.2 PIPE_BUF 与原子性八、管道的五大特性总结九、常见坑这几个错误 90% 的人都踩过十、总结与下一篇预告10.1 本篇核心结论10.2 下一篇预告一、先搞懂为什么不能用普通文件通信在讲管道之前先排除一个 “想当然” 的方案。既然通信的前提是 “看到同一份资源”那磁盘上现成的文件不就是天然的吗进程 A 把数据写进log.txt进程 B 打开log.txt读取 —— 看起来完美。但画一张图你就会发现问题三个致命问题性能差读写要走完整的文件系统路径路径解析、inode 查找还可能涉及刷盘时序失控A 写了没写、B 什么时候能读OS 不会帮你协调全靠进程自己碰运气语义缺失OS 不会因为 “文件里出现了新数据” 而通知任何人。所以通信需要的是一份内存级的、由内核专门管理、自带协调能力的公共资源 —— 这就是管道的出发点。二、管道是什么连接两个进程的一条数据流管道是 Unix 中最古老的进程间通信形式。我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”。这个名字起得非常形象 —— 想象一根水管两个关键特征先立住单向数据只能从一个方向流到另一个方向半双工。要双向通信建立两条管道先进先出先写进去的数据先被读出来像队列一样。三、pipe 接口创建一条管道3.1 函数原型#includeunistd.hintpipe(intpipefd[2]);参数一个整型数组pipefd这是输出型参数—— 调用成功后内核把两个文件描述符写回给你pipefd[0]读端数据从这里流出pipefd[1]写端数据从这里流入这个约定顺序要记死0像嘴巴读、1像笔写记反了后面全错。返回值成功返回 0失败返回 -1并设置 errno。3.2 为什么返回的是文件描述符一个细节管道明明是一段内存为什么返回的却是fd文件描述符因为 Linux 遵循 “一切皆文件” 的设计哲学 —— 管道被包装成了文件的形式对管道的读写操作和读写普通文件完全一致都是read/write/close迎合了 “Linux 一切皆文件” 的思想。这样一来上层接口完全复用不需要为管道学一套新 API内核里复用 struct file 那套框架来管理它下一节展开。3.3 fd 编号的规律验证一下如果进程刚启动就调用pipe()返回的 fd 是多少intpipefd[2]{0};pipe(pipefd);printf(pipefd[0]%d, pipefd[1]%d\n,pipefd[0],pipefd[1]);// 输出pipefd[0]3, pipefd[1]4结果是3 和 4—— 因为每个进程启动时0、1、2 三个位置已经被标准输入、标准输出、标准错误都指向终端 tty占住了新打开的 “文件” 只能从 3 开始编号。这个细节后面进程池实战中还有大用。四、内核视角管道是 “披着文件外皮的内存”这是本篇最核心的一节。我们分四步把管道从创建到使用的完整内核图景拼出来。4.1 第一步pipe() 在内核里建了什么调用pipe()时内核在内存里开辟一段缓冲区并创建两个 struct file一个读端、一个写端挂进当前进程的文件描述符表fd_array。注意两点这两个 struct file 对应的 inode不指向磁盘上的任何文件—— 管道从头到尾不碰磁盘不需要刷盘数据就放在 inode 关联的内存缓冲区里内核按环形队列的方式组织它。4.2 第二步fork 让子进程 “继承” 了管道关键问题来了pipe 是当前进程创建的另一个进程怎么才能看到同一条管道回忆一下fork 创建子进程时会把父进程的files_struct含整个fd_array拷贝一份—— 注意拷贝的是指向关系不是文件本身浅拷贝。于是父进程 子进程 ┌─────────────┐ ┌─────────────┐ │ fd_array │ │ fd_array │ │ 0 1 2 │ │ 0 1 2 │ │ 3 4 │fork 拷贝指向关系 │ 3 4 │ └─┼────┼──────┘ └─┼────┼──────┘ │ │ ┌─────────────────────────────┘ │ │ └──┼───────────────┐ │ ▼ ▼ ▼ ▼ ┌────────────────┐ ┌────────────────┐ │ struct file │ │ struct file │ │ 写端 │ │ 读端 │ │ 引用计数 2 │ │ 引用计数 2 │ └───────┬────────┘ └───────┬────────┘ │ 同一个 inode │ └──────────┬──────────────┘ ▼ ┌──────────────────────┐ │ 内存缓冲区环形队列 │ ← 真正存数据的地方 └──────────────────────┘父子进程的 fd_array 各有一条线指向同一批 struct file—— 这就满足了通信的前提两个进程看到了同一份资源。每个 struct file 上有引用计数fork 之后一个端被两个进程指着计数为 2各自关闭不用的端后减 1计数归零缓冲区才被内核回收。4.3 第三步关掉不用的端形成单向通道管道是单向的通信前双方要各让一步子进程要写、父进程要读 → 子进程close(pipefd[0])关读端父进程close(pipefd[1])关写端这样就形成了 “父写子读”或反过来的单向数据流。4.4 第四步为什么匿名管道只能用于亲缘进程这是 “匿名” 二字的根本来源一定要想透普通文件有路径名两个毫不相干的进程可以各自open(/tmp/log.txt)打开同一个文件而管道没有路径、没有名字是纯内存对象第三方进程根本 “找不到” 它想拿到这条管道的 fd唯一的途径就是 fork 那一刻对 fd 表的整体拷贝。所以匿名管道天然只能用于有血缘关系的进程父子、兄弟之间。那没有亲缘关系的进程怎么办—— 第四篇的命名管道 FIFO 会解决。五、动手实验父子进程通信5.1 简版五句话完成一次通信// simple_pipe.c#includeunistd.h#includestdlib.h#includestdio.h#includeerrno.h#includestring.h#defineERR_EXIT(m)\do{\perror(m);\exit(EXIT_FAILURE);\}while(0)intmain(intargc,char*argv[]){intpipefd[2];if(pipe(pipefd)-1)ERR_EXIT(pipe error);pid_tpid;pidfork();if(pid-1)ERR_EXIT(fork error);if(pid0){// 子进程写端close(pipefd[0]);// 关掉不用的读端write(pipefd[1],hello,5);close(pipefd[1]);exit(EXIT_SUCCESS);}close(pipefd[1]);// 父进程关掉不用的写端charbuf[10]{0};read(pipefd[0],buf,10);printf(buf%s\n,buf);return0;}运行输出bufhello。注意代码里的顺序纪律先 pipe再 forkfork 之后立刻关掉自己不用的那一端。5.2 完整版双向验证与进程回收实际项目中我们还要处理read的三种返回值、回收子进程避免僵尸// testpipe.cc#includeiostream#includecstdio#includecstring#includecstdlib#includeunistd.h#includesys/types.h#includesys/wait.hconstintsize1024;voidWriter(intwfd)// 子进程写端{std::string bufferhello, I am child-;pid_tselfgetpid();intnumber0;charbuffer_out[1024];while(true){sleep(1);// 每秒写一次方便观察父进程的阻塞// 用 snprintf 组装带 PID 和序号的消息snprintf(buffer_out,sizeof(buffer_out),%s%d-%d,buffer.c_str(),self,number);write(wfd,buffer_out,strlen(buffer_out));// 注意只写 strlen(buffer)不要 1 —— 不把多余的 \0 写进管道}}voidReader(intrfd)// 父进程读端{charbuffer[1024];while(true){sleep(5);// 故意读得慢5 秒才读一次ssize_tnread(rfd,buffer,sizeof(buffer));if(n0){buffer[n]0;// 手动补 \0 才能安全当字符串打印std::cout父进程收到消息: bufferstd::endl;}elseif(n0)// 写端全部关闭且管道为空 → EOF{std::cout写端已关闭读到了文件结尾EOFstd::endl;break;}else{std::cerrread errorstd::endl;break;}}}intmain(){intpipefd[2]{0};intnpipe(pipefd);if(n0)return1;pid_tidfork();if(id0){// 子进程close(pipefd[0]);// 关读端Writer(pipefd[1]);close(pipefd[1]);exit(0);}// 父进程close(pipefd[1]);// 关写端Reader(pipefd[0]);pid_tridwaitpid(id,NULL,0);// 回收子进程避免僵尸(void)rid;return0;}这段代码里埋了几个刻意的细节都是实际编码的纪律write(wfd, buffer_out, strlen(buffer_out))只写有效长度不要 1—— 避免 ‘\0’ 也进管道污染数据读到的数据要手动buffer[n] 0—— 管道传的是裸字节不保证字符串结尾父进程故意读得慢5 秒子进程写得快1 秒—— 就是为了引出下一节的读写规则waitpid(id, NULL, 0)回收子进程—— 配合(status 8) 0xFF还能拿到子进程退出码(status 0x7F)拿到终止信号这是进程控制系列的知识。用ps -l观察还能看到血缘关系的铁证子进程的PPID 等于父进程的 PID—— “匿名管道要求血缘关系” 在进程表上的直接体现。六、四种读写规则逐个实验验证管道的行为可以浓缩成 “四种情况”每一种都值得亲手验证一遍。设读写双方 “正常” 指的是对方 fd 还开着。6.1 情况一管道为空 读端正常 → 读阻塞上面 5.2 的代码就是这个实验子进程每 1 秒写一条父进程每 5 秒读一条。父进程读完管道里的存量数据后管道空了read就不再返回进程睡眠等待直到子进程写入新数据。当没有数据可读时默认O_NONBLOCKdisableread调用阻塞进程暂停执行一直等到有数据来到为止。6.2 情况二管道写满 读端正常 → 写阻塞让子进程疯狂写、父进程睡大觉不读// full_test.c —— 子进程不停写父进程 100 秒后才读if(id0){close(pipefd[0]);constchar*msgaaaaaaaaaaaaaaaaaaaa;while(1)write(pipefd[1],msg,20);// 全速写入}// 父进程 sleep(100) 之后才开始 read现象子进程会写一阵子然后卡住管道满write阻塞直到父进程开始读子进程才继续写。用ps能看到子进程处于睡眠态 —— 这就是管道自带的 “背压” 机制内核自动控制了写入方的节奏。6.3 情况三写端全部关闭 读端正常 → read 返回 0EOF// 子进程写一条就退出写端随之关闭if(id0){close(pipefd[0]);write(pipefd[1],bye,3);close(pipefd[1]);// 写端关闭exit(0);}// 父进程读完 bye 后继续 read// 返回值 n 0 —— 不是错误是 EOF注意区分read返回 0 不是出错而是 “所有写端都关了、管道也空了”等价于读到文件结尾EOF。5.2 完整版代码里n 0分支就是处理这种情况 —— 它是管道通信中判断 “对端退出” 的标准方法。6.4 情况四读端全部关闭 写端正常 → SIGPIPE 杀死写进程反过来父进程直接退出读端关闭子进程还在写// 父进程close(pipefd[1]) 后 close(pipefd[0])直接 return// 子进程还在 while(1) write(...);现象子进程写入时收到SIGPIPE13 号信号默认动作是终止 —— 子进程无声无息地死掉了。可以用kill -l | grep PIPE确认 13 号信号的存在。这个行为的含义是“都没人读了你还写管道已经没有存在意义内核直接处决写方”。它也是一把双刃剑—— 后面进程池实战中控制子进程退出的正确姿势和这里的错误姿势都会反复出现。6.5 补充非阻塞模式以上都是默认行为。如果通过fcntl给管道 fd 设置O_NONBLOCK管道空时read不再阻塞立即返回 -1errno 为EAGAIN管道满时write同样返回 -1、EAGAIN。这个模式在 IO 多路复用场景会大量使用本系列先知道有这回事即可。七、管道的大小与写入原子性7.1 管道容量有多大管道缓冲区不是无限的。Linux 中可以用两种方式查证$ulimit-a|greppipe size# 部分 shell 可以直接看$ getconf PAGESIZE# 管道容量与页大小相关在 Linux 2.6.11 之后默认管道容量是65536 字节64KB。验证思路就利用 6.2 的实验子进程全速写、父进程不读统计子进程实际写进去多少字节后卡住 —— 正好卡在 64KB 附近。7.2 PIPE_BUF 与原子性容量之外还有一个更重要的常量 ——PIPE_BUFLinux 下默认 4096 字节要写入的数据量不大于 PIPE_BUF时Linux 保证这次写入是原子的—— 要么完整写进去要么一条都不写多个进程同时往一条管道写也不会出现两条消息 “搅和” 在一起要写入的数据量大于 PIPE_BUF时不再保证原子性 —— 数据可能被分多次写入中间可能夹杂其他进程的数据。这解释了进程池实战中一个设计细节任务码用int4 字节 PIPE_BUF传递天然原子、不会乱序混叠。也再次埋下伏笔 —— 管道是字节流没有消息边界需要接收方按约定长度切分而第六篇的消息队列就是来解决 “边界” 问题的。八、管道的五大特性总结特性含义亲缘限制只能用于具有共同祖先亲缘关系的进程之间通信由一个进程创建后 fork 使用半双工数据只能单向流动双方都要通信时需要建立两条管道流式服务面向字节流数据没有边界、没有类型“写入多少次” 与 “读出多少次” 没有对应关系生命周期随进程一般而言进程退出管道释放自带同步互斥内核会对管道操作进行同步与互斥读写阻塞、原子写入都是体现—— 对比第五篇共享内存 “完全裸奔”这里是内核帮你兜底其中 “流式服务” 值得单独强调你写 3 次、每次 10 字节对端可能一次read读出 30 字节也可能分 3 次读出 ——字节流在管道里就是一条连续的水流没有 “一条一条消息” 的概念。想要边界要么像进程池那样 “定长任务码” 约定要么用分隔符协议要么换消息队列。九、常见坑这几个错误 90% 的人都踩过先 fork 后 pipe—— 两个进程各自建了各自的管道永远通信不上。正确顺序永远是先pipe()再fork()忘记关闭不用的端—— fork 把 fd 表拷贝给子进程后只要还有任何进程持有写端引用计数就不会归零读端永远等不到 EOF读端同理会干扰 SIGPIPE 的触发判断。fork 之后立刻各关一端write(fd, buf, strlen(buf) 1)—— 把 ‘\0’ 也写进管道接收方数据被污染管道传裸字节长度自己约定把 read 返回 0 当错误—— 它是 EOF是 “对端关闭” 的信号处理错了通信就断了低估 SIGPIPE—— 读端关闭后继续写进程被 13 号信号默认杀掉且没有 core 信息很多人以为程序 “凭空消失”。服务程序里通常要捕获或忽略 SIGPIPE 自行处理。十、总结与下一篇预告10.1 本篇核心结论管道的本质是内核内存里的一段缓冲区借用文件框架struct file / inode组织不碰磁盘 —— “披着文件外皮的内存”通信的前提是 “看见”pipe() 先在父进程 fd 表里挂上读写两端fork() 时子进程整体拷贝 fd 表拷贝的是指向关系两个进程因此共享同一条管道四种读写规则空读阻塞、满写阻塞、写关读返 0EOF、读关写收 SIGPIPE写入量 ≤ PIPE_BUF4096时保证原子性管道容量默认 64KB五大特性亲缘、半双工、字节流、生命周期随进程、内核自带同步互斥。10.2 下一篇预告管道的接口虽然只有一个pipe()但它的工程价值远不止 “父子传话”。下一篇《进程通信系列三匿名管道实战 —— 手写多进程任务分发进程池》我们用它做一个真实的小项目父进程Master创建一批子进程Worker通过管道负载均衡地派发任务码并处理 “退出时管道该怎么关、子进程该怎么收” 这类看似简单实则暗藏 bug 的收尾问题 —— 其中有一个藏得很深的 fd 泄漏 bug值得亲自踩一遍。