云计算百科
云计算领域专业知识百科平台

【Linux】进程为什么不能直接通信?匿名管道原理、fd 继承与读写规则

封面

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介 在这里插入图片描述

文章目录

  • 前言
  • 一、进程为什么不能直接通信
    • 1.1 进程为什么不能直接把数据交给对方
    • 1.2 IPC 的含义
    • 1.3 操作系统为什么要提供系统调用
  • 二、匿名管道到底是什么
    • 2.1 什么是管道
    • 2.2 `pipe()` 返回的两个文件描述符
    • 2.3 `fork()` 后为什么父子进程能访问同一条管道
    • 2.4 为什么必须关闭不需要的端
  • 三、先把最小 Demo 跑起来
    • 3.1 创建管道和子进程
    • 3.2 数据为什么是“读后消费”
  • 四、管道真正容易踩坑的读写规则
    • 4.1 匿名管道的几个特点
      • 1. 常用于具有亲缘关系的进程
      • 2. 管道自带同步关系
      • 3. 管道面向字节流
      • 4. 一条管道只提供一个数据方向
      • 5. 生命周期由所有引用共同决定
    • 4.2 四种典型情况
      • 情况一:写端正常,但暂时没有数据
      • 情况二:读端正常,但管道已经写满
      • 情况三:所有写端关闭,读端继续读取
      • 情况四:所有读端关闭,写端继续写
  • 五、管道容量和 `PIPE_BUF` 别混在一起
    • 5.1 管道容量不是固定写死的结论
    • 5.2 `ulimit` 中的 pipe size 不等于总容量
    • 5.3 `PIPE_BUF` 解决的是写入原子性
  • 总结

前言

前面学习进程时,我们一直强调进程具有独立性。

每个进程都有自己的 PCB、虚拟地址空间和页表。fork() 创建子进程以后,父子进程虽然起点相同,但仍然是两个独立的进程;一方修改普通变量,并不会直接改到另一方的地址空间。

隔离带来了安全性,也带来了新的问题:两个彼此独立的进程,数据到底怎么传?

终端里这条命令就是最直观的例子:

ls -l | wc -l

Shell 会分别创建 ls 和 wc 两个进程,再把 ls 的标准输出接到 wc 的标准输入。两个进程没有共享普通变量,却可以通过内核提供的管道协作。

wc -l 统计的是 ls -l 输出的行数,不等于“当前目录中准确的文件数量”。这里用它,只是为了观察两个进程怎样通过管道连接起来。

这篇先把匿名管道本身讲透:pipe() 做了什么、fork() 后 fd 怎样继承、为什么必须关闭无关端,以及管道为空、写满、关闭读端或写端时会发生什么。进程池放到下篇单独展开。


一、进程为什么不能直接通信

1.1 进程为什么不能直接把数据交给对方

假设进程 A 中有一个全局变量:

int data = 10;

即使进程 B 是通过 fork() 创建出来的子进程,B 修改自己的 data,也不会把 A 中的 data 一起改掉。

原因很直接:

  • A 和 B 有各自的虚拟地址空间;
  • 它们有各自的页表;
  • 进程看到的变量属于自己的用户空间;
  • 写入时会按照写时拷贝机制完成隔离。

所以,进程 A 不能把自己堆区中的一块地址直接告诉进程 B,然后让 B 当成自己的地址使用。

这也是进程协作比线程协作更麻烦的地方。

1.2 IPC 的含义

IPC 是 Inter-Process Communication 的缩写,也就是进程间通信。

进程通信通常有下面几类目的:

  • 数据传输:一个进程把处理结果交给另一个进程;
  • 资源共享:多个进程共同使用某类资源;
  • 事件通知:一个进程通知另一个进程某件事已经发生;
  • 进程控制:调试器等控制进程观察或干预目标进程。

既然进程彼此隔离,通信数据就不能放在某个进程自己的堆或栈里。它需要一个双方都能访问、同时又不属于任何一方普通用户空间的位置。

核心:进程间通信的前提,是让通信双方都能访问同一个由内核管理的通信资源。

这份资源不属于某个进程的普通堆区或栈区,而是由操作系统创建和维护。进程再通过文件描述符等方式拿到访问入口。

对于匿名管道来说,这个共同资源就是内核中的管道对象及其缓冲区。

通信过程可以概括成:

子进程用户空间
│ write

内核管道缓冲区
│ read

父进程用户空间

写进程先把数据从自己的用户空间复制到内核管道,读进程再把数据从管道复制到自己的用户空间。

父子进程的地址空间仍然是独立的,它们只是借助内核中的同一条管道完成数据传递。

1.3 操作系统为什么要提供系统调用

既然通信资源由操作系统管理,操作系统就需要负责:

  • 创建通信资源;
  • 提供访问资源的入口;
  • 维护通信过程中的同步关系;
  • 在没有进程继续使用时释放资源。
  • 用户进程不能直接操作内核对象,只能通过系统调用完成这些动作。

    匿名管道对应的核心接口就是:

    #include <unistd.h>

    int pipe(int pipefd[2]);

    接下来就从这条接口开始。


    二、匿名管道到底是什么

    2.1 什么是管道

    管道是 Unix 中非常早的一种进程间通信方式。

    可以把它理解成一条位于内核中的数据通道:一端负责写入,另一端负责读取,写进去的数据按照字节流的方式向前流动。

    进程通过内核管道传递数据

    管道本身不负责把数据保存到磁盘。

    普通文件中的数据最终需要落到存储设备,而匿名管道中的数据只是进程通信期间的临时数据。数据被读取以后,就从管道中消费掉;所有相关文件描述符关闭后,内核会释放这条管道。

    2.2 pipe() 返回的两个文件描述符

    调用:

    int pipefd[2] = {0};
    int ret = pipe(pipefd);

    成功时返回 0,失败时返回 -1。

    两个输出参数的含义固定:

    pipefd[0]:读端
    pipefd[1]:写端

    如果当前进程的 0、1、2 已经分别被标准输入、标准输出和标准错误占用,那么第一次调用 pipe() 时,经常会得到:

    pipefd[0] = 3
    pipefd[1] = 4

    这两个 fd 都在当前进程的文件描述符表中,但它们指向管道的不同端。

    pipe 创建读端和写端

    匿名管道没有路径,也没有可供其他无关进程重新打开的文件名,所以叫“匿名”管道。

    它最常见的使用场景,就是先由父进程调用 pipe(),再调用 fork() 创建子进程。

    2.3 fork() 后为什么父子进程能访问同一条管道

    父进程先创建管道:

    int pipefd[2];
    pipe(pipefd);

    再创建子进程:

    pid_t id = fork();

    fork() 以后,子进程会得到一份自己的文件描述符表。表是子进程自己的,但其中已有 fd 对应的内核引用会被继承。

    也就是说:

    父进程 pipefd[0] ─┐
    ├── 同一条管道的读端
    子进程 pipefd[0] ─┘

    父进程 pipefd[1] ─┐
    ├── 同一条管道的写端
    子进程 pipefd[1] ─┘

    这里需要纠正一个很容易写错的说法。

    这里最容易写错:fork() 不会重新复制一份 struct file、inode 和管道缓冲区给子进程。

    更准确的理解是:

    • 子进程得到自己的 fd 表;
    • fd 表中的条目继续引用已有的内核打开文件对象;
    • 父子进程因此能够通过各自的 fd 访问同一个管道对象;
    • 内核通过引用计数判断还有多少描述符正在使用这些对象。

    所以父子进程共享的是内核管道资源的访问关系,不是共享彼此用户空间中的普通变量。

    2.4 为什么必须关闭不需要的端

    pipe() 创建以后,父子进程最开始都同时持有读端和写端。

    如果我们设计成“子进程写,父进程读”,那么应该这样处理:

    pid_t id = fork();

    if (id == 0)
    {
    // 子进程只负责写
    close(pipefd[0]);

    // 写数据
    // …

    close(pipefd[1]);
    _exit(0);
    }

    // 父进程只负责读
    close(pipefd[1]);

    // 读数据
    // …

    close(pipefd[0]);

    关闭无用端不只是为了让代码好看,它还会直接影响管道的读写规则。

    例如,父进程读完现有数据后,只有当所有写端引用都已经关闭,read() 才会返回 0,表示读到了文件结尾。

    如果某个进程还意外持有写端,即使它从来不写数据,读进程也可能一直等不到 EOF。


    三、先把最小 Demo 跑起来

    下面设计成:

    子进程:向管道写消息
    父进程:从管道读消息

    3.1 创建管道和子进程

    #include <iostream>
    #include <cstring>
    #include <unistd.h>
    #include <sys/wait.h>

    void ChildWrite(int wfd)
    {
    char buffer[1024];
    int count = 0;

    while (count < 5)
    {
    int len = snprintf(
    buffer,
    sizeof(buffer),
    "I am child, pid: %d, count: %d\\n",
    getpid(),
    count++
    );

    write(wfd, buffer, len);
    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';
    std::cout << "child say: " << buffer;
    }
    else if (n == 0)
    {
    std::cout << "写端已经全部关闭,父进程读取结束" << std::endl;
    break;
    }
    else
    {
    perror("read");
    break;
    }
    }
    }

    int main()
    {
    int pipefd[2] = {0};

    if (pipe(pipefd) < 0)
    {
    perror("pipe");
    return 1;
    }

    std::cout << "read fd: " << pipefd[0] << std::endl;
    std::cout << "write fd: " << pipefd[1] << std::endl;

    pid_t id = fork();

    if (id < 0)
    {
    perror("fork");
    return 2;
    }
    else if (id == 0)
    {
    close(pipefd[0]);

    ChildWrite(pipefd[1]);

    close(pipefd[1]);
    _exit(0);
    }

    close(pipefd[1]);

    FatherRead(pipefd[0]);

    close(pipefd[0]);
    waitpid(id, nullptr, 0);

    return 0;
    }

    运行时,子进程每隔一秒向写端写一条消息,父进程从读端取出数据。

    父子进程使用匿名管道通信

    这里变化的 count 是子进程自己的普通变量。

    它的变化只能说明子进程在持续生成不同的消息,不能用来证明父子进程共享了变量,也不能用来证明写时拷贝是否发生。

    真正完成通信的是下面这条链路:

    子进程 buffer
    │ write

    内核管道
    │ read

    父进程 buffer

    3.2 数据为什么是“读后消费”

    管道是字节流。

    子进程调用 write() 后,字节进入内核管道;父进程调用 read() 取走一部分字节后,这部分数据就不再留在管道中等待第二次读取。

    例如子进程写入整数 14:

  • 14 的字节表示被复制到管道;
  • 父进程读取后,数据被复制到父进程自己的变量;
  • 已经读走的那部分字节从管道中被消费。
  • 所以匿名管道不是“父子进程共同拿着同一个用户变量修改”,而是一个典型的生产者—消费者模型。


    四、管道真正容易踩坑的读写规则

    4.1 匿名管道的几个特点

    1. 常用于具有亲缘关系的进程

    匿名管道没有文件名。

    父进程先拿到 fd,再通过 fork() 把 fd 的访问关系继承给子进程,因此它通常用于父子进程,或者拥有共同祖先的进程之间。

    2. 管道自带同步关系

    当管道为空,但写端仍然存在时,阻塞模式下的 read() 会等待数据。

    当管道已经写满,但读端仍然存在时,阻塞模式下的 write() 会等待空间。

    这里的“同步”可以直接理解成:

    一个进程的执行条件,要等待另一个进程的行为才能满足。

    3. 管道面向字节流

    管道不保存“消息边界”。

    假设子进程连续写入十条字符串,父进程一次 read():

    • 可能只读到一条;
    • 可能读到多条拼在一起;
    • 也可能读到某条消息的一部分。

    最终读多少,取决于:

    • 管道当前有多少数据;
    • read() 传入的缓冲区大小;
    • 内核当次返回的字节数。

    所以不能认为一次 write() 必然对应一次 read()

    下面这种“写得快、读得慢”的程序,很容易先把管道写满:

    void ChildWrite(int wfd)
    {
    char buffer[1024];
    int count = 0;

    while (true)
    {
    int len = snprintf(
    buffer,
    sizeof(buffer),
    "I am child, pid: %d, count: %d\\n",
    getpid(),
    count++
    );

    write(wfd, buffer, len);
    }
    }

    void FatherRead(int rfd)
    {
    char buffer[1024];

    while (true)
    {
    sleep(5);

    ssize_t n = read(rfd, buffer, sizeof(buffer) 1);

    if (n > 0)
    {
    buffer[n] = '\\0';
    std::cout << buffer;
    }
    }
    }

    当管道没有剩余空间时,子进程会阻塞在 write(),等待父进程读走一部分数据。

    写端速度快于读端时的现象

    管道写满后写进程阻塞

    反过来,如果子进程每隔一秒写一条,父进程一直读取,就会表现为写一条、读一条:

    写端较慢时的读取现象

    4. 一条管道只提供一个数据方向

    一条匿名管道有读端和写端,数据从写端流向读端。

    如果 A 要给 B 发数据,同时 B 也要给 A 发数据,通常需要创建两条管道:

    管道 1:A 写,B 读
    管道 2:B 写,A 读

    所以这里更准确的说法是:一条管道是单向字节流通道。需要双向通信时,要额外建立另一条通道。

    5. 生命周期由所有引用共同决定

    某一个进程退出,并不一定代表管道立即消失。

    只有当所有指向管道读端、写端的文件描述符都关闭后,内核才会真正释放对应资源。

    4.2 四种典型情况

    情况一:写端正常,但暂时没有数据

    如果写端仍然存在,管道又是空的:

    read(rfd, buffer, sizeof(buffer));

    阻塞模式下,读进程会等待写进程写入。

    这就是“读空则等”。

    管道为空时读进程等待

    情况二:读端正常,但管道已经写满

    如果还有读端存在,只是读得比较慢,写进程会在管道满时阻塞。

    等读进程取走数据、管道重新出现空间后,写操作才能继续。

    结论:管道写满后不会把旧数据直接覆盖掉,阻塞写会等待读端释放空间。

    情况三:所有写端关闭,读端继续读取

    当所有写端都关闭,并且管道中剩余数据已经读完后:

    ssize_t n = read(rfd, buffer, sizeof(buffer));

    返回:

    n == 0

    这个 0 表示 EOF

    写端关闭后 read 返回 0

    注意是所有写端引用都关闭,而不只是你认为的“那个负责写的子进程”关闭。

    情况四:所有读端关闭,写端继续写

    如果所有读端都已经关闭,写进程继续调用 write(),默认情况下会收到 SIGPIPE 信号。

    SIGPIPE 在 Linux 中通常是 13 号信号,默认动作是终止进程

    所有读端关闭后写进程收到 SIGPIPE

    注意:不要把它理解成“操作系统一定直接杀掉写进程”。更准确地说,是内核产生 SIGPIPE;如果进程没有捕获或忽略该信号,就会按照默认动作退出。若信号被忽略,write() 会失败并设置 errno = EPIPE。


    五、管道容量和 PIPE_BUF 别混在一起

    5.1 管道容量不是固定写死的结论

    可以让子进程每次写一个字节,父进程暂时不读,观察写入计数什么时候停止:

    void ChildWrite(int wfd)
    {
    char ch = 'A';
    int count = 0;

    while (true)
    {
    ssize_t n = write(wfd, &ch, 1);

    if (n == 1)
    {
    std::cout << count++ << std::endl;
    }
    }
    }

    某些 Ubuntu 环境中可能观察到大约 65536 字节,也就是 64 KiB:

    通过实验观察管道容量

    但是文章中不能把“64 KiB”写成所有 Linux 环境永远不变的固定值

    管道容量可能受到:

    • 内核版本;
    • 系统页大小;
    • 管道动态扩容策略;
    • fcntl() 调整;
    • 系统资源限制

    等因素影响。

    实验只能说明当前机器、当前环境中的结果。

    5.2 ulimit 中的 pipe size 不等于总容量

    执行:

    ulimit -a

    可能会看到与 pipe size 有关的项目:

    ulimit 中的管道相关限制

    这里显示的值不能直接和“管道当前总容量”画等号

    文章中最容易混淆的是两个概念:

    概念作用
    管道容量 管道缓冲区总共能容纳多少数据
    PIPE_BUF 多进程写同一条管道时,内核保证原子写的上限

    5.3 PIPE_BUF 解决的是写入原子性

    可以通过:

    man 7 pipe

    查看管道规则。

    man 7 pipe 中的 PIPE_BUF 说明

    当一次 write() 的数据量不大于 PIPE_BUF 时,Linux 保证这次写入不会和其他进程的小块写入交叉混合。

    例如两个进程同时写:

    进程 A:AAAA
    进程 B:BBBB

    如果每次写入都不超过 PIPE_BUF,可能得到:

    AAAABBBB

    也可能得到:

    BBBBAAAA

    但不会把两次写入撕裂成:

    AABABBAB

    这里保证的是同一次小块写入的完整性,不是保证谁先写,也不是保证 read() 一次恰好读到一条完整消息。


    总结

    匿名管道真正需要记住的,不是某一段固定代码,而是父子进程和内核资源之间的关系:

    写进程用户空间
    ↓ write
    内核管道缓冲区
    ↓ read
    读进程用户空间

    pipefd[0] 是读端,pipefd[1] 是写端。fork() 以后,父子进程有各自的 fd 表,但这些 fd 可以继续引用同一条内核管道。

    后面很多现象都由“还有没有读端、还有没有写端”决定:

    • 管道为空且写端仍存在,阻塞读会等待;
    • 管道写满且读端仍存在,阻塞写会等待;
    • 所有写端关闭并且数据读完,read() 返回 0;
    • 所有读端关闭,继续写会触发 SIGPIPE;
    • 管道容量决定能装多少数据,PIPE_BUF 解决的是小块写入原子性,两者不是一回事。

    把这些规则理清以后,下篇的进程池就不再只是“照着代码敲”,而是能看懂为什么每个子进程都必须关闭那些看起来无关的 fd。


    资源分享: 进程间通信相关代码 【Linux】没有公网 IP 也能 SSH 回内网:反向 SSH、ProxyJump 与 systemd 保活实战

    【Linux】系统彻底进不去怎么办?initramfs、recovery 与 chroot 救援指南

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux】进程为什么不能直接通信?匿名管道原理、fd 继承与读写规则
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!