Re:Linux 系统篇(三十四):进程间通信开篇Chapter1 —— 匿名管道 pipe 深度拆解与代码实战


观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇 前面我们把进程从创建、程序替换、地址空间到 ELF 加载完整讲透了。每个进程拥有独立的虚拟地址空间,互相隔离、互不干扰,这是进程安全的基础,但也带来了一个问题:进程之间怎么传递数据、协同工作?

这就需要 进程间通信(IPC,Inter-Process Communication) ------ 由内核提供公共通道,让隔离的进程可以交换数据。

IPC 是 Linux 系统编程的核心板块,包含管道、消息队列、共享内存、信号量、套接字等。我们从最基础、最经典的 匿名管道(pipe) 讲起,结合完整可运行代码,从内核原理到逐行实现,把管道的本质、特性、读写行为彻底讲透。


一、为什么进程通信需要专门机制?

在讲管道之前,我们先回到最基础的问题:两个进程为什么不能直接互相传数据?

答案就在我们之前讲的进程地址空间隔离

每个进程有自己独立的虚拟地址空间,有独立的页表映射到不同的物理内存。进程 A 里的一个内存地址,对进程 B 来说毫无意义,也根本访问不到 B 的内存。这是进程保护的核心设计,但也成了进程通信的天然屏障。

所以所有 IPC 机制的本质都是:

内核出面提供一块公共的缓冲区 / 通道,两个进程通过读写这个公共区域,间接完成数据交换。

匿名管道,就是最简单、最古老的一种 IPC:内核里开辟一块字节流缓冲区,两个进程通过文件描述符来读写这块缓冲区,实现数据传递。


二、匿名管道的本质与原理

2.1 什么是匿名管道

匿名管道(Anonymous Pipe) 本质是内核中的一块字节流缓冲区,它没有文件名、没有磁盘实体,只存在于内核内存中,所以叫「匿名」。

管道会返回两个文件描述符:

  • fd[0]读端,只能从管道里读取数据
  • fd[1]写端,只能往管道里写入数据

💡 这里完美呼应 Linux「一切皆文件」的设计思想

管道在内核中被抽象成一个特殊的文件对象,操作它用的就是我们学过的普通文件系统调用:readwriteclose。接口和操作磁盘文件完全一致,不用额外学习新的函数。

2.2 为什么能在父子进程间通信?------ 结合 fork 与文件描述符

很多初学者会问:管道是父进程创建的,子进程怎么也能用?

这就要结合我们之前讲的 fork 继承文件描述符 的知识点:

  1. 父进程先调用 pipe() 创建管道,拿到两个文件描述符,记录在父进程的文件描述符表中。
  2. 调用 fork() 创建子进程,子进程会完整复制父进程的文件描述符表。
  3. 所以子进程也拥有这两个 fd,并且指向同一个内核管道对象

这样一来,父子进程就有了共同的通信通道:子进程往写端写数据,父进程从读端读数据,反过来也可以。

2.3 半双工特性

管道默认是 半双工 的:同一时间数据只能往一个方向流动。

要么父读子写,要么子读父写,不能同时双向传输。

如果需要双向通信,通常的做法是创建两条管道,各自负责一个方向。


三、代码逐段精讲:父子进程管道通信

我们拿一段完整的「子进程写、父进程读」的代码为例,逐段拆解每一行的作用,以及背后的原理。

完整代码总览

c 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <cstdio>
using namespace std;

// 子进程:负责向管道写数据
void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while (true)
    {
        snprintf(buffer, sizeof(buffer),
            "I am child, pid: %d, cnt: %d",
            getpid(), cnt++);
        write(wfd, buffer, strlen(buffer));
        sleep(1);
    }
}

// 父进程:负责从管道读数据
void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0)
        {
            buffer[n] = '\0';
            cout << "child say: " << buffer << endl;
        }
        else if (n == 0)
        {
            cout << "pipe closed!" << endl;
            break;
        }
        else
        {
            perror("read");
            break;
        }
    }
}

int main()
{
    int fd[2];
    // ① 创建管道
    if (pipe(fd) < 0)
    {
        perror("pipe");
        return 1;
    }
    // ② 创建子进程
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 1;
    }

    // =========================
    // 子进程
    // =========================
    if (id == 0)
    {
        close(fd[0]);
        ChildWrite(fd[1]);
        close(fd[1]);
        return 0;
    }
    // =========================
    // 父进程
    // =========================
    else
    {
        close(fd[1]);
        FatherRead(fd[0]);
        close(fd[0]);
        waitpid(id, nullptr, 0);
    }
    return 0;
}

3.1 头文件与作用

表格

头文件 作用 对应知识点
iostream C++ 标准输入输出,cout 打印用 -
unistd.h 系统调用:pipeforkreadwritesleepgetpid 系统调用层
sys/types.h 类型定义,比如 pid_t -
sys/wait.h waitpid 进程回收 僵尸进程回收
cstring / cstdio 字符串操作:strlensnprintf -

3.2 子进程写函数:ChildWrite

c 复制代码
void ChildWrite(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while (true)
    {
        // 格式化要发送的数据
        snprintf(buffer, sizeof(buffer),
            "I am child, pid: %d, cnt: %d",
            getpid(), cnt++);
        // 写入管道
        write(wfd, buffer, strlen(buffer));
        // 每秒写一次
        sleep(1);
    }
}
逐行讲解
  1. 用户态缓冲区 buffer:先在用户态把数据拼接好,再一次性写入内核管道,减少系统调用次数,呼应我们之前讲的缓冲区思想。
  2. snprintf 格式化:把进程 PID、计数器拼成字符串,模拟实际业务数据。
  3. write 系统调用 :把用户态 buffer 里的数据,拷贝到内核的管道缓冲区中。
    • 第一个参数是管道写端文件描述符
    • 第三个参数是写入的字节长度
  4. sleep(1):每秒写一次,模拟持续发送的场景。

💡 对应知识点

write 是系统调用,会触发用户态到内核态的切换,把数据从用户空间拷贝到内核的管道缓冲区。


3.3 父进程读函数:FatherRead(核心重点)

c 复制代码
void FatherRead(int rfd)
{
    char buffer[1024];
    while (true)
    {
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
read 的返回值是关键

read 有三种返回值,对应三种情况,这是管道读写最重要的特性:

表格

返回值 n 含义
n > 0 成功读到数据,n 是实际读到的字节数
n == 0 管道所有写端都已关闭,没有新数据了,读到末尾
n < 0 读取出错

情况 1:读到数据(n > 0)
c 复制代码
if (n > 0)
{
    buffer[n] = '\0';
    cout << "child say: " << buffer << endl;
}
  • 重点细节read 是系统调用,只会老老实实读字节,不会自动给字符串补 \0
    所以我们手动在 buffer[n] 的位置补一个结束符,才能当成 C 字符串用 cout 打印。
  • 字节流特性:管道是字节流,没有消息边界。写端写了 30 字节,读端可能一次读 30,也可能分两次读,有多少读多少。应用层需要自己处理数据边界。
情况 2:写端全部关闭(n == 0)
复制代码
else if (n == 0)
{
    cout << "pipe closed!" << endl;
    break;
}

这是管道非常重要的特性:

只有当管道所有的写端文件描述符都被关闭时,read 才会返回 0,表示读到了末尾。

只要还有一个写端开着,哪怕暂时没数据,read 也会阻塞等待,不会返回 0。

这也是我们后面要「关闭不用的文件描述符」的核心原因之一。

情况 3:读取出错(n < 0)
c 复制代码
else
{
    perror("read");
    break;
}

系统调用出错,用 perror 打印错误信息。

② 默认阻塞特性

默认情况下,如果管道是空的,read阻塞等待 ,挂起当前进程,直到有数据进来才返回。

所以父进程不会空转消耗 CPU,会安静等子进程写数据,这是内核自带的同步机制。


3.4 main 函数:完整流程

① 创建管道
c 复制代码
int fd[2];
if (pipe(fd) < 0)
{
    perror("pipe");
    return 1;
}
  • pipe() 系统调用,创建一个匿名管道
  • 成功返回 0,两个文件描述符填入 fd 数组:fd[0] 读端,fd[1] 写端
  • 失败返回 -1,比如文件描述符耗尽
② fork 创建子进程
c 复制代码
pid_t id = fork();
if (id < 0)
{
    perror("fork");
    return 1;
}

调用一次,返回两次。子进程返回 0,父进程返回子进程 PID,这是我们非常熟悉的 fork 特性。

③ 子进程分支
c 复制代码
if (id == 0)
{
    close(fd[0]);  // 关闭不用的读端
    ChildWrite(fd[1]);
    close(fd[1]);
    return 0;
}

关键问题:为什么要先关闭 fd 0 读端?

两个原因:

  1. 资源节省:子进程只负责写,不需要读端,关闭释放文件描述符资源。
  2. 管道语义正确:只有所有写端都关闭,read 才会返回 0。如果子进程保留读端,哪怕父进程把写端关了,子进程这边还有一个写端引用吗?不,子进程保留的是读端。准确说:如果所有写端都没关,读就不会结束。反过来,如果我们要让读端能正确收到结束信号,就要保证没用的写端都关掉。这里子进程写,所以关掉读端,父进程读,关掉写端,让管道的两端各只有一个使用者,语义最清晰。
④ 父进程分支
c 复制代码
else
{
    close(fd[1]);  // 关闭不用的写端
    FatherRead(fd[0]);
    close(fd[0]);
    waitpid(id, nullptr, 0); // 回收子进程
}
  • 父进程只负责读,所以先关闭写端 fd[1]
  • 读完之后关闭读端,调用 waitpid 等待子进程退出,回收 PCB,避免产生僵尸进程。

这里呼应我们之前讲的僵尸进程回收知识点。


四、匿名管道的核心特性总结

4.1 半双工通信

数据只能单向流动。如果需要双向通信,需要创建两条管道。

4.2 仅用于亲缘进程

匿名管道没有名字,只能通过 fork 继承文件描述符来获取管道两端。所以只能在父子进程、兄弟进程等有亲缘关系的进程之间使用。

后面要讲的命名管道(FIFO) 就是为了解决这个问题,让任意进程都能通信。

4.3 字节流服务,无消息边界

写进去的是连续的字节流,读端不知道消息的边界。可能一次读半条消息,也可能一次读多条拼在一起。

需要应用层自己约定协议、处理分包。

我们代码里每次写一条、每次读一条,是因为每秒写一次、读的速度快,刚好每次读到完整的一条,这是时序巧合,不是管道保证的。

4.4 内置阻塞同步

默认阻塞模式:

  • 管道为空,read 阻塞,直到有数据
  • 管道为满,write 阻塞,直到有空间

内核自带同步,不需要用户自己加锁。管道缓冲区大小有限制(通常 4KB~64KB),写满之后再写就会阻塞,等读端把数据读走。

4.5 生命周期随引用

所有指向管道的文件描述符都关闭了,内核才会释放管道资源。


五、串联之前的知识体系

学到这里你会发现,管道不是孤立的知识点,它把我们前面学的很多内容都串起来了:

  1. fork 继承文件描述符:这是管道能在父子间通信的基础,子进程拿到和父进程一样的 fd,指向同一个内核管道对象。
  2. 一切皆文件 :管道在内核抽象成 file 对象,用 read/write/close 操作,和普通文件接口完全统一。
  3. 进程地址空间隔离:正是因为进程不能直接访问对方内存,才需要 IPC 这种内核中介。
  4. 系统调用pipeforkreadwrite 都是系统调用,都需要切换到内核态完成。
  5. 僵尸进程回收 :通信结束父进程调用 waitpid 回收子进程,呼应进程生命周期。

六、优缺点与适用场景

优点

  • 简单易用:接口和文件操作一致,学习成本低
  • 内核保证原子性:读写操作不会互相穿插
  • 自带阻塞同步:不用额外加锁

缺点

  • 只能单向通信
  • 仅限有亲缘关系的进程
  • 字节流无边界,需要应用层分包
  • 缓冲区大小有限,不适合大量数据传输

典型应用:Shell 管道符

我们天天用的 shell 管道符 |,本质就是匿名管道。

比如 ls | grep txt

  1. shell 创建一个匿名管道
  2. fork 两个子进程
  3. ls 进程的标准输出重定向到管道写端
  4. grep 进程的标准输入重定向到管道读端
    和我们代码里的逻辑完全一致。

全文总结

匿名管道是 IPC 的入门,也是最基础的进程通信方式。它的本质就是内核里的一块字节流缓冲区,通过 fork 继承文件描述符,让父子进程拥有共同的通信通道。

它的限制也很明显:只能单向、只能亲缘进程、无消息边界。下一篇我们讲 命名管道 FIFO ,解决匿名管道的这些限制,实现任意进程之间的通信。

相关推荐
花间相见1 小时前
【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错
java·linux·人工智能·后端·python·计算机网络·postman
AFinalStone1 小时前
Android7 SystemUI 源码解析(一)全景概览与架构设计
android·systemui
孙启超1 小时前
【AI开发之Rust】第 13 课:async/await 与 tokio 异步运行时
开发语言·后端·rust
kkkkkkkkkk_Z1 小时前
新手自学嵌入式|学习日记:从C语言到51单片机基础(GPIO、中断、定时器与PWM篇)
linux·笔记·学习·51单片机
云运维笔记1 小时前
华为设备IP地址配置全攻略
运维·网络·计算机网络·华为
凤年徐1 小时前
C++ 仿 muduo 高并发服务器:日志模块
linux·服务器
Am-Chestnuts1 小时前
DeepSeek内容转Word有哪几种方法?用DS随心转对比6条主流路径
开发语言·人工智能