进程间通信与匿名管道详解
两个进程怎么传纸条?
先交代一下环境:语言从 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++ -std=c++11,源文件后缀统一用.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/config),VS Code 会把主机信息写进去。此时左下角提示"已添加主机"。下拉列表里就能看到刚加的机器了。
点连接 → 输入密码 → 选 Linux 平台 → 等它同步插件。左下角出现绿点 + “已连接”,搞定。
怎么删除没用的主机记录?VS Code 没提供删除按钮。你得手动编辑
C:\Users\你的用户名\.ssh\config,删掉对应段落。或者打开 VS Code → 命令面板 → 搜索 “打开 SSH 配置文件”,直接在里面编辑。known_hosts也可以顺带清掉。
打开远端文件夹,写代码
连上后,跟本地一样操作:打开文件夹 → 选你远端机器上的目录(默认定位到家目录)→ 确认密码 → 信任作者。然后在 VS Code 里新建文件、新建文件夹,所有操作自动同步到远端。
Ctrl+S保存,文件内容立刻同步到云服务器。文件名旁边的小黑点表示未保存。
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就这样。环境搞定,下面说正事。
先跑一段代码。
#include<iostream>#include<unistd.h>#include<string>#include<cstdlib>#include<sys/types.h>voidWriteToPipe(intwfd){std::string message="hello, from child";intcount=1;while(true){message="hello, 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 n=read(rfd,buffer,sizeof(buffer)-1);if(n>0){buffer[n]='\0';std::cout<<"father["<<getpid()<<"] got: "<<buffer<<std::endl;}}}intmain(){intpipefd[2];intn=pipe(pipefd);(void)n;pid_t id=fork();if(id==0){// 子进程:写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)
- 信号量(Semaphore)
Linux、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()系统调用:
#include<unistd.h>intpipe(intpipefd[2]);返回 0 表示成功,小于 0 则失败。参数pipefd是一个输出型数组——它不接收输入,是系统往外填值。
调用完成后:
pipefd[0]— 读端文件描述符pipefd[1]— 写端文件描述符
你打印一下这两个值:
intpipefd[2]={0};pipe(pipefd);std::cout<<"pipefd[0] = "<<pipefd[0]<<std::endl;// 3std::cout<<"pipefd[1] = "<<pipefd[1]<<std::endl;// 43 和 4。因为 0(stdin)、1(stdout)、2(stderr)已经被占了——这跟文件描述符的分配规则完全一致,因为管道本质上就是文件。
怎么记谁读谁写?把 0 想象成嘴巴——读书用嘴。把 1 想象成一支笔——写字用笔。记住"1 是笔",那pipefd[1]就是写端。另一个自然就是读端。
坦白讲,0/1 对应读/写其实更直白——
0=stdin, 1=stdout。但写代码的时候 0 写 1 读很容易反过来搞混。我推荐"1=笔"这个记忆法:你只需要记住一个,另一个自动就知道了。
这跟普通open什么区别
你打开一个普通文件:
intfd=open("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] 读了一下,把自己的数据吃回来了,父进程就收不到了。最佳实践是关掉。不是必须,但不关就是在自找麻烦。
写段完整的收发代码
上面那段代码跑出来的现象我们已经看到了。现在把完整的构建过程写出来:
#include<iostream>#include<unistd.h>#include<string>#include<cstdlib>#include<sys/types.h>#include<sys/wait.h>voidSubProcessWrite(intwfd){std::string message="hello, from child";intcount=1;while(true){// 每次循环重建消息,避免反复拼接导致消息越来越长message="hello, 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 n=read(rfd,buffer,sizeof(buffer)-1);if(n>0){buffer[n]='\0';std::cout<<"father["<<getpid()<<"] got: "<<buffer<<std::endl;}}}intmain(){// 1. 创建管道intpipefd[2]={0};intn=pipe(pipefd);if(n<0){perror("pipe");return1;}// 2. 创建子进程pid_t id=fork();if(id<0){perror("fork");return2;}if(id==0){// 子进程:写端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;}Makefile:
test_pipe:test_pipe.cc g++ -o $@ $^ -std=c++11 .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+ 默认是 64KB(16 页 × 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,这是设计。
为什么管道文件没有名字?
你打开普通文件:
intfd=open("/home/user/log.txt",O_RDWR);有路径,有文件名。
管道文件呢?路径是什么?文件名是什么?——没有。
管道文件是纯内存的,不由磁盘上的文件系统管理。没有路径,没有文件名。pipe()系统调用在内核里直接创建了一套file+inode+ 缓冲区,然后把文件描述符填进你的数组里就完事了。
正因为没有名字,它才被称为匿名管道(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信号验证、字节流拆包方案留在后续课程和网络编程中展开。
