【Linux】-- 进程间通讯

目录

进程间通讯概念的引入

意义(手段)

思维构建

进程间通信方式

管道

站在用户角度-浅度理解管道

匿名管道 pipe函数

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

管道的特点总结

管道的拓展

单机版的负载均衡

匿名管道读写规则

命名管道

前言

原理

创建一个命名管道

用命名管道实现myServer&myClient通信

匿名管道与命名管道的区别

命名管道的打开规则

system V共享内存

共享内存数据结构

共享内存的创建

key概念引入

key概念解析

基于共享内存理解信号量

总结


进程间通讯概念的引入

意义(手段)

        在没有进程间通讯之前,理论上都是单进程的,那么也就无法使用并发能力,更无法实现多进程协同(将一个事,分几个进程做)。而进程间通讯,就是对于实现多进程协同的手段

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

思维构建

        进程间通讯重点,就在与如何让不同的进程资源的传递。而进程是具有独立性的,也就是说进程相通讯会难度较大  --  因为进程间通讯的本质是:先让不同的进程看见同一份资源

融汇贯通的理解:

        进程的设计天然就是为了保证独立性的(即,进程之间无瓜葛),所以深入的说:所谓的同一份资源不能所属于任何一个进程,更强调共享,不属于任何一个进程。

进程间通信方式

管道

  • 匿名管道pipe
  • 命名管道

System V IPC

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

POSIX IPC

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

管道

        我们把从一个进程连接到另一个进程的数据流称为一个“管道”。

        当在两个命令之间设置管道 "|" 时,管道符 "|" 左边命令的输出就变成了右边命令的输入。只要第一个命令向标准输出写入,而第二个令是从标准输入读取,那么这两个命令就可以形成一个管道。大部分的 Linux 命令都可以用来形成管道。

命令:who | wc -l


用于查看当前服务器下登陆的用户人数。

补充:

        Linux who命令:用于显示系统中有哪些使用者正在上面,显示的资料包含了使用者 ID、使用的终端机、从哪边连上来的、上线时间、呆滞时间、CPU 使用量、动作等等。使用权限:所有使用者都可使用。


        Linux wc命令:用于计算字数。在此处由于who中一个用户为一行,所以此处用 -l 显示行数,即登录用户个数。

        其中,运行起来后who命令与wc命令就是两个不同的进程。who进程作为数据提供方,通过标准输入将数据写入管道,wc进程再通过标准输入将数据从管道中读取出,进而再将数据进行处理 "-l" ,后以标准输出的方式将结果给用户。

站在用户角度-浅度理解管道

匿名管道 pipe函数

#include <unistd.h>

功能:
        创建一无名管道。
原型:
         int pipe ( int  pipefd[ 2 ]);
参数:
        输出型参数,通过调用该参数,得到被打开的文件fd。
数组元素含义
pipefd[0]管道读端文件描述符
pipefd[1]管道写端文件描述符

返回值:
        成功时,返回0。出现错误时,返回-1。

1. 父进程创建管道 

2. 父进程fork出子进程

3. 父进程关闭读 / 写,子进程关闭写 / 读。(fork之后各自关掉不用的描述符)

Note:对于pipe函数创建的管道,只能够进行单向通信。(反之,会导致读写导致管道中数据污染、混乱)。我们需要对于父或子进程中的fd参数中的,文件符号进行关闭。

        pipe函数的使用需要结合fork函数的父子进程。

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

#问:如何做到让不同的进程,看到同一份资源?

        以fork让子进程继承,能够让具有“血缘关系”的进程进行进程间通讯。(管道:常用于父进程进程)

融汇贯通的理解:

        fork创建子进程,等于系统中多了一个子进程。而进程 = 内核数据结构 + 进程代码和数据。进程相关内核数据结构来源于操作系统,进程代码和数据一般来源于磁盘。

        而由于为了进程具有独立性,所以创建子进程的同时,需要分配对应的进程相关内核结构。对于数据,被写入更改时操作系统采用写时拷贝技术,进行对父子进程数据的分离

        父进程与子进程拥有自身的fd_array[]存储文件描述符fd,但是其中存储的fd时相同的,而文件相关内核数据,并不属于进程数据结构,所以并不会单独为子进程创建。于是:父进程与子进程指向的是一个文件  ->  这就让不同的进程看到了同一份资源。

        管道本质上就是一个文件。一个具有读写功能,并且无需放入磁盘的文件(通道是进程进行通讯的临时内存空间,无需将内容放入磁盘中保留)。

(tty:标准输入、标准输出、标准错误) 

1. 父进程创建管道 

 2. 父进程fork出子进程

 3. 父进程关闭读 / 写,子进程关闭写 / 读。(fork之后各自关掉不用的描述符)

代码实现的关键:

  1. 创建管道 -- 分别以读写方式打开同一个问题
  2. 创建子进程 -- 以fork函数创建子进程
  3. 构造单向通讯的通道 -- 双方进程各自关闭自己不需要的文件描述符
#include <iostream>
#include <unistd.h>
#include <assert.h>
#include <string>
#include <string.h>
#include <sys/wait.h>
#include <sys/types.h>

using namespace std;

int main()
{
    //1.创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    assert(n != -1);
    (void)n; // 只被定义没有被使用,Release下就会出现代码大量告警  --  证明使用过

// 用于调试验证fd申请
#ifdef DEBUG
    cout << "pipefd[0]: " << pipefd[0] << endl;
    cout << "pipefd[1]: " << pipefd[1] << endl;
#endif

    //2.创建子进程
    pid_t id = fork();
    assert(id != -1);
    if(id > 1)
    {
        // 子进程 -- 只读
        // 3.构造单向通讯的通道, 父进程写入,子进程读取

        // 3.1 关闭子进程不需要的fd
        close(pipefd[1]);

        char child_buffer[1024*4];
        while(true)
        {
            ssize_t s = read(pipefd[0], child_buffer, sizeof(child_buffer) - 1);
            //3.2 访问控制:
            //   a、写入的一方,fd没有关闭,如果有数据,就读,没有数据就等
            //   b、写入的一方,fd关闭, 读取的一方,read会返回0,表示读到了文件的结尾!
            if(s > 0)
            {
                child_buffer[s] = 0;
                cout << "child get a message[" << getpid() << "] Father# " << child_buffer << endl;
            }
            else if(s == 0)
            {
                cout << "-----------writer quit(father), me quit!-----------" << endl;
                break;
            }
        }
        exit(0);
    }
    // 父进程 -- 只写
    // 3.构造单向通讯的通道, 父进程写入,子进程读取

    // 3.1 关闭父进程不需要的fd
    close(pipefd[0]);

    string message = "我是父进程,发送有效信息。";
    int count = 0; // 传递的次数
    char father_buffer[1024*4];
    while(true)
    {
        //3.2 构建一个变化的字符串
        snprintf(father_buffer, sizeof(father_buffer), "%s[%d] : %d",
                message.c_str(), getpid(), count++);
        
        //3.3 写入
        write(pipefd[1], father_buffer, strlen(father_buffer));

        //3.4 故意sleep凸显访问控制
        sleep(1);
        if(count == 3)
        {
            cout << "----------------father wirte quit!----------------" <<endl;
            break;
        } 
    }
    close(pipefd[1]);
    pid_t ret = waitpid(id, nullptr, 0);
    assert(ret > 0);
    (void)ret;

    return 0;
}

管道的特点总结

1. 管道是用来进程具有血缘关系的进程进行进程间通讯。

2. 管道具有通过让进程间通讯,提供访问控制。

        a、写端快,读端慢,写满了不能再写了。

        b、写端慢,读端快,管道没有数据的时候,读需要等待。

补充:

        c、写端关闭,读端为0,标识读到了文件结尾。

        d、读端关闭,写端继续写,操作系统终止写端进程。

3. 管道提供的是面向流式的通信服务 -- 面向字节流。

4. 管道是基于文件的,文件的生命周期是随进程的,所以管道的生命周期是随进程的。

5. 管道是单向通行的,就是半双工通信的一种特殊情况。

数据的传送方式可以分为三种:

        单工通信(Half Duplex)是通讯传输的一个术语。一方固定为发送端,另一方固定为接收端。即:一方只能写一方只能读。

        半双工通信(Half Duplex)是通讯传输的一个术语。指数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。即:一段时间内,只能一方写一方读。

        全双工通信(Full Duplex)是通讯传输的一个术语。指通信允许数据在两个方向上同时传输,它在能力上相当于两个单工通信方式的结合。即:一段时间内,每方能写且读。

管道的拓展

单机版的负载均衡

        以循环fork函数开辟多个子进程,并利用pipe函数。针对于每一个子进程开辟一个管道,父进程通过管道安排其中一个子进程做某任务。

#pragma once

#include <iostream>
#include <unordered_map>
#include <string>
#include <functional>

typedef std::function<void()> func;

std::vector<func> callbacks;
std::unordered_map<int, std::string> desc;

void readMySQL()
{
    std::cout << "sub process[" << getpid() << "]执行访问数据库的任务" << std::endl;
}

void executeUlt()
{
    std::cout <<  "sub process[" << getpid() << "]执行url解析\n" << std::endl;
}

void cal()
{
    std::cout << "sub process[" << getpid() << "] 执行加密任务\n" << std::endl;
}

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

void load()
{
    callbacks.push_back(readMySQL);
    desc.insert({callbacks.size(), "readMySQL: 执行访问数据库的任务"});

    callbacks.push_back(executeUlt);
    desc.insert({callbacks.size(), "executeUlt: 进行url解析"});

    callbacks.push_back(cal);
    desc.insert({callbacks.size(), "cal: 进行加密计算"});

    callbacks.push_back(save);
    desc.insert({callbacks.size(), "save: 执行数据持久化任务"});
}

// 功能展示
void showHandler()
{
    for(const auto &iter : desc)
        std::cout << iter.first << "  ->  " << iter.second << std::endl;
}

// 具有的功能数
int handlerSize()
{
    return callbacks.size();
}
#include <iostream>
#include <vector>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
#include <sys/types.h>
#include "Task.hpp"

using namespace std;

#define PROCESS_NUM 4

int waitCommand(int waitfd, bool& quit)
{
    //此处由于是父进程写入一个整数 -- 用以子进程执行相关内容
    //规定:子进程读取的数据必须是4字节
    uint32_t command = 0;
    ssize_t s = read(waitfd, &command, sizeof(command));
    if(s == 0)
    {
        quit = 1;
        return -1;
    }
    assert(s == sizeof(uint32_t));
    return command;
}

void wakeUp(pid_t who, int fd, uint32_t command)
{
    write(fd, &command, sizeof(command));
    cout << "main process call: " << who << "process,"  << " execute: " << desc[command] << ", through write fd: " << fd << endl;
}

int main()
{
    load();
    
    // 存储:<子进程id,父进程对应写端符fd>
    vector<pair<pid_t, int>> slots;
    
    //1. 创建多个进程
    for(int i = 0; i < PROCESS_NUM; ++i)
    {
        //1.1 创建管道
        int pipefd[2] = {0};
        int n = pipe(pipefd);
        assert(n == 0);
        (void)n;

        //1.2 fork创建子进程
        pid_t id = fork();
        assert(id != -1);
        (void)id;

        if(id == 0)
        {
            // 子进程 -- 关闭写端
            close(pipefd[1]);
            while(true)
            {
                // 用于判断是否
                bool quit = 0;
                int command = waitCommand(pipefd[0], quit);
                if(quit)
                {
                    break;
                }
                if(command >= 1 && command <= handlerSize())
                    callbacks[command - 1]();
                else
                    cout << "error, 非法操作" << endl;
            }
            exit(1);
        }

        //将父进程读端关闭
        close(pipefd[0]);
        slots.push_back(make_pair(id, pipefd[1]));
    }

    while(true)
    {
        int select;
        int command;
        cout << "############################################" << endl;
        cout << "##    1. show funcitons      2.command    ##" << endl;
        cout << "############################################" << endl;
        cout << "Please Select> ";
        cin >> select;
        if(select == 1)
            showHandler();
        else if(select == 2)
        {
            cout << "Enter command" << endl;
            cin >> command;
            // 随机挑选进程
            int choice = rand() % PROCESS_NUM;
            //将任务指派给指定的进程
            wakeUp(slots[choice].first, slots[choice].second, command);
        }
        else
            cout << "输入错误,请重新输入" << endl;
    }

    // 关闭父进程写端fd,所有的子进程都会退出
    for(const auto &slot : slots)
        close(slot.second);

    // 回收所有的子进程信息
    for(const auto &slot : slots)
        waitpid(slot.first, nullptr, 0);
        
    return 0;
}

匿名管道读写规则

  • 当没有数据可读时
    • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止
    • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
  • 当管道满的时候
    • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
    • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN
  • 如果所有管道写端对应的文件描述符被关闭,则read返回0
  • 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
  • 当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
  • 当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

原子性:要么做,要么不做,没有所谓的中间状态。

POSIX.1-2001要求PIPE_BUF至少为512字节。(在Linux上,PIPE_BUF为4096字节。)

拓展:

        讨论原子性,需要在多执行流下,数据出现并发访问的时候,讨论原子性才有意义。(此处不深入)

融会贯通的理解:

        匿名管道就是一个文件,一个内存级别的文件,并不会在磁盘上存储,并不会有自身的文件名。作为基础间通讯的方式是:看见同一个文件 -- 通过父子进程父子继承的方式看见。

        是一个,只有通过具有 “血缘关系” 的进程进行使用,可以称做:父子进程通讯。

命名管道

前言

        匿名管道只能使用于具有“亲缘关系”的进程之间通信,而对于毫无关系的两个进程无法使用匿名管道通讯,如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件。

原理

        当两个进程需要同时带开一个文件的时候,由于为了保证进程的独立性,所以两个进程会有各自的files_struct,而对于文件数据,并不会为每一个进程都备一份(是内存的浪费),此时A进程的files_struct与B进程的files_struct是不同的,但是其中的文件符fd指向的是由磁盘文件加载到内存中的同一份数据空间。

        命名管道就是如此,其原理与匿名管道很相识。命名管道在磁盘中,所以其有自己的文件名、属性信息、路径位置……,但是其没有文件内容即,命名管道是内存文件,其在磁盘中的本质是命名管道在磁盘中的映像,且映像的大小永远为0。意义就是为了让毫无关系的基进程,皆能够调用到命名管道。而管道中的数据是进程通讯时的临时数据,无存储的意义,所以命名管道在磁盘中为空。

创建一个命名管道

  • 命名管道可以从命令行上创建:

命令:mkfifo fifo


创建一个名为fifo命名管道

此时文件类型不是常用 - d 而是 p ,此文件的类型为管道:

         此时会发现处于等待状态因为由于我们写了,但是对方还没有打开,于是处于阻塞状态。

        此时 echo "hello name_pipe"(进程A)就是写入的进程, cat(进程B)就是读取的进程。这就是所谓的一个进程向另一个进程写入消息的过程(通过管道写入的方式)。

        我们可以在命令行上使用循环的方式,往管道内每隔1s写入数据。即,进程A原来应向显示器文件写入的数据,通过输入重定向的方式,将数据写入管道中,再将管道中数据通过输出重定向,通过进程B将数据写入到显示器文件中。如此,以毫无相关的进程A与进程B通过命名管道进行数据传输进程间通信。

        此时我们通过终止读取进程方,导致写入端向管道写入的数据无意义了(无读取端),此时作为写入端的进程就应该被操作系统杀掉。此时需要注意,echo是内置命令,所以是bush本身自行执行的命令,所以此时杀掉写入端的进程无疑就是杀掉bush。于是bush被操作系统杀死,云服务器即退出。

内置命令:让父进程(myshell)自己执行的命令,叫做内置命令,内建命令。

  • 命名管道可以从程序里创建:

#include <sys/types.h>

#include <sys/stat.h>

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

参数:

    pathname:创建的命名管道文件。

  • 路径的方式给出。(会在对应路径下创建)
  • 文件名的方式给出。(默认当前路径下创建)

    mode:创建命名管道文件的默认权限。

  • 我们创建的文件权限会被umask(文件默认掩码)进行影响,umask的默认值:0002,而实际创建出来文件的权限为:mode&(~umask)。于是导致我们创建的权限未随我们的想法,如:0666 -> 0664。需要我们利用umask函数更改默认。
  • umask(0); //将默认值设为 0000

返回值:        

        命名管道创建成功,返回0。

        命名管道创建失败,返回-1。

用命名管道实现myServer&myClient通信

        利用命名管道,实现服务端myServer与客户端myClient之间进行通讯。将服务端myServer运行起来并用mkfifo函数开辟一个命名管道。而客户端myClient中利用open打开命名管道(命名管道本质为文件),以write向管道中输入数据。以此服务端myServer利用open打开命名管道,以read从管道中读取数据。

comm.hpp

        所展开的头文件集合。

#ifndef _COMM_H_
#define _COMM_H_

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

std::string ipcPath = "./fifo.ipc";

#endif

Log.hpp

        编程的日志:就是当前程序运行的状态。

#ifndef _LOG_H_
#define _LOG_H_

#include <iostream>
#include <ctime>

#define Debug 0
#define Notice 1
#define Warning 2
#define Error 3

std::string msg[] = {
    "Debug",
    "Notice",
    "Warning",
    "Error"
}

std::ostream &Log(std::string message, int level)
{
    std::cout << " | " << (unsigned)time(nullptr) << " | " << msg[level] << " | " << message;
}

#endif

myServer.cc 

细节:

        mkfifo的第二个参数传入权限0666之前需要以umask(0),对于服务端因为只需要在命名管道中读取数据,所以以只读的方式(O_RDONLY)open管道文件,后序以fork开辟子进程,让子进程read读取即可,同时也需要注意,C语言的字符串结尾必须是 '\0'(读取大小:sizeof(buffer) - 1)。

        由于我们让子进程执行读取工作,所以需要以waitpid等在子进程(此处我们让nums个子进程进行,所以waitpid的第一个参数为 -1 ,等待任意一个子进程)。

        由于open打开了管道类型的文件,所以需要以close(fd)关闭文件,由于mkfifo开辟了管道,所以需要以unlink删除管道文件。

#include "comm.hpp"

// 管道文件创建权限(umask == 0)
#define MODE 0x0666

// 读取数据大小
#define READ_SIZE 64

// 从管道文件读取数据
static void getMessage(int fd)
{
    char buffer[READ_SIZE];
    while(true)
    {
        memset(buffer, '\0', sizeof(buffer));
        ssize_t s = read(fd, buffer, sizeof(buffer) - 1); // C语言字符串需要保证结尾为'\0'
        if(s > 0)
        {
            std::cout <<"["  << getpid() << "] "<< "myClient say> " << buffer << std::endl;
        }
        else if(s == 0)
        {
            // 写端关闭 - 读到文件结尾
            std::cerr <<"["  << getpid() << "] " << "read end of file, clien quit, server quit too!" << std::endl;
        }
        else
        {
            // 读取错误
            perror("read");
            exit(3);
        }
    }
}

int main()
{
    //1. 创建管道文件
    umask(0);
    if(mkfifo(ipcPath.c_str(), MODE) < 0)
    {
        perror("mkfifo");
        exit(1);
    }
    
    #ifdef DEBUG
    Log("创建管道文件成功", Debug) << " step 1 " << std::endl;
    #endif

    //2. 正常的文件操作
    int fd = open(ipcPath.c_str(), O_RDONLY);
    if(fd < 0)
    {
        perror("open");
        exit(2);
    }

    #ifdef DEBUG
    Log("打开管道文件成功", Debug) << " step 2 " << std::endl;
    #endif

    int nums = 3;
    // 创建3个子进程
    for(int i = 0; i < nums; ++i)
    {
        pid_t id = fork();
        if(fd == 0)
        {
            // 子进程 - 读取管道数据
            getMessage(fd);
            exit(1);
        }
    }
    // 父进程 - 等待子进程
    for(int i = 0; i < nums; i++)
    {
        waitpid(-1, nullptr, 0);
    }

    // 4. 关闭管道文件
    close(fd);
    #ifdef DEBUG
    Log("关闭管道文件成功", Debug) << " step 3 " << std::endl;
    #endif

    unlink(ipcPath.c_str()); // 通信完毕,就删除管道文件
    #ifdef DEBUG
    Log("删除管道文件成功", Debug) << " step 4 " << std::endl;
    #endif

    return 0;
}

myClient.cc

细节:

        对于客户端因为只需要在命名管道中写入数据,所以以只写的方式(O_WRONLY)open管道文件,后序write即可。

#include "comm.hpp"

int main()
{
    //1. 获取管道文件 - 以写的方式打开命名管道文件
    int fd = open(ipcPath.c_str(),  O_WRONLY);
    if(fd < 0)
    {
        perror("open");
        exit(1);
    }
    
    //2. ipc过程
    std::string buffer; //用户级缓冲区
    while(true)
    {
        std::cout << "Please Enter Message Line :> ";
        std::getline(std::cin, buffer);
        write(fd, buffer.c_str(), buffer.size());
    }

    //3. 通信完毕,关闭命名管道文件
    close(fd);
    return 0;
}

        由于命名管道的创建是在服务端myServer中,所以需要先运行myServer。

        服务端myServer进程运行起来,我们就能看到创建的命名管道文件。此时服务端myServer处于阻塞状态也是管道文件的特性(写入端未开辟,读取端需要等待写入端开辟)。

        可以通过 ps 命令查看进程是否相关:

        从此可以看出myServer与myClient是毫无相关的进程,即myServer的三个子进程与myClient也是毫无相关的进程。

匿名管道与命名管道的区别

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

命名管道的打开规则

  • 如果当前打开操作是为读而打开FIFO时
    • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
    • O_NONBLOCK enable:立刻返回成功
  • 如果当前打开操作是为写而打开FIFO时
    • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
    • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO

system V共享内存

        system V共享内存是与管道不同的,管道基于操作系统已有的文件操作。文件部分,无论有没有通讯的需求,这个文件都需要维护,有没有通讯都需要和指定进程建立关联,通不通讯都会有。

        而共享内存是,不用来通讯,操作系统就不用进行管理,只有需要使用时,操作系统才提供 - 有通讯才会有,共享内存。共享内存是操作系统单独设立的内核模块,专门为进程间通讯设计 --  这个内核模块就是system V。

        即:前面的匿名管道、命名管道通讯是恰好使用文件方案可以实现。而共享内存是操作系统专门为了通讯设计。

共享内存的建立:

  • 共享区:共享内存、内存映射和共享库保存位置。

共享内存数据结构

        共享内存的提供者,是操作系统。

        大量的进程进行通讯 -> 共享内存是大量的。所以,操作系统对于共享内存需要进行管理,需要管理 -> 先描述,再组织 -> 重新理解:共享内存 = 共享内存块 + 对应的共享内存的内核数据结构。

共享内存的数据结构 shmid_ds  /usr/include/linux/shm.h 中定义:

(cat命令即可)

struct shmid_ds

{
        struct ipc_perm        shm_perm;    
/* operation perms */
        int            shm_segsz;    /* size of segment (bytes) */
        __kernel_time_t        shm_atime;    /* last attach time */
        __kernel_time_t        shm_dtime;    /* last detach time */
        __kernel_time_t        shm_ctime;    /* last change time */
        __kernel_ipc_pid_t    shm_cpid;    /* pid of creator */
        __kernel_ipc_pid_t    shm_lpid;    /* pid of last operator */
        unsigned short        shm_nattch;    /* no. of current attaches */
        unsigned short         shm_unused;    /* compatibility */
        void             *shm_unused2;    /* ditto - used by DIPC */
        void            *shm_unused3;    /* unused */
};

        此处首先提一下key值(后面共享内存的建立引入),其是在上面的共享内存的第一个参数struct ipc_perm类型的shm_perm变量中的一个变量。

 /usr/include/linux/ipc.h 中定义:

struct ipc_perm
{
        __kernel_key_t    key;
        __kernel_uid_t    uid;
        __kernel_gid_t    gid;
        __kernel_uid_t    cuid;
        __kernel_gid_t    cgid;
        __kernel_mode_t    mode; 
        unsigned short    seq;
};

共享内存的创建

#include <sys/ipc.h>
#include <sys/shm.h>
// 用来创建共享内存
int shmget(key_t key, size_t size, int shmflg);

参数:

        key:这个共享内存段名字。

        size:共享内存大小。

  • 大小建议为4096的整数倍。(原因使用时讲解)

        shmflg:由九个权限标志构成,它们的用法和创建文件时使用的mode模式标志是一样的。

组合方式作用
IPC_CREAT创建共享内存,如果底层已经存在,获取之,并且返回。如果底层不存在,创建之,并且返回。
IPC_EXCL没有意义
IPC_CREAT | IPC_EXCL创建共享内存,如果底层不存在,创建之,并且返回。如果底层存在,出错返回。

IPC_CREAT | IPC_EXCL意义:可以保证,放回成功一定是一个全新的共享内存(shm)。

此外创建需要权限的初始化:

        如:IPC_CREAT | IPC_EXCL | 0666

返回值:

        成功返回一个非负整数,即该共享内存段的标识码(用户层标识符);失败返回-1。

key概念引入

        进程间通讯,首先需要保证的看见同一份资源。

融会贯通的理解:

  • 匿名管道:通过pipe函数开辟内存级管道 -- 本质是文件 -- 通过pipe函数的参数(文件符fd)-- 看见同一份资源。
  • 命名管道:通过mkfifo函数根据路径开辟管道文件(可以从权限p看出)-- 本质是开辟一个文件(可以从第二个参数需要初始化权限看出)-- 利用open、write、read、close文件级操作 -- 看见同一份资源。

        管道 -- 内存级文件 -- 恰巧利用文件操作。前面已有所提system V共享内存,是操作系统为进程间通讯专门设计 ,并无法利用类似于管道利用文件实现。于是便有了key。

key概念解析

        key其实就是一个整数,是一个利用算法实现的整数。我们可以将key想象为一把钥匙,而共享内存为一把锁

        更像是同心锁和一对对情侣,情侣拿着同样的钥匙只可解一堆锁中的一把锁。

        如同一把钥匙会按照固定的形状制造。其会使用同样的算法规则形成一个唯一值key,同时再创建共享内存时,会将key值设置进其中,此时两个毫无关系的进程,就可以通过key值用共享内存进行通讯(一方创建共享内存,一方获取共享内存)。

制造唯一值key的算法:

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

key_t ftok(const char *pathname, int proj_id);

        其不进行任何系统调用,其内部是一套算法,该算法就是将两个参数合起来,形成一个唯一值就可以,数值是几不重要。(对于第一个参数,ftok是拿带文件的inode标号,所以路径可以随意写,但必须保证具体访问权限),proj_id(项目id),随意写即可,一般是0~255之间,可以随便写,因为超了其也会直接截断。

返回值:

        成功后,返回生成的key_t值。失败时返回-1。


note:

  • 终究就是个简易的算法,所以key值可能会产生冲突,于是可以对传入ftok函数的参数进行修改。
  • 需要保证需要通讯的进程使用的 pathname  proj_id 相同,如此才能保证生成的是同一个key值。

简易的使用shmget函数结合ftok函数:

        其不进行任何系统调用,其内部是一套算法,该算法就是将两个参数合起来,形成一个唯一值就可以,数值是几不重要。(对于第一个参数,ftok是拿带文件的inode标号,路径可以随意写,但必须保证具体访问权限)

        两个进程要通讯,就要保证两个看见统一个共享内存,本质上:保证两个看到同一个key。

        与文件不同,文件是打开了,最后进程退出,文件没有进程与其关联,文件就会自动释放。

        操作系统为了维护共享内存,就需要先描述,再组织。所以,共享内存在内核里,处理共享内存的存储内存空间,也需要存储对其描述信息的数据结构。所以,为了设置或获取其的属性,就通过第三个参数。(当只需要删除的时候,第三个参数设为nullptr即可)

        操作系统管理物理内存的时候,页得大小是以4KB为单位。也就是4096byte,如果我们用4097byte,就多这1byte,操作系统就会在底层,直接创建4096 * 2byte的空间,此时多余的4095byte并不会使用,就浪费了。

        此处,我们以4097byte申请,操作系统开辟了4096 * 2byte,但是查询下是4097byte,因为,操作系统分配了空间,但是并不代表对所有都有权利访问,我们要的是4097byte,那操作系统只会给对应的权限。所以建议配4096byte的整数倍

        prems:权限。此处为0 ,代表任何一个人,包括我们,都没有权力读写共享内存,此时创建共性内存也就没了意义。于是我们需要再加一个选项,设置权限。

        nattch:n标识个数,attch表示关联。表示有多少个进程与该共享内存关联

        需要将指定的共享内存,挂接到自己的进程的地址空间。

参数:

  1. 要挂接的共享内存的用户管理的对应的id。(获取共享内存时的id)
  2. 我们需要指定的虚拟地址。共享内存挂接时,可将其挂接到指定的虚拟地址。(一般不推荐,因为虚拟地址的使用情况我们并不是十分的清楚。即使,我们能获取到),设置为nullptr让操作系统自行挂接即可。
  3. 挂接方式。设置为0即可,默认会以读写的方式挂好。

·        范围值,共享内存的起始地址。

文件描述符,文件有其对应的文件指针,可用户从来不会用文件指针,用的全是文件描述符,它们都可以用来标定一个文件。同样的道理shmid与key,它们都可以用来标定共享内存的唯一性。(key:标定共享内存在系统级别上的唯一性。shmid:标定共享内存的用户级别上的唯一性。)所以我们在用的时候全部都是shmid。只要是指令编写的时候,就是在用户层次的,所以ipcs等用的是shmid。

        system V IPC资源,生命周期随内核,与之相对的是生命周期随进程。即,操作系统会一直保存这个资源,除非用户用手动命令删除,否则用代码删除。

        共享内存由操作系统提供,并对其进行管理(先描述,再组织) -> 共享内存 = 共享内存块 + 对应的共享内存的内核数据结构。

融会贯通的理解:

        一个内存为4G的地址空间,0~3G属于用户,3~4G属于内核。所谓的操作系统在进行调度的时候,执行系统调用接口、库函数。本质上都是要将代码映射到地址空间当中,所以我们的代码无论是执行动态库,还是执行操作系统的代码。都是在其地址空间中完成的。所以对于任何进程,3~4G都是操作系统的代码和数据,所以无论进程如何千变万化,操作系统永远都能被找到。

        堆栈之间的共享区:是用户空间,该空间拿到了,无需经过系统调用便可直接访问。 -- 共享内存,是不用经过系统调用,直接可以进行访问!双方进程如果要通讯,直接进行内存级的读和写即。

融会贯通的理解:

        前面所说的匿名管道(pipe)、命名管道(fifo)。都需要通过read、write(IO系统调用)来进行通讯。因为这两个属于文件,而文件是在内核当中的特定数据结构,所以其是操作系统维护的 -- 其是在3~4G的操作系统空间范围中。(无权访问,必须使用系统接口)

 共享内存在被创建号之后,默认被清成全0,所以打印字符是空串。

        共享内存就是天然的为了让我们可以快速访问的机制,所以其内部没有提供任何的控制策略。(共享内存中有数据读端读,没数据读端也读。甚至客户端(写入端)不在了,其也读。)更直接的说:写入端和读取端根本不知道对方的存在。

        缺乏控制策略 -- 会带来并发的问题。

拓展:

并发的问题,如:

        客户端想让一个进程处理一个完整的数据内容,然而客户端在未完全写入共享内存时,读取方就将不完整的数据读取并处理,此时处理结果为未定义。 --  数据不一致问题

基于共享内存理解信号量

根据前面的学习:

  • 匿名管道通过派生子进程的方式看见同一份资源。
  • 命名管道通过路径的方式看见同一份资源。
  • 共享内存通过key值得方式看见同一份资源。

        所以,为了让进程间通讯 -> 让不同的进程之间,看见同一份资源 -> 本质:让不同的进程看见同一份资源。

        通过前面得到学习我们会发现,如共享进程,其并没有访问控制,即:独断读取的时机是不确定的,这也就带来了一些时序问题 —— 照成数据的不一致问题。

引入两个概念:

  1. 临界资源:我们把多个进程(执行流)看到的公共的一份志愿,称作临界资源。
  2. 临界区:我们把自己的进程,访问的临界资源的代码,称作临界区。

        所以,多个进程(执行流),互相运行的时候互相干扰,主要是我们不加以保护的访问了相同的资源(临界资源),在非临界区多个进程(执行流)互相是不干扰的。

        而为了更好的进行临界资源的保护,可以让多个进程(执行流)在任何时刻,都只能有一个进程进入临界区 ——  互斥 

互斥的理解:

        我们可以将,一个执行流:人,临界区:电影院(一个位置的电影院)。

        看电影一定要有位置(电影院中的唯一位子)。当前一个人在其中看电影,那么其他人必须等待他看完才可进入观看。并且电影院中,此唯一的位置是并不属于观影人的,而是买票,只要买了票,即在你进去看完电影之前,就拥有了这个位置。买票:就是对座位的 预定 机制。

        同样的道理,进程想进入临界资源,访问临界资源,不能让进程直接去使用临界资源(不能让用户直接去电影院内部占资源),需要先申请票 ——  信号量

        信号量 的存在是等于一张票。"票"的意义是互斥,而互斥的本质是串形化,互斥就是一个在跑另一个就不能跑,需要等待跑完才能跑。其必须串形的去执行。但是一旦串形的去执行,多并发的效率就差了。所以:

        当有一份公共资源,只要有多个执行流访问的是这个公共资源的不同区域,这个时候可以允许多个执行流同时进入临界区。这个时候可以根据区域的数量(如同电影院座位的个数 -> 允许观影的人数)可以让对应的进程个数并发的执行自己临界区的代码(看电影的自行观影)

        信号量本质上:就是一个计数器,类似于int count = n(n张票)。

申请信号量:

  1. 申请信号量的本质:让信号量计数器 -- 。
  2. 释放信号量的本质:让信号量计数器++。
  3. 信号量申请成功,临界资源内部就一定会预留所需要的资源 —— 申请信号量本质其实是对临界资源的一种“ 预定 ”机制。

        只要申请信号量成功 ……只要申请成功,一定在临界区中有一个资源对应提供的。

        换句话说:首先,我们要进行访问信号量计数器,要每一个线程访问计数器,必须保证信号量本身的 --操作 以及 ++操作原子的。否者很难保护临界资源。其次,信号量需要是公共的,能被所有进程能看到的资源,叫做临界资源 —— 而信号量计数器存在的意义就是保护临界资源,但是其有又成了临界资源,所以其必须保证自己是安全的,才能保证临界资源的安全。

#:如果用一个整数,表示信号量。假设让多个进程(整数n在共享内存里),看见同一个全局变量,都可以进行申请信号量 —— 不可以的。

CPU执行指令的时候:

  1. 将内存中的数据加载到CPU内的寄存器中(读指令)。
  2. n--(分析 && 执行指令)。
  3. 将CPU修改完的数据n写回到内存(写回结果)。

复习:

        执行流在执行的时候,在任何时刻都可能被切换。

切换的本质:CPU内的寄存器是只有一份的,但是寄存器需要存储的临时数据(上下文)是多份的,分别对应不同的进程!

        我们知道,每一个进程的上下文是不一样的,寄存器只有一份,那么根据并发,为下一个进程让出位置。并且由于,上下文数据绝而对不可以被抛弃!

        当进程A暂时被切下来的时候,需要进程A顺便带走直接的上下文数据!带走暂时保存数据的是为了下一次回来的时候,能够恢复上去,以此继续按照之前的逻辑继续向后运行,就如同没有中断过一样。

        由于寄存器只有一套,被所有的执行流共享,但是寄存器里面的数据,属于一个执行流(属于该执行流的上下文数据)。所以对应的执行流需要将上下文数据进行保护,方便与上下文数据恢复(重新回到CPU,更具上下文数据继续执行)。

        当myClient执行的时候,重点在于n--,到n++,因为时序的问题,会导致n有中间状态。切换为myServer执行的时候,中间状态会导致数据不一致。

        即,CPU执行myClient中的写入数据到共享内存时,就被替换了:

(CUP执行到n的中间状态)

(myClient被切换为myServer)

(myServer信号量执行完了,并将n写回

(myCilent带着自己的上下文数据,并将n写回)

        此时1 -> 2,凸显了信号量操作必须是原子性的,只有原子性才不会怕因时序,导致的数据不一致问题。

总结:

  • 申请信号量 -> 计数器-- -> P操作 -> 必须是原子的
  • 申请信号量 -> 计数器++ -> V操作 -> 必须是原子的

总结

        所以,由于信号量的思想,也是让我们看见同一份资源,所以其本质与上面的管道、共享内存没有太大的区别。所以,信号量被纳入进程间通讯的范畴。

        信号量是为了保证特定的临界资源不被受侵害,保证临界资源数据一致性。前面所讲:信号量也是一个临界资源,所以首先其需要保证自己的安全性 —— 提出信号量操作需是原子性的。 

        而信号量理论的提出是由于:临界区、临界资源的 互斥 ,当多个执行流(进程)才会真正的凸显出来,所以此处由于是进程间通讯 —— 需要提出信号量,但作用凸显在多线程 —— 多线程再深入讲解信号量。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川入

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值