【linux】进程间通信

前言

.cpp、.cc和.cxx都是用来表示C++文件的!
三者没有区别

    //纯数字没有任何意义,必须有类型才有意义
    //int a =10;编译器推导+隐式类型转换
    //100;字面值
    //10u 无符号整数
    //10L
    //10.0f
    //10

1、三个问题

1-1、什么是通信?

数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止 时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

以上4点都是进程间通信

1-2、为什么要有通信

我们到目前为止学习的代码都是单进程的,不管是C++还是linux都是一个进程。但是,我们一定会遇到多个进程协同完成某种业务(场景),这个时候就需要由通信来处理

cat file | grep 'hello'

1-3、怎么进行通信?

管道——基于文件系统
System V进程间通信——只能在本地通信,也就是同一台机器
POSIX进程间通信——可以跨主机通信

1-4、进程间通信分类

管道

匿名管道
pipe 命名管道

System V IPC

System V 消息队列
System V 共享内存
System V 信号量

POSIX IPC

消息队列
共享内存
信号量
互斥量
条件变量
读写锁

上面就是我们进行通信的方法(我们这里学习的通信方法是主流的),但是由于System V进程间通信只能在本地进行通信,所以我们只学习一部分该内容,主要学习的还是POSIX进程间通信

2、管道

什么是管道

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

在这里插入图片描述

管道分为:匿名管道和命名管道
我们先来学习一下匿名管道,学完匿名管道之后,命名管道就很简单了

这里要注意:有人问为什么管道不是双向的。管道管道,既然叫管道双向不是更好吗?

这是因为:早期科学家先研究出管道这项技术,然后再取名为管道。先有技术,后有名字。所以,管道是单向的!
最简单的例子:家里的水龙头,只会从水源地向家里来水.

2-1、匿名管道

我们知道父进程fork创建子进程处了task_struct(进程控制块PCB)会拷贝以外,struct_files_struct(叫做用户打开文件表)也会被拷贝。这些拷贝的数据是内核数据结构,但是文件是属于文件系统的,他不会进行拷贝。所以父子进程的文件描述符表都指向同一个文件

在这里插入图片描述

2-1-1、理解通信本质问题

我们两个进程a、b进行通信,一定要有一块空间是我们两个进程都能够找到/看到的,不然通信没有办法完成。这个进程既不属于a也不属于b,因为不管空间属于谁,都属于该进程的私有空间。但是,进程具有独立性,所以不能与另一个进程进行通信
那么,这个空间只能由OS来提供,这样就可以进行通信了!

结论1:OS需要直接或者间接给通信双方/多方进程提供“内存空间”
结论2:要通信的进程必须看到一份公共资源(这份公共资源由OS提供)
结论3:不同的通信种类:本质上就是:公共资源是由哪一个模块提出来的!
举个例子:结论2的公共资源由操作系统中的文件系统提供,那么就叫做管道通信;如果是System V和POSIX提供的,那么就叫做System V或者POSIX进程间通信!

这就是为什么通信成本不低的原因。要进行通信,先要做好两件事情:

1、让通信的所有进程看到同一份资源(OS内部的不同模块提出,就形成不同的进程间通信),因为进程具有独立性,再进程内部创建公共资源,其他进程看不到!!!
2、进行通信

所以,进行小结:
在这里插入图片描述

这种基于文件系统的管道,就叫做管道文件。管道文件是一个内存级的文件。管道文件是进程在内存里面就进行了通信,速度很快。如果采用普通文件,把数据写到磁盘,再从磁盘读出来,这就很慢了
我们总是把磁盘文件加载到内存,然后操作系统创建struct_file对象,再形成对应的内核缓冲区…
但是,OS就算不打开磁盘内部的文件,也是可以生成struct_file结构体对象,然后形成内核缓冲区的!所以,管道是不需要进行磁盘刷新的! 我们说的管道文件是种内存级文件!

那么,我们任何让两个进程看到同一个管道文件呢?

1、父进程打开一个文件
2、fork创建子进程
但是,我们这种管道文件是没有起名字的!(父子进程不是通过文件名来查找的)。所以这类内存级管道文件就叫做匿名文件
在这里插入图片描述

 #include <unistd.h>

功能:创建一无名管道

原型
int pipe(int fd[2]);

参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码

在这里插入图片描述

2-1-2、进一步理解管道

站在文件描述符角度-深度理解管道

在这里插入图片描述

这里父进程打开文件必须是分别以读和写两种方式打开文件!因为父进程只有一种操作,子进程继承父进程也只有一种操作,这就不能称之为管道了。
然后,我们要分别关闭父子进程不需要的操作端口,比如父进程只负责写,子进程只负责读,那么就关闭父进程的读端和子进程的写端。具体场景具体操作。
父子进程都不关端口也是可以的,但是,我们所没有关闭的端口可能被其他人使用,那样就会产生危害了!

到目前为止我们也得出来了小结论:

匿名管道:能够用来进行父子进程之间进行进程间通信(具有血缘关系的进程都可以采用匿名管道)

2-1-3、代码实现

pipe函数

pipe 创建一个管道
在这里插入图片描述

在这里插入图片描述

所以,要使用管道,调用一些pipe函数接口就行

样例:

#include <iostream>
#include <unistd.h>
#include <cassert>
//#include <assert.h>//对比于assert.h和stdio.h我们写成cassert和cstdio更好——去掉.h,在头文件前面加上c

using namespace std;
int main()
{
    int fds[2];
    int ret = pipe(fds);
    assert(ret==0);
    //0,1,2是输入输出错误流,那pipe的结果哪一个是读,哪一个是写呢?
    cout<<"fds[0]"<<fds[0]<<endl;
    cout<<"fds[1]"<<fds[1]<<endl;
    return 0;
}

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
接下来我们继续拓展:

#include <iostream>
#include <unistd.h>//#include <assert.h>//对比于assert.h和stdio.h我们写成cassert和cstdio更好——去掉.h,在头文件前面加上c
#include <cstdio>
#include <cstring>
#include <string>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;
int main()//父进程读取,子进程写入
{
    //1、创建管道,打开读写端
    int fds[2];
    int ret = pipe(fds);
    assert(ret==0);

    //2、fork子进程
    pid_t id = fork();
    assert(id >= 0);
    const char *s = "我是子进程,我正在给你发消息";
    int cnt=0;
    if(id==0)//子进程
    {
        //子进程通信代码
        close(fds[0]);
        while(1)
        {
            char buffer[1024];
            ++cnt;
            snprintf(buffer,sizeof (buffer),"child->parent say: %s[%d][%d]",s,cnt,getpid());
            write(fds[1],buffer,strlen(buffer));//不考虑\0作为结尾
            sleep(1);//每隔1s向管道写一次
        }
        close(fds[1]);
        exit(-1);
    }
    //父进程通信代码
    close(fds[1]);
    while(1)
    {
        char buffer[1024];
        ssize_t n = read(fds[0],buffer,sizeof(buffer)-1);
        if(n>0)  
        	buffer[n]=0;
        cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;
    }
    ret = waitpid(id,nullptr,0);
    assert(ret == id);
    close(fds[0]);
    // 0,1,2是输入输出错误流,那pipe的结果哪一个是读,哪一个是写呢?
    // [0]: 读取,嘴巴,读书的
    // [1]: 写入,钢笔,写的
    // cout << "fds[0]" << fds[0] << endl;
    // cout << "fds[1]" << fds[1] << endl;
    return 0;
}

在这里插入图片描述
这就叫做管道(匿名管道)

2-1-4、读写特征

上面例子的改进:

#include <iostream>
#include <unistd.h> //#include <assert.h>//对比于assert.h和stdio.h我们写成cassert和cstdio更好——去掉.h,在头文件前面加上c
#include <cstdio>
#include <cstring>
#include <string>
#include <cassert>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;
int main() // 父进程读取,子进程写入
{
    // 1、创建管道,打开读写端
    int fds[2];
    int ret = pipe(fds);
    assert(ret == 0);

    // 2、fork子进程
    pid_t id = fork();
    assert(id >= 0);
    const char *s = "我是子进程,我正在给你发消息";
    int cnt = 0;
    if (id == 0) // 子进程
    {
        // 子进程通信代码
        close(fds[0]);
        while (1)
        {
            char buffer[1024];
            ++cnt;
            snprintf(buffer, sizeof(buffer), "child->parent say: %s[%d][%d]", s, cnt, getpid());
            write(fds[1], buffer, strlen(buffer)); // 不考虑\0作为结尾
            // sleep(1);//每隔1s向管道写一次
            // sleep(5);//5s写一次
            cout << "count: " << cnt << endl; // 统计缓冲区的容量
            // sleep(50);
            // break;//写入一行消息,直接退出写端
        }
        close(fds[1]); // 退出写端,关闭文件描述符
        cout << "子进程关闭自己的写端" << endl;
        //sleep(10000);
        exit(-1);
    }
    // 父进程通信代码
    close(fds[1]);
    while (1)
    {
        sleep(2);//写端写一行数据退出,关闭文件描述符。读端等待两秒,读取到0就退出
        char buffer[1024];
        // cout << "AAAAAAAAAAAAAAAAAAAAAA" << endl;
        // 如果管道中没有了数据,读端在读,默认会直接阻塞当前正在读取的进程!
        ssize_t n = read(fds[0], buffer, sizeof(buffer) - 1); // 写入速度慢,读取会卡在read这里,等待写端继续写入
        // cout << "BBBBBBBBBBBBBBBBBBBBBB" << endl;
        if (n > 0)
        {
            buffer[n] = 0;
            cout << "Get Message# " << buffer << " | my pid: " << getpid() << endl;
        }
        else if (n == 0)//写端关闭
        {
            // 读到文件结尾
            cout << "read: " << s << endl;
            break;
        }
        //else
        //{
            break;//写端一直写,读端关闭
        //}
    }
    close(fds[0]);//提前关闭文件描述符
    cout << "父进程关闭读端" << endl;
    int status = 0;
    ret = waitpid(id, &status, 0);
    assert(ret == id);
    cout <<"pid->"<< ret << " : "<< (status & 0x7F) << endl;

    // ret = waitpid(id, nullptr, 0);
    // assert(ret == id);
    // close(fds[0]);
    // 0,1,2是输入输出错误流,那pipe的结果哪一个是读,哪一个是写呢?
    // [0]: 读取,嘴巴,读书的
    // [1]: 写入,钢笔,写的
    // cout << "fds[0]" << fds[0] << endl;
    // cout << "fds[1]" << fds[1] << endl;
    return 0;
}

根据上面的例子,加以改进,我们可以得出结论:

1、读取速度慢,写入速度快——写端直接写满管道(管道是有大小的),发送写端阻塞,等待读端读取(读端一次性读取1024字节,不是一行一行读取的)(读慢,写快)

2、读取速度快,写入速度慢——读取会在read处阻塞等待,等待写端写入,然后进行读取(由于写端过慢,所以写端写一行,读端就读一行)(读快,写慢)

3、写入操作关闭,读取读到0——写端关闭了,读端读取完管道剩余数据,读到0就停止(写关闭,读到0)

4、读取操作关闭,OS会终止写端(给进程发送信号,终止写端)

2-1-5、管道的特点(重点)

1、管道的生命周期随进程退出而销毁。管道基于文件而产生,随着打开文件的进程关闭而销毁
2、管道可以使具有血缘关系的进程进行通信,常用于父子进程
3、管道是面向字节流的(网络知识)——比如:我们写了很多数据,但是读取的时候,读端不管我们写入数据的类型(字符、字符串或者其他格式),只按照读端最大读取字节数来读
4、半双工——单向通信(特殊概念)。数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
5、互斥与同步机制——对共享资源进行保护的一种方案

这个机制进程是没有的,因为进程具有独立性,所以互相不知道有对方的存在。
但是对于管道来说,读端读完了等写端写继续读,写端写满了等读端读完了继续写,好像管道的读端和写端都很照顾对方,这就是互斥与同步机制

所以

sleep 1000 | sleep 2000

这两个是兄弟进程!

2-1-6、基于匿名管道的设计(重点)

任务:我们将我们的任务均衡的下发/分配给每一个子进程,让子进程进行负载均衡的操作(单机版)

简单来说就是:父进程随机给任意一个子进程,发送任意的任务,然后子进程执行

在这里插入图片描述

#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstdlib>
#include <cassert>
#include <ctime>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

using namespace std;

#define MakeRand() srand((unsigned long)time(nullptr) ^ getpid() ^ 0x171237 ^ rand() % 1234) // 获取随机数
#define C_NUM 5	//创建子进程个数
typedef void (*func_t)(); // 函数指针类型

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> *out)
{
    assert(out); // 判断数组不为空,也就是任务表不能为空
    out->push_back(downLoadTask);
    out->push_back(ioTask);
    out->push_back(flushTask);
} ///

class SubEp // 我们将这个结构体放到数组里面,那么父进程通过任意管道对进程的操作就变成了对数组下标对应的操作
{
public:
    SubEp(pid_t subId, int writeFd)
        : subId_(subId), writeFd_(writeFd)
    {
        char my_buffer[1024];
        snprintf(my_buffer, sizeof(my_buffer), "process-%d[pid(%d)-fd(%d)]", num++, subId_, writeFd_); // 查看num的值,子进程pid和文件描述符
        name_ = my_buffer;
    }

public: // 这里为了方便正文代码部分的调用,就把成员变量设置为public的
    static int num;
    std::string name_; // 进程的名字
    pid_t subId_;      // 子进程pid
    int writeFd_;      // 管道的写端,很重要,父进程靠写端给子进程发消息
};
int SubEp::num = 0;
// 这里定义num是因为我们创建子进程的时候,子进程的id值是波动的,导致我们这里为进程取名字的时候每次都不一样,所以加个num,每次初始化为0,然后++保证创建的name是可以一样的

int recvtask(int readfd)
{
    int code = 0;
    int n = read(readfd, &code, sizeof(code));
    if (n == sizeof(int))
        return code;
    else if (n <= 0)
        return -1;
    else
        return 0;
}

void SendTask(const SubEp &my_id, int fm_num) // 将任务发送给进程
{
    cout << "send task num: " << fm_num << " send to -> " << my_id.name_ << endl;
    int num = write(my_id.writeFd_, &fm_num, sizeof(fm_num));
    assert(num == sizeof(int));
    (void)num;
}

void creatSubPoints(std::vector<SubEp> *subs, std::vector<func_t> &funcMap)
{
    for (int i = 0; i < C_NUM; i++)
    {
        int fds[2];        // 获取文件描述符,fds[0]是读端,fds[1]是写端
        int n = pipe(fds); // 创建匿名管道
        assert(n == 0);
        (void)n;           // 这里是因为debug模式下assert生效,但是在release模式下assert失效了。这个时候n没有被使用就报警告,变量没有被使用,这里强转是表示使用了变量,避免警告
        pid_t id = fork(); // 创建子进程
        if (id == 0)
        {
            // 子进程, 进行处理任务
            close(fds[1]); // 子进程关闭写端
            while (true)   // 子进程一直做下面的事情
            {
                // 1、子进程要获取命令码,如果没有发送,应该阻塞
                int my_code = recvtask(fds[0]); // 从读端获取命令码
                // 2、完成任务
                if (my_code >= 0 && my_code < funcMap.size())
                    funcMap[my_code]();
                else if (my_code == -1 || my_code == 0) // 其实这里不可能等于0
                    break;
                // else
                // cout << "my_code error!" << endl;
            }
            exit(0);
        }
        close(fds[0]);
        SubEp sub(id, fds[1]); // 这里创建对象
        subs->push_back(sub);  // 将创建好的对象尾插到数组里面,方便正文进行操作
    }
}

void MyidLoadBash(const std::vector<SubEp> &subs, const std::vector<func_t> &funcMap, int count)
{
    int Id_num = subs.size(); // 子进程个数
    int fm_num = funcMap.size();
    bool quit = (count == 0) ? true : false;
    while (true)
    {
        // 1、选择一个进程——> std::vector<SubEp> ——> index下标
        int subidx = rand() % Id_num;
        // 2、选择一个任务——> std::vector<func_t> ——> index下标
        int fmidx = rand() % fm_num;
        // 3、将任务发给进程
        SendTask(subs[subidx], fmidx); // 将任务fmidx发送给数组中的具体进程
        sleep(1);
        if (!quit)
        {
            --count;
            if (count == 0)
                break;
        }
    }
    // write quit -> read 0
    for (int i = 0; i < Id_num; i++)
        close(subs[i].writeFd_); // 关闭父进程读端,进行waitpid();
}

void waitMyid(std::vector<SubEp> Myid)
{
    int num = Myid.size();
    for (int i = 0; i < num; ++i)
    {
        waitpid(Myid[i].subId_, nullptr, 0);
        cout << "wait sub process success ...: " << Myid[i].subId_ << endl;
    }
}

int main()
{
    MakeRand(); // 生成随机数
    // 1. 建立子进程并建立和子进程通信的信道, 这里是有bug的,但是不影响我们后面编写,我们代码写完了然后讲这个bug
    // 1.1加载方发表
    std::vector<func_t> funcMap; // 创建一个表格,表格里面存放着我们要执行的任务
    LoadTaskFunc(&funcMap);
    // 1.2 创建子进程,并且维护好父子通信信道
    std::vector<SubEp> subs;        // 这样父进程通过管道对子进程发送消息就简化成为了对数组下标的处理
    creatSubPoints(&subs, funcMap); // 将创建管道和进程封装为一个函数接口,然后把任务表也加载过去

    // 2. 走到这里就是父进程, 控制子进程
    int Count = 3; // 0: 永远进行
    MyidLoadBash(subs, funcMap, Count);

    // 3. 回收子进程信息
    waitMyid(subs);
    return 0;
}

在这里插入图片描述
我们上面说了,这段代码是有bug的,但是bug不会影响我们上面的代码,接下来我们就来讲述一下这个bug

在这里插入图片描述

大家动手画一下就明白了,简单来说就是:

父进程每次创建一个新的子进程,新的子进程就会继承前面所有子进程的写端!

我们上面的处理方法本质上是倒着关闭子进程的写端,接下来就来看看如何正着来关闭写端:

void creatSubPoints(std::vector<SubEp> *subs, std::vector<func_t> &funcMap)
{
    std::vector<int> deleteFd;///这个数组存放着上一个子进程的写端
    for (int i = 0; i < C_NUM; i++)
    {
        int fds[2];        // 获取文件描述符,fds[0]是读端,fds[1]是写端
        int n = pipe(fds); // 创建匿名管道
        assert(n == 0);
        (void)n;           // 这里是因为debug模式下assert生效,但是在release模式下assert失效了。这个时候n没有被使用就报警告,变量没有被使用,这里强转是表示使用了变量,避免警告
        pid_t id = fork(); // 创建子进程
        if (id == 0)
        {
            for(int i = 0; i < deleteFd.size(); i++) close(deleteFd[i]);//清除上一个子进程的读端
            // 子进程, 进行处理任务
            close(fds[1]); // 子进程关闭写端
            while (true)   // 子进程一直做下面的事情
            {
                // 1、子进程要获取命令码,如果没有发送,应该阻塞
                int my_code = recvtask(fds[0]); // 从读端获取命令码
                // 2、完成任务
                if (my_code >= 0 && my_code < funcMap.size())
                    funcMap[my_code]();
                else if (my_code == -1 || my_code == 0) // 其实这里不可能等于0
                    break;
                // else
                // cout << "my_code error!" << endl;
            }
            exit(0);
        }
        close(fds[0]);
        SubEp sub(id, fds[1]); // 这里创建对象
        subs->push_back(sub);  // 将创建好的对象尾插到数组里面,方便正文进行操作
        deleteFd.push_back(fds[1]);/将该进程的写端存放到数组里面
    }
}

2-2、命名管道

有了上面匿名管道的基本知识,命名管道学起来十分简单!

命名管道就可以让两个毫不相干的进程进行交互

在这里插入图片描述

2-2-1、创建一个命名管道

命名管道可以从命令行上创建,命令行方法是使用下面这个命令:

$ mkfifo filename

在这里插入图片描述

命名管道也可以从程序里创建,相关函数有:

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

创建命名管道:

int main(int argc, char *argv[])
{
 	mkfifo("p2", 0644);
 	return 0;
}

2-2-2、匿名管道与命名管道的区别

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

2-2-3、命名管道的打开规则

如果当前打开操作是为读而打开FIFO时

O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
O_NONBLOCK enable:立刻返回成功

如果当前打开操作是为写而打开FIFO时

O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO O_NONBLOCK
enable:立刻返回失败,错误码为ENXIO

2-2-4、命名管道的样例

makefile:

.PHONY:all
all:server client

server:server.cc
	g++ -o $@ $^ -std=c++11 -g
client:client.cc
	g++ -o $@ $^ -std=c++11 -g

.PHONY:clean
clean:
	rm -f server client

comm.hpp:

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define NAMED_PIPE "/tmp/mypipe.106"

bool createFifo(const std::string &path)
{
    umask(0);
    int n = mkfifo(path.c_str(), 0600);
    if (n == 0)
        return true;
    else
    {
        std::cout << "errno: " << errno << " err string: " << strerror(errno) << std::endl;
        return false;
    }
}

void removeFifo(const std::string &path)
{
    int n = unlink(path.c_str());
    assert(n == 0); // debug , release 里面就没有了
    (void)n;
}

client.cc:

#include "comm.hpp"

int main()
{
    std::cout << "client begin" << std::endl;
    int wfd = open(NAMED_PIPE, O_WRONLY);
    std::cout << "client end" << std::endl;
    if(wfd < 0) exit(1); 

    //write
    char buffer[1024];
    while(true)
    {
        std::cout << "Please Say# ";
        fgets(buffer, sizeof(buffer), stdin); // abcd\n
        if(strlen(buffer) > 0) buffer[strlen(buffer)-1] = 0;//去掉我们输入时敲的回车
        ssize_t n = write(wfd, buffer, strlen(buffer));
        assert(n == strlen(buffer));
        (void)n;
    }

    close(wfd);
    return 0;
}

server.cc:

#include "comm.hpp"

int main()
{
    bool r = createFifo(NAMED_PIPE);
    assert(r);
    (void)r;

    std::cout << "server begin" << std::endl;
    int rfd = open(NAMED_PIPE, O_RDONLY);
    std::cout << "server end" << std::endl;
    if(rfd < 0) exit(1);

    //read
    char buffer[1024];
    while(true)
    {
        ssize_t s = read(rfd, buffer, sizeof(buffer)-1);
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << "client->server# " << buffer << std::endl;
        }
        else if(s == 0)
        {
            std::cout << "client quit, me too!" << std::endl;
            break;
        }
        else
        {
            std::cout << "err string: " << strerror(errno) << std::endl;
            break;
        }
    }

    close(rfd);

    // sleep(10);
    removeFifo(NAMED_PIPE);
    return 0;
}

这样我们在client下面输入就能够打印到server界面中

3、system V共享内存

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

3-1、共享内存的原理

在这里插入图片描述

第一步:申请一块物理内存空间(共享内存)
第二步:将创建好的内存分别映射到多个进程的进程地址空间(进程和共享内存挂接)
第三步:取消进程和内存的映射关系(去关联),并且释放内存(释放共享内存)

理解:

1、进程间通信是专门设计的(说明有专门的接口),用来IPC(进程间通信的简称)。C语言的malloc等操作只能让自己看到堆区上面的空间,不能让其他人看到同一份空间,所以行不通。
2、共享内存是一种通信方式,所有想通信的进程都可以用
3、OS中一定会存在很多的共享内存

在这里插入图片描述

共享内存示意图
在这里插入图片描述

3-2、共享内存的概念

通过让不同的进程,看到同一个内存块的方式,就叫做:共享内存

3-3、认识共享内存的接口

linux老套路了,先把makefile给写好

makefile:

//我们需要用到shm_client.cc和shm_server.cc两个文件,然后编译链接生成shm_client shm_server
.PHONY:all
all:shm_client shm_server

shm_client:shm_client.cc
	g++ -o $@ $^ -std=c++11
shm_server:shm_server.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f shm_client shm_server

我们搜先需要创建一份内存

3-3-1、shmget接口

在这里插入图片描述

我们一一介绍这些参数:

shmflg:最常用的选项有两个
如果shmflg给0,那么就表示选项IPC——CREAT

在这里插入图片描述

size:共享内存的大小

返回值:shmget操作成功之后,会返回一个共享内存的标识符,这个标识符就是一个数字,这个数字就是数组的下标。但是,在不同的操作系统下这个数组下标是不同的。并且这个标识符与文件是两套不同的,所以我们用这个很少,我们只需要知道,将这个数字当做标识符就可以了,未来我们想要对共享内存做一些操作,通过这个标识符就能够完成了

key:我们在调用完shmget接口之后,能够得到一个标识符,通过这个标识符才能够判断两个进程是不是看到的是同一块共享内存,但是在没有调用完shmget之前,我们怎么能够保证两个或者多个进程看到的是同一块共享内存呢?
所以,key是什么不重要,重要的是,它能够进行唯一标识性最重要(和人的身份证号码一样,号码是多少其实没有什么用,重要的是号码是唯一的,它代表你这个人)!

3-3-2、ftok接口

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

key_t ftok(const char *pathname, int proj_id);
pathname:路径
proj_id:项目标识符,也就是项目id

将路径名和项目标识符(也就是项目id)进行整合,然后转化为一个IPC的key,也就是一个唯一的数字,然后将这个数字返回

所以,ftok接口生成一个唯一的数字(标识符),然后返回给shmget函数中的key

那么,是不是我们pathname和proj_id两个参数一样,就能够得到一模一样的key,key一样shmget返回的标识符(数字)也就一样,这就可以确定多个进程看到的是同一份共享内存了!!!

那么我们先来见见猪跑:
comm.hpp:

#ifndef _COMM_HPP_
#define _COMM_HPP

#include<iostream>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<cerrno>
#include<cstring>
#include<cstdlib>
using namespace std;
using std::cout;
using std::endl;

#define PATHNAME "." //确定多个进程采用相同的路径
#define PROJ_ID 0x55 //确定多个进程采用相同的项目id(标识符)

key_t GetKey()
{
    key_t k = ftok(PATHNAME,PROJ_ID);
    if(k < 0)
    {
        //cin,cout,cerr -> stdin,stdout,stderr -> 0,1,2
        //strerror将错误码转化为错误码的描述
        cerr << errno << ":" << strerror(errno) << endl;//像文件标识符2打印,也就是标准错误输出
        exit(1);
    }
    return k;
}

#endif

shm_server.cpp:

#include "comm.hpp"

int main()
{
    key_t k = GetKey();
    printf("0x%x\n",k);
    return 0;
}

shm_client.cpp:

#include "comm.hpp"

int main()
{
    key_t k = GetKey();
    printf("0x%x\n",k);
    return 0;
}

在这里插入图片描述

3-3-3、再谈key

我们C语言的malloc开辟堆空间会多开辟一些空间,这些空间用来记录此次开辟空间的一系列数据,方便os做管理
那么同理,我们的共享内存也是需要被os管理起来的!先描述,再组织

共享内存 = 物理内存块 + 共享内存的相关属性

那么,我们在创建共享内存的时候,怎么保证共享内存在系统中是唯一的呢?
这就要用到key值了

我们只要保证其他进程看到同一个key值,那么就可以保证多个进程看到的是同一个共享内存了

那么key值在什么地方呢?

struct shm{
	key_t k;//k在某一个共享内存的相关属性里面,我们只需要查找到key即可
}

key我们创建出来了,是要设置进共享内存相关属性当中的!用来表示该共享内存在内核中的唯一性
这里key和shmid,为什么有了key还要shmid呢?直接返回key不行吗?与前面的fd和inode一样
是为了宏观上面的解耦。举个例子,我们都有自己的身份证号,为什么到了学校不用身份证号而是用名字,为什么到了企业用的是工号,而不是名字。就是为了不全部耦合在一起,不会互相产生影响
所以,底层用key,上层用shmid,这样没有强耦合,操作系统将底层代码等数据修改了不会影响上层数据

3-3-4、补充接口

shmget函数

功能:用来创建共享内存
原型

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

参数

 key:这个共享内存段名字

 size:共享内存大小

 shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的
返回值:成功返回一个非负整数,即该共享内存段的标识码;失败返回-1

shmat函数

功能:将共享内存段连接到进程地址空间
原型

 void *shmat(int shmid, const void *shmaddr, int shmflg);

参数

 shmid: 共享内存标识

 shmaddr:指定连接的地址

 shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY

返回值:成功返回一个指针,指向共享内存第一个节;失败返回-1


shmaddr为NULL,核心自动选择一个地址
shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。公式:shmaddr - 
(shmaddr % SHMLBA)
shmflg=SHM_RDONLY,表示连接操作用来只读共享内存

shmdt函数

功能:将共享内存段与当前进程脱离
原型

 int shmdt(const void *shmaddr);

参数

 shmaddr: 由shmat所返回的指针
返回值:成功返回0;失败返回-1

注意:将共享内存段与当前进程脱离不等于删除共享内存段

shmctl函数

这里的控制包括了删除等一系列操作

功能:用于控制共享内存
原型

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

参数

 shmid:由shmget返回的共享内存标识码

 cmd:将要采取的动作(有三个可取值)

 buf:指向一个保存着共享内存的模式状态和访问权限的数据结构
返回值:成功返回0;失败返回-1

在这里插入图片描述

3-4、IPC的特点(system V版本进程间通信特点)

在这里插入图片描述
IPC的特点:共享内存的生命周期是随os的,不是随进程的!,这是所有system v版本通信的特性

查看IPC资源:
ipcs -m/-q/-s

删除共享内存:
ipcrm -m shmid


 ipcs 查看进程间通信资源/ipcrm 删除进程间通信资源
  -m 针对共享内存的操作
  -q 针对消息队列的操作
  -s 针对信号量的操作
  -a 针对所有资源的操作

在这里插入图片描述

3-5、共享内存特点

3-5-1、优点

所有的进程间通信速度最快,能大大减少数据拷贝次数

3-5-2、题目(小难点)

相同的代码,通过共享内存和管道,经过键盘输入,显示器输出,分别要进行几次拷贝呢?

管道:

在这里插入图片描述

共享内存:
在这里插入图片描述

共享内存直接充当了进程通信的缓冲区!!!

当然!如果考虑特殊场景,比如:键盘输入数据就是要经过缓冲区特殊处理然后输出,这样我们就必须定义一个缓冲区出来来处理数据;这样就要加一次拷贝(+1);后面也可能从缓冲区拿出来要经过特殊处理,那又要加一次拷贝了(+1).特殊场景特殊对待!

3-5-3、缺点

共享内存是没有给我们进行同步和互斥操作的(后面会讲),也就是说没有对数据进行保护

那么有没有什么方法可以避免共享内存的缺点呢?
我们可以通过共享内存+管道(匿名和命名都可以)的方法来实现

在这里插入图片描述
补充:如果s服务端读完数据,也可以建立一根管道,发送字符等数据,告诉c用户端,我已经读完了共享内存的数据了,你可以继续写了

3-6、共享内存测试代码

shm_client.cc
用户端口:

#include "comm.hpp"

int main()
{
    key_t k = GetKey();
    printf("key : 0x%x\n",k);

    int shmid = create_shm(k);
    printf("shmid : %d\n",shmid);//第一次成功之后,后面都会报文件已存在的错误

    sleep(5);

    //挂接成功
    char* start = (char*)attchshm(shmid);
    printf("attch start : %p\n",start);
    
    //进行通信
    //sleep(5);
    while(true)
    {
        //以前拿数据的方法:定义一个buffer,通过read一个个读取出来
        printf("client say : %s",start);

        //获取共享内存的内核结构!!
        struct shmid_ds ds;
        shmctl(shmid,IPC_STAT,&ds);
        printf("获取属性: size: %d, pid: %d, myself: %d, key: 0x%x",\
                ds.shm_segsz, ds.shm_cpid, getpid(), ds.shm_perm.__key);

        sleep(1);
    }

    /这里直接删除共享内存太粗暴了,我们进行去关联操作即可,剩下的操作交给os就行

    //去关联
    detachshm(start);
    sleep(10);//一定要让server最后推出,所以休眠时间长一点

    //先去关联,10s后,删除共享内存

    //删除共享内存
    delshm(shmid);//谁创建共享内存,谁就来删除
    return 0;
}

shm_server.cc
服务端口:

#include "comm.hpp"
#include <unistd.h>
int main()
{
    key_t k = GetKey();
    printf("key : 0x%x\n",k);

    int shmid = get_shm(k);
    printf("shmid : %d\n",shmid);

    //sleep(5);

    char* start = (char*)attchshm(shmid);
    //这里的start就是我们获取的共享内存,也相当于我们用户自己定义的缓冲区
    printf("attch start : %p\n",start);
    //sleep(5);


    const char* message = "hello server,my name is client,我正在和你通信!";
    pid_t id = getpid();
    int cnt = 1;
    // //以前的写法
    // char buffer[2048];
    // while(true)
    // {
    //     snprintf(buffer,sizeof(buffer),"%s[pid:%d][消息编号:%d]",message,id,cnt++);
    //     memcpy(start,buffer,strlen(buffer)+1);
    //     //将我们buffer里面准备好的消息拷贝到start里面就相当于发送了,因为server看得到
    // }
    //既然你都是共享内存了,我直接把消息打到statr里面server不就自动看到了?
    while(true)
    {
        sleep(1);
        //snprintf会给我们默认添加\n !!!!!!!!
        snprintf(start,MAX_SIZE,"%s[pid:%d][消息编号:%d]",message,id,cnt++);
    }

    detachshm(start);
    //sleep(5);

    //不需要删除,由server删除
    return 0;
}

comm.hpp
各个函数的实现:

#ifndef _COMM_HPP_
#define _COMM_HPP

#include<iostream>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<unistd.h>
#include<cerrno>
#include<cstring>
#include<cstdlib>
using namespace std;
using std::cout;
using std::endl;

#define PATHNAME "." //确定多个进程采用相同的路径
#define PROJ_ID 0x55 //确定多个进程采用相同的项目id(标识符)
#define MAX_SIZE 4096//共享内存块的大小,单位是字节

key_t GetKey()
{
    key_t k = ftok(PATHNAME,PROJ_ID);
    if(k < 0)
    {
        //cin,cout,cerr -> stdin,stdout,stderr -> 0,1,2
        //strerror将错误码转化为错误码的描述
        cerr << errno << ":" << strerror(errno) << endl;//像文件标识符2打印,也就是标准错误输出
        exit(1);
    }
    return k;
}

int get_shmflg(key_t k,int flag)
{
    int shmid = shmget(k,MAX_SIZE,flag);
    if(shmid < 0)
    {
        cerr << errno <<" : "<<strerror(errno)<<endl;
        exit(2);
    }
    return shmid;
}

int create_shm(key_t k)//创建共享内存块
{
    return get_shmflg(k,IPC_CREAT | IPC_EXCL | 0600);//这个0600是权限的意思,是ipsc -m中perms需要的
}

int get_shm(key_t k)//获取共享内存块
{
    return get_shmflg(k,IPC_CREAT);//这里也可以是0,0也表示获取。这就相当于if else if 和else,这里的0就是else,匹配获取
}

void delshm(int shmid)
{
    if(shmctl(shmid,IPC_RMID,nullptr) == -1);//删除失败
    {
        cerr << errno<<" : "<< strerror(errno) << endl;
    }
}

void* attchshm(int shmid)//挂接函数
{
    //纯数字没有任何意义,必须有类型才有意义
    //int a =10;编译器推导+隐式类型转换
    //100;字面值
    //10u 无符号整数
    //10L
    //10.0f
    //10
    void* mm = shmat(shmid,nullptr,0);//64位系统指针大小是8个字节,int是4个字节,精度丢失
    if((long long)mm < 0)
    {
        cerr << errno<<" : "<< strerror(errno) << endl;
        exit(3);
    }
    return mm;
}

void detachshm(void* start)
{
    if(shmdt(start) == -1)
    {
        cerr << errno<<" : "<< strerror(errno) << endl;
        exit(4);
    }
}

#endif

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

共享内存的大小,一般建议是4KB的整数倍
系统分配共享内存是以4KB为单位的!—— 内存划分内存块的基本单位Page
#define MAX_SIZE 4097 ———— 内核给你的会向上取整, 内核给你的,和你能用的,是两码事

所以,我们不要使用4097这种数据大小,采用4096这种整数倍大小就行

所以,我们不要使用4097这种数据大小,采用4096这种整数倍大小就行!!

1、通过key值,让不同的进程看到同一份资源,key是几不重要,只要这个k具有唯一性就可以了,形成key值的操作交给用户,让用户自己形成就行了
2、shmid是我们在用户层使用的,用shmid来作为标识符的,然后再通过key值让多个进程看到同一份资源
3、os中可能存在相当多的进程使用共享内存进行通信,所以共享内存也是要被os管理起来的(先描述,再组织),所以共享内存=共享内存空间+共享内存数据结构

bytesnattch 有时间可以研究一下

4、system V消息队列(选学了解即可)

1、消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法
2、每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值

特性方面

 	IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核

msgget——获取消息队列
msgctl——对消息队列进行一系列操作
msgsnd——用户层向消息队列放数据
msgrcv——从消息队列中读取出数据

在这里插入图片描述

在这里插入图片描述
消息队列也是要被os管理起来的,所以也要先描述,再组织,os里面也有消息队列的结构体

5、system V信号量(选学了解即可)

信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥

由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥
系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。在进程中涉及到互斥资源的程序段叫临界区

特性方面

IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核

下面的信号量是我们为后面的知识做铺垫的,我们这里system V用的少,但是后面的多线程等内容用的非常多,一定要吃透这个知识点

5-1、信号量

信号量本质是一个计数器,通常用来表示公共资源中,资源数量多少是问题

比如:一共有多少公共资源,已经使用了多少公共资源,还剩下多少公共资源等等

那么我们不能够自己定义一个全局变量cnt,然后通过这个cnt来实现信号量的功能吗?

答案是不能的

因为:

1、就算有了cnt全局变量,我们父子进程对于同一个全局变量要进行写实拷贝,我们看不到

2、在默认情况下,想让两个毫不相干的进程看到同一份数据,根本不可能。必须使用一些通信技术才可以

5-2、公共资源

什么我们提到了公共资源

公共资源:被多个进程可以同时访问的资源(比如:匿名/命名管道,system V的共享内存和消息队列)

访问没有保护的公共资源:数据不一致问题(还有其他的问题,这里就举该例)

数据不一致问题:1,2两个进程在通信的时候没有保护策略。1是发送方,2是读取方。1发送数据abcd123,在1发到一半的时候,2直接读取了,只读取到abcd,123没有读取到,这就导致了数据不一致了

5-3、深入理解(重点)

为什么我们要让不同的进程看到同一份资源呢?->因为我想要通信,实现进程间协同(进程发送,进程读取,进程通知等等操作)->因为进程具有独立性->所以我们没有办法让两个/多个进程进行通信,为了解决进程具有独立性的问题,我们引入了通信机制->通信机制的本质就是:让多个进程看到同一份资源!->所以让多个进程看到同一份资源是为了解决前面的一系列问题的->而提出了解决问题的方法,但也产生了新的问题——数据不一致问题!

所以,我们未来被保护起来的公共资源就叫做:临界资源。但是,多个进程中,有大部分资源是独立的(堆栈区资源,都是属于每个进程自己的),我们只是为了多个进程能够通信,开了一个小窗口,让多个进程能够看到罢了,这个窗口不是特别大,所以资源也不会特别多

资源(内存,文件,网络等)是要被使用的->怎么被进程使用呢?->一定是该进程内部有对应的代码来访问临界资源->而这部分访问临界资源的代码被称为:临界区!(少部分代码)->大部分代码不访问临界资源,所以代码大部分都是非临界区

多进程在通信时,本质是要看到一份公共资源,这份公共资源在未来被保护起来,就称为临界资源。我们访问临界资源的代码就叫做临界区,而没有访问临界资源的代码就叫做非临界区

举例:之前学习的管道

管道要被两个进程看到,所以管道就是一个公共资源。而管道具有自己的保护策略,所以管道就是一个临界资源!而我们未来调用read,write来访问管道,这部分代码就是临界区

那么说了这么多,我们如何保护资源呢?

方案:同步与互斥(这两个机制我们本来是要到后面讲的,这里提到了,我们先简单的了解互斥机制,后面会再次讲解的)

互斥:当有两个/多个进程来访问同一个公共资源的时候,我们只允许一个进程对该公共资源进行访问

原子性:要么不做,要么就做完,只有两种状态的情况称之为原子性!

共享资源/公共资源:

1、作为一个整体使用(电脑/管道,写满了就来读,没数据就不能读)

2、划分为一个一个的资源子部分(核酸试管/每一个进程访问公共资源的不同区域)

总结:信号量是我们未来在多进程/多线程完成原子性时的同步与互斥机制的一种解决法案

5-4、为什么要有信号量

信号量是我们未来在多进程/多线程完成原子性时的同步与互斥机制的一种解决法案

只有这一句话还不够

我们通过一个简单的例子说明:看电影

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oawXwGoN-1692422656199)(D:\AppData\Typora\typora-user-images\image-20230819122947540.png)]

只要我买了票就一定有我的座位,只要我坐到了座位上面,就表示我曾经一定买过票

预定:我现在不用,但是你要给我留着,我要用的时候你再给我

电影院要保证票不能买多了导致票不够

划分为一个一个的资源子部分(核酸试管/每一个进程访问公共资源的不同区域)

1、count表示电影院剩余的座位

2、我们只要有票了,哪怕我们不来,这个电影院的座位还是给我们留着的,我们未来想用是可以使用的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qMcRrZX6-1692422656200)(D:\AppData\Typora\typora-user-images\image-20230819124633285.png)]

如果一个信号量初始值为:1

表示这个公共资源是一个整体,只能有一个进程来访问,其他进程不能访问(提供互斥功能的信号量——二元信号量)

总结:我们申请信号量成功了,并不是就能直接使用公共资源了,而是表示公共资源内部会给我们留一个资源,但是具体访问哪一个资源是要通过特定的方式来区分的——简单来说,我们申请信号量不仅要保证可以预定一个资源,还要保证我访问的这个公共资源,其他人不能访问,这部分工作由程序员自己完成,使得不同的进程访问哪一个资源(多线程代码讲解)

semget:获得信号量(可以一次性申请很多个信号量)

semctl:多个信号量存放在一个数组中,通过下标对每一个信号量进行不同的操作

semop:对多个信号量同时进行pv操作

6、IPC资源的组织方式

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

system V标准的进程间通信

1、接口相似度非常高,获取与删除等操作
2、都有xx id_ds的结构(semid_ds等等)
3、描述xx id_ds结构的第一个字段都是_perm

标准就是大家用的方式,接口用的设计,数据结构的设置的必须遵守某种标准(上面3个都属于同一个范畴里面的)

上面共享内存,消息队列,信号量内核结构中第一个成员对应的结构都是:

在这里插入图片描述
所以我们可以使用同一个key来标识唯一性

在这里插入图片描述
在这里插入图片描述
结构体第一个成员的地址,和结构体对象本身的地址,在数字上是一样的,相等的

在这里插入图片描述

我们把system V的共享内存,消息队列,信号量的内核数据,这些不同的资源统一使用一个数组来进行保存

在这里插入图片描述
简单来说:就是用一个统一的数组对不同的资源进行保护

上面的perms[]就相当于基类,右边的结构体就相当于派生类,我们通过基类然后调用派生类的内部成员(未来我们在派生类里面加一下函数指针,就相当于模拟c++的多态了!)

7、总结

本期共享内存内容非常丰富,要理解管道和共享内存不是一次性就解决的事情,所以我要理解好每一个知识点,分别我们学习后续的内容
上面的system V是只能在本地通信,也就是同一台机器,所以system V算是用的比较少的了,我们用system V共享内存就足够了,所以system V的消息队列和下面的信号量不会讲的特别全,但是原理已经讲的很清楚,只不过没有代码部分

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值