第七章:进程间通信(IPC)——构成进程间通信的信道方案

系列文章目录


文章目录


前言

进程通信是数据传输的重要方式。


进程间通信介绍

进程间通信目的

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

  2. 资源共享:多个进程之间共享同样的资源。

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

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

进程间通信发展

  1. 管道

  2. System V进程间通信

  3. POSIX进程间通信

进程间通信分类

  1. 管道

    • 匿名管道pipe

    • 命名管道

  2. System V IPC

    • System V 消息队列

    • System V 共享内存

    • System V 信号量

  3. POSIX IPC

    • 消息队列

    • 共享内存

    • 信号量

    • 互斥量

    • 条件变量

    • 读写锁

进程通信的原理

让两个不同的进程进行通信,前提条件是让两个进程看到同一份"资源"(不违背进程的独立性并且由OS提供直接或间接提供)。

任何进程通信手段:

  1. 先让不同的进程看到同一份资源。(通信方式)

  2. 让一方写入,一方读取,完成通信过程。

管道

什么是管道

管道是Unix中最古老的进程间通信的形式。

我们把从一个进程连接到另一个进程的一个数据流称为一个"管道",管道也是文件 。

bash 复制代码
who | wc -l
// | 管道符
  1. 创建子进程的时候,只会浅拷贝进程相关的数据结构对象,不会赋值父进程打开的文件对象!

  2. 管道是一个OS提供的内存匿名文件,以读写的方式打开。

  3. fork之后需要确定数据的流向,关闭不必要的fd。

  4. 因为只支持单向通信,所以称之为管道。

pipe

cpp 复制代码
man pipe

#include <unistd.h>

int pipe(int pipefd[2]);

On success, zero is returned.  On error, -1 is returned, and errno is set appropriately.

管道通信特点

  1. 单向通信(半双工)。

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

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

  4. 管道通信中,写入的次数,和读取的次数,并不是严格匹配的(读写没有强相关)------ 面向字节流

  5. 管道具有一定的协同能力,让reader和writer能够按照一定的步骤进行通信 ------ 自带同步与互斥机制

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

    • 如果write端将管道写满了,就不能写了(管道容量有限)。

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

    • 如果写端一直再写,读端关闭,OS会通过信号的方式杀死一直在写入的进程。

  6. 当要一次写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性;当要一次写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

简单设计

父进程可以通过向子进程写入特定的消息,唤醒子进程,甚至让子进程定向的执行某种任务。

父进程要管理自己创建的管道和进程。

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include "Task.hpp"
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;
public:
    EndPoint(int id, int fd) : _child_id(id), _write_fd(fd)
    {
        //process-0[pid:fd]
        char namebuffer[64];
        snprintf(namebuffer, sizeof(namebuffer), "process-%d[%d:%d]", number++, _child_id, _write_fd);
        processname = namebuffer;
    }
    std::string name() const
    {
        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 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);//关闭其他进程的管道写端

            // std::cout << getpid() << " 子进程关闭父进程对应的写端:";
            // for(auto &fd : fds)
            // {
            //     std::cout << fd << " ";
            //     close(fd);
            // }
            // std::cout << std::endl;
            
            // 1.3 关闭不要的fd
            close(pipefd[1]);
            // 我们期望,所有的子进程读取"指令"的时候,都从标准输入读取
            // 1.3.1 输入重定向,可以不做
            dup2(pipefd[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]));

        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) 
{
    // 2.1 我们可以写成自动化的,也可以搞成交互式的
    int num = 0;
    int cnt = 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::string name = end_points[index].name();
        std::cout << "选择了进程: " <<  name << " | 处理任务: " << command << std::endl;

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

        sleep(1);
    }
}

void waitProcess(const vector<EndPoint> &end_points)
{
    // 1. 我们需要让子进程全部退出 --- 只需要让父进程关闭所有的write fd就可以了!
    // for(const auto &ep : end_points) 
    // for(int end = end_points.size() - 1; end >= 0; end--)
    for(int end = 0; end < end_points.size(); end++)
    {
        std::cout << "父进程让子进程退出:" << end_points[end]._child_id << std::endl;
        close(end_points[end]._write_fd);

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

    // 2. 父进程要回收子进程的僵尸状态
    // for(const auto &ep : end_points) waitpid(ep._child_id, nullptr, 0);
    // std::cout << "父进程回收了所有的子进程" << std::endl;
    // sleep(10);
}


// #define COMMAND_LOG 0
// #define COMMAND_MYSQL 1
// #define COMMAND_REQEUST 2
int main()
{
    vector<EndPoint> end_points;
    // 1. 先进行构建控制结构, 父进程写入,子进程读取 , bug?
    createProcesses(&end_points);

    // 2. 我们的得到了什么?end_points
    ctrlProcess(end_points);

    // 3. 处理所有的退出问题
    waitProcess(end_points);
    return 0;
}
cpp 复制代码
#pragma once

#include <iostream>
#include <vector>
#include <unistd.h>
#include <unordered_map>

// typedef std::function<void ()> func_t;

typedef void (*fun_t)(); //函数指针

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

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

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

// void ExitProcess()
// {
//     exit(0);
// }

//约定,每一个command都必须是4字节
#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:
    std::vector<fun_t> funcs;
    // std::unordered_map<std::string, fun_t> funcs;
};

命名管道

  • 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。

  • 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。

  • 命名管道是一种特殊类型的文件

什么是命名管道

bash 复制代码
mkfifo fifo

//创建一个文件名为filo的命名管道

有文件属性的内存级文件。

  1. 不同的进程打开同一个文件不会打开新的文件属性结构体对象。

  2. 两个进程打开同一个磁盘文件也可以构成磁盘通信信道。

  3. 为了不使用磁盘文件(避免需要缓冲区刷新),直接用内存级文件------命名管道文件来做"资源"。

  4. 命名管道与管道的原理一样。

  5. 命名管道必须要有文件名,所以称为命名管道。

【总结】:让不同的进程通过文件路径+文件名看到同一个文件,并打开就是看到了同一个"资源"。

mkfifo

cpp 复制代码
man 3 mkfifo

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);

On success mkfifo() returns 0.  In the case of an error, -1 is returned (in which case, errno is set appropriately).

strcmp/strncasecmp

cpp 复制代码
man strncasecmp

#include <strings.h>

int strcasecmp(const char *s1, const char *s2);

int strncasecmp(const char *s1, const char *s2, size_t n);

man strcmp

#include <string.h>

int strcmp(const char *s1, const char *s2);

int strncmp(const char *s1, const char *s2, size_t n);
cpp 复制代码
man 2 unlink

#include <unistd.h>

int unlink(const char *pathname);

getch

cpp 复制代码
man getch

#include <curses.h>

int getch(void);

简单设计

cpp 复制代码
#pragma once

#include <iostream>
#include <string>

#define NUM 1024

const std::string fifoname = "./fifo";
uint32_t mode = 0666; 
cpp 复制代码
#include <iostream>
#include <cerrno>
#include <cstring>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "comm.hpp"

//少年们, 我刚刚写了一个基于匿名管道的进程池
// 可不可以把它改成使用命名管道呢??
int main()
{
    // 1. 创建管道文件,我们今天只需要一次创建
    umask(0); //这个设置并不影响系统的默认配置,只会影响当前进程
    int n = mkfifo(fifoname.c_str(), mode);
    if(n != 0)
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 1;
    }
    std::cout << "create fifo file success" << std::endl;
    // 2. 让服务端直接开启管道文件
    int rfd = open(fifoname.c_str(), O_RDONLY);
    if(rfd < 0 )
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        return 2;
    }
    std::cout << "open fifo success, begin ipc" << std::endl;

    // 3. 正常通信
    char buffer[NUM];
    while(true)
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            buffer[n] = 0;
            //std::cout << "client# " << buffer << std::endl;
            printf("%c", buffer[0]);
            fflush(stdout);
        }
        else if(n == 0)
        {
            std::cout << "client quit, me too" << std::endl;
            break;
        }
        else 
        {
            std::cout << errno << " : " << strerror(errno) << std::endl;
            break;
        }
    }

    // 关闭不要的fd
    close(rfd);

    unlink(fifoname.c_str());

    return 0;
}
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
// #include <ncurses.h>
#include "comm.hpp"

int main()
{
    //1. 不需创建管道文件,我只需要打开对应的文件即可!
    int wfd = open(fifoname.c_str(), O_WRONLY);
    if(wfd < 0)
    {
        std::cerr << errno << ":" << strerror(errno) << std::endl;
        return 1;
    }

    // 可以进行常规通信了
    char buffer[NUM];
    while(true)
    {
        // std::cout << "请输入你的消息# ";
        // char *msg = fgets(buffer, sizeof(buffer), stdin);//C库函数读取的是字符串,会处理'\0',系统调用处理字节流,不会处理'\0'.
        // assert(msg);
        // (void)msg;
        // int c = getch();
        // std::cout << c << std::endl;
        // if(c == -1) continue;

        system("stty raw");
        int c = getchar();
        system("stty -raw");

        //std::cout << c << std::endl;
        //sleep(1);

        //buffer[strlen(buffer) - 1] = 0;
        // abcde\n\0
        // 012345
        //if(strcasecmp(buffer, "quit") == 0) break;

        ssize_t n = write(wfd, (char*)&c, sizeof(char));
        assert(n >= 0);
        (void)n;
    }

    close(wfd);

    return 0;
}

共享内存

【system V共享内存】:

共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到\内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。

什么是共享内存

  1. 让不同的进程,先看到同一份资源------内存块--共享内存。

  2. 虚拟进程空间的共享区映射同一块物理内存。

  3. 在任意一个时刻,可能有多个共享内存在被用来进行通信,所以系统中一定存在很多shm同时存在,OS要管理所有的共享内存。所以共享内存不仅仅会在内存中开辟空间,OS也会构建对应的描述共享内存的结构体对象!

  4. 共享内存 = 共享内存的内核数据结构(伪代码:struct shm) + 真正开辟的内存空间

shmget/ftok

cpp 复制代码
man shmget

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);


On success, a valid shared memory identifier is returned.  On errir, -1 is returned, and errno is set to indicate the error.

man ftok
#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
// pathname:路径字符串 proj_id:项目ID

让两个进程映射同一个内存块。

  1. 传入同样的参数形成一样key_t,一个进程用key_t创建一个内存块并映射,另一个进程用key_t映射该内存块,使两个进程地址空间映射同一个内存块。

  2. key_t是在内核中使用的,用数字来标识唯一的内存块。

  3. key vs shmid:key类比文件inode编号,shmid类比文件的fd。所以在用户层的操作都是用shmid。

ipcs

bash 复制代码
[admin1@VM-4-17-centos linux_code]$ ipcs

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
//消息队列
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
//共享内存
------ Semaphore Arrays --------
key        semid      owner      perms      nsems     
//信号量

//显示ipc通信系统所支持的三种ipc通信策略
//1.key:表示共享内存的key
//2.shmid:表示共享内存的编号
//3.owner:表示创建共享内存的用户
//4.perms:表示共享内存的使用权限
//5.bytes:表示共享内存的大小
//6.nattch:表示连接到共享内存的进程数
//7.status:表示共享的状态(不显示则为正常使用,显示"dest"表示共享内存已被删除,但任有用户使用)

[admin1@VM-4-17-centos linux_code]$ ipcs -m
//查看共享内存
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status 

//-m 针对共享内存的操作
//-q 针对消息队列的操作
//-s 针对消息队列的操作
//-a 针对所有资源的操作

[admin1@VM-4-17-centos linux_code]$ ipcrm -m 196611
//删除shmid为196611的共享内存

shmctl

cpp 复制代码
man shmctl

#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

//删除共享内存

shmat/shmdt

cpp 复制代码
man shmat/shmdt

#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);
//关联共享内存

int shmdt(const void *shmaddr);
//去关联共享内存

共享内存的特点

  1. 创建共享内存的进程已经退出了,但我们发现共享内存一定还存在!共享内存的生命周期不随进程,随OS。

  2. 一旦共享内存映射到进程的地址空间,该共享内存就直接被所有的进程看到了。可以让进程通信的时候减少拷贝次数,所以共享进程是所有进程通信方式中速度最快的。

  1. 共享内存没有任何的保护机制(同步互斥)。因为管道通过系统接口通信,共享内存直接通信。

消息队列

msgget/msgctl

cpp 复制代码
man msgget

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msgsnd/msgrcv

cpp 复制代码
#include <sys/types.h>
 #include <sys/ipc.h>
 #include <sys/msg.h>

 int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

 ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
                int msgflg);

信号量

互斥等四个概念

我们把大家都能看到的资源称为"公共资源"。

  1. 互斥:任何一个时刻,都只允许一个执行流在进行共享资源的访问------加锁。

  2. 我们把任何一个时刻,都只允许一个执行流在进行访问的共享资源称为"临界资源"。

  3. 临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做"临界区"。

  4. 原子性:要么不做,要么做完,只有两种确定状态的属性。

什么是信号量

任何计技术都有自己的应用场景。

看电影之前,先要买票。

买票的本质功能:1. 对坐位资源的预定机制 2. 确保不会因为多放出去特定的座位资源而导致冲突。

假设一个放映厅只有一个座位→互斥

信号量/信号灯:本质就是一个计数器!描述资源数量的计数器。

任何一个执行流,想访问临界资源的一个子资源的时候,不能直接访问。先申请信号量资源(--count),只要我申请信号量成功,我就一定未来能够拿到一个子资源。如果信号量用完了,即没有成功申请,就会挂起阻塞进程。------ P操作

成功申请后进入自己的临界区,访问对应的临界资源。

释放信号量资源(++count)只要将计数器增加,就表示将我们对应的资源进行了归还。阻塞的进程就可以继续申请并执行了。------ V操作

进程通过执行代码来申请,每个进程都要遵守这个规则。

所有的进程都得先看到信号量------共享资源------必须保证自己的操作++/--是原子的!

两个进程可以看到同一个count,即让不同的进程看到同一个"计数器"(资源)。所以信号量被归类到了进程间通信!

如果这个计数器是1呢?这就是二元信号量,完成了互斥功能!互斥的本质就是将临界资源独立使用!!!!!

semget/semctl

cpp 复制代码
man semget

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);


#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ...);

semop

cpp 复制代码
man semop

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semop(int semid, struct sembuf *sops, unsigned nsops);

理解IPC

"多态":


总结

进程通信的本质是让不同进程看到同一份"资源"。
如果你真的愿意为自己的梦想去努力,最差的结果,不过是大器晚成。

相关推荐
专注API从业者1 分钟前
Python/Java 代码示例:手把手教程调用 1688 API 获取商品详情实时数据
java·linux·数据库·python
Ribou34 分钟前
Ubuntu 24.04.2安装k8s 1.33.4 配置cilium
linux·ubuntu·kubernetes
tan180°1 小时前
Boost搜索引擎 网络库与前端(4)
linux·网络·c++·搜索引擎
Mr. Cao code2 小时前
Docker:颠覆传统虚拟化的轻量级革命
linux·运维·ubuntu·docker·容器
抓饼先生2 小时前
Linux control group笔记
linux·笔记·bash
挺6的还3 小时前
25.线程概念和控制(二)
linux
您的通讯录好友3 小时前
conda环境导出
linux·windows·conda
代码AC不AC4 小时前
【Linux】vim工具篇
linux·vim·工具详解
码农hbk4 小时前
Linux signal 图文详解(三)信号处理
linux·信号处理
bug攻城狮4 小时前
Skopeo 工具介绍与 CentOS 7 安装指南
linux·运维·centos