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

目录

一、进程通信的概念

1.1 什么是进程间通信

1.2 进程间通信的目的

1.3 进程通信的本质

1.4 进程间通信的分类

二、管道

2.1 什么是管道

2.2 管道形成的原理

2.3 匿名管道

2.3.1 使用pipe来创建匿名管道

2.3.2 模拟进程间通过管道方式进行通信

2.3.2.1 读取速度比写入速度快

2.3.2.2 读取速度比写入速度慢

2.3.2.3 读取数据时写入端关闭

2.3.2.4 写入数据时读取端关闭

2.4 管道通信的特点与读写规则

2.5 匿名管道实用举例

2.5.1 基本代码实现

2.5.2 优化代码

2.6 命名管道

2.6.1 使用mkfifo创建命名管道

2.6.2 通过命名管道进行不同进程间的通信


一、进程通信的概念

1.1 什么是进程间通信

进程间通信(IPC(Interprocess communication))就是不同进程之间相互交换信息

1.2 进程间通信的目的

● 数据传输: 一个进程需要将它的数据发送给另一个进程

● 资源共享: 多个进程之间共享同样的资源

● 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程

● 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变

1.3 进程通信的本质

如果进程能相互访问数据,这不是破坏了进程之间的独立性嘛

为了维护进程之间的独立性,我们可以让操作系统内部在开辟一块空间,这块空间共所有不同的进程访问

因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。 由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。

1.4 进程间通信的分类

管道

● 匿名管道

● 命名管道

System V IPC

● System V 消息队列

● System V 共享内存

● System V 信号量

POSIX IPC

● 消息队列

● 共享内存

● 信号量

● 互斥量

● 条件变量

● 读写锁

二、管道

2.1 什么是管道

管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的数据流称为一个“管道”

例如:我们在Linux中使用|来将who指令进程的数据交给wc进程,来统计当前登录用户数

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

2.2 管道形成的原理

我们先联系之前学过的知识来解释管道的实现原理:

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

当我们启动一个进程时,操作系统会创建一块内存级的空间,该空间不会与磁盘文件进行交互只存在于内存中。该进程对该内存级文件进行了读和写的方式打开,所以有两个元素数组指向了该file结构体

下面该进程fork之后创建了一个子进程:

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

我们知道创建子进程只会继承父进程的task_struct和files_struct,并不会复制文件对象,这样子就造成了父子进程指向的文件对象是一样的,这也就可以解释为什么fork之后父子进程使用printf之类的函数都会向同一块屏幕打印数据

父子进程访问同一块内存空间进行数据的交互,这就是管道的实现原理

但是我们知道每个file类型的缓冲区只有一个,每个缓冲区的读写位置只有一个,所以在单个缓冲区的条件下,如果有两个进程访问同一个缓冲区的话,就必定只有一个进程是向缓冲区中写入数据,另一个进程是从缓冲区读取数据的!

所以这种管道只支持单向通信

那既然只支持单向通信,父子进程就不可能同时以读写两种方式打开该文件,所以在创建子进程后,两个进程会根据数据流向来决定是否关闭自己的读或写文件,最终实现单向信道

那父进程只能读或写该文件,那为什么刚开始要读和写两种方式打开文件呢?

因为子进程要继承父进程的files_struct,如果父进程只读或只写打开文件,将会造成子进程也只读或只写打开文件,从而形成不了管道

2.3 匿名管道

上面这种被打开但没有被命名管道叫做匿名管道

匿名管道仅限于本地父子进程之间的通信

2.3.1 使用pipe来创建匿名管道

下面我们进入代码实践:

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

我们要手动创建匿名管道需要用到pipe函数(包含在头文件unistd.h中)

● 该函数有一个int类型的返回值:如果创建成功就返回0;如果创建失败就返回-1,并设置错误码

● 该函数的形参是一个输出型形参,传入一个int[2]类型的数组,创建管道成功后,这两个元素返回管道的两个文件描述符,第一个元素是以读方式打开该文件的文件描述符,第二个元素是以写方式打开该文件的文件描述符

来创建一个进程实验一下:

#include
#include
#include
#include
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<

运行效果:

我们可以看到我们已经成功的创建了一个管道,下面我们来模拟一下进程间通过管道方式的通信:

2.3.2 模拟进程间通过管道方式进行通信

#include
#include
#include
#include
#include
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<

上面的代码我们模拟了一下父子进程间通过管道方式进行通信的情况,运行效果:

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

下面我们改一下父子进程读写速度,来看看会发生什么情况:

2.3.2.1 读取速度比写入速度快
#include
#include
#include
#include
#include
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<

我们这次让子进程每隔10秒向管道中写入一次数据,而父进程一直死循环向管道中读取数据,运行效果:

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

我们可以看到当读取速度比写入速度快时,如果管道中没有数据让父进程读取时,父进程会等待管道中重新载入数据后再读

所以在管道的缓冲区没有数据时,进程向其读取数据会进入阻塞状态,直到管道中重新载入数据

2.3.2.2 读取速度比写入速度慢
#include
#include
#include
#include
#include
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<

我们这次让子进程一直死循环向管道中写入数据,而父进程每隔10秒向管道中读取一次数据,运行效果:

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

我们可以看到子进程的一次性向管道的缓冲区中写入了65536字节后的数据停下来了,再经过10s后父进程再向管道中读取了一部分数据

从这个现象中可以看出:当读取速度比写入速度慢时,如果管道中的缓冲区被写满了,向管道写入数据的进程会进入阻塞,等待另一个进程来读取管道中的数据

2.3.2.3 读取数据时写入端关闭
#include
#include
#include
#include
#include
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<0)
        {
            buffer[1023]='\0';
            std::cout<<"我是父进程,收到子进程的信息:"<

这次让父进程一直死循环向管道中读取数据,而子进程向管道中写入一次数据后过两秒关闭写入端的文件描述符并退出,运行效果:

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

从这个现象中可以看出,如果管道中的写入端关闭,还有进程再用read来读取数据,会返回0值

2.3.2.4 写入数据时读取端关闭
#include
#include
#include
#include
#include
#include 
#include 
int main()
{
    int fd[2]={0};
    //创建管道
    int ret=pipe(fd);
    if(ret<0)
    {
        std::cout<<"pipe error"<0)
        {
            buffer[1023]='\0';
            std::cout<<"我是父进程,收到子进程的信息:"<

这次让子进程一直死循环向管道中写入数据,而父进程向管道中读取一次数据后过关闭写入端的文件描述符并等待子进程的退出,运行效果:【Linux】进程间通信——管道_第9张图片

我们可以看到父进程关闭读取端后子进程直接退出了 

这是因为当管道的读端被关闭时,进程再向管道写入数据就变成了一件无意义的事情,但操作系统并不会维护无意义的进程,所以直接会向子进程传递13号信号将进程杀掉

2.4 管道通信的特点与读写规则

从上面的实例中可以看出管道的特点:

● 单向通信(半双工)

● 管道的本质是文件,因为文件描述符的生命周期随进程,所以管道的生命周期是随进程的

● 管道通信,通常用来进行具有“血缘”关系的进程,进行进程间通信

● 在管道通信中,写入的次数,和读取的次数,不是严格匹配的,读写次数的多少没有强相关(写入和读取数据的方式为字节流)

● 具有一定的协同能力,让read和write能够按照一定的步骤进行通信——自带同步机制

 还有管道的读写规则:

● 当没有数据可读时:

O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。

O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

● 当管道满的时候:

O_NONBLOCK disable: write调用阻塞,直到有进程读走数据

O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

● 如果所有管道写端对应的文件描述符被关闭,则read返回0

● 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出

● 当要写入的数据量不大于PIPE_BUF(常量PIPE_BUF(在limits.h中定义)规定了内核的管道缓冲区大小,为4096byte)时,linux将保证写入的原子性。 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

2.5 匿名管道实用举例

我们现在要实现一个父进程通过管道让多个子进程去执行不同的任务的场景:

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

2.5.1 基本代码实现

任务列表的头文件:Task.hpp

#pragma once

#include
#include
#include

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_REQEUST 2
#define COMMAND_QUIT 3

//任务列表
class Task
{
public:
    Task()
    {
        _funcs.push_back(PrintLog);
        _funcs.push_back(InsertMySQL);
        _funcs.push_back(NetRequest);
    }
    void Execute(int command)
    {
        _funcs[command]();
    }
    ~Task()
    {}
public:
    std::vector _funcs;
};

进程通信运行文件:CtrlProcess.cpp

#include "Task.hpp"
#include 
#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;

Task t; // 初始化任务列表,通过构造函数来将任务装载至t,子进程通过t对象来执行其内部的任务

class EndPoint // 该类保存子进程pid和管道写入端的文件描述符
{
public:
    EndPoint(pid_t id, int fd)
        : _id(id),
          _fd(fd)
    {
        char name[128];
        snprintf(name, sizeof(name), "子进程%d,[pid:%d,fd:%d]", _cnt++, id, fd);
        _name = name;
    }

    ~EndPoint()
    {
    }

    string GetName() const
    {
        return _name;
    }

public:
    pid_t _id;    // 子进程的pid
    int _fd;      // 子进程对应管道的文件描述符
    string _name; // 进程名

private:
    static int _cnt;
};
int EndPoint::_cnt = 1;

void WaitCommand(int fd) // 子进程等待父进程下派任务
{
    while (1)
    {
        int command;
        int n = read(fd, &command, sizeof(command)); // 父进程没输入数据时,子进程处于阻塞状态
        if (n == sizeof(command))
            t.Execute(command); // 通过调用t对象来执行commend所对应的任务
        else if (n == 0)        // n为0时管道写入端被关闭,进程退出
            break;
    }
}

void CreateProcess(vector &endPoint)
{
    for (int i = 0; i < 5; ++i)
    {
        int fd[2];
        // 创建管道
        int ret = pipe(fd);
        if (ret < 0)
        {
            cout << "pipe error" << errno << ":" << strerror(errno) << endl; // 创建失败打印错误码
            exit(1);
        }

        // 创建子进程
        pid_t id = fork();
        if (id < 0)
        {
            cout << "fork error" << errno << ":" << strerror(errno) << endl; // 创建失败打印错误码
        }
        else if (id == 0)
        {
            // 子进程
            close(fd[1]);       // 让子进程读取管道中的数据,这里关闭写入操作所对应的文件描述符
            WaitCommand(fd[0]); // 等待父进程的指令
            close(fd[0]);
            exit(0);
        }
        // 父进程
        close(fd[0]);                            // 让父进程向管道中写入数据,这里关闭读取操作所对应的文件描述符
        endPoint.push_back(EndPoint(id, fd[1])); // 创建完子进程,使用vector容器保存相关数据
    }
}

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)
{
    int cnt = 0;
    while (1)
    {
        int commend = ShowBoard(); // 选择任务
        if (commend == COMMAND_QUIT)
            break;
        else if (commend < COMMAND_QUIT && commend >= COMMAND_LOG)
        {
            int n = cnt++; // 选择执行任务的进程
            cnt %= endPoint.size();
            cout << "选择了:" << endPoint[n].GetName() << endl;
            write(endPoint[n]._fd, &commend, sizeof(int)); // 通过向管道写入数据,下派任务
            sleep(1);
        }
    }
}

void WaitProcess(const vector &endPoint)
{
    for (const auto &e : endPoint) // 关闭所有管道的写入端
    {
        close(e._fd);
    }
    sleep(1);
    for (const auto &e : endPoint) // 回收所有处于僵尸状态的子进程
    {
        waitpid(e._id, nullptr, 0);
        cout << e.GetName() << " 已退出" << endl;
    }
    cout << "所有子进程已退出" << endl;
}

int main()
{
    vector endPoint; // 用vector存储子进程相关信息
    CreateProcess(endPoint);   // 创建子进程
    CtrlProcess(endPoint);     // 控制子进程
    WaitProcess(endPoint);     // 回收子进程
    return 0;
}

运行效果: 

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

2.5.2 优化代码

但是上面的运行代码会让大家产生一个疑问:

void WaitProcess(const vector &endPoint)
{
    for (const auto &e : endPoint) // 关闭所有管道的写入端
    {
        close(e._fd);
    }
    sleep(1);
    for (const auto &e : endPoint) // 回收所有处于僵尸状态的子进程
    {
        waitpid(e._id, nullptr, 0);
        cout << e.GetName() << " 已退出" << endl;
    }
    cout << "所有子进程已退出" << endl;
}

这个回收子进程的函数怎么有两个for循序,我们关闭一个文件描述符,就回收被关闭的管道所对应的子进程不可以吗?像下面这样:

void WaitProcess(const vector &endPoint)
{
    for (const auto &e : endPoint) 
    {
        close(e._fd);// 关闭管道的写入端
        waitpid(e._id, nullptr, 0);// 回收对应的子进程
        cout << e.GetName() << " 已退出" << endl;
    }
    cout << "所有子进程已退出" << endl;
}

好,我们就先用一个for循环来试试看:

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

怎么回事,输入退出指令的时候怎么卡住了?

我们再来回到管道创建的原理来仔细分析分析:

该程序创建子进程时是循环创建的,毫无疑问所有被创建的子进程都会继承父进程的文件描述符,当父进程创建第一个字进程:

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

再创建第二个子进程:

【Linux】进程间通信——管道_第14张图片这下我们就可以看出来这里出问题了,所有后续创建的字进程都会继承父进程的文件描述符,这样导致了除了最后创建的子进程,其他子进程的管道的写入端都不只被父进程一个打开!

所以当我们循环来关闭子进程管道对应的父进程的写入端时,还有其他的子进程打开这个写入端,此时read函数还会处于等待,被关闭管道对应的子进程无法退出,就造成了程序卡住了

那我们如果想用一个for循环来解决问题的话是不是可以先关闭最后创建的子进程,然后倒数第二个·······直到关闭第一个子进程:

void WaitProcess(const vector &endPoint)
{

    for (auto crit = endPoint.crbegin(); crit != endPoint.crend(); ++crit) // 逆向关闭子进程
    {
        close(crit->_fd);               // 关闭子进程的管道的写入端
        waitpid(crit->_id, nullptr, 0); // 回收对应的子进程
        cout << crit->GetName() << " 已退出" << endl;
    }
    cout << "所有子进程已退出" << endl;
}

完美运行: 

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

但是这样还是不严谨的,如果子进程在运行时还打开着别的进程管道的写入端,万一向里面写入数据了怎么办?所以我们最好在父进程创建完一个子进程后,关闭所有其进程所打开的其他进程管道的写入端:

void CreateProcess(vector &endPoint)
{
    for (int i = 0; i < 5; ++i)
    {
        int fd[2];
        // 创建管道
        int ret = pipe(fd);
        if (ret < 0)
        {
            cout << "pipe error" << errno << ":" << strerror(errno) << endl; // 创建失败打印错误码
            exit(1);
        }

        // 创建子进程
        pid_t id = fork();
        if (id < 0)
        {
            cout << "fork error" << errno << ":" << strerror(errno) << endl; // 创建失败打印错误码
        }
        else if (id == 0)
        {
            // 子进程
            for(auto & e:endPoint)//关闭子进程所有继承的其他进程的管道写入端
            {
                close(e._fd);
            }
            close(fd[1]);       // 让子进程读取管道中的数据,这里关闭写入操作所对应的文件描述符
            WaitCommand(fd[0]); // 等待父进程的指令
            close(fd[0]);
            exit(0);
        }
        // 父进程
        close(fd[0]);                            // 让父进程向管道中写入数据,这里关闭读取操作所对应的文件描述符
        endPoint.push_back(EndPoint(id, fd[1])); // 创建完子进程,使用vector容器保存相关数据
    }
}

这样子就万无一失了~

2.6 命名管道

2.6.1 使用mkfifo创建命名管道

在Linux中可以使用mkfifo指令来创建命名管道:

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

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

我们可以看到创建的文件是p类型的管道文件

下面我们尝试输出重定向,向该管道文件内写入些东西:

我们可以看到进程卡住了,这时因为管道文件是一个内存级文件并不会与磁盘进行交互,所以我们需要对其内部数据进行读取才能继续运行

下面我们使用cat指令进行输入重定向,让fifo中的内容输入到cat指令上:

2.6.2 通过命名管道进行不同进程间的通信

既然命名管道是一个内存级文件,那我们让两个不同的进程共同看到这份文件,不就可以实现进程间通信了吗?

没错,下面有专门的系统调用接口供我们创建管道文件:

2.6.2.1 mkfifo函数 

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

我们使用mkfifo函数(包含在头文件中)来创建命名管道文件

该函数有两个形参:

● pathname:传入所要创建的管道文件的文件名及创建路径

● mode:传入创建文件的权限(八进制方案)

该函数创建成功后返回0,在出现错误的情况下,将返回-1(在这种情况下,会适当地设置errno)

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