进程间通信与匿名管道详解 两个进程怎么传纸条先交代一下环境语言从 C 切换成 C操作系统从 CentOS 7 换成 Ubuntu 24.04内核 6.8.0。CentOS 7 已经停更了——虽然有些企业还没换但 VS Code 新版本已不再支持 CentOS 7它自带的 glibc 太老了连都连不上。所以换。前面学过的所有系统调用接口在 Ubuntu 上 100% 一样——内核接口不随发行版变平滑切换。多说一句内核版本服务器选型不会选最新的内核像 6.x 就很少见也不会选太老的——选 3.x、4.x、5.x 这种正值壮年的居多。不是越新越好。我们学的时候新旧都用两种都感受一下。编译用g -stdc11源文件后缀统一用.cc也支持.cpp、.cxx团队里统一一种就行我们选.cc。先把家伙什准备好——VS Code 远程连 Ubuntu后面所有代码都在 VS Code 上写连远端 Linux 搞开发。Windows 本地只负责敲字编译、运行、调试都在云端机器上。VS Code 是个啥VS Code 只是一款文本编辑器。它不默认提供编译器、链接器、调试器——你不是在用 Visual Studio 2022那叫 IDE集成开发环境。VS Code 轻量、插件化你需要什么功能就装什么插件它自己是空壳子。微软出品Windows 平台天然适配。基于 Electron 框架底层是 C上层用 web 技术做 UI。轻量但有时的确会有插件兼容性的小毛病——瑕不掩瑜。必装的三个插件打开 VS Code左侧扩展面板搜Chinese (Simplified) Language Pack— 汉化界面。装上重启即生效。Remote-SSH— 远程连 Linux 的核心。装好后左侧会出现一个小电脑图标远程资源管理器。如果装完了图标没出来——重启 VS Code。这插件早年不太稳定最近两年好多了但有些插件就是要重启才起效。不能免俗。C/C Extension Pack— 语法高亮、智能感知、代码补全。这一个插件就够用了。你首次创建.cc文件时 VS Code 会自动推荐它点安装就行。还有两个可选GBK to UTF-8— 看别人用 GBK 编码写的中文注释时自动转码防乱码。各种颜色主题——按自己审美来不影响功能。怎么连上远端机器点击远程资源管理器里的小电脑图标 → 点号 → 输入ssh 你的用户名你的服务器IP回车后选第一个配置文件~/.ssh/configVS Code 会把主机信息写进去。此时左下角提示已添加主机。下拉列表里就能看到刚加的机器了。点连接 → 输入密码 → 选 Linux 平台 → 等它同步插件。左下角出现绿点 “已连接”搞定。怎么删除没用的主机记录VS Code 没提供删除按钮。你得手动编辑C:\Users\你的用户名\.ssh\config删掉对应段落。或者打开 VS Code → 命令面板 → 搜索 “打开 SSH 配置文件”直接在里面编辑。known_hosts也可以顺带清掉。打开远端文件夹写代码连上后跟本地一样操作打开文件夹 → 选你远端机器上的目录默认定位到家目录→ 确认密码 → 信任作者。然后在 VS Code 里新建文件、新建文件夹所有操作自动同步到远端。CtrlS保存文件内容立刻同步到云服务器。文件名旁边的小黑点表示未保存。Ctrl反引号ESC 下面那个键调出终端——这个终端就是你远端机器的 shell。可以直接ls、cd、make、./a.out。不需要在 VS Code 里配什么编译器路径因为你用的是远端系统里自带的g和make。调试呢不推荐在 VS Code 里远程调试——网络延迟大数据量大基本上一跑就卡死。VS Code 的 gdb debug 插件装上了F5/F9/F10 跟 VS 一套用起来也可以但它的通信是在本地和远端之间来回传调试数据的配置低的云服务器根本扛不住。大点断点等三四秒才响应查变量经常查不到。用原生的 cgdb 或直接 gdb 命令行调试就行。操作系统怎么从 CentOS 切到 Ubuntu去腾讯云/阿里云后台 → 找到你的实例 → 更多 → 重装操作系统 → 选 Ubuntu 24.04 镜像 → 重装。重装前先备份。你从开学到现在的所有代码、所有文件基本都在你的家目录/home/你的用户名/下。操作步骤# 打包整个家目录tar-czfbackup.tar.gz /home/你的用户名/# 发到本地 Windowssz backup.tar.gz# 重装系统后从 Windows 传回来rz# 然后解包tar-xzfbackup.tar.gz就这样。环境搞定下面说正事。先跑一段代码。#includeiostream#includeunistd.h#includestring#includecstdlib#includesys/types.hvoidWriteToPipe(intwfd){std::string messagehello, from child;intcount1;while(true){messagehello, from child;message count: std::to_string(count);message pid: std::to_string(getpid());write(wfd,message.c_str(),message.size());count;sleep(1);}}voidReadFromPipe(intrfd){charbuffer[1024];while(true){ssize_t nread(rfd,buffer,sizeof(buffer)-1);if(n0){buffer[n]\0;std::coutfather[getpid()] got: bufferstd::endl;}}}intmain(){intpipefd[2];intnpipe(pipefd);(void)n;pid_t idfork();if(id0){// 子进程写close(pipefd[0]);WriteToPipe(pipefd[1]);close(pipefd[1]);exit(0);}// 父进程读close(pipefd[1]);ReadFromPipe(pipefd[0]);close(pipefd[0]);return0;}编译运行$make./test_pipe father[5842]got: hello, from child count:1pid:5843father[5842]got: hello, from child count:2pid:5843father[5842]got: hello, from child count:3pid:5843...就这 50 行代码两个进程聊起来了。子进程每秒发一条消息父进程收到就打印。但现在把时间调一下——让子进程写慢点每 10 秒才写一条父进程照旧每秒去读sleep(10);// 子进程写完一条干等 10 秒才写下一条跑起来你会发现父进程读完第一条之后卡住了。不是报错不是乱码就是静静地等着。等到 10 秒后子进程又写了一条父进程立刻弹起来把第二行打出来了。这里有两个关键事实先记住管道里没数据读端会睡觉等你——跟scanf等你敲键盘一个德行。一个进程的栈变量count、message另一个进程居然实时看到了更新——这跟你以前学的父子进程全局变量写时拷贝各玩各的完全是两码事。这就是进程间通信。下面拆开说。进程间通信先问是不是再问为什么什么叫两个进程通信就是两个或多个进程之间互相传数据。你可能会想等一下我以前fork之后子进程不是能看到父进程的全局变量吗那算不算通信不算。两个原因。第一只能单向继承而且只能继承一次。fork那一刻子进程拿到父进程当时的数据快照。从此往后父进程改了全局变量子进程看不见。反过来子进程改了自己的副本父进程更不可能看见。你不可能一直给对方发消息——你只能出生时看一眼爹妈长什么样。能通信和一直能通信是完全不同的两件事。第二写时拷贝决定了各玩各的。父子进程的代码段是共享的代码只读改了也没意义但数据段一旦有一方试图修改操作系统就给这一方单独拷贝一份物理内存。两个人各拿一份谁都影响不了谁。这恰恰是进程独立性的体现而不是通信。真正的通信必须是持续的、双向可传递的——我随时可以发消息给你你随时可以收反过来也行。为什么要通信你不是天天在操作系统上跑着微信、QQ、网易云、抖音吗你见过微信挂掉把 QQ 也带崩吗没有。进程具有独立性这是操作系统故意设计的。一个进程到底等于什么进程 内核数据结构PCB 代码和数据。每个进程——即便是亲如父子——都有自己独立的一份 PCB、独立的虚拟地址空间、独立的页表、独立的文件描述符表。一个进程崩溃了释放自己的代码、数据和内核数据结构别的进程毫发无伤。但问题是——独立归独立有时候进程们真需要协作数据传输你写了个 HTTP 服务器收到请求后要把数据交给后台数据库进程——MySQL、Redis、MongoDB 这些都算。数据不过去业务走不通。资源共享多个进程想同时看到同一份配置、同一份缓存。各存各的副本内存浪费同步也麻烦。通知与控制你启动gdb调试一个程序——gdb是一个进程被调试的程序是另一个进程。gdb得能控制它“给我停”“跑”“把变量值交出来”一个将军想指挥千军万马首先得有传令兵——能让指令上下通达。进程间通信就是这个传令兵。一句话说清楚本质来看图进程A --- --- 进程B进程 A 在自己堆上用malloc申请了一块内存填了些数据。进程 B 能读到吗读不到。为什么A 申请的物理内存是 A 自己的页表映射过去的。B 的页表里根本没有任何条目指向那块物理内存。A 的数据对 B 来说就是不存在的东西。反过来也一样。B 的全局变量、堆空间、栈空间A 统统看不见。所以怎么办必须有一个人在中立地带放一块公共内存A 能看到B 也能看到。A 往里写B 从里读反过来也行。这块公共内存谁提供既不能由 A 提供A 提供的只有 A 自己能看见也不能由 B 提供同理。只能由操作系统提供。两个进程唯一的公共交集就是它们跑在同一台操作系统上。这就好比两个黑帮老大王不见王各自有各自的地盘从不踏入对方领地。但偶尔需要协作怎么办需要一个中间人——既不在 A 的地盘也不在 B 的地盘而是在一个公共区域把两边都叫过来。所以进程间通信的本质前提是先让不同的进程看到同一份资源。这份资源由操作系统提供通过系统调用来创建和访问。换个问法不用这些词还能说清楚吗——操作系统在路边摆了一个公共邮箱两个人都能往里面投信和取信。就这么回事。标准是后来才有的——复用现有代码才是第一步在聊管道之前有一个背景要说清楚。操作系统刚诞生的年代各家都在搞自己的 IPC 机制——Windows 一套Unix 一套macOS 一套谁也不服谁。程序员换一个平台就得学一套新接口成本很高。后来有人站出来统一了标准。System V 标准读作系统五就是其中之一。它规定了三种通信方式消息队列Message Queue共享内存Shared Memory信号量SemaphoreLinux、macOS、大部分类 Unix 系统都支持 System V 的接口。你学了这套系统调用换个 Unix 系的平台照样用。除此之外还有POSIX 标准——后面学网络和多线程的时候会碰到比如pthread那套接口就属于 POSIX。两套标准并行存在互不代替。但说实话System V 里大约 60% 的内容现在已经被淘汰了。消息队列基本没人用了信号量我们留到多线程部分再讲共享内存还有必要学——到时再说。重点是标准是后来才有的。刚开始的时候操作系统的程序员想的根本不是定什么标准——他们想的是能不能复用现有的代码最朴素的认识文件。磁盘文件天生就能被多个进程各自打开——一个写一个读通信不就成立了吗但文件得落盘。写到磁盘上再读回来要经历寻址、全缓冲、IO 调度——太慢了。进程间通信的效率要的是足够快而磁盘 IO 是个很好的减速器。所以能不能搞一个纯内存级的文件长得跟文件一模一样——有file结构体、有inode、有内核缓冲区——但它跟磁盘没半毛钱关系数据只存在于内存里操作系统说可以。管道就是这么来的。路径是这样的各家各自搞 IPC → 需要统一标准 → System V 标准 ↘ 程序员想偷懒能不能复用文件代码 → 搞一个纯内存的文件 → 管道管道不属于 System V。它是复用文件系统代码的产物——最小成本实现通信。后来管道才逐渐发展成熟最终变成了标准的一部分。先有偷懒后有标准。这个顺序很重要。多说一句标准这件事。不是所有标准都像网络协议那样有强制力——你造一部不能上网的手机根本卖不出去所以你得遵守 4G/5G 标准。但操作系统层面的标准约束力就弱得多。Windows 说不支持你的接口你拿它没办法。Linux 上的客户多所以 Linux 支持的接口慢慢就变成了事实标准。华为定 5G 标准、ARM 卖芯片授权——制定标准本身就有商业利益在里面。USB 插头为什么长那样内存条为什么那么宽键盘上的字母为什么是 QWERTY这个世界上到处都是标准和约定没有它们计算机根本无法互连。管道——偷文件代码偷出来的通信既然进程都独立那有没有现成的东西能看到同一份资源有文件。磁盘文件天生就能被多个进程各自open——一个写打开一个读打开一个往里写一个从里读。这不就是通信吗但文件有个问题它得跟磁盘打交道。写到磁盘上再从磁盘上读回来中间要经历寻址、全缓冲、IO 调度——这太慢了。进程间通信的效率要的是足够快而磁盘 IO 是个很好的减速器。所以能不能搞一个纯内存级的文件长得跟文件一模一样——有file结构体、有inode、有内核缓冲区——但它跟磁盘没半毛钱关系数据只存在于内存里操作系统说可以。管道就是这么来的。管道是怎么建起来的pipe()系统调用#includeunistd.hintpipe(intpipefd[2]);返回 0 表示成功小于 0 则失败。参数pipefd是一个输出型数组——它不接收输入是系统往外填值。调用完成后pipefd[0]— 读端文件描述符pipefd[1]— 写端文件描述符你打印一下这两个值intpipefd[2]{0};pipe(pipefd);std::coutpipefd[0] pipefd[0]std::endl;// 3std::coutpipefd[1] pipefd[1]std::endl;// 43 和 4。因为 0stdin、1stdout、2stderr已经被占了——这跟文件描述符的分配规则完全一致因为管道本质上就是文件。怎么记谁读谁写把 0 想象成嘴巴——读书用嘴。把 1 想象成一支笔——写字用笔。记住1 是笔那pipefd[1]就是写端。另一个自然就是读端。坦白讲0/1 对应读/写其实更直白——0stdin, 1stdout。但写代码的时候 0 写 1 读很容易反过来搞混。我推荐1笔这个记忆法你只需要记住一个另一个自动就知道了。这跟普通open什么区别你打开一个普通文件intfdopen(log.txt,O_RDWR);只有一个文件描述符读写位置f_pos共用一个。写完必须lseek回开头才能读到自己刚写的数据。管道文件不是这样。pipe()做了一件普通open做不到的事以读方式和写方式分别打开同一个文件。相当于调了两次打开形成了两个独立的struct file各自有各自的f_pos。为什么必须读写都打开因为fork只会原样拷贝文件描述符表——父进程打开的是什么模式子进程继承的就是什么模式。如果父进程只以读方式打开子进程就只能读只以写方式打开子进程就只能写。那样永远形不成一个写一个读的单向信道。所以pipe()一次性把读端和写端都打开让子进程各取所需——你想读就关掉写端你想写就关掉读端。所以写端往后写了 10 个字节写端的f_pos变成 10读端的f_pos照样是 0从头开始读。两个文件对象指向同一个内核缓冲区同一个inode的 data page但各自的读写位置互不干扰。父子进程怎么共享这个管道pipe()调完之后父进程拿到了两个文件描述符3读和 4写。这些记录在父进程的文件描述符表里——这个表是进程的私有内核数据结构。然后fork()。fork会把父进程的 PCB、虚拟地址空间、文件描述符表全部给子进程拷贝一份。注意——是浅拷贝父进程文件描述符表: 子进程文件描述符表: fd[0] → stdin fd[0] → stdin fd[1] → stdout fd[1] → stdout fd[2] → stderr fd[2] → stderr fd[3] → 管道文件(读) ←──→ fd[3] → 管道文件(读) fd[4] → 管道文件(写) ←──→ fd[4] → 管道文件(写)有个细节struct file本身要不要给子进程拷贝要。因为管道的读写位置必须父子分开——你的写位置和我的读位置不是一回事。inode和缓冲区要不要拷贝不要。整个系统中同一个打开的文件只存在一份。内核不傻不会因为创建了个子进程就把同一块内存复制一遍。于是父子双方都能通过文件描述符 3 和 4访问到同一个管道文件的同一个内核缓冲区。接下来关闭自己不需要的那端父进程想读 → 关闭 fd[4]写端只留 fd[3]子进程想写 → 关闭 fd[3]读端只留 fd[4]最终的通信信道子进程 ──write(4)── [管道内核缓冲区] ──read(3)── 父进程单向通信。就这么建立起来了。命名不等于理解——两个咬文嚼字的追问“为什么叫管道”不是因为它叫管道所以单向通信。是因为它为了简单设计天然被做成了单向通信所以才叫管道。双向通信用一个管道不就行了吗可以但麻烦。你得在数据里标记这条是 A 发给 B 的那条是 B 发给 A 的接收方得区分数据的朝向。你想偷懒复用文件代码最简单的方式就是——一个方向一个管道。想双向建两根管道就行。跟现实世界的水管、天然气管道一个逻辑——液体只往一个方向流。换个问法不用管道这个词还能说清楚吗——操作系统在路边挖了一条单向槽你从这头倒水我从那头接水。想双向再挖一条。没毛病。“一定要关闭不需要的那端吗”不关也行。子进程只写不读——那你就用 fd[4] 写就行了fd[3] 留着不用也不影响。代码可以正常工作。但是。一来不关闭就破坏了单向通信的语义——管道明明天生就是单向的你留着另一端就是个隐患。二来你可能误操作哪天不小心用 fd[3] 读了一下把自己的数据吃回来了父进程就收不到了。最佳实践是关掉。不是必须但不关就是在自找麻烦。写段完整的收发代码上面那段代码跑出来的现象我们已经看到了。现在把完整的构建过程写出来#includeiostream#includeunistd.h#includestring#includecstdlib#includesys/types.h#includesys/wait.hvoidSubProcessWrite(intwfd){std::string messagehello, from child;intcount1;while(true){// 每次循环重建消息避免反复拼接导致消息越来越长messagehello, from child;message count: std::to_string(count);message pid: std::to_string(getpid());write(wfd,message.c_str(),message.size());count;sleep(1);}}voidParentProcessRead(intrfd){charbuffer[1024];while(true){ssize_t nread(rfd,buffer,sizeof(buffer)-1);if(n0){buffer[n]\0;std::coutfather[getpid()] got: bufferstd::endl;}}}intmain(){// 1. 创建管道intpipefd[2]{0};intnpipe(pipefd);if(n0){perror(pipe);return1;}// 2. 创建子进程pid_t idfork();if(id0){perror(fork);return2;}if(id0){// 子进程写端close(pipefd[0]);// 关闭不用的读端SubProcessWrite(pipefd[1]);// 持续写入close(pipefd[1]);exit(0);}// 父进程读端close(pipefd[1]);// 关闭不用的写端ParentProcessRead(pipefd[0]);// 持续读取close(pipefd[0]);waitpid(id,nullptr,0);return0;}Makefiletest_pipe:test_pipe.cc g -o $ $^ -stdc11 .PHONY:clean clean: rm -f test_pipe写完make ./test_pipe子进程每秒自增计数并发送父进程收到就打印。你可以在另一个终端里实时监控这两个进程# 每秒刷新一次查看父子进程是否都活着whiletrue;dopsaux|greptest_pipe|grep-vgrep;sleep1;done你会看到两个进程一个是父进程一个是它的子进程PPID 指向父进程 PID。跑起来之后每秒钟计数加一消息流不断。一个真实的翻车现场。第一版代码里我把message hello, from child;第 18 行那条重置语句漏掉了——结果message 发生在while循环外每次循环都往上一次的消息尾巴上继续拼接第一次: hello, from child count: 1 pid: 5843 第二次: hello, from child count: 1 pid: 5843 count: 2 pid: 5843 第三次: hello, from child count: 1 pid: 5843 count: 2 pid: 5843 count: 3 pid: 5843消息越来越长但计数值count确实在变——肉眼看到的 count 还停留在一开始拼接上去的那个 1。这就是字节流让人抓狂的地方你读到的数据是对的但你分不清边界以为自己看到了 bug。其实不是 bug——只是边界被旧内容污染了。每次循环重建message把重置语句放回循环体内一切都干净了。管道的五种特性上面代码跑完来提炼一下这个叫匿名管道的东西到底有什么脾气。特性一基于文件的单向通信管道本质上是一个纯内存的文件。有file结构体有inode有内核缓冲区有读写位置。只是它的数据不落盘。因为基于文件读写接口就是普普通通的read()和write()——你不需要学新的系统调用。因为单向一个进程只写另一个进程只读。和现实世界的水管没有区别。特性二只能用于有血缘关系的进程子进程是通过继承父进程的文件描述符表拿到管道两端 fd 的。两个完全没有亲缘关系的进程没有办法通过匿名管道通信。但血缘关系不限于父子父进程创建管道 →fork出子进程 → 父子通信 ✓父进程创建管道 →fork出两个子进程 → 兄弟之间通信 ✓子进程再fork出孙子 → 爷孙通信 ✓只要共同的祖先创建了管道并保持文件描述符不断后代们就能共享这根管道。准确说管道的通信范围是具有血缘关系的进程而非严格限于父子。但最常用的场景始终是父子通信。特性三生命周期随进程管道是文件。一个进程退出时操作系统自动关闭它打开的所有文件描述符。所以进程退出的那一刻它持有的管道端就自动关闭了。但注意——只要还有任何一个进程持有管道任意一端读或写这个管道就不会销毁。内核里有个引用计数当所有引用它的进程都退出了管道才会被释放。换个问法你close(pipefd[1])或close(pipefd[0])到底关的是什么关的是你当前进程对这个管道文件的引用。只要还有别的进程引用着内核缓冲区和inode就不回收。特性四自带同步机制回到开头那个实验——子进程 10 秒写一次父进程每秒读一次。父进程读完第一条后管道空了。此时read()不是返回 0也不是返回乱码而是阻塞在当前行等待管道里出现新数据。子进程写完新数据的那一刻父进程的read()立刻被唤醒把数据读走。反过来也成立写端写得飞快把管道写满了读端还没来读——写端的write()会阻塞等待管道有空位。这是管道内建的同步机制。两个进程的执行节奏通过管道自动协调。快的等慢的。这里我对同步这个词的定义需要澄清一下。在管道的语境里同步指的是读写双方的执行节奏被自动对齐——有数据就读没数据就等有空间就写没空间也等。这和线程同步互斥锁、信号量是不同层面的概念但核心思想相同协作者之间需要一种约定来控制谁先谁后。后面讲到线程时我们会从这个角度重新审视。特性五面向字节流回到前面调换读写节奏的实验——子进程每秒写一条父进程每 5 秒才读一次。你会看到父进程 5 秒后醒来一次把缓冲区里积攒的 5 条消息全读上来了。读写的次数不匹配。写了 5 次读了 1 次数据一条不少全过去了。这就是面向字节流。就像拧开水龙头接水——自来水厂可能一次给你家输了 10 吨水但你可以用杯子一杯一杯接也可以用盆一盆一盆接想怎么接怎么接。输水的次数和你用水的次数没任何对应关系。对比面向数据包——发快递的场景就是数据包对方寄了 3 个快递你就得收 3 次次数严格匹配。你以前学文件操作的时候其实就在用字节流了——你往文件缓冲区里写 10 次数据fflush一次就全刷到磁盘上了。这就是读写次数不匹配。再举一个更头疼的例子你往同一个文件里混着写——一下写字符串一下写整数一下写浮点数。写的时候很爽管你什么类型统统往里倒。读的时候傻眼了——读上来的是一串连续的字节哪几个字节是字符串哪几个是整数分不清楚。这就是字节流的本质数据进来的时候没有天然边界读出去的时候要靠你自己切。解决这个问题的办法叫序列化与反序列化——写的时候把每个字段变成固定长度或者加分隔符或者约定消息头里标注长度读的时候按同样的规则反向解析。这个活我们在网络编程里会做透彻的练习。字节流真正让人头疼的地方是什么你没办法天然知道一条消息的边界在哪。一次read可能读上来 3 条半消息你得自己想办法拆分——固定长度、分隔符、或者约定消息头里标注长度。这个拆包的活在学网络编程的时候会彻底解决。现在你只需要知道管道不在乎你的消息边界它只负责搬运字节。管道的四种场景四种读写速度的组合推到底会发生什么。场景一写端慢读端快子进程每 10 秒写一条父进程不sleep一直循环读。现象父进程读完第一条下一条还没来——read阻塞。整个通信节奏以慢的写端为准。场景二写端快读端慢交换一下子进程sleep(1)每秒写一条父进程sleep(5)每 5 秒才读一次。// 子进程写快sleep(1);// 每秒写一条// 父进程读慢sleep(5);// 每 5 秒才读一回现象父进程每次醒来一次把缓冲区里攒了 5 秒的数据全部读走。写了 5 次读了 1 次——读写次数不匹配但数据一条不少。管道不丢数据只是堆在缓冲区里等人取。向极端推写端一直写读端干脆不读但也不关闭读端。管道的大小是有限的。不同发行版、不同内核版本不一样——常见的有 32KB、64KB、65KB、67KB 等。Linux 2.6.11 默认是 64KB16 页 × 4KB。可以通过fcntl(fd, F_GETPIPE_SZ)编程获取或cat /proc/sys/fs/pipe-max-size查看上限。写端一直写写到缓冲区满了——write()阻塞直到读端来读走数据腾出空间。管道内部实现了同步满了不让写空了不让读。两个进程天然被对齐。场景三写端关闭读端还在读子进程写完了数据关闭了写端close(pipefd[1])或直接exit。父进程的读端还开着。现象read()把管道里剩余的数据全部读完之后返回0——这表示文件结束。管道空了且不会再有新数据来因为写端已经全部关闭了。这和读普通文件读到末尾的返回值一模一样——因为管道就是文件。场景四读端关闭写端还在写父进程关闭了读端close(pipefd[0])。子进程还往管道里写。现象操作系统不会允许这种浪费——往一个没人读的管道里写数据毫无意义。操作系统会直接杀掉写进程发送SIGPIPE信号信号编号 13。13) SIGPIPE你可以在终端里验证——写进程莫名其妙就没了没有任何报错输出只是进程消失了。操作系统不做任何浪费时间和空间的事。管道已死写端留着就是多余——直接干掉。这不是 bug这是设计。为什么管道文件没有名字你打开普通文件intfdopen(/home/user/log.txt,O_RDWR);有路径有文件名。管道文件呢路径是什么文件名是什么——没有。管道文件是纯内存的不由磁盘上的文件系统管理。没有路径没有文件名。pipe()系统调用在内核里直接创建了一套fileinode 缓冲区然后把文件描述符填进你的数组里就完事了。正因为没有名字它才被称为匿名管道anonymous pipe。那有没有有名管道有。后面会讲到mkfifo——它会在磁盘上创建一个管道文件节点多个没有亲缘关系的进程可以通过这个文件名找到同一根管道。但那是另一回事了。“你说的这些怎么证明”板书的最后画了这么一张图——关于进程到底是怎么退出的正常终止 → 退出状态exit status 异常终止 → 终止信号termination signal → core dump 标志我们上面说的四种场景里“写端关闭读端读到 0” 和 “读端关闭写端被 SIGPIPE 杀掉”都属于可以精确验证的行为。验证方法用strace跟踪系统调用看read和write的返回值用echo $?看退出码用dmesg看内核日志里有没有 signal 记录。这些我们下节课做完整验证——包括管道大小的精确测量fcntl(fd, F_GETPIPE_SZ)或者直接写满试出来SIGPIPE信号的完整复现以及核心转储core dump的开关验证。所以到底发生了什么就三件事第一管道就是文件只不过不碰磁盘。它复用了文件系统的全部代码——file、inode、缓冲区、read、write——操作系统的开发者不想重写一套通信机制直接把文件那套拿来只把落盘的那一步砍掉了。第二管道的核心价值不是传数据而是让两个进程看到同一块内存。匿名管道通过fork的文件描述符表继承机制让父子进程的 fd[3] 和 fd[4] 指向同一个内核缓冲区。看见同一份资源通信才有可能。第三管道不只是一个搬运工它自己就带着规矩。有数据就读没数据就等有空间就写没空间也等读端关了写端就被杀写端关了读端就被告知结束。同步、流控、生命周期管理——全在文件那一套机制里天然带着。你问管道还有什么用——你天天在用。Linux 命令行里的竖线psaux|grepbashps aux的输出通过一根匿名管道流进了grep bash的标准输入。Shell 帮你pipe()fork()dup2()exec()你只敲了一个|符号。明天我们聊管道的应用场景——写一个进程池。到时你就知道这根管子不只是传字符串用的。笔记对应课上代码[lesson34 anonymous pipe demo]板书中标识的四种场景完整验证将在下节课进行。管道大小测试、SIGPIPE信号验证、字节流拆包方案留在后续课程和网络编程中展开。