两个进程怎么传纸条?

先交代一下环境:语言从 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,左侧扩展面板搜:

  1. Chinese (Simplified) Language Pack — 汉化界面。装上重启即生效。
  2. Remote-SSH — 远程连 Linux 的核心。装好后左侧会出现一个小电脑图标(远程资源管理器)。如果装完了图标没出来——重启 VS Code。这插件早年不太稳定,最近两年好多了,但有些插件就是要重启才起效。不能免俗。
  3. 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。可以直接 lscdmake./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 -czf backup.tar.gz /home/你的用户名/

# 发到本地 Windows
sz backup.tar.gz

# 重装系统后,从 Windows 传回来
rz
# 然后解包
tar -xzf backup.tar.gz

就这样。环境搞定,下面说正事。


先跑一段代码。

#include <iostream>
#include <unistd.h>
#include <string>
#include <cstdlib>
#include <sys/types.h>

void WriteToPipe(int wfd)
{
    std::string message = "hello, from child";
    int count = 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);
    }
}

void ReadFromPipe(int rfd)
{
    char buffer[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;
        }
    }
}

int main()
{
    int pipefd[2];
    int n = 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]);

    return 0;
}

编译运行:

$ make && ./test_pipe
father[5842] got: hello, from child count: 1 pid: 5843
father[5842] got: hello, from child count: 2 pid: 5843
father[5842] got: hello, from child count: 3 pid: 5843
...

就这 50 行代码,两个进程聊起来了。子进程每秒发一条消息,父进程收到就打印。

但现在把时间调一下——让子进程写慢点,每 10 秒才写一条,父进程照旧每秒去读:

sleep(10);  // 子进程写完一条,干等 10 秒才写下一条

跑起来你会发现:父进程读完第一条之后,卡住了。不是报错,不是乱码,就是静静地等着。等到 10 秒后子进程又写了一条,父进程立刻弹起来,把第二行打出来了。

这里有两个关键事实,先记住:

  1. 管道里没数据,读端会睡觉等你——跟 scanf 等你敲键盘一个德行。
  2. 一个进程的栈变量(countmessage),另一个进程居然实时看到了更新——这跟你以前学的"父子进程全局变量写时拷贝各玩各的"完全是两码事。

这就是进程间通信。下面拆开说。


进程间通信:先问是不是,再问为什么

什么叫"两个进程通信"?

就是两个或多个进程之间互相传数据。

你可能会想:等一下,我以前 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>

int pipe(int pipefd[2]);

返回 0 表示成功,小于 0 则失败。参数 pipefd 是一个输出型数组——它不接收输入,是系统往外填值

调用完成后:

  • pipefd[0] — 读端文件描述符
  • pipefd[1] — 写端文件描述符

你打印一下这两个值:

int pipefd[2] = {0};
pipe(pipefd);
std::cout << "pipefd[0] = " << pipefd[0] << std::endl;  // 3
std::cout << "pipefd[1] = " << pipefd[1] << std::endl;  // 4

3 和 4。因为 0(stdin)、1(stdout)、2(stderr)已经被占了——这跟文件描述符的分配规则完全一致,因为管道本质上就是文件。

怎么记谁读谁写? 把 0 想象成嘴巴——读书用嘴。把 1 想象成一支笔——写字用笔。记住"1 是笔",那 pipefd[1] 就是写端。另一个自然就是读端。

坦白讲,0/1 对应读/写其实更直白——0=stdin, 1=stdout。但写代码的时候 0 写 1 读很容易反过来搞混。我推荐"1=笔"这个记忆法:你只需要记住一个,另一个自动就知道了。

这跟普通 open 什么区别

你打开一个普通文件:

int fd = 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>

void SubProcessWrite(int wfd)
{
    std::string message = "hello, from child";
    int count = 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);
    }
}

void ParentProcessRead(int rfd)
{
    char buffer[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;
        }
    }
}

int main()
{
    // 1. 创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0)
    {
        perror("pipe");
        return 1;
    }

    // 2. 创建子进程
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 2;
    }

    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);
    return 0;
}

Makefile

test_pipe:test_pipe.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f test_pipe

写完 make && ./test_pipe,子进程每秒自增计数并发送,父进程收到就打印。

你可以在另一个终端里实时监控这两个进程:

# 每秒刷新一次,查看父子进程是否都活着
while true; do ps aux | grep test_pipe | grep -v grep; sleep 1; 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,这是设计。


为什么管道文件没有名字?

你打开普通文件:

int fd = open("/home/user/log.txt", O_RDWR);

有路径,有文件名。

管道文件呢?路径是什么?文件名是什么?——没有。

管道文件是纯内存的,不由磁盘上的文件系统管理。没有路径,没有文件名。pipe() 系统调用在内核里直接创建了一套 file + inode + 缓冲区,然后把文件描述符填进你的数组里就完事了。

正因为没有名字,它才被称为匿名管道(anonymous pipe)。

那有没有"有名管道"?有。后面会讲到 mkfifo——它会在磁盘上创建一个管道文件节点,多个没有亲缘关系的进程可以通过这个文件名找到同一根管道。但那是另一回事了。


“你说的这些,怎么证明?”

板书的最后画了这么一张图——关于进程到底是怎么退出的:

正常终止 → 退出状态(exit status)
异常终止 → 终止信号(termination signal)
         → core dump 标志

我们上面说的四种场景里,“写端关闭,读端读到 0” 和 “读端关闭,写端被 SIGPIPE 杀掉”,都属于可以精确验证的行为。

验证方法:用 strace 跟踪系统调用,看 readwrite 的返回值;用 echo $? 看退出码;用 dmesg 看内核日志里有没有 signal 记录。

这些我们下节课做完整验证——包括管道大小的精确测量(fcntl(fd, F_GETPIPE_SZ) 或者直接写满试出来),SIGPIPE 信号的完整复现,以及核心转储(core dump)的开关验证。


所以到底发生了什么?

就三件事:

第一,管道就是文件,只不过不碰磁盘。 它复用了文件系统的全部代码——fileinode、缓冲区、readwrite——操作系统的开发者不想重写一套通信机制,直接把文件那套拿来,只把落盘的那一步砍掉了。

第二,管道的核心价值不是"传数据",而是"让两个进程看到同一块内存"。 匿名管道通过 fork 的文件描述符表继承机制,让父子进程的 fd[3] 和 fd[4] 指向同一个内核缓冲区。看见同一份资源,通信才有可能。

第三,管道不只是一个搬运工,它自己就带着规矩。 有数据就读,没数据就等;有空间就写,没空间也等;读端关了写端就被杀;写端关了读端就被告知结束。同步、流控、生命周期管理——全在文件那一套机制里天然带着。

你问"管道还有什么用"?——你天天在用。Linux 命令行里的竖线:

ps aux | grep bash

ps aux 的输出通过一根匿名管道流进了 grep bash 的标准输入。Shell 帮你 pipe() + fork() + dup2() + exec(),你只敲了一个 | 符号。

明天我们聊管道的应用场景——写一个进程池。到时你就知道这根管子不只是传字符串用的。


笔记对应课上代码:[lesson34 anonymous pipe demo],板书中标识的四种场景完整验证将在下节课进行。管道大小测试、SIGPIPE 信号验证、字节流拆包方案留在后续课程和网络编程中展开。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐