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

发布时间:2024年01月16日

需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)

1. 进程间通信

1.1 什么是进程间通信

我们知道,进程具有独立性,每个进程都有自己的PCB,都是独立的。总有一些场景是需要不同进程之间进行交流的。我们把这个不同进程之间进行信息交流的行为叫做进程间通信

这里的通信的本质是:让不同的进程看到同一份资源

进程的通信是需要一定成345本的,并且成本不低,这是因为我们需要让不同进程看到同一份资源

1.2 为什么要有进程间通信

进程件通信的目的在于:有时候我们需要多进程协同的,完成某种业务内容

  • 数据传输:一个进程需要将它的数据发送给另一个进程
  • 资源共享:多个进程之间共享同样的资源
  • 通知事件:一个进程需要向另一个进程或一组进程发送消息,通知他们发生了某种事件(如进程终止时要通知父进程)
  • 进程控制:有些进程需要完全控制另一个进程的执行(如Debug进程)

1.3 进程间通信的实现方式

进程间通信按照发展经历分成以下几个阶段:

  • 管道:管道是一种最早的进程间通信机制,它基于操作系统提供的文件描述符进行通信
  • 信号量:用于进程间的同步和互斥。进程可以使用信号量来保证共享资源的访问顺序和互斥性。信号量机制可以控制进程再访问共享资源时的并发性
  • 消息队列:是一种可以再不同进程之间传递数据的通信机制。进程可以将消息放入队列中,然后其他进程可以从队列中获取消息。消息队列提供了一种可靠的、异步的通信方式
  • 共享内存:是一种在多个进程之间共享数据的机制。多个进程可以将数据映射到它们的地址空间中,从而允许它们直接访问共享数据。共享内存通常比消息队列更高效,但也需要更仔细的同步机制来保证数据的一致性
  • 套接字(Socket):套接字是一种用于网络通信的进程间通信机制。它允许不同计算机上的进程通过网络进行通信。套接字提供了一种标准的接口,使得进程可以使用各种协议(如TCP和UDP)进行通信。(这个在未来的网络专栏中会详细讲解)

本篇文章主要介绍管道的相关内容

2. 管道

2.1 什么是管道

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

见见“猪跑”

我们在刚接触到进程的时候,使用过一串命令,用于查看指定进程ps axj | grep mypipe

image-20240111185639708

这里psgrep都是程序,当他们运行起来就变成了两个进程,其中ps进程把本来输出的数据重定向到管道中,grep从管道中读取到数据,然后再进行处理,至此就完成了两个不同进程间的数据传输,即进程间通信

image-20240111190432073

2.2 匿名管道

我们上面使用的管道实际上是一种匿名管道

匿名管道用于进程间通信,同时仅限于本地具有亲缘关系的进程间的通信,这个结论的原因跟匿名管道的原理有关,看完下面的内容相信读者心中就已经有答案了。

2.2.1 匿名管道的原理

我们知道:进程间通信的本质就是让不同的进程看到同一份资源,然后一个进程向该资源中写入,另一个进程读取该资源。我们以一个父子进程来表示具有亲缘关系的进程。父进程以读写的方式打开一个进程,之后在这个进程的PCB中的fd_array数组中就有了两个文件描述符,分别是以读和写的方式打开了一个文件,然后使用fork创建子进程,子进程将父进程的内容进行拷贝(包括fd_array),此时就完成了父子进程能够看到同一份资源的操作。

image-20240111195241212

注意:

  • 这里父子进程看到的同一份文件资源是由OS来维护的,所以父子进程对该文件写入的时候,该文件缓冲区的数据并不会进程写时拷贝
  • 管道虽然采用的是文件描述符的方案,但是OS不会把这个数据刷新到磁盘中,因为IO的速度是非常慢的,而且也没有这么做的必要,所以管道文件是内存级的文件

2.2.2 匿名管道的使用

根据上述的原理,我们知道管道实际上就是一个内存级的文件,但是我们创建文件一般都是在磁盘上创建,所以如果要创建管道,一定要有一个特殊的方式,Linux提供了一个系统调用pipe

image-20240111200034217

函数原型: int pipe(int pipefd[2]);
参数解释:pipefd是一个输出型参数,里面保存了创建的管道使用读和写的方式打开的fd,注意第一个元素是读的fd,第二个是写的fd
返回值:调用成功返回0,调用失败返回-1同时设置错误码

父子进程间使用管道通信的步骤:

1. 父进程调用pipe创建管道

image-20240111200812730

2. 调用fork创建子进程

image-20240111201417236

3. 父进程关闭读端(写端),子进程关闭写端(读端)

image-20240111201638219

注意:

  • 管道只能够进行单向通信,因此当父进程创建完子进程之后,需要确认父子进程的读写关系,然后分别关闭父子进程相应的读写端
  • 从管道写端写入的数据会被内核缓冲区缓冲,直到从管道的读端被读取
#include <iostream>
#include <unistd.h>
#include <cassert>
#include <cstring>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    int fds[2];
    int ret = pipe(fds);
    assert(ret == 0);

    // 创建子进程
    int id = fork();
    if (id == 0)
    {
        // 子进程
        // 子进程向管道中写数据,关闭读端
        close(fds[0]);
        // 通信
        const char *s = "这里是子进程,正在向父进程发送信息";
        int cnt = 0;
        while (true)
        {
            cnt++;
            char buffer[1024];
            snprintf(buffer, sizeof buffer, "child->parent say:%s[%d][pid:%d]", s, cnt, getpid());
            write(fds[1], buffer, strlen(buffer));
            if(cnt == 10) break;
            sleep(1); // 每秒写一次
        }
        close(fds[1]); // 子进程退出前关闭写端
        exit(0);
    }
    // 父进程
    // 父进程从管道中读数据,关闭写端
    close(fds[1]);
    // 通信
    while(true)
    {
        char buffer[1024];
        ssize_t s = read(fds[0], buffer, sizeof(buffer) - 1);
        if(s > 0) 
        {
            buffer[s] = 0;
            std::cout << "Get Massage# " << buffer << "| mypid:" << getpid() << std::endl;
        }
        else if(s == 0)
        {
            std::cout << "read file end" << std::endl;
            break;
        }
        else
        {
            std::cout << "read error" << std::endl;
            break;
        }
    }
    ret = waitpid(id, nullptr, 0);
    assert(ret == ret);

    close(fds[0]);
    return 0;
}

image-20240111225756902

2.2.3 管道的读写规则

和pipe同系的系统调用还有一个pipe2,也是用于创建匿名管道

函数原型:int pipe2(int pipefd[2], int flags);

pipe2和pipe类似,其中第二个参数用于设置选项:

1、 当管道中没有数据可读的时候:

  • O_NONBLOCK disable:read调用阻塞,直到有数据为止
  • O_NONBLOCK enable:read调用返回-1,errno设置为EAGAIN

2、 当管道满的时候:

  • O_NONBLOCK disable:write调用阻塞,知道有进程读走数据
  • O_NONBLOCK enable:write调用返回-1,errno设置为EAGAIN

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

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

5、 当要写入的数据量不大于PIPE_BUF(管道缓冲区)时,Linux将保证写入的原子性,反之不保证原子性

2.2.4 管道的特点

  • 管道内部自带同步与互斥机制

    对共享资源进行保护的方案,我们后面会详细解释

  • 管道的生命周期跟随进程

    管道是为进程服务的,所以进程生命周期结束,管道也会相应的结束

  • 管道是面向字节流的

  • 管道是半双工通信的

    单项通信的特殊概念,后面讲解

  • 管道可以用来进行具有血缘关系的进程之间的通信,常用于父子通信

2.2.4 基于匿名管道的负载均衡式进程池设计

通过匿名管道,我们可以实现一个父进程创建若干个子进程,均衡的给若干个子进程分配任务,也就是说父进程创建一个进程池,负载均衡的为进程池内的进程分配任务。

/***************header.h*************/
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

// 种随机数种子的一个宏,为了让随机数更随机,加上了一些处理
#define MakeSeed() srand((unsigned long)time(nullptr) ^ getpid() ^ 0x1234321 ^ rand() % 1234)

#define PROCSS_NUM 5 // 进程池中的进程个数

typedef void (*func_t)(); // 子进程要完成的任务函数指针

// 任务描述(这里是所有的任务)
void downLoadTask();
void ioTask();
void flushTask();

void loadTaskFunc(std::vector<func_t>& funcMap); // 加载任务清单
/***************tasks.cc*************/
#include "header.h"

//任务列表
void downLoadTask()
{
    std::cout << getpid() << ":下载任务\n" << std::endl;
    sleep(1);
}

void ioTask()
{
    std::cout << getpid() << ":IO任务\n" << std::endl;
    sleep(1);
}
void flushTask()
{
    std::cout << getpid() << ":刷新任务\n" << std::endl;
    sleep(1);
}

void loadTaskFunc(std::vector<func_t>& funcMap) // 加载任务清单
{
    //funcMap.emplace_back({downLoadTask, ioTask, flushTask});
    funcMap.push_back(downLoadTask);
    funcMap.push_back(ioTask);
    funcMap.push_back(flushTask);
}
/***************pipePool.cc*************/
#include "header.h"

class subEp // EndPoint
{
public:
    subEp(pid_t subId, int writeFd)
        : subId_(subId), writeFd_(writeFd)
    {
        char nameBuffer[1024];
        snprintf(nameBuffer, sizeof nameBuffer, "process-%d[pid(%d)-fd(%d)]", num++, subId_, writeFd_);
        name_ = nameBuffer;
    }

public:
    static int num;    // 创建的进程的唯一标识符(序号)
    std::string name_; // 子进程名字
    pid_t subId_;      // 子进程pid
    int writeFd_;      // 父进程的该管道写入文件描述符
};
int subEp::num = 0; // 初始化静态成员变量

int recvTask(int readFd) // 读取管道内的信息(任务码)
{
    int code = 0;
    ssize_t ret = read(readFd, &code, sizeof code);
    if (ret == 4) // 读取到的是4byte(一个整形)读取成功
        return code;
    else if (ret <= 0)
        return -1;
    else
        return 0;
}

void sendTask(const subEp &process, int taskNum) // 给指定的子进程分配任务码
{
    std::cout << "send task num: " << taskNum << " send to -> " << process.name_ << std::endl;
    int ret = write(process.writeFd_, &taskNum, sizeof(taskNum));
    assert(ret == sizeof(int));
    (void)ret;
}

void createSubProcess(std::vector<subEp> &subs, std::vector<func_t> &funcMap)
{
    for (int i = 0; i < PROCSS_NUM; ++i)
    {
        int fds[2];
        int n = pipe(fds);
        assert(n == 0);
        (void)n; // 这里由于assert只会在debug模式下使用,在release模式下不执行,所以加上一句使用变量n的无影响的代码,用于去掉一些警告:变量n未使用
        pid_t id = fork();
        if (id == 0)
        {
            // 子进程读取父进程写入管道的内容(分配的任务)
            // 子进程关闭写端,父进程关闭读端
            close(fds[1]);
            while (true)
            {
                // 子进程处理任务
                int commandCode = recvTask(fds[0]); // 读取管道内容获取命令码
                // 完成任务
                if (commandCode >= 0 && commandCode <= funcMap.size())
                {
                    // 任务码无误
                    funcMap[commandCode](); // 调用任务函数
                }
                else if (commandCode == -1)
                    break; // 任务码出现问题,退出进程
            }
            exit(0); // 处理完成退出
        }
        // 下面都是父进程会执行的代码
        close(fds[0]);                                // 父进程关闭读端
        subs.push_back(std::move(subEp(id, fds[1]))); // 使用子进程pid和父进程写入该父子进程之间管道的写端fd构建subEp对象,move成右值(将亡值)尾插
    }
}
void loadBlanceContrl(std::vector<subEp> &subs, std::vector<func_t> &funcMap, int count)
{
    int processNum = subs.size();
    int taskNum = funcMap.size();
    bool forever = (count == 0 ? true : false);

    while (true)
    {
        // 1. 选择一个子进程(随机数)
        int subIdx = rand() % processNum;
        // 2. 选择一个任务
        int taskIdx = rand() % taskNum;
        // 3. 任务发给选择的进程
        sendTask(subs[subIdx], taskIdx);
        sleep(1);
        if (!forever)
        {
            count--;
            if (count == 0)
                break;
        }
    }
    //任务完成后关闭和该子进程通信的写端 write close -> read 0
    for(int i = 0; i < processNum; ++i)
    {
        close(subs[i].writeFd_);
    }
}
void waitProcess(std::vector<subEp> &processes)
{
    int processNum = processes.size();
    for (int i = 0; i < processNum; ++i)
    {
        waitpid(processes[i].subId_, nullptr, 0);
        std::cout << "wait sub process success ..." << processes[i].subId_ << std::endl;
    }
}
int main()
{
    MakeSeed(); // 随机数种子
    // 加载方法表
    std::vector<func_t> funcMap;
    loadTaskFunc(funcMap);

    // 创建子进程,并且维护好父子进程通信信道
    std::vector<subEp> subs;
    createSubProcess(subs, funcMap);

    // 只有父进程会执行完函数后退出,到当前位置
    int taskCnt = 3;                          // 一共要完成的任务个数,0表示永远进行
    loadBlanceContrl(subs, funcMap, taskCnt); // 负载均衡的加载任务给所有进程

    waitProcess(subs); // 回收(等待)子进程

    return 0;
}

当然上述的代码还是有一点点小bug的,但是不影响我们做演示,这个bug就是在每次父进程创建新的子进程的时候,之前创建的子进程的管道写端都会被下一个子进程共享,这样新创建的子进程有可能会往之前创建的子进程的管道中写入内容。

解决方案:父进程维护一个deleteFd数组,里面存放的内容就是创建的所有管道的写端文件描述符,然后在每次创建新的子进程的时候,让子进程首先关闭deleteFd数组中存放的fd

2.3 命名管道

上文中,我们讲到的匿名管道,只能用于具有亲缘关系的进程之间的通信,但是在实际的应用场景中,还有很多需要让没有任何关系的进程之间进行通信。这就要使用到命名管道

命名管道本质上就是一种特殊的文件,让两个需要通信的进程打开同一个管道文件,此时两个进程就可以通过这个管道文件进行通信。

注意:

  1. 普通文件很难做到通信,就算可以,也会出现一些无法解决的安全问题
  2. 命名管道文件是的数据是内存级的,相比于匿名管道,命名管道在磁盘上有一个数据大小为0的映像。匿名管道和命名管道都不会将通信数据刷新到磁盘上

2.3.1 创建命名管道的方法

1. 指令创建

image-20240114232949204

使用mkfifo指令创建一个命名管道

image-20240114233331105

image-20240114233551317

image-20240114233652048

2. 在程序中创建

image-20240114233843008

函数原型:int mkfifo(const char *pathname, mode_t mode);
参数解释:
    pathname:要创建的管道文件的文件名。注意若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。(注意当前路径的含义);
	mode:表示创建命名管道文件的默认权限。注意umask的影响;
返回值:如果管道创建成功返回0,创建失败返回-1,同时设置错误码
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>

#define PATHNAME "pipefile"

int main()
{
    //创建管道
    umask(0);
    int ret = mkfifo(PATHNAME, 0666);
    if(ret != 0)
    {
        perror("make named_pipe:");
        exit(-1);
    }
    std::cout << "named_pipe create success" << std::endl;

    return 0;
}

image-20240115185551371

2.3.2 命名管道的使用

在代码中,命名管道的创建使用mkfifo,命名管道的打开使用open ,命名管道的删除使用unlink

image-20240115190151043

函数原型: int unlink(const char *pathname);
参数解释:pathname表示要删除的文件名
返回值:调用成功返回0,失败返回-1同时设置错误码

应用:创建一个管道实现本地的server端和client端进行通信(两个不相关进程)

/*公共头文件,保存一些公共内容*/
#pragma once

#include <iostream>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define PIPE_NAME "/tmp/named_pipe"

bool createPipe(const char *pathname)
{
    umask(0);
    int ret = mkfifo(pathname, 0666);
    if (ret != 0)
    {
        perror("make named_pipe error:");
        return false;
    }
    return true;
}

void removePipe(const char *pathname)
{
    unlink(pathname);
}
/*client*/
#include "header.hpp"

// client创建管道,向管道中写入数据,server读取

int main()
{
    bool ret = createPipe(PIPE_NAME);
    if (ret == false)
    {
        exit(-1);
    }

    int Wfd = open(PIPE_NAME, O_WRONLY);
    if (Wfd == -1)
    {
        perror("open pipe fail:");
        exit(-2);
    }

    // 通过输入,循环向管道中写入数据
    char buffer[1024];
    while (true)
    {
        std::cout << "请输入通信信息# ";
        fgets(buffer, sizeof buffer, stdin);
        if(strlen(buffer) > 0)
        {
            buffer[strlen(buffer) - 1] = 0;//去掉输入的结尾字符'\n'
        }
        ssize_t ret = write(Wfd, buffer, strlen(buffer));
        if(ret != strlen(buffer))
        {
            std::cout << "数据写入异常" << std::endl;
            break;
        }
    }
    close(Wfd);
    removePipe(PIPE_NAME);
    return 0;
}
/*server*/
#include "header.hpp"

int main()
{
    int Rfd = open(PIPE_NAME, O_RDONLY);
    if (Rfd == -1)
    {
        perror("open pipe fail:");
        exit(-3);
    }
    char buffer[1024];
    while (true)
    {
        ssize_t ret = read(Rfd, buffer, sizeof(buffer) - 1);
        if (ret > 0)
        {
            buffer[ret] = 0;
            std::cout << "server端读取到内容: " << buffer << std::endl;
        }
        else if(ret == 0)
        {
            std::cout << "客户端已退出,通信结束" << std::endl;
            break;
        }
        else
        {
            std::cout << "err string: " << strerror(errno) << std::endl;
            break;
        }
    }
    close(Rfd);
    return 0;
}

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

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

本节完。。。

文章来源:https://blog.csdn.net/weixin_63249832/article/details/135614106
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。