进程通信----匿名管道

1.引入

1.1 进程通信的概念

指两个或多个进程在数据层面实现交互,因进程具有独立性,进程通信的成本较高。

1.2 进程通信的目的

满足进程间的多种交互需求,包括:

  • 基本数据的传递
  • 发送命令
  • 进行某种协同
  • 传递通知等

1.3 实现逻辑

1.3.1 本质

让不同的进程能够看到同一份 "资源"(特定形式的内存空间)。

关于**"资源"**的说明:

1.通常由操作系统提供,而非通信进程中的某一个:

  • 若由某一进程提供,会涉及 "资源归属" 问题,破坏进程独立性
  • 操作系统相当于第三方空间,可保证公平性和独立性

2.进程访问该资源实现通信,本质是访问操作系统:

  • 资源的创建、使用、释放均需通过系统调用接口完成
  • 操作系统会设计独立的通信模块(隶属于文件系统),即 IPC 通信模块

1.3.2 通信的标准方式

  • 标准:主要有 system V(本地通信)和 posix(网络通信)
  • 重点讲解的 system V 通信方式包括 3 种:
    1. 消息队列
    2. 共享内存
    3. 信号量
  • 其他方式:基于文件级别的通信 ------ 管道

2.管道的原理

2.1 管道的本质

管道是 Unix 中最古老的进程间通信形式,本质是内存级文件(非磁盘文件),一个文件可被多个进程打开并访问,管道符|通过重定向实现进程间数据流传递(如who | wc -l中,who的标准输出作为wc -l的标准输入)。

2.2 内存级文件

2.2.1 进程打开文件的基础

  • 每个进程打开文件时会有文件描述符表和对应的struct file对象,进程启动时默认打开stdinstdoutstderr(分别对应键盘和显示器文件)。
  • 当进程再打开一个文件时,会创建新的struct file对象,操作系统会遍历文件描述符表,分配最小且未使用的文件描述符(通常是 3 号),将该文件地址填入对应位置后,把文件描述符数字返回上层。
  • 每个文件需具备三个核心属性:inode、访问底层的方法集(file_operators)、属于文件自己的页缓冲区。
  • 磁盘文件存在分区分组、属性和数据块,可通过属性、数据块预加载数据到缓冲区供用户读写;写操作时,数据先写入缓冲区,修改后内存与磁盘数据不一致(脏数据),后续刷新到磁盘(数据落盘);无论读写,都需先将数据加载到文件缓冲区。

2.2.2 内存级文件的可行性及特性

  • 技术上可行:操作系统可为其创建对应的struct file对象,包含inodefile_operators、页缓冲区,无需关联磁盘文件;具体实现时,只需将file_operators中原先指向磁盘操作的读写方法改为直接对缓冲区进行读写,无需将数据刷新到磁盘,磁盘中也不会存在该文件的实体。
  • 存在形式:内存级文件在操作系统内核中广泛存在,仅在内存中可用,可挂接到文件系统让用户通过目录结构看到,本质是去掉数据刷新到磁盘步骤的文件逻辑,与已知文件体系兼容。

2.2.3 内存级文件与进程通信

  • 子进程创建时,操作系统会为其重新创建PCB、地址空间等,子进程会拷贝父进程的files_struct(文件描述符表),但父进程的struct file结构体及对应的文件本身不会被拷贝(文件属于文件系统,与进程是并列关系,非从属关系),子进程的文件描述符表中记录的指针会指向与父进程相同的文件。
  • 父进程和子进程会看到同一个文件资源(包括新建的内存级文件),而进程间通信的本质是让不同进程看到同一份资源 ,因此借助这个内存级文件,父进程可向其缓冲区写入数据,子进程通过对应的文件描述符读取,从而实现进程间通信,这也是管道的一个朴素原理(类似fork创建的父子进程能向同一终端打印,因它们共享标准输入输出文件)。

2.3 引用计数确保父子进程文件共享稳定性

当父进程以读方式打开管道文件后通过fork创建子进程,此时父子进程的文件描述符会指向同一个文件,为避免一方关闭文件影响另一方,struct file中包含cnt引用计数 ------fork时父子进程指向同一文件会使该引用计数加 1 变为 2;当其中一方关闭文件时,只会将引用计数减 1,若此时计数不为 0,文件不会被真正关闭,只有当引用计数减到 0 时文件才会关闭,因此即便父进程关闭文件,只要子进程未关闭,引用计数不为 0,子进程仍能正常读取,不会因一方关闭而导致文件及缓冲区被销毁,从而保证了父子进程通信的稳定性。

2.4 管道的具体实现流程(站在内核角度)

为实现父子进程通信并避免因访问同一位置出错,需要区分读位置和写位置以防止互相干扰,因此操作系统会以读方式和写方式各打开一次同一内存级文件,产生两个struct file;由于打开的是同一个文件,操作系统会保证这两个struct file对应的inode、文件大小、属性、文件缓冲区等保持一致。

进程以读方式打开该内存级文件时,会被分配 3 号文件描述符,该文件必然有自己的inode和文件缓冲区(任何文件的数据都需先加载到内存才能被访问,内存级文件也不例外);若父进程需同时以写方式打开,则需在操作系统层面再创建一个struct file并分配 4 号文件描述符,而非仅修改原文件属性 ------ 这是因为读写方式不同,每个文件有各自的读写位置,若让 4 号文件描述符直接指向原文件,会导致struct file因读写混用出问题,而新创建的写方式打开的文件与第一个读方式打开的文件会共享同一个inode和缓冲区。

子进程创建与引用计数变化:创建子进程时,会拷贝父进程的PCBfiles_struct,子进程的 3 号和 4 号文件描述符分别指向父进程对应文件描述符所指向的struct file,且这些struct file的引用计数会各自加一;但数据放入文件缓冲区后,若允许父子进程双向读写,会难以区分数据来源(父进程或子进程所写),操作过程也会很复杂,因此设计者规定父子进程只能通过管道进行单向通信。

具体示例:以子进程写入、父进程读取为例,父进程会关闭 4 号文件描述符(该描述符指向以写方式打开的struct file),即关闭自身写端;子进程会关闭 3 号文件描述符(该描述符指向以读方式打开的struct file),即关闭自身读端,同时这些struct file的引用计数会相应减一。

2.5 管道通信的适用情况

  • 适用范围:管道通信只适用于有血缘关系的进程(常用于父子),无血缘关系的进程无法用管道通信;父进程创建的多个子进程之间可以通信,父进程与子进程的子进程也可以通信。
  • 原因:有血缘关系才能继承同一个files_struct
  • 管道的别称:内存级文件没有名字、路径、inode,所以管道也被叫做匿名管道。

3.管道的接口:pipe

函数原型

cpp 复制代码
#include <unistd.h>
int pipe(int pipefd[2]);

作用:创建一个匿名管道,这是一种半双工的通信方式,数据只能在一个方向上流动。它会返回两个文件描述符,分别用于读取和写入操作。

参数: pipefd[2]:这是一个输出型参数,是包含两个整数的数组,用于存储管道的文件描述符。

  • pipefd[0]:代表管道的读端,用于从管道读取数据。
  • pipefd[1]:代表管道的写端,用于向管道写入数据。

返回值

  • 若成功,返回 0。
  • 若失败,返回 - 1,并且会设置 errno 来指示错误类型,常见的错误如下:
    • EMFILE:当前进程打开的文件描述符数量已达到上限。
    • ENFILE:系统范围内的文件描述符资源已耗尽。
    • EFAULTpipefd 数组所在的地址空间不可访问。

4.管道的4种核心行为

管道是半双工的进程间通信机制,其读写行为受两端状态(是否打开、数据量)直接影响,具体规则如下:

4.1 读写端均正常,管道为空

  • 行为:读端调用 read 会阻塞,直到管道中有数据写入。
  • 原理:无数据可读取时,读操作进入等待状态,待写端写入数据后被唤醒。

4.2 读写端均正常,管道被写满

  • 行为:写端调用 write 会阻塞,直到读端读取数据释放管道空间。
  • 说明:管道容量固定(通常为 64KB),写满后无法继续写入,需等待读端消费数据。

4.3 读端正常,写端已关闭

  • 行为:读端 read 会读取剩余数据,读完后返回 0(表示 EOF),不再阻塞。
  • 意义:写端关闭意味着数据传输结束,读端无需继续等待,可正常退出。

4.4 写端正常,读端已关闭

  • 行为:操作系统会通过 13 号信号(SIGPIPE) 终止正在写入的进程。
  • 原因:读端已关闭,写入的数据无人接收,继续写入属于无效操作,系统通过信号强制终止以避免资源浪费。

5. pipe接口使用示例代码

父子进程通过管道单向通信:父进程循环 5 次向管道写入 "I'm a father"(每次间隔 1 秒),子进程读取数据并打印。

cpp 复制代码
#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<string.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;

void Write(int wfd){
    const char* msg="I'm a father";
    int cnt=5;
    while(cnt){
        // 注意:write不会自动处理'\0',此处传入strlen(msg)表示只写有效字符
        write(wfd,msg,strlen(msg));
        --cnt;
        sleep(1);
    }
    close(wfd);// 必须关闭写端:
               // 1. 告知读端"数据已写完",使读端read返回0(触发EOF)
               // 2. 避免读端一直阻塞等待数据(符合管道第3种情况)
}

void Read(int rfd){
    char buffer[1024]={0};
    while(true){
        // buffer[0] = '\0'; // 何时需要?
        // 仅当多次读取时需手动清空缓冲区残留数据(如按行读取场景)
        // 本代码中无需,因read会覆盖旧数据,且后续用buffer[n]='\0'截断
        
        // 注意:第三个参数必须用缓冲区大小(sizeof(buffer)),而非strlen(buffer)
        // strlen(buffer)初始为0,会导致read读取0字节,无法获取数据
        ssize_t n=read(rfd,buffer,sizeof(buffer));
        
        if(n>0){
            buffer[n]='\0';// 必须添加:
                           // 1. read读取的是二进制数据,不会自动加'\0'
                           // 2. 确保cout输出字符串时能正确识别结束位置,避免乱码
            cout<<buffer<<endl;
        }else if(n==0){
            cout<<"end of file"<<endl;
            break;
        }else{
            perror("read");
            break;
        }
    }
}

int main(){
    //创建通信管道
    int pipefd[2]={0};
    int n=pipe(pipefd);
    if(n<0){
        perror("pipe");
        return 1;
    }
    
    //开始通信,子进程读,父进程写
    pid_t id=fork();
    if(id==0){
        //child
        close(pipefd[1]);// 必须关闭子进程的写端:
                        // 1. 管道是单向的,子进程只需要读端
                        // 2. 避免写端未关闭导致读端无法识别EOF(管道第3种情况)
        Read(pipefd[0]);
        exit(0);
    }else if(id>0){
        //father
        close(pipefd[0]);// 必须关闭父进程的读端:
                        // 1. 管道是单向的,父进程只需要写端
                        // 2. 防止读端残留导致写端关闭后仍有无效引用
        Write(pipefd[1]);
        pid_t ret=waitpid(id,nullptr,0);
        if(ret>0){
            cout<<"wait successfully!"<<endl;
        }else if(ret<0){
            cout<<"wait failed"<<endl;
            return 2;
        }
    }
    return 0;
}

父子进程通信过程中数据发生了几次拷贝?

2次,父进程写入时,父进程将数据从父进程应用层缓冲区拷贝到文件缓冲区;子进程读取时,子进程从文件缓冲区拷贝到子进程应用层缓冲区。

6.管道的核心特征

  1. 血缘关联性:仅用于具有血缘关系的进程(如父子进程)间通信。
  2. 单向性:数据传输方向固定,一端读、一端写。
  3. 进程协同机制:父子进程通过同步与互斥保护管道数据安全(例如,子进程休眠时父进程会等待,避免读取无效数据)。
  4. 面向字节流:在管道通信中,前端无论写入多少次,读端都不会在意写入次数,而是按照自己的读取方式来处理数据,甚至可能一次就读完前端多次写入的内容。这是因为在管道通信里,数据被视为一连串的字节,读端并不关注数据的格式、分割符以及如何将字符串拆分成一行行的字符串等问题,这些格式上的区分是由应用层来处理的,这种特性就叫做字节流。
  5. 基于文件的生命周期:随进程存在而存在,进程结束后管道消失。

7.管道相关查看与参数

  • 查看命令ulimit -a(可查看与管道相关的系统限制)
  • 关键参数
    • open files:单个进程能打开的文件个数。

    • file size:文件大小限制。

    • pipe size:显示为 8 个(每个 512 字节),但并非管道真实大小,实际大小因内核而异。
      *

      补充:管道读取的原子性

      • 定义:多进程读管道时,保证一个进程的读取操作未完成前,其他进程不会插队,避免数据被拆乱。
      • 实现条件 :依赖PIPE_BUF(管道缓冲区大小),当进程读写单位小于PIPE_BUF时,读取过程具有原子性。

8.应用场景

8.1. 命令行管道通信(如 cat test.txt | head -10 | tail -5

  • 底层原理 :通过 pipe 实现多个进程间的通信。
    • 每个命令(catheadtail)会成为独立的兄弟进程,父进程为 bash,默认相互隔离。
    • | 符号本质是创建管道,连接前一进程的 "标准输出(stdout)" 与后一进程的 "标准输入(stdin)"。
    • 示例流程:cat 输出通过第一个管道传给 head 作为输入,head 输出再通过第二个管道传给 tail

8.2. 实现简易版进程池

  • 进程池概念:预先创建一定数量的进程并维护在 "池" 中,任务到来时分配空闲进程执行,完成后进程放回池中复用。
  • 核心作用
    • 减少频繁创建 / 销毁进程的开销,提高效率。
    • 控制进程数量,避免系统资源耗尽。
  • 负载均衡
    • 概念:让池内进程均匀分担任务,避免部分进程繁忙、部分空闲,最大化资源利用。
    • 实现方式:轮询。
相关推荐
贺贺丿3 分钟前
Docker2-容器应用工具及docker命令
linux·运维·docker·容器·自动化·云计算
速易达网络13 分钟前
旧手机部署轻量级服务器
运维·服务器·智能手机
ankleless15 分钟前
C语言————原码 补码 反码 (试图讲清楚版)
c语言·开发语言
awonw25 分钟前
[python][flask]Flask-Login 使用详解
开发语言·python·flask
awonw25 分钟前
[python][flask]flask中session管理
开发语言·python·flask
白云~️41 分钟前
js实现宫格布局图片放大交互动画
开发语言·javascript·交互
滴水成川41 分钟前
现代 C++ 开发工作流(VSCode / Cursor)
开发语言·c++·vscode·cursor
张同学的IT技术日记1 小时前
重构 MVC:让经典架构完美适配复杂智能系统的后端业务逻辑层(内附框架示例代码)
c++·后端·重构·架构·mvc·软件开发·工程应用
万能的小裴同学1 小时前
星痕共鸣数据分析2
c++·数据分析
大新新大浩浩1 小时前
ubuntu22.04.4锁定内核应对海光服务器升级内核无法启动问题
运维·服务器