您现在的位置是:首页 >技术教程 >【Linux】如何实现单机版QQ,来看进程间通信之管道网站首页技术教程

【Linux】如何实现单机版QQ,来看进程间通信之管道

朵猫猫. 2023-07-07 16:00:03
简介【Linux】如何实现单机版QQ,来看进程间通信之管道

学会了管道,就可以实现简单的qq哦~

文章目录


前言

为什么要进行进程间通信呢?因为需要以下这些事:

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

一、管道

1.匿名管道

首先我们前面说过,进程是具有独立性的,但是要实现两个进程通信就必须让这两个进程看到同一份资源,这该怎么办呢两个独立的进程如何看到同一份资源呢?其实这个操作是操作系统直接或间接提供的。下面我们先看看管道,然后再画图讲解如何让进程看到同一份资源。

who这个命令是一个进程,wc也是一个进程通过管道完成了进程间通信,那么如何证明这两个是进程呢?我们证明一下:

首先我们用sleep命令创建了3个进程,然后后面加个&符号是让这些进程在后台运行,否则如果在前台运行我们就不能输入指令了,然后通过查看 进程发现三个sleep进程都在并且他们的父进程都是bash。将sleep比作刚刚的who和wc指令就证明了进程间通信。

下面我们讲解一下管道的原理:

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

下面我们写一个管道的程序:

 首先0 1 2是默认打开的标准输入,标准输出,标准错误,而3就是读端,4就是写端,我们要实现单向信道一定是父子进程一读一写。

首先我们在VScode中创建等会要用的.cc文件和makefile。

要创建管道,首先我们要知道创建管道的函数pipe:

此函数有一个参数是一个一维数组,这个一维数组只有两个元素,这个参数也叫输出型参数(这两个元素我们的例子中就是读端和写端)。

下面我们完成主体:

#include <iostream>
#include <string>
#include <cassert>
#include <unistd.h>
#include <vector>
#include "task.hpp"
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;
int main()
{
    //1.1创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n<0)
    {
       std::cout<<"pipe error,"<<errno<<":"<<strerror(errno)<<std::endl;
       return 1;
    } 
    std::cout<<"pipefd[0]:"<<pipefd[0]<<std::endl;
    std::cout<<"pipefd[1]:"<<pipefd[1]<<std::endl;
    //1.2创建进程
    pid_t id = fork();
    if (id==-1)
    {
       //创建子进程失败
       exit(-1);
    }
    if (id==0)
    {
       //子进程
       close(pipefd[0]);
       const std::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]);
    //关闭不需要的fd,让父进程读取,子进程写入。
    char buffer[1024];
    while (true)
    {
       int n = read(pipefd[0],buffer,sizeof(buffer-1));
       if (n>0)
       {
           buffer[n] = '';
           std::cout<<"我是父进程,child give me message:"<<buffer<<std::endl;
       }
    }
    return 0;

}

 我们的程序很简单,首先创建管道,如果创建失败就报错打印错误码,然后我们打印一下读端和写端(如果没问题的话读端是3,写端是4),然后第二步我们创建子进程,创建子进程同样要判断创建失败的情况,如果创建成功我们先关闭子进程的读端,然后搞一个1024的缓冲区让字符串写入到缓冲区里,sleep可以让现象更明显,写完后我们为了保证管道安全所以关闭刚刚使用的写端,在父进程中同理只不过是变成了读,下面我们运行一下代码:

 我们确实看到了两个进程,并且确实也完成了简单的单向通信,因为子进程把自己的数据给了父进程。通过上面的实例代码我们能知道管道的特点吗?

1.单向通信

2.管道的本质是文件,因为fd的生命周期随进程,管道的生命周期也是随进程的。

3.管道通信,通常用来进行具有"血缘"关系的进程,进行进程间通信,常用于父子通信----pipe打开管道,并不清楚管道的名字,所以是匿名管道。

下面我们修改一下代码,刚刚我们让子进程发的慢一点(sleep了1秒),现在我们让父进程读的慢一点看看是是什么样子:

 下面我们运行起来:

 通过结果我们发现写入的次数和读取的次数不是严格匹配的。所以这也是管道的一个特点。

 下面我们再将代码修改一下,让父进程读取恢复正常,让子进程写的慢一些我们再看看效果:

 

通过程序运行结果我们发现,当写入很慢的时候读取也变慢了,也就是说写入是会影响读取的。

下面我们再将代码修改一下,我们让子进程持续写入一个字符,父进程先等10秒再读:

 

 

通过上图我们可以看到当子进程写入65535个也就是2的16次方的数据后,父进程直接打印了,并且时间并没有到10秒,所以打印的原因是因为管道是有容量的,当我们的写端将管道文件写满了我们就不能继续写了,这与之前read端读完了所有的管道数据,如果写端不发就只能等待是相对应的。所以管道的另一个特点来了:具有一定的协同能力,让reader和writer能够按照一定的步骤进行通信(自带同步机制)。

下面我们再试试将写端关闭了读端会怎么样呢?

 read函数的返回值可以判断读取了多少,比如返回值大于0就是将缓冲区的数据都读完了,返回值等于0就是读到了文件结尾,否则就是读取异常,下面我们将代码运行起来:

结果就是子进程写了一个字符退出后,父进程读取到就会读到文件结尾而退出。那么如果我们写端一直写,将读端关闭了会发生什么呢?因为这里的行为是无意义的,所以操作系统会杀死这个一直写入的进程(因为操作系统不会维护无意义,低效率,或者浪费资源的事情,操作系统会杀死一直写入的进程,会通过信号来终止进程(13号信号)),那么如何证明会被操作系统杀死呢,下面我们让父进程等待一下子进程然后看看:

 通过结果我们发现确实是这样,子进程被操作系统用13号信号终止了。以上就是匿名管道的相关知识,下面我们用一批管道实现控制进程的代码:

 首先我们创建一个ctrlProecess.cc的文件夹,然后写一个makefile,准备工作做好后我们就可以按照思路写代码了,我们第一步要先进行构建控制结构,父进程写入,子进程读取,并且既然是多个管道我们直接用个循环来控制:

 我们在判断是否成功创建子进程的时候直接用assert断言了,其实这里应该要用if语句判断的,但是由于这个函数基本不会出错并且我们只是演示一下代码所以就用断言了,当返回值等于0的时候那么一定是子进程了,否则就是父进程了,那么我们该如何通信呢,让父进程写入,子进程读取,所以我们先关闭不需要的端口。

现在前期准备工作已经完成了,下面如何让父进程管理自己创建的管道和进程呢?还记得我们之前说过的先描述,在组织吗?答案就是这个。

 我们为了描述这些创建的管道所以用一个类来封装,成员变量包括子进程的pid以及父进程写入所需要的端口,然后我们在构造函数中将这些变量初始化一下。

class EndPoint
{
public:
     pid_t _child_id;
     int _write_fd;
     EndPoint(int id,int fd)
          :_child_id(id)
          ,_write_fd(fd)
     {
    
     }
     ~EndPoint()
     {

     }

};

因为是多个管道所以我们将刚刚的自定义对象放到vector中去管理,这不就完成了先描述,在组织的任务吗,所需要的头文件大家记得加上,下面我们将vector定义到函数最开始。

我们的目的是让父进程写入,子进程读取,所以肯定是在父进程的地方来描述这个子进程:

 将一个个子进程和写端的对象放入向量后,父进程就拿到了一批Endpoint对象,这个结构里包含了要写入的文件描述符的写端和新的子进程。下面的工作就是要子进程读取指令,我们要让子进程在读取指令的时候,都从标准输入读取,要从标准输入读取我们就要用的以前学的知识了,那就是输入重定向,输入重定向的方式很多,我们主要用函数的方式:

 还记得我们之前学的dup2函数吗,在这里就可以派上用场了。我们的目的是要子进程都要从标准输入读取,所以函数的第一个参数old就是pipefd[0]了因为我们要让pipefd[0]重定向到0(标准输入),所以第二个参数new就是0了,然后我们让子进程开始等待获取命令,这里我们直接写一个函数WaitCommand()将所以的代码写入这个函数里,并且将刚刚创建进程的代码也放入一个creatProcess函数中,让各个函数完成相应的功能会让代码看的更简洁:

 下面在让子进程要执行方法的函数中先写一个read函数和一个死循环我们来看看代码能否正常创建子进程,然后再编写后序的代码:

 

 运行起来后我们可以看到之前写的创建进程的函数没毛病,下面我们来设计一下让子进程执行的方法的函数。在完成这个函数前先新建一个头文件task.hpp,然后创建一些简单的打印任务:

 我们在Task类中先定义一个vector,vector中存放的类型为函数指针类型,每一个函数指针都指向一个函数方法,这样我们就可以通过指令来调用不同的方法,对于指令command来讲我们之前也提到过其实就是位图,我们定义三个宏值分别代表三种打印方法,相要调用哪个方法就让command参数&上对应的宏值就完成了。我们在.cc文件中加上头文件,然后定义一个全局的Task类:

接下来我们继续实现子进程任务函数:

 在子进程函数中我们默认指令为int类型,然后通过read函数的返回值判断是否读取成功,如果返回值等于4字节说明读到了指令那么就调用对应的函数方法,如果返回值等于0就说明当前的子进程要退出了直接break,剩下的就是读取失败也是直接退出,当然我们不能只执行一次,所以我们加个循环让子进程周而复始的去执行任务:

 那么接下来的父进程还需要干什么呢?父进程需要选择任务,选择进程然后下发任务,下面我们来实现:

 这里的任务是可以随机选择的,但是我们为了测试就只用了LOG任务,然后选择进程的时候直接用随机函数选择即可,下发任务也就是让父进程写入,写入的是指令对应的方法,然后我们将之前答应的代码加上getpid,把子进程的pid也打印出来:

 以下是运行起来后的代码:

 这样我们就完成了要实现的功能,现在我们再把相应的函数优化一下,让选择任务的时候可以是交互式的,首先第一步让用户选择任务,这里我们先实现一个选择面板:

 将选择面板搞定后我们就可以实现让用户选择对应的任务了:

下面我们让选择进程的功能变成按顺序的,这样就不会出现每次随机让一个进程执行任务的情况:

 下面我们我们再实现一下每次拿到任务后打印正在执行哪个任务:

  我们先创建一个静态的int变量用来充当第几个进程,然后我们在类外将number初始化为0,string类型的进程名接收缓冲区里的字符,函数name可以返回一个进程名。然后我们在选择任务那块也打印一下:

 然后如果进程退出的话也要有提示,所以我们在子进程那块加上打印提示:

 当n等于0说明父进程将写端关闭了,随之就打印一下告诉用户。然后我们再将代码简化一下,把刚刚main函数中写的控制函数代码都放到ctrlProcess函数中:

 写到这里发现124行有个报错,是因为返回的name函数需要加上const让静态变量也能使用(因为我们的ctrlProcess函数的参数是const对象)

 

 下面我们运行一下程序:

 程序运行起来后我们发现输入3退出不了,并且输入其他指令程序也没有打印动作,所以我们用if语句判断一下:

 除了这个问题我们还要解决子进程退出问题,还记得我们之前说过的僵尸进程吗,如果子进程没有被父进程等待那么子进程就变成了僵尸状态,为了防止这种情况我们再写一个进程等待函数函数:

 回收子进程之前我们需要将父进程的所有写端都关了,然后等待10秒后我们让父进程把每个子进程都回收了,这样就完成了简单的回收子进程函数。

当我们运行起来后发现可以完成我们的需求,并且在退出后也能正常的回收子进程。

不知道有没有人会有疑问,为什么我们刚刚在同一个循环内不能直接边关闭边回收子进程呢,就像下图这样:

 我们先用这样的代码运行一下:

 我们发现这样写后程序就卡在了回收进程处,这是为什么呢?其实是和子进程有关,我们早就说过子进程会继承父进程打开的文件描述符(这就是管道的原理),这里的坑就在于当第二次及第二次以后的创建子进程时,这个子进程会继承前面所有父进程的端口,也就是说越往后的子进程继承的端口越多,如下图所示:

也就是说刚刚卡主的原因是写端没有全部关完就回收了子进程,就造成了阻塞。而我们用一个循环单独关闭父进程的端口时是不会出现这个问题的,因为我们是先关闭了父进程的写端,都关闭后才去回收子进程。

由于上面的讲解都是通过图片的形式,下面我们将此次的实例代码发出来:

首先是ctrlProcess.cc文件中的:

#include <iostream>
#include <string>
#include <cassert>
#include <unistd.h>
#include <vector>
#include "task.hpp"
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;
const int gnum = 3;
Task t;
class EndPoint
{
private:
     static int number;
public:
     pid_t _child_id;
     int _write_fd;
     std::string processname;
     EndPoint(int id,int fd)
          :_child_id(id)
          ,_write_fd(fd)
     {
         char namebuffer[64];
         snprintf(namebuffer,sizeof(namebuffer),"process-%d[%d:%d]",number++,_child_id,_write_fd);
         processname = namebuffer;
     }
     std::string &name()
     {
        return processname;
     }
     ~EndPoint()
     {

     }

};
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)
        {
            std::cout<<"父进程让我退出,我就退出了: "<<getpid()<<std::endl;
            break;
        }
        else 
        {
            break;
        }
    }
   
}
void CreatProcess( vector<EndPoint>* end_points)
{
     //1.先进行构建控制结构,父进程写入,子进程读取
    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);
        //返回值等于0一定是子进程
        if (id==0)
        {
            //先关闭不要的fd
            close(pipefd[1]);
            //我们期望所有的子进程读取指令的时候,都从标准输入读取
            //1.3.1输入重定向
            dup2(pipefd[0],0);  //读0就像读管道一样
            //1.3.2让子进程开始等待获取命令
            WaitCommand();
            close(pipefd[0]);
            exit(0);
        }

        //一定是父进程
        //1.3关闭不要的fd
        close(pipefd[0]);

        //1.4将新的子进程和他的管道写端,构建对象
        end_points->push_back(EndPoint(id,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(vector<EndPoint>& end_points) 
{
    //2.1我们可以写成自动化的,也可以写成交互式的
    int cnt = 0;
    int num = 0;
    while (true)
    {
        //1.选择任务
        int command = ShowBoard();
        if (command==3)
        {
            break;
        }
        if (command<0|| command>2)
        {
            continue;
        }
        //2.选择进程
        int index = cnt++;
        cnt%=end_points.size();
        std::cout<<"选择了进程:"<<end_points[index].name()<<" | 处理任务"<<command<<std::endl;
        //3.下发任务
        write(end_points[index]._write_fd,&command,sizeof(command));

        //sleep(1);
    }
}
void ExitProcess()
{
    exit(0);
}
void waitProcess(const vector<EndPoint>& end_points)
{
     //1.我们需要让子进程全部退出 -- 只需要让父进程关闭所有的写端
     //for (const auto &ep:end_points)
     for (int end = end_points.size()-1;end>=0;end--)
     {
        std::cout<<"父进程让所有的子进程全部退出"<<std::endl;
        //先关闭最后一个写端倒着一直关闭,因为子进程会继承父进程的文件描述符所以
        //后面的子进程都会链接到第一个父进程的写端,如果关第一个无法全部关闭,会造成
        //阻塞
        close(end_points[end]._write_fd);
        std::cout<<"父进程回收了所有的子进程"<<std::endl;
        waitpid(end_points[end]._child_id,nullptr,0);

     }
     //std::cout<<"父进程让所有的子进程全部退出"<<std::endl;
     sleep(10);
     //2.父进程要回收子进程的僵尸状态
     //for (const auto& ep:end_points)
     //{
     //   waitpid(ep._child_id,nullptr,0);
     //}
     //std::cout<<"父进程回收了所有的子进程"<<std::endl;
     //sleep(10);
}
int main()
{
    //1.先进行构建控制结构,父进程写入,子进程读取
    vector<EndPoint> end_points;
    CreatProcess(&end_points);
    //2.我们得到了什么?
    ctrlProcess(end_points);
    //3.处理所有的退出问题
    waitProcess(end_points);
    return 0;
}

下面是头文件task.hpp中的:

#include <iostream>
#include <vector>
#include <unistd.h>
typedef void(*fun_t)();  //函数指针


void PrintLog()
{
    std::cout<<"pid:"<<getpid()<<",打印日志任务,正在被执行..."<<std::endl;
}
void InsertMySQL()
{
    std::cout<<"执行数据库任务,正在被执行..."<<std::endl;
}
void NetRequest()
{
    std::cout<<"执行网络请求任务,正在被执行..."<<std::endl;
}
#define COMMAND_LOG 0
#define COMMAND_MYSQL 1
#define COMMAND_REQUEST 2
class Task
{
public:
    Task()
    {
        funcs.push_back(PrintLog);
        funcs.push_back(InsertMySQL);
        funcs.push_back(NetRequest);
    }
    ~Task()
    {

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

public:
    std::vector<fun_t> funcs;
};

总结

管道读写规则:

当没有数据可读时:
O_NONBLOCK disableread调用阻塞,即进程暂停执行,一直等到有数据来到为止。
O_NONBLOCK enableread调用返回-1errno值为EAGAIN
当管道满的时候:
O_NONBLOCK disable write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:调用返回-1errno值为EAGAIN
如果所有管道写端对应的文件描述符被关闭,则read返回0
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致writ进程退出
当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
管道特点:
只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
管道提供流式服务
一般而言,进程退出,管道释放,所以管道的生命周期随进程
一般而言,内核会对管道操作进行同步与互斥
管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。