管道与共享内存

158 阅读17分钟

进程通信

如何通信

进程运行具有独立性,通信成本高

进程间通信的前提:先让不同进程看到同一份内存空间/资源,同时这块内存不能属于任何一方进程

为什么让进程通信

单进程,无法使用并发能力,更加无法实现多进程协同.

例如:要实现1个进程处理完,让另一个进程对数据进行二次加工,需要使用进程间通信

进程间通信的标准

1 linux原生提供的管道(匿名管道、命名管道),本机通信

2 SystemV,多进程,用于本地通信

共享内存
消息队列
信号量

3 posix,多线程,用于网络通信

管道

原理

管道是Unix中最古老的进程间通信的形式,只能单向通信

在使用行文本过滤工具grep时,经常需要让进程输出的结果经过管道传递grep进程进行过滤

image.png

1 父进程以读写方式打开同一个文件,3号文件描述符是读,4号文件描述符是写

image.png

2 fork创建子进程,创建子进程的内核数据结构,父进程的文件描述符表的内容拷贝给子进程

此时不同的进程看到了同一份资源 —— struct file文件对象(里面有内核缓冲区)

image.png

3 双方进程各自关闭自己不需要的文件描述符.

例如:父进程进行写入,子进程进行读取 —— 父进程关闭读端,子进程关闭写端

单向通信,这种方式就是管道

4 两个进程在通信,通信数据要不要刷新到磁盘?

管道通信不会将数据保存到磁盘中的,这些数据都是临时数据

以下面的sleep命令为例子:它们的父进程PID都是20084 image.png 管道的本质就是文件

匿名管道

os内核创建一个struct file对象,不需要在磁盘上有对应的文件,仅仅用来进程间通信,这就是匿名管道

fork让子进程继承父进程的文件描述符表的内容,让父子进程看到同一份struct file文件对象

image.png

image.png

int pipefd[2]:输出型参数,通过调用函数获得被打开文件的读写端各自的fd

pipefd[0]是读端,pipefd[1]是写端

int main()
{
    // 创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd); // pipefd[0]读端     pipefd[1]写端
    assert(n != -1);
    //创建子进程
    pid_t id = fork();
    assert(id != -1);
    if(id == 0)
    {
        //子进程
        //构建单向通信的信道,父进程写入,子进程读取
        //关闭子进程不需要的文件描述符
        close(pipefd[1]);
        char buffer[1024];
        while(true)
        {
            //像读文件一样读取
            ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
            if(s > 0)
            {
                buffer[s] = '\0';
                std::cout << "father say# " << buffer << std::endl;
            }
        }
        exit(0);
    }

    //关闭父进程不需要的文件描述符
    close(pipefd[0]);

    std::string message = "i am father process";
    int count = 0;
    char send_buffer[1024];
    while(true)
    {
        //将特定的内容格式化显示到send_buffer中
        snprintf(send_buffer,sizeof(send_buffer),"%s : %d",message.c_str(),count++);
        write(pipefd[1],send_buffer,strlen(send_buffer));
        sleep(1);
    }
    return 0;
}

image.png

匿名管道的特点(就是|)

(1) 管道是用来让具有血缘关系的进程进行进程间通信 —— 常用于父子通信

(2) 管道为进程间协同,提供访问控制

父进程写得慢,管道空了,子进程必须等父进程写入数据,才能读取

子进程读得慢,管道满了,父进程必须等管道的内核缓冲区有空间了,才能往里面写

读端会一次性把管道内的数据全读出来,只要读端的buffer够大

    int pipefd[2] = {0};
    int n = pipe(pipefd); // pipefd[0]读端     pipefd[1]写端
    assert(n != -1);
    pid_t id = fork();
    assert(id != -1);
    if(id == 0)
    {
        //构建单向通信的信道,父进程写入,子进程读取
        close(pipefd[1]);
        char buffer[1024];
        while(true)
        {
            //读端慢,会一次性把管道内的数据全读出来
            sleep(5);
            ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
            if(s > 0)
            {
                buffer[s] = '\0';
                std::cout << "father say# " << buffer << std::endl;
            }    
        }
        exit(0);
    }

    close(pipefd[0]);
    std::string message = "i am father process";
    int count = 0;
    char send_buffer[1024];
    while(true)
    {
        snprintf(send_buffer,sizeof(send_buffer),"%s : %d\n",message.c_str(),count++);
        write(pipefd[1],send_buffer,strlen(send_buffer));
    }

(3) 管道是面向字节流式的通信服务,即写端可能写了10次、100次,但是读端一次性就把全部数据读上来了,写的次数和读的次数没有关系.

(4) 管道是基于文件的:

写入的一方,fd没有关闭,如果有数据就读,没有数据就等

写入的一方,fd关闭,读取的一方,read会返回0,表示读到了文件结尾

读取的一方,fd关闭,os会自动终止写端进程(因为写的数据没有人来读了)

    // 创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd); // pipefd[0]读端     pipefd[1]写端
    assert(n != -1);
    //创建子进程
    pid_t id = fork();
    assert(id != -1);
    if(id == 0)
    {
        //子进程
        //构建单向通信的信道,父进程写入,子进程读取
        //关闭子进程不需要的文件描述符
        close(pipefd[1]);
        char buffer[1024];
        while(true)
        {
            //写入的一方关闭fd后,read会返回0
            ssize_t s = read(pipefd[0],buffer,sizeof(buffer)-1);
            if(s > 0)
            {
                buffer[s] = '\0';
                std::cout << "father say# " << buffer << std::endl;
            }
            else if(s == 0)
            {
                std::cout << "父进程退出"<< std::endl;
                break;
            }
        }
        exit(0);
    }

    //关闭父进程不需要的文件描述符
    close(pipefd[0]);
    std::string message = "i am father process";
    int count = 0;
    char send_buffer[1024];
    while(true)
    {
        //将特定的内容格式化显示到send_buffer中
        snprintf(send_buffer,sizeof(send_buffer),"%s : %d\n",message.c_str(),count++);
        write(pipefd[1],send_buffer,strlen(send_buffer));
        if(count == 5)
            break;
        sleep(1);
    }
    close(pipefd[1]);
    waitpid(id,nullptr,0);

image.png

(5) 管道的生命周期是随进程的,因为这个管道文件只给这两个进程使用,所以这两个通信进程退出,管道文件自然会被os释放掉

(6) 管道是单向通信的,是半双工通信的一种特殊情况

进程间通信一定是有人收,有人发。

作为通信的一方,不能同时进行收和发数据,在一个时刻要么在收要么在发 —— 半双工

作为通信的一方,收和发可以同时进行 —— 全双工

进程池

父进程,创建若干个子进程,跟这些子进程都建立管道,子进程内部都预先放置很多处理任务的方法

子进程读取父进程发送的指令,执行对应任务

如果用户来了一个任务,父进程可以把任务指派给某一个子进程(随机挑选),让某一个子进程帮我们去完成这个任务

1 父进程给子进程发送特定指令,用不同的数字表示让进程执行不同的任务。

2 往哪个管道里写,就让哪个进程执行任务。

ProcessPool.cc

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <vector>
#include "Task.hpp"
#include <time.h>
#define PROCESS_NUM 5

int waitCommand(int fd, int& quit)
{
    uint32_t command = 0;
    ssize_t s = read(fd,&command,sizeof(command));
    if(s == 0)
    {
        //写端关闭文件描述符
        quit = true;
        return -1;
    } 
    return command;
}
int main()
{
    load();
    //让父进程在一张表里,选择让哪个子进程来执行任务
    //放的是写端的fd
    std::vector<int> table;
    //先创建多个子进程
    for(int i = 0;i < PROCESS_NUM;++i)
    {
        int pipefd[2] = {0};
        pipe(pipefd);

        pid_t id = fork();
        if(id == 0){
            std::cerr << "子进程创建成功,正在等待命令"<<std::endl;
            //子进程关闭写端
            close(pipefd[1]);
            int quit = false;
            while(!quit)
            {
                //等命令
                int command = waitCommand(pipefd[0], quit);
                if(quit == true)break;
                //执行命令
                if(command >= 0 && command < callbacks.size())
                {
                    callbacks[command]();
                }
                else{
                    std::cerr << "非法命令"<<std::endl; 
                }
            }
            exit(0);
        }
        //父进程关闭读端
        close(pipefd[0]);
        //把写端插入表中
        table.push_back(pipefd[1]);
    }
    sleep(2);
    //父进程派发任务
    //要较为均衡的把任务派发给每一个子进程 —— 负载均衡
    srand((unsigned long)time(nullptr));
    while(true)
    {
        uint32_t command;
        int select;
        std::cout << "#######################################"<<std::endl;
        std::cout << "1 查看可执行任务         2 发送指令执行特定任务     3 quit"<<std::endl;
        std::cout << "#######################################"<<std::endl;
        std::cout << "请输入# ";
        std::cin >> select;
        if(select == 1) showDesc();
        else if(select == 2)
        {
            std::cout << "输入指令# ";
            //选择要执行的任务
            std::cin >> command;
            //随机在table中选择一个进程执行该任务
            write(table[rand()%PROCESS_NUM],&command,sizeof(command));
        }
        else if(select == 3)
        {
            break;
        }
    }
    for(int fd:table)
        close(fd);
    //回收所有子进程
    for(int i = 0; i < PROCESS_NUM;++i)
    {
        pid_t ret = waitpid(-1,nullptr,0);
        printf("%d被回收\n",ret);
    }
    return 0;
}

Task.hpp

#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <functional>
#include <vector>
#include <unordered_map>
#include <stdio.h>
typedef std::function<void()> func;
//方法列表
std::vector<func> callbacks;
//方法列表描述
std::unordered_map<int,std::string> desc;
void readMySQL()
{
    std::cout << "["<<getpid()<<"] :" << "执行访问数据库任务" << std::endl;
}

void parseURL()
{
    std::cout << "[" << getpid() << "] :" << "执行解析URL任务" << std::endl;
}

void save()
{
    std::cout << "[" << getpid() << "] :" << "执行数据持久化任务" << std::endl;   
}

void test()
{
    std::cout << "[" << getpid() << "] :" << "执行测试任务" << std::endl;   
}

//加载任务 和 任务描述
void load()
{
    desc.insert(std::make_pair(callbacks.size(),"读取数据库数据"));
    callbacks.push_back(readMySQL);
    desc.insert(std::make_pair(callbacks.size(),"解析网页URL"));
    callbacks.push_back(parseURL);
    desc.insert(std::make_pair(callbacks.size(),"保存数据到文件中"));
    callbacks.push_back(save);
    desc.insert(std::make_pair(callbacks.size(),"测试数据"));
    callbacks.push_back(test);
}

void showDesc()
{
    for(const auto& iter: desc)
    {
        printf("%d : %s\n",iter.first,iter.second.c_str());
    }
}

命名管道

匿名管道只能让有亲缘关系的进程来通信

如果想让两个毫不相关的进程通信,可以使用命名管道(先让不同进程看到同一份资源)

两个进程打开同一个文件,这样在这两个进程的文件描述符表中,一定看到了同一份struct file对象

在磁盘上可以创建一种管道文件,可以被多个进程打开,但是不会将内存数据刷新到磁盘

这种管道文件就叫命名管道文件(没有文件内容)

命令行上创建一个命名管道文件: mkfifo name_pipe

往命名管道里写数据,但是因为还没有人打开读取,所以会阻塞

image.png

另一个进程可以读取命名管道内部的数据

image.png

image.png common.hpp

#ifndef _COMMON_HPP
#define _COMMON_HPP
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string>
#include <assert.h>
#include <unistd.h>
using namespace std;
string ipcPath = "./pipe.ipc";
#endif

server.cc

#include "common.hpp"
int main()
{
    //1 创建管道文件
    mkfifo(ipcPath.c_str(),0666);
    //2 正常文件操作
    int fd = open(ipcPath.c_str(),O_RDONLY);  //要等客户端也打开管道文件,否则会一直阻塞
    assert(fd >= 0);
    cout << "打开管道文件成功"<<endl;
    //3 正常通信代码
    char buffer[1024];
    while(true)
    {
        ssize_t s = read(fd,buffer,sizeof(buffer) - 1);
        if(s == 0)
        {
            cout << "客户端关闭连接"<<endl;
            break;
        }
        else if(s > 0)
        {
            buffer[s] = '\0';
            cout << "client send# " << buffer << endl;
        }
        else{
            cout << "read error" << endl;
            perror("read");
            break;
        }
    }
    close(fd);
    //删除管道文件
    unlink(ipcPath.c_str());
    return 0;
}

client.cc

#include "common.hpp"
int main()
{
    //打开管道文件,服务端一定已经创建好管道文件了
    int fd = open(ipcPath.c_str(),O_WRONLY);
    assert(fd >= 0);

    string buffer;
    while(true)
    {
        cout << "请输入要发送给服务端的信息# ";
        getline(cin,buffer);
        write(fd,buffer.c_str(),buffer.size());
    }
    close(fd);
    return 0;
}

image.png

image.png

共享内存

原理

os在内存里申请一份空间,将这个空间经过通信进程的页表,映射到共享区的地址

返回映射到共享区的起始地址,该虚拟地址经过页表映射,就访问到了同一块内存资源。

image.png

内核中可能存在多对进程,都在用共享内存通信 —— 先描述,再组织,将它管理起来

共享内存 = 共享内存块 + 对应共享内存的内核数据结构

image.png

代码

共享内存的创建

image.png

image.png

1 size_t size:要创建的共享内存的大小

共享内存的大小,最好是页的整数倍(PAGE:4096byte)

os管理物理内存时,页的大小是以4KB为单位的(4096byte)

如果申请了4097byte,os在底层会申请4096*2byte的空间,剩下的4095byte浪费了

inux文件系统模块和磁盘进行IO的基本单位是4KB

2 int shmflg:

(1) IPC_CREAT单独使用

创建共享内存,如果已经存在,获取并返回;如果不存在,就创建并返回

(2) IPC_EXCL单独使用无意义,需要和IPC_CREAT配合

如果底层不存在,创建并返回;底层存在,出错返回

IPC_EXCL | IPC_CREAT 返回成功,一定是全新的共享内存

(3) 共享内存刚创建出来,默认没有进程有权限使用

所以刚创建时需要写明权限perms

int shmid = shmget(key,SHM_SIZE,IPC_CREAT | IPC_EXCL | 0666);

3 返回值:返回共享内存的用户层标识符,类似于fd

4 key_t key: server&&client使用同一个key,就能看到同一个共享内存

例如:本机的两个不同进程client和server要通信,

(1) 在底层用同一套算法形成一个相同唯一值key

(2) server调用接口,在底层创建共享内存;

(3) shmget会把key值写到描述共享内存的结构体里

(4) 然后client拿着这个数字在底层找到匹配的共享内存,找到返回标识该共享内存的标识符

image.png

ftok内部是一套算法,将一个路径和数字组合形成一个唯一值.

拿文件的inode编号和数字进行一些运算,形成随机值

不一定会创建成功,因为key必须唯一标识一个共享内存

common.hpp

#pragma once
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <assert.h>
#define PATH_NAME "/home/lyh"
#define PROJ_ID 11
#define SHM_SIZE 1024
using std::endl;
using std::cin;
using std::cout;

server.cc

    //1 创建公共key值
    key_t key = ftok(PATH_NAME,PROJ_ID);
    assert(key != -1);
    cout << "server key:" << key <<endl;

    //2 创建共享内存 —— 双方通信的发起者,要创建全新的共享内存
    int shmid = shmget(key,SHM_SIZE,IPC_CREAT | IPC_EXCL);
    assert(shmid != -1);

image.png 第一次运行,会创建共享内存并返回;

第二次运行,共享内存已经存在,会创建失败

当进程运行结束,共享内存仍然存在,共享内存的生命周期是随os的,需要手动释放

查看所有共享内存:ipcs -m

image.png

共享内存的删除

删除共享内存:ipcrm -m shmid

image.png

代码删除共享内存

image.png

int cmd:对共享内存执行什么操作,删除是 IPC_RMID

struct shmid_ds * buf:描述共享内存的结构体,删除设为nullptr即可

    // finally:删除共享内存
    shmctl(shmid,IPC_RMID,nullptr);

关联/去关联共享内存

image.png

创建好共享内存后,需要把共享内存映射进当前进程的进程地址空间的共享区里

const void* shmaddr:共享内存创建后,要把共享内存挂接到指定的虚拟地址处,但一般设为nullptr,表示让os帮进程挂接

shmflg:挂接方式,0表示以读写方式挂接

返回值:成功返回 挂接好的共享内存虚拟地址的起始位置(类似malloc)

    //将指定的共享内存,挂接到自己的地址空间
    char* shmaddr = (char*)shmat(shmid,nullptr,0);
    
    //将指定的共享内存,和自己的地址空间去关联
    shmdt(shmaddr);
// finally:删除共享内存(即使有进程和当下的shm挂接,依旧删除共享内存)
    shmctl(shmid,IPC_RMID,nullptr);

使用共享内存通信

共享内存一旦映射到进程地址空间,双方进程要通信,直接往虚拟地址读写即可

之前的管道都要用read和write要通信,因为 公共资源属于文件,文件是在内核的数据结构,os维护,必须通过系统调用接口才能访问。

而共享内存是创建在共享区,在用户空间,用户不需要调用任何os接口,可以直接对共享内存进行读写,所以将共享内存当成一个大字符串即可。另外,共享内存被创建,默认清0

1 通信双方,直接把共享内存当成一个char数组,直接写入

2 共享内存是所有进程间通信IPC速度最快的

管道:至少有两次拷贝,需要调用write将数据写到struct file的内核缓冲区中,还要调用read从缓冲区中读取数据

而共享内存的一方将数据拷贝到共享内存,另一方直接看到共享内存的数据

image.png

server.cc

#include "common.hpp"
int main()
{
    //1 创建公共key值
    key_t key = ftok(PATH_NAME,PROJ_ID);
    assert(key != -1);
    cout << "server key:" << key <<endl;

    //2 创建共享内存 —— 双方通信的发起者,要创建全新的共享内存
    int shmid = shmget(key,SHM_SIZE,IPC_CREAT | IPC_EXCL | 0666);
    assert(shmid != -1);

    //3 将指定的共享内存,挂接到自己的地址空间
    char* shmaddr = (char*)shmat(shmid,nullptr,0);

    //4 通信逻辑
    while(true)
    {
        printf("%s\n",shmaddr);
        if(strcmp(shmaddr,"quit") == 0)break;
        sleep(1);
    }
    //5 将指定的共享内存,和自己的地址空间去关联
    shmdt(shmaddr);
    //finally:删除共享内存(即使有进程和当下的shm挂接,依旧删除共享内存)
    shmctl(shmid,IPC_RMID,nullptr);
    return 0;
}

client.cc

#include "common.hpp"
int main()
{
    //1 创建唯一的key
    key_t key = ftok(PATH_NAME,PROJ_ID);
    assert(key != -1);
    //2 获取服务端创建的共享内存
    int shmid = shmget(key,SHM_SIZE,IPC_CREAT);
    assert(shmid != -1);
    //3 将当前进程的地址空间和共享内存关联
    char* shmaddr =(char*)shmat(shmid,nullptr,0);
    assert(shmaddr != nullptr);

    //4 通信逻辑
    //client将共享内存看作一个char类型的buffer
    while(true)
    {
        cout << "请输入发送给服务端的数据# ";
        cin >> shmaddr;
        if(strcmp(shmaddr,"quit") == 0)break;
    }

    //5 将当前进程的地址空间和共享内存去关联
    shmdt(shmaddr);
    return 0;
}

image.png

image.png

3 如果管道里没数据,读端会阻塞;管道满了,写端会阻塞 —— 有访问控制

但是共享内存没有访问控制.无论共享内存有无数据,读端都可以一直读,写端也可以一直写

用管道帮助共享内存实现简单的访问控制:

客户端写数据期间,服务端必须一直等待;

当客户端发送数据时,唤醒服务端,让服务端读取,读取完继续等待;

common.hpp

#pragma once
#include <iostream>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>

#define PATH_NAME "/home/lyh"
#define PROJ_ID 11
#define SHM_SIZE 4096 
#define PIPE_FILE "./pipe.ipc"
using std::endl;
using std::cin;
using std::cout;

class Init{
public:
    Init()
    {
        umask(0);
        mkfifo(PIPE_FILE,0666);
    }
    ~Init()
    {
        unlink(PIPE_FILE);
    }
};

//管道里写入或读取的数据不重要,重要的是管道的阻塞机制
int openPIPE(int flag)
{
    return open(PIPE_FILE,flag);
}
void wait(int fd)
{
    cout << "正在等待"<<endl;
    uint32_t tmp = 0;
    ssize_t s = read(fd,&tmp,sizeof(tmp));
}
void wake(int fd)
{
    cout << "开始唤醒"<<endl;
    uint32_t tmp = 1;
    write(fd,&tmp,sizeof(tmp));
}
void closePIPE(int fd)
{
    close(fd);
}

server.cc

#include "common.hpp"
Init init;
int main()
{
    //1 创建公共key值
    key_t key = ftok(PATH_NAME,PROJ_ID);
    assert(key != -1);
    cout << "server key:" << key <<endl;

    //2 创建共享内存 —— 双方通信的发起者,要创建全新的共享内存
    int shmid = shmget(key,SHM_SIZE,IPC_CREAT | IPC_EXCL | 0666);
    assert(shmid != -1);

    //3 将指定的共享内存,挂接到自己的地址空间
    char* shmaddr = (char*)shmat(shmid,nullptr,0);

    //打开管道文件
    int fd = openPIPE(O_RDONLY);
    assert(fd >= 0);
    //4 通信逻辑
    while(true)
    {
        //等待
        wait(fd);

        printf("%s\n",shmaddr);
        if(strcmp(shmaddr,"quit") == 0)break;
        sleep(1);
    }
    closePIPE(fd);
    //5 将指定的共享内存,和自己的地址空间去关联
    shmdt(shmaddr);
    //finally:删除共享内存(即使有进程和当下的shm挂接,依旧删除共享内存)
    shmctl(shmid,IPC_RMID,nullptr);
    return 0;
}

client.cc

#include "common.hpp"
int main()
{
    //1 创建唯一的key
    key_t key = ftok(PATH_NAME,PROJ_ID);
    assert(key != -1);
    //2 获取服务端创建的共享内存
    int shmid = shmget(key,SHM_SIZE,IPC_CREAT);
    assert(shmid != -1);
    //3 将当前进程的地址空间和共享内存关联
    char* shmaddr =(char*)shmat(shmid,nullptr,0);
    assert(shmaddr != nullptr);

    //4 通信逻辑
    int fd = openPIPE(O_WRONLY);
    while(true)
    {
        cout << "请输入发送给服务端的数据# ";
        cin >> shmaddr;
        wake(fd);
        if(strcmp(shmaddr,"quit") == 0)break;
    }

    //5 将当前进程的地址空间和共享内存去关联
    shmdt(shmaddr);
    closePIPE(fd);
    return 0;
}

注意:不同的两个终端下的进程

image.png

image.png