欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

Linux] 进程间通信 - 管道/共享内存进程间通信 - 管道/共享内存

最编程 2024-07-04 07:02:57
...

文章目录

  • 1. 进程间通信
  • 2. 管道
    • 匿名管道
    • 命名管道
    • 管道的特性
    • 管道的应用:简易的进程池
  • 3. System V共享内存
    • 共享内存的概念
    • 共享内存的结构
    • 共享内存的使用
    • 代码实现

1. 进程间通信

进程间通信(Inter-Process Communication,简称IPC)是指不同进程之间进行数据交换和共享信息的机制和技术。在操作系统中,每个进程都是独立运行的,有自己的地址空间和数据,因此进程之间需要一种机制来进行通信,以便彼此协调工作、共享数据或者进行同步操作。

进程间通信的前提,也是重中之重,是让不同的进程看到同一份资源。 由于进程的独立性,只有先让不同进程看到同一份资源,有了通信的平台,才能实现通信。本文重点在于如何搭建进程间通信的平台,使得不同进程看到同一份资源。

2. 管道

管道,是一种传统的进程间通信方法。管道的本质是一个特殊文件,一个进程作为写入端,一个进程作为读取段,通过写入和读取管道实现通信。

????管道分为匿名管道命名管道,它们的使用场景不同。

匿名管道

????匿名管道(pipe)应用于有亲缘关系的进程之间通信(如:父子进程、兄弟进程)。以父子进程为例,原理:

  1. 父进程创建管道,并分别以写方式和读方式打开管道,此时父进程就拥有了两个新的文件描述符,以写方式打开管道的文件描述符称为写端fd,以读方式打开管道的文件描述符称为读端fd

  2. 接着创建子进程,子进程继承了父进程的文件描述符表,二者有了相同的写端fd和读端fd。

  3. 然后根据需求关闭不要的文件描述符,如:父进程写数据给子进程,即父进程作为写入端,子进程作为读取端,那就关闭父进程的读端fd和子进程的写端fd。

  4. 此时父子进程已经能看到同一份资源了,通信开始,父进程调用write写入管道,子进程调用read读取管道,和文件操作相同。

在这个过程中创建的管道,称之为匿名管道。之所以是匿名管道,是因为整个过程中用户都无法获知管道的名称等具体信息,该管道由OS维护。

上述过程的逻辑演绎如下:

在这里插入图片描述

????补充

  • 管道是一种特殊的文件,它在内存中以缓冲区的形式存在。因此打开管道就和打开文件一样,OS也会在内存中创建一个打开文件句柄来维护管道。通过打开文件句柄,我们可以引用到管道的缓冲区,从而对其进行读写操作。

  • 匿名管道的生命周期随进程。当引用该管道的所有进程退出,OS自动关闭并删除匿名管道。(打开文件句柄和inode的引用计数问题)

  • 因为管道是一种临时的通信机制,不像普通文件具有持久性的存储需求,所以管道是没有磁盘文件的。那么管道是否像文件一样拥有一个inode呢?是的。管道文件的inode主要用于标识和管理管道,记录与管道相关的元数据信息,并跟踪管道的引用计数。管道文件的inode并不链接实际数据,数据是通过内核的缓冲区进行传递和管理的。

  • 管道是一种半双工的通信方式,即一端写一端读,单向数据流动。

在这里插入图片描述

  • 下面是代码分析。

????首先是创建匿名管道的接口

int pipe(int pipefd[2]);

pipe是一个系统调用接口。当前进程创建匿名管道,传入参数pipefd是一个能够存放2个元素的整型数组,调用成功后,管道的写端fd和读端fd存入pipefd中,pipefd[0]是读端fd,pipefd[1]是写端fd。

下面是pipe在2号手册中的介绍。

NAME
       pipe, pipe2 - create pipe

SYNOPSIS
       #include <unistd.h>

       int pipe(int pipefd[2]);
RETURN VALUE
       On success, zero is returned.  On error, -1 is returned, and errno is  set appropriately.

下面是使用匿名管道实现进程间通信的一段代码

#include <iostream>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;
const int NUM = 1024;

// 先创建管道,进而创建子进程,父子进程使用管道进行通信
// 父进程向管道当中写“i am father”,
// 子进程从管道当中读出内容, 并且打印到标准输出

int main()
{
    // 1.创建管道
    int pipefd[2] = {0};
    int ret = pipe(pipefd);
    if (ret < 0)
    {
        cerr << errno << ":" << strerror(errno) << endl;
        return 1;
    }

    // 2.创建子进程
    pid_t id = fork();
    assert(id >= 0);

    if (id == 0)
    {
        // 子进程读
        // 3.关闭不要的fd
        close(pipefd[1]);

        // 4.通信
        char buf[NUM] = {0};
        int n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0)
        {
            buf[n] = '\0';
            cout << buf << endl;
        }
        else if (n == 0)
        {
            cout << "读取到文件末尾" << endl;
        }
        else
        {
            exit(1);
        }
        close(pipefd[0]);
        exit(0);
    }

    // 父进程写
    // 3.关闭不要的fd
    close(pipefd[0]);

    // 4.通信
    const char *msg = "I am father";
    write(pipefd[1], msg, strlen(msg));

    close(pipefd[1]);

    // 5.等待子进程退出
    int n = waitpid(id, nullptr, 0);
    if (n == -1)
    {
        cerr << errno << ":" << strerror(errno) << endl;
        return 1;
    }

    return 0;
}

⭕执行结果

[ckf@VM-8-3-centos Testpipe]$ ./a.out 
I am father #子进程成功读取并输出父进程发送的信息

命名管道

????命名管道(named pipe)应用于无亲缘关系的进程之间通信。无亲缘关系的两个进程,无法通过继承文件描述符表来获得同一个匿名管道,因此就需要命名管道。命名管道有特定的文件名,多个进程可以通过相同的文件名找到相同的管道,进而实现通信。使用命名管道的步骤如下:

  1. 创建命名管道

    创建命名管道的方式有两种,通过指令或系统调用。

    指令:

    mkfifo [选项] [name]
    OPTION:
    	-m MODE #设置管道的权限
    

    系统调用:

    NAME
           mkfifo - make a FIFO special file (a named pipe)
    
    SYNOPSIS
           #include <sys/types.h>
           #include <sys/stat.h>
    
           int mkfifo(const char *pathname, mode_t mode);
    RETURN VALUE
           On success mkfifo() returns 0.  In the case of an error, -1 is returned (in which case, errno is set appropriately).
    
  2. 进程打开命名管道

    进程可以调用open接口,以读或写方式打开命名管道,此时必须保证命名管道是存在的。注意:进程要有命名管道对应的权限才能正确地读取或写入数据,权限在创建管道时设定。

  3. 通信

  4. 关闭管道,删除管道

    进程调用close关闭管道,退出程序。命名管道的生命周期不随进程,进程退出命名管道依旧存在。因此需要用户自行删除,可以通过指令rm删除命名管道文件,也可以在进程中调用unlink接口。

    NAME
           unlink - delete a name and possibly the file it refers to
    
    SYNOPSIS
           #include <unistd.h>
    
           int unlink(const char *pathname);
    RETURN VALUE
           On success, zero is returned.  On error, -1 is returned, and errno is set appropriately.
    

????下面是两个进程使用命名管道实现进程间通信,client是写进程,负责创建namedpipe和删除namedpipe,并向server发送数据,数据由用户交互传递。server是读进程,只负责读取client发送的数据。

注意: 对于打开命名管道的写端,调用open时,若此时该命名管道没有读端,则写端会阻塞等待至少一个读端打开该管道,写端才会打开。同理,若想打开读端但是没有写端,也会阻塞等待。

//client
#include "common.hpp"

int main()
{
    // 1.创建命名管道
    umask(0);
    int ret = mkfifo(pipename.c_str(), 0666);
    if (ret < 0)
    {
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        return 1;
    }
    
    // 2.以写方式打开命名管道
    int wfd = open(pipename.c_str(), O_WRONLY);
    if (wfd < 0)
    {
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        return 1;
    }

    //3.向管道中写入数据
    char buf[NUM] = {0};
    std::cout << "请输入您想要发送给服务端的信息: " << std::endl;
    while (true)
    {
        char *str = fgets(buf, sizeof(buf), stdin);
        assert(str);
        (void)str;

        int n = strlen(buf);
        buf[n - 1] = '\0'; // 消除'\n'

        if (strcasecmp(buf, "quit") == 0)
            break;

        int ret = write(wfd, buf, sizeof(buf));
        assert(ret > 0);
        (void)ret;
    }

    // 4.退出,关闭写端
    close(wfd);
    unlink(pipename.c_str());

    return 0;
}
//server
#include "common.hpp"

int main()
{
    // 1.以读方式打开命名管道
    int rfd = open(pipename.c_str(), O_RDONLY);
    if (rfd < 0)
    {
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        return 1;
    }

    //2.读取管道中的数据
    char buf[NUM] = {0};
    while (true)
    {
        int cnt = read(rfd, buf, sizeof(buf));
        if (cnt > 0)
        {
            buf[cnt] = '\0';
            std::cout << "message from client: " << buf << std::endl;
        }
        else if (cnt == 0)
        {
            std::cout << "通信结束" << std::endl;
            break;
        }
        else
        {
            return 1;
        }
    }

    // 3.关闭读端
    close(rfd);

    return 0;
}
//common.hpp
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>	
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <cassert>

const std::string pipename = "fifo";
const int NUM = 1024;

⭕实操演示

在这里插入图片描述


管道的特性

????作为特殊的文件,管道具有一些特性(匿名管道和命名管道同时具备)

  1. 当管道为空时或读进程读完数据时,读进程再次读取时会阻塞等待写进程写入数据后才开始读取。
  2. 当管道为满时,读进程没有读取数据,写进程会阻塞等待读进程读取出一些数据后再写入数据,否则未被读取的数据可能会被覆盖。
  3. 若所有写进程被关闭,读进程仍在读取,此时读进程调用的read函数会返回0,表示读取到文件末尾,即读取结束
  4. 若所有读进程被关闭,写进程再写入数据就无意义了,因此OS会发送信号SIGPIPE,终止写进程

????这种特性也被称为“管道的阻塞机制”。管道的阻塞机制确保了数据在写进程和读进程之间的可靠传递和同步处理,提高了数据处理的准确性和效率,为进程之间的通信和数据交换提供了便利和可靠性。


管道的应用:简易的进程池

使用匿名管道制作一个简易的进程池,大概思路:先创建一个父进程,然后让这个父进程创建多个子进程,通过用户交互的模式,让父进程下发指定的任务给不同的子进程。其中,”下发任务“这个过程,就是利用管道来实现,父进程对于每个子进程都有唯一一个管道用以传输“任务”数据。

  1. 管理子进程

    一个父进程对多个子进程,且每个子进程对应一个管道,那么肯定要先将多个子进程管理起来。根据“先描述,再组织”的管理思想,我的设计如下:先将子进程描述为一个结构体,该结构体中包含子进程pid、子进程对应管道在父进程中的写端fd、以及一个子进程名称(自定义格式,为了后续方便调试观察)。然后在父进程中定义一个容器,用以组织这些创建出来的子进程结构体,方便后续管理。

    //描述子进程结构体
    struct ChildProc
    {
        ChildProc(int pid, int write_fd) : _pid(pid), _write_fd(write_fd)
        {
            _proc_name = "proc->" + to_string(_pid) + ":" + to_string(_write_fd);
        }
    
        int _pid;
        int _write_fd;
        string _proc_name;
    };
    
    //父进程主函数,即整个进程池的框架
    int main()
    {
        //定义一个vector容器,用以组织ChildProc
        vector<ChildProc> child_processes;
    
        // 1.创建子进程
        CreatProcess(child_processes);
    
        // 2.父进程下发命令(用户交互式)
        OrderProcess(child_processes);
    
        // 3.进程退出
        WaitProcess(child_processes);
        cout << "子进程已全部成功退出,并被回收!" << endl;
    
        return 0;
    }
    
  2. 创建子进程

    父进程循环创建子进程。每次子进程创建完毕后,由于父进程尚且没有向管道写入数据,当前子进程read阻塞等待,父进程继续创建下一个子进程。父进程每次fork创建完一个子进程,要将其描述为ChildProc结构体,再插入管理的容器中。

    const int child_process_num = 3;
    
    void CreatProcess(vector<ChildProc> &cps)
    {
        for (int i = 0; i < child_process_num; i++)
        {
            // 1.创建管道
            int pipefd[2] = {0};
            int ret = pipe(pipefd);
            if (ret < 0)
            {
                perror("The following error happen:");
            }
            
            // 父进程写,子进程读(父进程向子进程发送命令)
            
            // 2.创建子进程,一个子进程在父进程中对应一个写端
            int id = fork();
            assert(id >= 0);
            
            // 子进程
            if (id == 0)
            {
                // 3.关闭不要的fd
                close(pipefd[1]);
                
                // 子进程接收并执行命令
                while (true)
                {
                    int n = 0;
                    // 此时管道为空时,子进程read阻塞等待父进程下发命令
                    int cnt = read(pipefd[0], &n, sizeof(int));
                    if (cnt > 0)
                    {
                        //FuncArray在Tasks.hpp中实现
                        FuncArray[n]();
                        cout << endl;
                    }         
                    else if (cnt == 0)
                    {
                        //父进程退出,即写端关闭,read返回值为0,子进程也随之退出
                        cout << "读取结束,子进程退出"
                             << " pid: " << getpid() << endl;
                        break;
                    }
                    else
                    {
                        exit(1);
                    }
                }
                close(pipefd[0]);
                exit(0);
            }
    
            // 父进程
            // 将子进程(子进程pid和写端fd)管理起来,父进程才方便下发命令
            cps.push_back(ChildProc(id, pipefd[1]));
            close(pipefd[0]);
        }
    }
    

    在common.hpp头文件中,简单写几个子进程可执行的任务,这里没有定义实际任务,只是打印语句以表示任务成功执行。后续这块可完善。

    #pragma once
    #include <iostream>
    #include <functional>
    using namespace std;
    
    void TaskWeChat()
    {
        cout << "wechat is running..." << endl;
    }
    
    void TaskChrome()
    {
        cout << "chrome is running..." << endl;
    }
    
    void TaskSteam()
    {
        cout << "steam is running.." << endl;
    }
    
    const function<void()> FuncArray[] = {TaskWeChat,TaskChrome,TaskSteam};
    
  3. 父进程下发命令给子进程

    int SelectBoard()
    {
        //用户选择面板
        cout << "#########################" << endl;
        cout << "# 0.wechat     1.chrome #" << endl;
        cout << "# 2.steam      3.quit   #" << endl;
        cout << "#########################" << endl;
        cout << "请选择你将下发的命令: ";
    
        int command = 0;
        cin >> command;
        return command;
    }
    
    void OrderProcess(vector<ChildProc> &cps)
    {
        int num = -1;
        while (true)
        {
            // 用户交互, 下发命令
            int command = SelectBoard();
            if (command == 3)
                break;
            if (command < 0 || command > 2)
                continue;
    
            // 轮询调用子进程
            num = (num + 1) % cps.size();
            printf("调用了子进程%d号, ", num);
            cout << cps[num]._proc_name << endl;
            
            // 将命令写入对应子进程的管道中
            write(cps[num]._write_fd, &command, sizeof(command));
            sleep(1);
        }
    }
    
  4. 等待子进程进程退出并回收

    void WaitProcess(vector<ChildProc> &cps)
    {
        // 先关闭父进程的所有写端,根据管道的特性(关闭管道所有写端,读端退出),关闭写端让对应的子进程退出
        // 随后,父进程要回收所有的子进程
    
        for (auto &cp : cps)
        {
            close(cp._write_fd);
            waitpid(cp._pid, nullptr, 0);
        }
    }
    

⭕运行程序,并进行测试。发现让父进程发送0、1、2命令都正常,可当发送3号退出命令,让父进程等待并回收子进程时,程序卡住了。

在这里插入图片描述

这里有一个隐藏的bug。匿名管道,我们运用了子进程继承父进程文件描述符表的机制,但在进程池中,由于利用了这个继承机制,又会产生bug。父进程创建0号子进程时是没问题的,如我们预期。当创建1号子进程时,由于此时父进程文件描述符表有了0号子进程的写端fd,被1号子进程继承了,所以此时0号子进程的管道有了两个写端fd,这并不符合我们的预期,我们的设计是让父进程和每个子进程之间有一个独立的管道。若创建三个子进程,最后进程池的结构如下:

在这里插入图片描述

再看看我们刚才写的WaitProcess函数。造成阻塞的原因是:close关闭第一个子进程管道的写端时,并没有关闭全部写端,因此该子进程并没有退出,waitpid阻塞等待。

void WaitProcess(vector<ChildProc> &cps)
{
    for (auto &cp : cps)
    {
        close(cp._write_fd);
        waitpid