【Linux】进程间通信——管道

文章目录

  • 一、进程间通信
    • 进程间通信的目的
    • 进程间通信的发展和分类
  • 二、匿名管道
    • 管道介绍
    • 管道的原理
    • 管道的实现
    • 管道的特点
    • 管道的四种场景
    • mini进程池的实现
  • 三、命名管道
    • 命名管道模拟客户端和服务端


一、进程间通信

进程间通信(Interprocess Communication) 就是两个进程之间进行通信。进程是具有独立性(虚拟地址空间 + 页表保证进程运行的独立性),所以进程间通信成本会比较高!进程间通信的前提条件是先让不同的进程看到同一份资源(内存空间),该资源不能隶属于任何一个进程,应该属于操作系统,被进行通信的进程所共享。

进程间通信的目的

  • 数据传输:一个进程需要将它的数据发送给另一个进程
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程间通信的发展和分类

进程间通信的发展和分类如下:

  • Linux 原生能提供的管道,管道主要包括匿名管道 pipe 和命名管道。
  • SystemV 进程间通信,System V IPC 主要包括 System V 消息队列、System V 共享内存和 System V 信号量。System V 只能本地通信。
  • POSIX 进程间通信,POSIX IPC 主要包括消息队列、共享内存、信号量、互斥量、条件变量和读写锁。POSIX 进程通信既能进行本地通信,又能进行网络远程通信,具有高扩展和高可用性。

二、匿名管道

管道介绍

日常生活中,有非常多的管道,如:天然气管道、石油管道和自来水管道等。管道是 Unix 中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为管道。管道传输的都是资源,并且只能单向通信。

【Linux】进程间通信——管道_第1张图片

管道的原理

每个进程都有对应的文件描述符表,文件描述符表中有相应的数组,数组中存放了标准输入0,标准输出1,标准错误2,而每个进程描述符都会存放相应struct file的地址,在进程间通信的时候系统会提供一个内存文件,这个内存文件不会在磁盘刷新,这个文件被称为匿名文件,当我们以读和写方式打开一个文件,然后我们fork创建一个子进程,子进程也具有task_struct,并且子进程会继承父进程的文件描述符表(但是不会复制父进程打开的文件对象),而文件描述符表中存放文件的地址都是相同的,所以子进程的文件描述符表也指向父进程的文件,正是因为这样,在父进程以读和写打开一份文件,而子进程也同样读和写打开和父进程打开的一样的一份文件,这就让两个进程看到了同一份资源。但是这种管道只能实现单向通信,比如我们关闭父进程的写端,关闭子进程的读端让子进程去写这两个进程就实现单向通信了。管道只能单向通信的原因是文件只有一个缓冲区,一个写入位置一个读取位置所以只能单向通信,要是想双向通信那就打开两个管道!而上面所讲的管道就是匿名管道

【Linux】进程间通信——管道_第2张图片


管道的实现

Makefile

mypipe:mypipe.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -rf mypipe

代码实现

#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

int main()
{
    // 让不同的进程看到同一份资源
    int pipefd[2] = {0};
    
    // 1.创建管道
    int n = pipe(pipefd);

    if(n < 0)
    {
        std::cout << "pipe error, " << errno << ": " << strerror(errno) << std::endl;
        return 1;
    }

    // printf("pipefd[0]:%d\n",pipefd[0]);
    // printf("pipefd[1]:%d\n",pipefd[1]);

    // 创建子进程
    pid_t id = fork();
    assert(id != -1);
    
    if(id == 0) //子进程 —— 往管道中写入数据
    {
        close(pipefd[0]);

        //开始通信
        const string namestr = "hello,我是子进程";
        int cnt = 1;
        char buffer[1024];

        while(true)
        {
            snprintf(buffer, sizeof buffer, "%s, 计数器: %d, 我的PID: %d", namestr.c_str(), cnt++, getpid());
            write(pipefd[1], buffer, strlen(buffer));
            sleep(1);
        }
        close(pipefd[1]);
        exit(0); 
    }

    //父进程 —— 从管道中读取数据
    close(pipefd[1]);
    char buffer[1024];
    //int cnt = 0;
    while(true)
    {
        int n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = '\0';
            cout << "我是父进程,子进程给我的message是:" << buffer << endl;
        }
        else if(n == 0)
        {
            cout << "我是父进程,读到了文件的结尾" << endl;
            break;
        }
        else
        {
            cout << "我是父进程,读取异常了" << endl;
            break;
        }
    }
    close(pipefd[0]);

    return 0;
}

运行结果如图:
【Linux】进程间通信——管道_第3张图片
【Linux】进程间通信——管道_第4张图片

这里确实完成了进程间的单向通信,我们可以清晰地看到有两个进程,并且子进程将自己的数据给了父进程。


管道的特点

  • 单向通信
  • 管道的本质是文件,因为fd的生命周期随进程,管道的生命周期也是随进程的。
  • 管道通信,通常是用来进行 “血缘关系” 的进程,进行进程间通信,常用于父子进程间通信——pipe打开管道,并不清楚管道的名字,所以是匿名管道。
  • 在管道通信中,写入的次数,和读取的次数,不是严格匹配的 读写次数的多少没有强相关 — 表现 ----字节流。
  • 具有一定的协同能力,让reader和writer能够按照一定的步骤进行通信 — 自带同步机制。
  • 管道是基于文件的,文件的生命周期是随进程的,那么管道的生命周期也是随进程的。
  • 一般而言,内核会对管道操作进行同步与互斥
  • 管道是单向通信的,就是半双工通信的一种特殊情况,数据只能向一个方向流动。需要双方通信时,需要建立起两个管道。半双工通信就是要么在收数据,要么在发数据,不能同时在收数据和发数据(比如两个人在交流时,一个人在说,另一个人在听);而全双工通信是同时进行收数据和发数据(比如两个人吵架的时候,相互问候对方,一个人既在问候对方又在听对方的问候)。
  • 当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性。
  • 当要写入的数据量大于 PIPE_BUF 时,Linux 将不再保证写入的原子性。
  • 指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。

管道的四种场景

  • 如果我们read读取完毕了所有的管道数据,如果对方不发,我就只能等待

这里我们让父进程的读取速度不变,让子进程写的慢一些。

【Linux】进程间通信——管道_第5张图片
在这里插入图片描述

这里我们可以看到,光标卡在这儿不动了,这是因为子进程每隔5秒向管道写入一次数据,因此,如果管道中没有数据,读端在读,此时默认会直接阻塞当前正在读取的进程

  • 如果我们writer端将管道写满了,我们将不能继续往管道中写入数据。

【Linux】进程间通信——管道_第6张图片
【Linux】进程间通信——管道_第7张图片

管道是固定大小的缓冲区,当管道被写满,就不能再写了。此时写端会阻塞。因此管道具有一定的协同能力,能让reader和writer按照一定的步骤进行通信

  • 如果关闭了写端,读取完毕管道数据,在读,就会read返回0,表明读到了文件结尾。

【Linux】进程间通信——管道_第8张图片

【Linux】进程间通信——管道_第9张图片

  • 写端一直写,读端关闭,操作系统不会维护无意义,低效率,或者浪费资源的事情。因此OS会杀死一直在写入的进程!

【Linux】进程间通信——管道_第10张图片
【Linux】进程间通信——管道_第11张图片

这里我们看到确实如此,OS不会维护无意义,低效率,或者浪费资源的事情,因此操作系统通过13号信号来杀死了子进程。


mini进程池的实现

// Task.hpp的实现
#pragma once 

#include 
#include 
#include 
using namespace std;

//定义函数指针
typedef void (*func_t) ();

void PrintLog()
{
    std::cout << "pid: "<< getpid() << ", 打印日志任务,正在被执行..." << std::endl;
}

void InsertMySQL()
{
    std::cout << "执行数据库任务,正在被执行..." << std::endl;
}

void NetRequest()
{
    std::cout << "执行网络请求任务,正在被执行..." << std::endl;
}

//这里我们规定,每一个command都必须是四字节
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQEUST 2

class Task
{
public:
    Task()
    {
        funcs.push_back(PrintLog);
        funcs.push_back(InsertMySQL);
        funcs.push_back(NetRequest);
    }

    void Execute(int command)
    {
        if(command >=0 && command < funcs.size())
            funcs[command]();
    }

    ~Task()
    {};
public:
    vector<func_t> funcs;
};

// ctrlProcess的实现
#include 
#include 
#include 
#include 
#include 
#include 
using namespace std;
#include "Task.hpp"

const int gnum = 3;
Task t;

class Endpoint
{
private:
    static int number;
public:
    Endpoint(pid_t id, int write_fd)
        :_child_id(id)
        ,_write_fd(write_fd)
    {
        char namebuffer[64];
        snprintf(namebuffer, sizeof namebuffer, "process-%d[%d:%d]", number++, _child_id, _write_fd);
        processname = namebuffer;
    }

    string name() const
    {
        return processname;
    }

    ~Endpoint()
    {};
public:
    pid_t _child_id;
    int _write_fd;
    string processname;
};

int Endpoint::number = 0;

//子进程要执行的方法
void WaitCommand()
{
    while(true)
    {
        int command = 0;
        int n = read(0, &command, sizeof(int));
        if(n == sizeof(int))
        {
            t.Execute(command);
        }
        else if(n == 0)
        {
            cout << "父进程让我退出,我就退出了: " << getpid() << endl; 
            break;
        }
        else
        {
            break;
        }
    }
}

void createProcesses(vector<Endpoint>* end_points)
{
    vector<int> fds;
    for(int i = 0; i < gnum; i++)
    {
        // 1.1 创建管道
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        assert(n == 0);
        (void)n;

        // 1.2 创建进程
        pid_t id = fork();
        assert(id != -1);

        //子进程 —— 从管道中读取数据
        if(id == 0)
        {
            for(auto& fd : fds) close(fd);

            close(pipefd[1]);
            dup2(pipefd[0], 0); // 子进程读取指令的时候,从标准输入读取 == 输入重定向
            
            WaitCommand(); // 子进程等待获取命令
            close(pipefd[0]);
            exit(0);
        }
        //1.3 父进程——关闭不需要的fd
        close(pipefd[0]);

        // 1.4 将新的子进程和他的管道写端构建对象
        end_points->push_back(Endpoint(id, pipefd[1]));

        fds.push_back(pipefd[1]);
    }
}

int ShowBoard()
{
    std::cout << "------------------------------------------" << std::endl;
    std::cout << "|   0. 执行日志任务   1. 执行数据库任务    |" << std::endl;
    std::cout << "|   2. 执行请求任务   3. 退出             |" << std::endl;
    std::cout << "------------------------------------------" << std::endl;
    std::cout << "请选择$ ";
    int command = 0;
    std::cin >> command;
    return command;
}

void ctrlProcess(const vector<Endpoint>& end_points)
{
    int num = 0;
    int cnt = 0;
    while(true)
    {
        // 1. 选择任务
        int command = ShowBoard();
        if(command == 3) break;
        if(command < 0 || command > 3) continue;

        // 2.选择进程
        int index = cnt++;
        cnt %= end_points.size();

        cout << "选择了进程: " <<  end_points[index].name() << " | 处理任务: " << command << endl;

        // 3. 下发任务
        write(end_points[index]._write_fd, &command, sizeof(command));
        sleep(1);
    }
}

void waitProcess(const vector<Endpoint>& end_points)
{
    for(int end = 0; end < end_points.size(); end++)
    {
        cout << "父进程让子进程退出:" << end_points[end]._child_id << endl;
        close(end_points[end]._write_fd);

        waitpid(end_points[end]._child_id, nullptr, 0);
        cout << "父进程回收了子进程:" << end_points[end]._child_id << endl;
    }
    sleep(5);
}

int main()
{
    // 1. 构建控制结构,父进程写入,子进程读取。
    vector<Endpoint> end_points;
    createProcesses(&end_points);

    // 2. 进程控制
    ctrlProcess(end_points);

    // 3. 处理所有的退出问题
    waitProcess(end_points);
    return 0;
}

随机派发任务:

【Linux】进程间通信——管道_第12张图片
用户派发指定任务

【Linux】进程间通信——管道_第13张图片


三、命名管道

匿名管道有一个 缺陷 就是:只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想要让两个毫不相干的进程进行通信,可以使用FIFO文件来实现,他就是 命名管道

命名管道的创建:

mkfifo named_pipe

在这里插入图片描述

往管道里面写入数据 / 从管道中读取数据

在这里插入图片描述

原理如下:

两个进程打开同一个文件,站在内核的角度,第二个文件不需要再被创建struct file对象,因为OS会识别到打开的文件被打开了。在内核中,此时就看到了同一份资源,有着操作方法和缓冲区,不需要把数据刷新到磁盘上去,所以无论是匿名还是命名管道,本质上都是管道。

【Linux】进程间通信——管道_第14张图片

匿名管道:通过继承的方式看到同一份资源。
命名管道:通过让不同的进程打开指定名称(路径+文件名,具备唯一性)的同一个文件看到同一份资源,所以命名管道是通过文件名来标定唯一性的。而匿名管道是通过继承的方式来标定的。


命名管道模拟客户端和服务端

创建一个管道文件,让读写端进程分别按照自己的需求打开文件,然后进行通信。

makefile

.PHONY:all
all:server client

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

.PHONY:clean
clean:
	rm -f client server

comm.hpp

#pragma once

#include 
#include 

#define NUM 1024

const std::string fifoname = "./fifo";
uint32_t mode = 0666;

server.cc

【Linux】进程间通信——管道_第15张图片

client.cc

【Linux】进程间通信——管道_第16张图片

运行结果:

【Linux】进程间通信——管道_第17张图片

匿名管道和命名管道的区别

  • 匿名管道由pipe函数创建并打开
  • 命名管道由mkfifo函数创建,打开用open
  • FIFO(命名管道)和 pipe(匿名管道)之间唯一的区别在于它们创建和打开的方式不同,一旦这些工作完成之后,他们具有相同的语义。

你可能感兴趣的:(Linux,linux,运维,服务器)