Linux进程通信——匿名管道

目录

1、进程间通信基础概念

2、管道的工作原理

[2.1 什么是管道文件](#2.1 什么是管道文件)

3、匿名管道的创建与使用

[3.1、pipe 系统调用](#3.1、pipe 系统调用)

[3.2 父进程调用 fork() 创建子进程](#3.2 父进程调用 fork() 创建子进程)

[3.3. 父子进程的文件描述符共享](#3.3. 父子进程的文件描述符共享)

[3.4. 关闭不必要的文件描述符](#3.4. 关闭不必要的文件描述符)

[3.5 父子进程通过管道进行通信](#3.5 父子进程通过管道进行通信)

父子进程通信的具体例子

4.管道的四种场景

[4.1 场景一:父进程不写,子进程尝试读取](#4.1 场景一:父进程不写,子进程尝试读取)

[4.2 场景二:父进程不断写入,直到管道写满,子进程不读取](#4.2 场景二:父进程不断写入,直到管道写满,子进程不读取)

[4.3 场景三:关闭写端,子进程读取数据](#4.3 场景三:关闭写端,子进程读取数据)

[4.4 场景四:关闭读端,父进程写入数据](#4.4 场景四:关闭读端,父进程写入数据)

5、匿名管道实操-进程控制

[5.1 逻辑设计](#5.1 逻辑设计)


🌇前言

在操作系统中,进程间通信(Interprocess Communication,简称IPC)是两个不同进程之间进行协同工作和信息交换的基础 。IPC 允许不同的进程相互协调,协作完成任务。进程间通信的方式有很多种,而管道则是一种非常经典且常用的方式。 本文将详细探讨 匿名管道,它在进程间通信中扮演着重要的角色。

🏙️正文

1、进程间通信基础概念

在深入探讨匿名管道之前,我们先来了解一些基本概念。进程间通信的目的是为了使多个独立的进程能够协同工作,进行信息交换。主要有四个目的:

  • 数据传输:不同进程之间需要传输数据。例如,将数据从客户端传送到服务器。

  • 资源共享:多个进程共享系统资源,保证高效使用。

  • 事件通知:某个进程需要通知其他进程某个事件的发生,例如进程的终止。

  • 进程控制:用于进程管理,协调进程的执行和资源的分配。

这些目的的核心是打破进程的独立性,让它们能够共享资源和信息,协同完成任务。

2、管道的工作原理

管道是一种用于进程间通信的方式,它本质上是一个文件 。无论是匿名管道还是命名管道,它们的原理都是通过文件描述符来共享数据。每个管道都有两个端口:一个是写端,另一个是读端。

管道最初是由 Unix 系统引入的,它允许具有"血缘关系"的进程(如父子进程)通过管道进行通信。管道的实现通常会涉及到内核为进程分配文件描述符。父进程在创建管道后,会为其子进程继承文件描述符,并通过关闭不需要的端口,确保通信的流向。

2.1 什么是管道文件

管道文件是操作系统中用于实现进程间通信的特殊文件,具有以下几个显著特点:

  • 单向通信: 管道是 单向 的通信方式,意味着数据只能从一个端流向另一个端 。通常情况下,一个进程写数据到管道,而另一个进程从管道中读取数据。这种方式被称为"半双工通信",如果需要实现双向通信,需要两个管道。

  • 基于文件的设计 ,管道本质上是内存中的文件; 管道文件并不是磁盘级别的文件,而是内存级别文件,管道文件没有自己的inode,也没有名字。过内存中的缓冲区进行存储,操作系统会将管道作为文件来处理。

  • 管道分为 匿名管道命名管道。匿名管道没有名字,是由操作系统在内存中创建的,仅限于有血缘关系(如父子进程或兄弟进程)的进程间通信。由于没有名字,匿名管道无法在进程间直接共享。

    与此不同,命名管道(FIFO)则拥有一个系统中的路径名,因此它可以被不具备血缘关系的进程之间共享。这使得命名管道的通信更加灵活。

  • 生命周期与进程绑定 ,管道的生命周期与创建它的进程生命周期紧密相关**。当进程结束时,管道也会被操作系统回收。管道文件的生命周期由打开它的进程的生命周期决定,在进程终止时,管道的资源会被释放。**

  • 内存缓冲区,管道的一个重要特点是,它在内存中创建一个缓冲区,用于存储待传输的数据。由于是内存中的缓冲区,管道中的数据并不会被持久化到磁盘中。这使得管道比磁盘文件更为高效,但数据在管道中的存储是临时的,不会在系统重启后保留

  • 阻塞行为与同步机制

    **管道的通信遵循 阻塞 和 同步 机制。**当读端尝试读取数据时,如果管道为空,进程会阻塞,直到写端写入数据 。同样,写端如果尝试写入数据时,如果管道已满,进程也会阻塞,直到读端读取部分数据。

    这种阻塞行为本身提供了一定的同步机制。管道会保证写入数据的顺序,并且数据在被读取之前不会丢失。这使得进程间的通信是同步的,确保数据完整传输。

  • 管道大小限制,管道的大小在不同的操作系统和系统配置中可能有所不同。通常,管道大小会受到系统配置的限制。在 Linux 中,管道大小的默认值通常为 64KB(从 Linux 2.6.11 版本开始),不过在不同的系统或不同的内核版本中,管道的大小也可能有所变化

  • 在管道中,写入 与 读取 的次数并不是严格匹配的,此时读写次数没有强相关关系,管道是面向字节流读写的面向字节流读写又称为 流式服务:数据没有明确的分割,不分一定的报文段;与之相对应的是 数据报服务:数据有明确的分割,拿数据按报文段拿不论写端写入了多少数据,只要写端停止写入,读端都可以将数据读取。

  • 具有一定的协同能力, 让 读端 和 写端 能够按照一定的步骤进行通信(自带同步机制)当读端进行从管道中读取数据时,如果没有数据,则会阻塞,等待写端写入数据;如果读端正在读取,那么写端将会阻塞等待读端,因此 管道自带 同步与互斥机制。

3、匿名管道的创建与使用

具体流程:

父进程创建匿名管道,同时以读、写的方式打开匿名管道,此时会分配两个 fd

fork 创建子进程,子进程拥有自己的进程系统信息,同时会继承原父进程中的文件系统信息,此时子进程和父进程可以看到同一份资源:匿名管道 pipe

因为子进程继承了原有关系,因此此时父子进程对于 pipe 都有读写权限,需要确定数据流向,关闭不必要的 fd,比如父进程写、子进程读,或者父进程读、子进程写都可以。

3.1、pipe 系统调用

匿名管道的创建通过 pipe() 系统调用来实现。该函数会创建一个管道,并返回两个文件描述符:一个用于读,另一个用于写。函数原型如下:

cpp 复制代码
#include <unistd.h>

int pipe(int pipefd[2]);

传入一个大小为2的整型数组作为输出型参数操作系统就会生成一个管道文件,并且让进程以读写的方式分别打开进程,并且将进程的读管道文件的文件标识符写道pipe[1],写管到文件描述符写道pipe[1]之中。

cpp 复制代码
int pipefd[2];
pipe(pipefd);  // 创建管道
3.2 父进程调用 fork() 创建子进程

当父进程调用 fork() 时,操作系统会创建一个新的子进程。子进程会继承父进程的文件描述符表 ,因此,父子进程可以共享父进程所创建的管道文件描述符。也就是说,父进程和子进程都会拥有相同的管道读端和写端。

cpp 复制代码
pid_t pid = fork();
3.3. 父子进程的文件描述符共享

父进程和子进程共享管道的读写端口意味着:

  • 父进程子进程 都可以操作 pipefd[0]pipefd[1]但它们之间的角色(读或写)通常是根据进程的需求来确定的。

  • 父进程和子进程在创建时各自拥有自己的 进程资源但文件描述符表会被子进程继承,指向相同的管道内存资源。

3.4. 关闭不必要的文件描述符

由于管道是单向通信的,所以为了避免数据混乱,父进程和子进程通常会关闭不必要的文件描述符。例如,如果父进程要写数据到管道而子进程读取数据,父进程应该关闭管道的读端,子进程应该关闭管道的写端

cpp 复制代码
// 父进程关闭管道的读端,子进程关闭管道的写端
close(pipefd[0]);  // 父进程关闭读端
close(pipefd[1]);  // 子进程关闭写端
3.5 父子进程通过管道进行通信
  • 父进程写数据 :父进程通过管道的写端 pipefd[1] 向管道中写入数据。

    cpp 复制代码
    write(pipefd[1], "Hello from parent", 17);

    子进程读数据 :子进程通过管道的读端 pipefd[0] 从管道中读取数据。

    cpp 复制代码
    char buf[128];
    read(pipefd[0], buf, sizeof(buf));
  • 父进程写入的数据会通过管道传递给子进程。

  • 子进程从管道中读取数据,通常会按顺序接收父进程写入的数据。

父子进程通信的具体例子

下面是一个完整的示例代码,展示了父子进程如何通过管道进行通信:

cpp 复制代码
#include <iostream>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

int main()
{
    // 1、创建匿名管道
    int pipefd[2]; // 数组
    int ret = pipe(pipefd);
    assert(ret == 0);
    (void)ret; // 防止 release 模式中报警告

    // 2、创建子进程
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程内
        close(pipefd[1]); // 3、子进程关闭写端

        // 4、开始通信
        char buff[64]; // 缓冲区
        while (true)
        {
            int n = read(pipefd[0], buff, sizeof(buff) - 1);    //注意预留一个位置存储 '\0'
            buff[n] = '\0';

            if (n >= 5 && n < 64)
            {
                // 读取到了信息
                cout << "子进程成功读取到信息:" << buff << endl;
            }
            else
            {
                // 未读取到信息
                if (n == 0)
                    cout << "子进程没有读取到信息,通信结束!" << endl;
                // 读取异常(消息过短)
                else
                    cout << "子进程读取数据量为:" << n << " 消息过短,通信结束!" << endl;
                break;
            }
        }

        close(pipefd[0]); // 关闭剩下的读端
        exit(0);          // 子进程退出
    }

    // 父进程内
    close(pipefd[0]); // 3、父进程关闭读端

    char buff[64];

    // 4、开始通信
    srand((size_t)time(NULL)); // 随机数种子
    while (true)
    {
        int n = rand() % 26;
        for (int i = 0; i < n; i++)
            buff[i] = (rand() % 26) + 'A'; // 形成随机消息
        buff[n] = '\0';                    // 结束标志

        cout << "=============================" << endl;
        cout << "父进程想对子进程说: " << buff << endl;
        write(pipefd[1], buff, strlen(buff)); // 写入数据

        if (n < 5)
            break; // 消息过短时,不写入

        sleep(1);
    }

    close(pipefd[1]); // 关闭剩下的写端

    // 父进程等待子进程结束
    int status = 0;
    waitpid(id, &status, 0);

    // 通过 status 判断子进程运行情况
    if ((status & 0x7F))
    {
        printf("子进程异常退出,core dump: %d   退出信号:%d\n", (status >> 7) & 1, (status & 0x7F));
    }
    else
    {
        printf("子进程正常退出,退出码:%d\n", (status >> 8) & 0xFF);
    }

    return 0;
}

站在 文件描述符 的角度理解上述代码:

所以,看待 管道 ,就如同看待 文件 一样!管道 的使用和 文件 一致,迎合 Linux一切皆文件思想。

4.管道的四种场景

4.1 场景一:父进程不写,子进程尝试读取

情况描述:

  • 父进程没有写入数据到管道。

  • 子进程尝试从管道中读取数据。

结果:

  • 由于管道为空,子进程在尝试读取时会进入阻塞状态。

  • 只有当父进程开始向管道中写入数据后,子进程才会成功读取数据。

形象化理解:

  • 这就像一个垃圾桶,子进程是倒垃圾的工作人员,而父进程是往垃圾桶里扔垃圾。如果垃圾桶为空,子进程(倒垃圾的人)就无法工作,必须等待父进程(扔垃圾的人)开始丢垃圾,才能开始工作。
4.2 场景二:父进程不断写入,直到管道写满,子进程不读取

情况描述:

  • 父进程持续向管道写入数据,直到管道被写满。

  • 子进程不进行读取操作。

结果:

  • 当管道的缓冲区满了,父进程会被阻塞,无法继续写入数据,直到子进程读取数据。

  • 这是因为管道有大小限制,管道满时,写端无法继续写入,必须等待管道中有空间才能继续写入。

形象化理解:

  • 就像垃圾桶满了后,不能继续往里面丢垃圾,必须等到垃圾桶被清空(子进程读取数据)之后,才能继续丢垃圾。
4.3 场景三:关闭写端,子进程读取数据

情况描述:

  • 父进程写入数据到管道,并关闭写端。

  • 子进程从管道中读取数据,并在读取到末尾时判断写端是否关闭。

结果:

  • 当父进程关闭写端后,子进程可以继续读取管道中的数据,直到数据读取完。

  • 子进程在读取到数据末尾时会收到 read的,表示已经没有更多数据可读取,且写端已关闭。

形象化理解:

  • 这类似于垃圾桶的垃圾已经被倒空,子进程(倒垃圾的人)会看到垃圾桶已经没有垃圾了。即使它继续尝试"倒垃圾",也不会有新的垃圾,显示读取到了文件末尾。
4.4 场景四:关闭读端,父进程写入数据

情况描述:

  • 父进程是写端,子进程是读端。父进程写入数据。

  • 父进程在读取五次后关闭读端。

结果:

  • 当关闭读端后,写端(父进程)会收到 SIGPIPE 信号,通常导致进程终止。

  • 因为操作系统会发现,写端已没有可用的读取端(读端关闭了),它会强制终止写端进程以防止资源浪费。

形象化理解:

这就像垃圾桶的"倒垃圾的人"(写端)发现没有"垃圾桶"(读端)可以丢垃圾,因此操作系统会终止写端,避免无意义的行为继续发生。


5、匿名管道实操-进程控制

匿名管道作为 IPC 的其中一种解决方案,那么肯定有它的实战价值

场景:父进程创建了一批子进程,并通过多条匿名管道与它们链接,父进程选择某个子进程,并通过匿名管道与子进程通信,并下达指定的任务让其执行

5.1 逻辑设计

首先创建一批子进程及匿名管道 -> 子进程(读端)阻塞,等待写端写入数据 -> 选择相应的进程,并对其写入任务编号(数据)-> 子进程拿到数据后,执行相应任务

1.创建一批进程及管道

首先需要先创建一个包含进程信息的类,最主要的就是子进程的写端 fd,这样父进程才能通过此 fd 进行数据写入

循环创建管道、子进程,进行相应的管道链接操作,然后子进程进入任务等待状态,父进程将创建好的子进程信息注册

假设子进程获取了任务代号,那么应该根据任务代号,去执行相应的任务,否则阻塞等待
注意: 因为是创建子进程,所以存在关系重复继承的情况,此时应该统计当前子进程的写端 fd,在创建下一个进程时,关闭无关的 fd

具体体现为:每次都把 写端 fd 存储起来,在确定关系前 "清理" 干净

关于上述操作的危害,需要在编写完进程等待函数后,才能演示其作用 。

完整代码如下:

Task.hpp

cpp 复制代码
#pragma once 
#include<iostream>
#include<vector>

typedef void (*task_t)();

void task1()
{
   std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{
   std::cout << "lol 更新野区,刷新出来野怪" << std::endl;
}
void task3()
{
    std::cout << "lol 检测软件是否更新,如果需要,就提示用户" << std::endl;
}
void task4()
{
   std::cout << "lol 用户释放技能,更新用的血量和蓝量" << std::endl;
}
void LoadTask(std::vector<task_t>& task)
{
      task.push_back(task1);
      task.push_back(task2);
      task.push_back(task3);
      task.push_back(task4);
}

processpool.cc

cpp 复制代码
#include "Task.hpp"
#include <string>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>

using namespace std;

const int processnum =4;
vector<task_t> tasks; //把所有的任务,装进去

class channel
{
   public:
    channel(int &cmdfd ,int &mypid,string &name)
    :_name(name)
    ,_cmdfd(cmdfd)
    ,_mypid(mypid)
    {}
   public:
    string _name;//子进程的名字
    int _cmdfd;//发信号的文件描述符  
    int _mypid;//我的PID
};

void Menu()
{
    std::cout << "################################################" << std::endl;
    std::cout << "# 1. 刷新日志             2. 刷新出来野怪        #" << std::endl;
    std::cout << "# 3. 检测软件是否更新      4. 更新用的血量和蓝量  #" << std::endl;
    std::cout << "#                         0. 退出               #" << std::endl;
    std::cout << "#################################################" << std::endl;
}

void slaver(int pool)
{
    while(true)
    {
        int cmdcode=0;//通过调用码,去领任务
        int n=read(pool,&cmdcode,sizeof(int));
        cout <<"slaver say@ get a command: "<< getpid() << " : cmdcode: " <<  cmdcode << endl;
        if(cmdcode>0&&cmdcode<=tasks.size()) tasks[cmdcode-1]();
        if(n==0) break;
        
    }
}

void InitProcesspool(vector<channel>&channels)
{
     vector<int> d;//把父进程的所有打开的写管道存储到里面
     for(int i=1;i<=processnum;i++)
     {
        int pipeid[2];
        int n=pipe(pipeid);
        assert(!n);

        int pid=fork();
        if(pid==0)
        {
        cout<<"创建的"<< i<<"号子进程"<<endl;
          for(auto &t:d)
          {
            close(t);//关掉所有不相关的管道
          }
          close(pipeid[1]);//关闭写管道

          slaver(pipeid[0]);//读操作
        // close(pipeid[0]);//多此一举
        cout<<"关闭的"<< i<<"号子进程"<<endl;
          exit(0);
        }

        close(pipeid[0]);
        string name="创建的子进程"+to_string(i);
        channels.push_back({pipeid[1],pid,name});//那个管道发数据记录下来,
        d.push_back(pipeid[1]);//把一会发数据的管道号记下来
        sleep(1);
     }
}



void setslaver(vector<channel>&channels)
{
    int which=0;
    int cnt=4;
    while(cnt--)
    {
        int slect=0;
        Menu();
        cin>>slect;     
        if(!slect) break;   
        // rand((void)time(nullptr));
        // int i=srand()%5;
        cout<<"farher message"<<channels[which]._name<<endl;
        write(channels[which]._cmdfd,&slect,sizeof(int));     
        which++;
        which%=channels.size();

        sleep(1);
    }
}

void Quitpool(vector<channel>&channels)
{
    for(auto& t:channels)//关闭所有的写管道
    {
    close(t._cmdfd);
    waitpid(t._mypid,nullptr,0);
    }
}

int main()
{
    vector<channel> channels;//把打开的子进程装进来
    LoadTask(tasks);
    InitProcesspool(channels);//创建子进程
    
    setslaver(channels);//发配任务,采用轮询

    Quitpool(channels);//关闭写管道,等到read=0子进程退出,全部关闭

    return 0;
}

总体来说,在使用这个小程序时,以下关键点还是值得多注意的

注册子进程信息时,存储的是 写端 fd,目的是为了通过此 fd 向对应的子进程写数据,即使用不同的匿名管道

创建管道后,需要关闭父、子进程中不必要的 fd

需要特别注意父进程写端 fd 被多次继承的问题,避免因写端没有关干净,而导致读端持续阻塞关闭读端对应的写端后,读端会读到 0,可以借助此特性结束子进程的运行

在选择进程 / 任务 时,要做好越界检查

等待子进程退出时,需要先关闭写端,子进程才会退出,然后才能正常等待。