【Linux】-- 进程间通讯

news2024/11/13 8:03:58

目录

进程间通讯概念的引入

意义(手段)

思维构建

进程间通信方式

管道

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

匿名管道 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操作 -> 必须是原子的

总结

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

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/391929.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

深入理解java虚拟机精华总结:性能监控和故障处理工具、类加载机制

深入理解java虚拟机精华总结&#xff1a;性能监控和故障处理工具、类加载机制性能监控和故障处理工具、类加载机制jpsjstatjinfojmapjhatjstackVisualVM类加载机制类加载的时机类加载的过程加载验证准备解析初始化类加载器类与类加载器双亲委派模型破坏双亲委派模型往期内容&am…

HTML5的新特性

前言 为了更好地处理今天的互联网应用&#xff0c;HTML5添加了很多新元素及功能&#xff0c;比如: 图形的绘制&#xff0c;多媒体内容&#xff0c;更好的页面结构&#xff0c;更好的形式 处理&#xff0c;和几个api拖放元素&#xff0c;定位&#xff0c;包括网页 应用程序缓存…

八股总结(二)计算机网络与网络编程

layout: post title: 八股总结&#xff08;二&#xff09;计算机网络与网络编程 description: 八股总结&#xff08;二&#xff09;计算机网络与网络编程 tag: 八股总结 文章目录计算机网络网络模型网络体系结构在浏览器输入一个网址后回车&#xff0c;背后都发生了什么&#x…

C/C++每日一练(20230306)

目录 1. 判断素数的个数 ☆ 2. 分隔链表 ★★ 3. 数据流的中位数 ★★ 1. 判断素数的个数 在一个数组A中存放100个数据&#xff0c;用子函数判断该数组中哪些是素数&#xff0c;并统计该素数的个数&#xff0c;在主函数中输出该素数的个数。 代码&#xff1a; #includ…

三、Spark 内存管理

文章目录Spark 内存管理堆内和堆外内存堆内内存堆外内存堆外与堆内的平衡内存空间分配静态内存管理&#xff08;早期版本&#xff09;统一内存管理Spark 内存管理 堆内和堆外内存 Spark 引入了堆外&#xff08;Off-heap&#xff09;内存&#xff0c;使之可以直接在工作节点的…

Java——打开轮盘锁

题目链接 leetcode在线oj题——打开轮盘锁 题目描述 你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字&#xff1a; ‘0’, ‘1’, ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’ 。每个拨轮可以自由旋转&#xff1a;例如把 ‘9’ 变为 ‘0’&#xff0…

对话镕铭微电子蔡媛:深入理解用户痛点,满足差异化需求

“芯片不仅仅是一个硬件产品&#xff0c;为了更好的贴近用户、服务用户&#xff0c;我们需要在固件和软件的框架上投入足够的研发资源&#xff0c;与客户同步迭代更新&#xff0c;以适应其快速的业务迭代要求。在深入理解用户需求和痛点的基础上&#xff0c;满足用户差异化的功…

【STL】模拟实现list

目录 1、list介绍 所要实现类及其成员函数接口总览 2、结点类的模拟实现 基本框架 构造函数 3、迭代器类的模拟实现 迭代器类存在的意义 3.1、正向迭代器 基本框架 默认成员函数 构造函数 运算符重载 --运算符重载 !运算符重载 运算符重载 *运算符重载 …

MapReduce全排序和二次排序

排序是MapReduce框架中最重要的操作之一。MapTask和ReduceTask均会对数据按照key进行排序。该操作属于Hadoop的默认行为。任何应用程序中的数据均会被排序&#xff0c;而不管逻辑上是否需要。默认排序是按照字典顺序排序&#xff0c;且实现该排序的方法是快速排序。对于MapTask…

考研复试——操作系统

文章目录操作系统1. 操作系统的特征&#xff1a;2. 进程与线程的关系以及区别3. 简述进程和程序的区别4. 进程的常见状态&#xff1f;以及各种状态之间的转换条件&#xff1f;5. 进程的调度算法有哪些&#xff1f;6. 什么是死锁&#xff1f;产生条件&#xff1f;如何避免死锁&a…

【强化学习】强化学习数学基础:随机近似理论与随机梯度下降

强化学习数学基础&#xff1a;随机近似理论与随机梯度下降Stochastic Approximation and Stochastic Gradient Descent举个例子Robbins-Monro algorithm算法描述举个例子收敛性分析将RM算法用于mean estimationStochastic gradient descent算法描述示例和应用收敛性分析收敛模式…

Rainbond V5.12 版本发布,支持平台级插件和能力扩展

在这个版本中&#xff0c;我们主要支持了平台级的插件和能力扩展。希望能通过外部插件扩展平台能力&#xff0c;实现微内核的效果&#xff1b;同时以后将会继续精简安装&#xff0c;能让用户按需扩展平台功能。在 Kubernetes 兼容性这方面&#xff0c;我们也通过平台级的能力将…

详解JAVA类加载

目录 1.概述 2.双亲委派 3.ServiceClassLoader 4.URLClassLoader 5.加载冲突 1.概述 概念&#xff1a; 类加载器&#xff08;Class Loader&#xff09;是Java虚拟机&#xff08;JVM&#xff09;的一个重要组件&#xff0c;负责加载Java类到内存中并使其可以被JVM执行。类…

2023/3/6 VUE - 组件传值【通信】方式

1 父亲传子代传值【子代使用父代的数据】 1.1 props传值 父亲给儿子传值&#xff1a; 爷爷给孙子传值&#xff1a; 这个props传值的方式&#xff0c;只能一代一代的往下传&#xff0c;不能跨代传值。 有一个问题&#xff1a;子组件不能修改父组件的值&#xff1a; 1.2 …

MyBatis学习笔记(十) —— 动态SQL

10、动态SQL MyBatis框架的动态SQL技术是一种根据特定条件动态拼装SQL语句的功能&#xff0c;它存在的意义是为了解决拼接SQL语句字符串的痛点问题。 动态SQL&#xff1a; 1、if 标签&#xff1a;通过test属性中的表达式判断标签中的内容是否有效&#xff08;是否会拼接到sql中…

RTOS中相对延时和绝对延时的区别

相信许多朋友都有过这么一个需求&#xff1a;固定一个时间&#xff08;周期&#xff09;去处理某一件事情。 比如&#xff1a;固定间隔10ms去采集传感器的数据&#xff0c;然后通过一种算法计算出一个结果&#xff0c;最后通过指令发送出去。 你会通过什么方式解决呢&#xf…

Redis缓存击穿,缓存穿透,缓存雪崩,附解决方案

前言在日常的项目中&#xff0c;缓存的使用场景是比较多的。缓存是分布式系统中的重要组件&#xff0c;主要解决在高并发、大数据场景下&#xff0c;热点数据访问的性能问题&#xff0c;提高性能的数据快速访问。本文以Redis作为缓存时&#xff0c;针对常见的缓存击穿、缓存穿透…

Java中 new Integer 与 Integer.valueOf 的区别

引入&#xff1a;new Integer(18) 与 Integer.valueOf(18) 有区别吗&#xff1f;有的话&#xff0c;有什么区别&#xff1f; 我们都知道&#xff0c;使用 new 关键字的时候&#xff0c;每次都会新创建一个对象。但是&#xff0c;Integer.valueOf() 会新创建一个对象吗&#xf…

Linux环境下实现并详细分析c/cpp线程池(附源码)

一、线程池原理 如果并发的线程数量很多&#xff0c;并且每个线程都是执行一个时间很短的任务就结束了&#xff0c;这样频繁创建线程就会大大降低系统的效率&#xff0c;因为频繁创建线程和销毁线程需要时间。 线程池是一种多线程处理形式&#xff0c;处理过程中将任务添加到…

Unity Animator.Play(stateName, layer, normalizedTime) 播放动画函数用法

原理 接口&#xff1a; public void Play(string stateName, int layer -1, float normalizedTime float.NegativeInfinity);参数含义stateName动画状态机的某个状态名字layer第几层的动画状态机&#xff0c;-1 表示播放第一个状态或者第一个哈希到的状态normalizedTime从s…