当前位置: 首页 > news >正文

进程间通信(下)

对于上面一篇的学习,由于内容太多,我们接着继续(下篇可点击进入查看)

进程间通信(中)https://blog.csdn.net/Small_entreprene/article/details/145801575?fromshare=blogdetail&sharetype=blogdetail&sharerId=145801575&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link

进程IPC

我们接下来来看一看system V版本的进程间通信方案。

System V进程间通信(IPC)方案的出现是为了满足多进程系统中高效、灵活的通信需求。在多进程环境中,进程之间需要交换数据、协调操作,但早期的通信机制(如管道)功能有限,无法满足复杂场景的需求。System V IPC 提供了三种核心机制:消息队列、信号量(这两个后面会提到)和共享内存,分别解决了数据传输、同步控制和高效共享的需求,极大地提升了进程间通信的效率和灵活性。它为开发者提供了一套标准化的工具,使得进程间通信更加可靠、高效,同时也简化了跨进程数据交互的复杂性,成为现代操作系统中不可或缺的通信框架。

system V是一套标准,Linux在内核中支持了这种标准,Linux内核在自己的源代码当中专门设计了一个通信模块,当然,这套标准里面规定了许多细节,比如说通信的原理,通信的接口,一个内部,一个外部,是具有相似性的(原理和接口)。

我们知道进程间通信的本质规则就是让不同的进程看到同一份资源(管道的继承,打开同一份资源),system V虽然是专门设计出来进行进程间通信的,与管道是不同原理的,是不同范畴的,不同体系的一种通信的模块,但是system V也是要遵守这个本质规则的!

system V共享内存

共享内存的原理

我们在学习动态库的时候知道:在Linux系统当中,如果一个进程是需要使用动态库的,动态库其实在磁盘上就是一个普通问价,当进程需要使用动态库的话,该动态库文件也需要加载到内存,通过地址的映射,实现进程对动态库的访问使用,不仅仅可以映射到一个进程,也可以映射到多个进程,所以,动态库就可以在操作系统内为多个进程实现代码级的共享,其实共享内存也是类似的道理。

第一步:在物理内存中申请一块内存空间

  1. 申请物理内存:操作系统为进程A或进程B在物理内存中分配一块连续的内存空间,假设大小为4KB(或其他需要的大小)。

  2. 记录物理地址:操作系统记录这块物理内存的起始物理地址,以便后续的映射操作。

第二步:将申请的物理内存映射到进程A的地址空间中

  1. 选择映射位置:在进程A的虚拟地址空间中选择一个合适的位置来映射这块物理内存。通常,这个位置位于堆和栈之间的共享区,因为这个区域通常用于动态内存分配,有足够的空间来容纳共享内存。

  2. 更新页表:在进程A的页表中添加一条映射记录,将选定的虚拟地址范围映射到之前申请的物理内存地址。这样,当进程A访问这个虚拟地址范围时,CPU会通过页表将其转换为对应的物理地址。

第三步:将物理内存映射到进程B的地址空间中

  1. 选择映射位置:在进程B的虚拟地址空间中选择一个合适的位置来映射这块物理内存。这个位置可以与进程A相同,也可以不同,但必须确保进程B能够访问到。

  2. 更新页表:在进程B的页表中添加一条映射记录,将选定的虚拟地址范围映射到之前申请的物理内存地址。这样,当进程B访问这个虚拟地址范围时,CPU会通过页表将其转换为对应的物理地址。

第四步:进程间通信

  1. 访问共享内存:进程A和进程B都可以通过它们各自映射的虚拟地址访问到同一块物理内存,从而实现进程间的通信。

  2. 同步机制:由于进程A和进程B可能会同时访问共享内存,因此需要引入同步机制(如互斥锁、信号量等)来避免数据竞争和一致性问题。

通过上述步骤,进程A和进程B就可以通过共享内存进行通信,实现数据的共享和交换。

所以在物理空间上申请的内存空间就是共享内存!!!

用户在申请内存时,通常无法直接操作物理内存,因为物理内存的管理是由操作系统负责的。操作系统作为硬件资源的管理者,会为进程分配虚拟内存空间。这个过程涉及到对进程虚拟内存空间的管理,包括修改内存管理相关的内核数据结构,例如mm_structvm_area_struct等。

mm_struct是内核中用于描述进程虚拟内存空间的数据结构,它记录了进程的虚拟内存区域、页表等信息。而vm_area_struct则用于描述虚拟内存空间中的一个区域,例如代码段、数据段、堆、栈等。这些结构体是内核内部的实现细节,用户程序无法直接修改它们。

当进程需要申请内存时,它会通过系统调用(如mallocmmap)向操作系统请求内存。操作系统会根据请求的大小和类型,分配合适的虚拟内存空间,并更新mm_structvm_area_struct等内核数据结构。同时,操作系统还会负责将虚拟地址映射到物理内存,这涉及到修改页表。

页表是内核用来管理虚拟地址到物理地址映射的数据结构。操作系统会根据内存分配情况,更新页表,确保进程能够正确访问分配给它的物理内存。这些操作都是在内核态完成的,用户程序无法直接干预。

总之,用户程序只能通过系统调用请求内存,而内存的分配、虚拟内存空间的管理以及页表的更新等操作,都是由操作系统在内核态完成的。

所以:

  • 上面所说的所有的工作,都是由操作系统自己完成的,操作系统为用户提供相应的系统调用,我们作为用户,调用系统待用来完成上面的操作,这是跟文件系统一样的;
  • 经过也表映射的方式,进程A和进程B就可以看到同一份资源了,所以共享内存就是进程通过自己的虚拟地址和页表的虚拟地址映射,看到映射到同一块物理内存空间的共享区域,自此,我们就由了通信的前提;
  • 如果未来不想通信了,想要关闭,那么未来想要关闭的时候,一定是进程A要将自己申请的进程地址空间释放掉,页表的映射关系也要去掉,进程B也是相同的过程,共享内存没有对象指向了,当然就可以被操作系统释放掉了,所以我们要去掉共享内存,第一步就是取消关联关系,第二步是让操作系统释放内存;

现在有个问题:

假如在C语言代码当中调用了malloc,比如说malloc申请了1024个字节,那么申请的1024个字节是该进程在申请,那么操作系统是不是会立刻在物理内存当中申请1024个字节???

其实是不会的,在C语言中,当程序调用`malloc`函数申请内存时(例如申请1024字节),这个操作实际上是进程向操作系统发起的内存分配请求。然而,操作系统并不会立即为这1024字节分配物理内存。相反,它会在进程的虚拟地址空间中预留出一块大小为1024字节的区域,记录下该区域的起始地址(start)和结束地址(end)。此时,页表中并没有建立虚拟地址到物理地址的映射关系。

这种机制的核心在于,操作系统采用了一种“按需分配”的策略。只有当进程真正尝试访问或使用这块虚拟内存时(例如写入数据),操作系统才会触发缺页中断。缺页中断是操作系统检测到进程访问的内存页面尚未分配物理内存时的一种机制。此时,操作系统会为该页面分配物理内存,并建立虚拟地址到物理地址的映射关系,完成页表的更新。

因此,虽然进程在虚拟地址空间中已经预留了1024字节的空间,但物理内存的分配是在进程实际访问时才动态完成的。这种机制不仅提高了内存的使用效率,还避免了为未使用的内存分配宝贵的物理资源。

所以,其实我想说的是:

用户程序通过系统调用请求资源(如内存分配或共享内存),而操作系统负责管理这些资源的分配、映射和回收。这种协作关系不仅适用于内存管理,也适用于文件系统等其他资源管理场景。用户程序只需通过系统调用接口与操作系统交互,而具体的实现细节由操作系统完成。

进程间通信并不是两个进程特有的,可以是所有想通信的进程都可以实现的,那么这里就会有一个问题:因为可能有多组内存都会使用不同的共享内存来实现进程间通信,那么在操作系统内,就一定会有多个空想内存会同时存在,这些共享内存当中,有的正在使用,有的正在创建,有的要释放了等等,所以操作系统就需要管理共享内存。

先描述,再组织!!!!(共享内存一定要有对应的描述共享内存的内核结构体对象!+物理内存)(进程和共享内存的关系,就是内核数据结构之间的关系)

每个共享内存段都有一个对应的内核结构体(如shmid_ds),其中包含了引用计数字段(进程之间的通信状态)。以下是关键部分的描述:

struct shmid_ds {
    key_t shmid;               // 共享内存的键值
    size_t shm_segsz;          // 共享内存段的大小
    unsigned short shm_nattch; // 当前附加到该共享内存段的进程数(引用计数)
    pid_t shm_cpid;            // 创建共享内存段的进程ID
    pid_t shm_lpid;            // 最后一个对共享内存进行操作的进程ID
    struct ipc_perm shm_perm;  // 访问权限结构体
};
使用共享内存的接口(system V)通信的特性

在Linux系统中,创建共享内存的接口主要包括以下几个函数:shmgetshmatshmdtshmctl。这些函数共同实现了共享内存的创建、映射、解除映射和销毁。以下是这些接口的详细说明:

1. 创建共享内存:shmget

shmget 函数用于创建或获取一个共享内存段。其函数原型如下:

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

key:用于标识共享内存段的键值。通常通过 ftok 函数生成。

size:指定共享内存段的大小(以字节为单位)。仅在创建新共享内存段时有效。

shmflg:控制共享内存段的行为,常用标志包括:

  1. IPC_CREAT:如果目标共享内存段不存在,则创建一个新段,否则,打开这个已经存在的共享内存,并返回。
  2. IPC_EXCL:与 IPC_CREAT 一起使用,确保创建的是一个全新的共享内存段,如果已存在则报错。(单独使用无意义)
  3. 权限值(如 0644):设置共享内存的访问权限,与文件权限类似。

返回值成功时返回共享内存段的标识符(shmid)(key是用户级的了,完成获取共享内存后,后续使用的其他系统调用就是需要该函数调用返回值,是属于内核级别的了,就像文件描述符一样)。失败时返回 -1。

我们怎么评估,共享内存,存在还是不存在?怎么保证两个不同的进程,拿到的就是同一个共享内存呢?

这就需要key参数,key用于标识共享内存段的唯一性,这个key不是内核直接形成的,而是在用何层构建并传入给OS的,这是为什么呀?

评估共享内存是否存在以及确保不同进程访问同一块共享内存的关键在于使用key参数。在Linux系统中,key是通过ftok函数在用户层生成的,它基于一个路径名和项目标识符来创建一个唯一的标识符(算法的结果)。这个key的作用是让操作系统能够识别和区分不同的共享内存段。当进程需要访问共享内存时,它会通过shmget系统调用,传入这个key来请求访问共享内存。如果共享内存已经存在,shmget会返回一个与该内存段关联的标识符shmid;如果不存在,系统会根据key创建一个新的共享内存段。不同进程只要使用相同的key,就能通过shmget获取到相同的shmid,进而通过shmat将共享内存映射到自己的地址空间,从而确保它们访问的是同一块共享内存。

key用户层生成而不是由内核直接形成的原因在于,它需要提供一种灵活且可配置的方式,让用户能够根据自己的需求生成唯一的标识符,同时避免不同应用程序之间的冲突(也就是:如果是内核创建的,那么假设是A进程创建了,那么B进程该怎么得到这个key,进程间是独立的,B进程该怎么获得共享内存的地址???),确保共享内存的正确访问和管理。

A进程ftok出来的key可以评估共享内存,key作为一个基准,B进程传入后,根据key在当前的OS中一一比对(A设置key,B查找key),实现A进程和B进程共同访问到同一个共享内存空间。约定同一个key就是类比于命名管道中打开的同一路径的文件(唯一性!)


ftok 函数的原型定义在 <sys/ipc.h> 头文件中,其函数原型如下:

#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
  1. pathname:指向一个以空字符结尾的字符串,表示文件路径。这个文件必须是实际存在的,且进程对该文件有访问权限。ftok 函数会使用该文件的 inode 信息来生成键值。其实设置成其他的也可以,只是为了可读性!!!

  2. proj_id:是一个项目标识符,通常是一个小的正整数(1 到 255)。它用于进一步区分同一路径下的不同 IPC 资源,确保生成的键值具有更高的唯一性。

返回值:成功时,ftok 返回一个非负的键值(key_t 类型)。失败时,返回 -1,并设置 errno 以指示错误类型。

冲突了再修改参数,因为这个函数是算法级的,并没有去在内核当中一一查找,出现冲突也是有可能的。


我们可以通过命令:

ipcs -m

 来查看共享内存资源。

2. 映射共享内存:shmat(attach:挂接,关联)

shmat 函数用于将共享内存段映射到进程的虚拟地址空间。其函数原型如下:

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

shmid:共享内存段的标识符。

shmaddr:指定映射的地址。通常传入 NULL,让系统自动选择地址。

shmflg:控制映射的行为,常用标志包括 SHM_RDONLY(只读模式)。如果未指定任何标志,使用默认设置,那么共享内存将以读写模式(0)附加到进程的地址空间。(就像:代码段可读不可写)

我们在ipcs -m查看共享内存的情况是,perms字段就是代表共享内存的权限问题。

未来我们不是通信场景的时候,假如是要将C/C++的库加载到内存当中,进程也需要映射库,映射库到进程地址空间中,使用的也是共享内存,只不过库不是用的system V这样专门用来通信的共享内存,而是使用的mmap(加餐会谈)的映射。 

返回值:成功时返回映射的地址。失败时返回 (void *)-1

返回映射的起始虚拟空间的地址,因为现在还没谈到内存管理的话题,我们也并没有谈虚拟地址到物理地址映射的具体的问题,因为页表还没详细谈到,我们后面要理解线程,就不得不谈页表,那时候,我们再来谈他是如何进行内存级管理的。

共享内存是一个内存块,所以将来供给用户使用的一定是连续的内存空间,所以映射到对应的虚拟地址是连续的,也就是只需要知道其实虚拟地址和共享内存的大小,就可以以线性遍历的方式读取到共享内存的任意一个字节了,所以共享内存,只需要得到其实虚拟地址就可以。

其实shmat的行为和malloc是有点像的,malloc是在堆上申请的,而shmat是在我们的堆栈之间。malloc就在虚拟堆空间申请,填页表,等使用的时候缺页中断,实现虚拟到物理的映射。 

3. 解除映射:shmdt

shmdt 函数用于解除共享内存段与进程地址空间的映射。其函数原型如下:

int shmdt(const void *shmaddr);

shmaddr:映射的共享内存段的起始地址。

返回值:成功时返回 0。失败时返回 -1。

4. 控制共享内存:shmctl

shmctl 函数用于对共享内存段进行控制操作,如删除共享内存段。其函数原型如下:

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

shmid:共享内存段的标识符。

cmd:指定操作命令,常用命令包括:

  1. IPC_RMID:删除共享内存段。
  2. IPC_STAT:获取共享内存段的状态信息。
  3. IPC_SET:设置共享内存段的状态信息。

buf:用于存储共享内存段的状态信息。

返回值:成功时返回 0。失败时返回 -1。

共享内存的demo

我们先简单实现一个common.hpp:

#pragma once

#include <iostream>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string>
#include <stdio.h>

const int defaultid = -1;
const int gsize = 4096;
const std::string pathname = ".";
const int projid = 0x66;

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

class Shm
{
public:
    Shm()
        : _shmid(defaultid), _size(gsize)
    {
    }
    ~Shm()
    {
    }

    bool Create()
    {
        key_t k = ftok(pathname.c_str(), projid);
        if (k < 0)
        {
            ERR_EXIT("ftok");
        }
        printf("key: 0x%x\n", k);
        _shmid = shmget(k, _size, IPC_CREAT | IPC_EXCL); // 修改权限标志
        if (_shmid < 0)
        {
            ERR_EXIT("shmget");
        }
        printf("shmid: %d\n", _shmid);
        return true;
    }

    

private:
    int _shmid;
    int _size;
};

server.cc

#include "common.hpp"

int main()
{
    Shm shm;
    shm.Create();
    return 0;
}

我们在程序执行之前来看看共享内存资源的情况:

我们首次执行代码发现:(本来shmid应该是0的,我删了几次)

我们再执行一次:(这是根据key值来判断的,所以也是证实了key是用来标识共享内存的唯一性)

这时候,我们发现报错:文件已经存在!这是很理所应当的,因为我们刚刚就将其共享内存资源创建出来了,但是我们将进程退出后,clean掉我们生成的server可执行程序,我们再次编译运行:

会报错,显示的依旧是文件存在。 

我们就可以得出一个结论:进程结束了,但是如果没有进行删除共享内存资源,那么共享内存资源会一直存在。这与我们之前的文件操作不一样,进程一旦退出,之前打开没有关闭的所有文件会自动关闭。(因为进程退出,进程的PCB,虚拟地址空间,页表等,全部都会被释放,所以struct file结构体对应的引用计数就会减减,进程都退了,引用计数减为0,操作系统就识别到文件该关闭了,所以文件的生命周期称为随进程),所以共享内存的资源的生命周期是随内核的。

所以如果没有显示的删除,即便进程退出了,IPC资源依旧被占用。

我们有指令级别的删除方式:

使用 ipcrm 命令

ipcrm 是一个命令行工具,用于删除IPC资源。可以通过以下方式删除共享内存:

  • 通过 shmid 删除

    ipcrm -m <shmid>

    例如,删除 shmid3 的共享内存:

    ipcrm -m 3

注意:虽然key是标识共享内存的,但是我们未来要删除该共享内存,还有之后的控制共享内存操作,在我们的用户层,我们作为用户不能使用key,key是我们用户给内核来区分共享内存的唯一性的!!!在用户层,我们需要使用shmid来进行管理共享内存!!!因为我们所采用的指令,本质也是对系统调用的封装。(key只有一种用途:单纯的给内核来进行区分共享内存的唯一性的!!!)

除了使用上面的ipcrm来删除共享内存,还可以使用代码级别的删除:shi(在上面)

我们就可以在上面代码中新增删除操作:

    void Destroy()
    {
        if (_shmid == defaultid)
        {
            return; // 这个共享内存就没有被建立过
        }
        int n = shmctl(_shmid, IPC_RMID, nullptr);
        if (n == 0) // 修改成功条件
        {
            printf("shmctl delete shm: %d success!\n", _shmid);
        }
        else
        {
            ERR_EXIT("shmctl");
        }
    }

到这,我们已经能够对共享内存进行较为完整的生命周期的管理了,能创建能删除了,未来我们的目的不就是想用共享内存吗,那我们该如何使用共享内存呢?

我们一旦将共享内存创建出来了,那么第二步就是要将共享内存映射到进程A,进程B所对应的虚拟地址当中,那我们就需要考虑一个问题:要进行对两个进程的虚拟地址空间的映射的话,我们该怎么实现映射?

我们上面提到是在堆栈之间开辟一个共享区,然后通过映射实现,这是基本原理,但是我们该如何实操?

使用shmat(上面),来实现两个接口:

void Attach()
    {
        if (_shmid == defaultid)
        {
            ERR_EXIT("Shared memory not created. Call Create() first.");
        }
        _start_mem = shmat(_shmid, nullptr, 0);
        if (_start_mem == (void *)-1) // 修改返回值检查
        {
            ERR_EXIT("shmat");
        }
        printf("attach success!\n");
    }

    void *VirtualAddr()
    {
        if (_start_mem == nullptr)
        {
            ERR_EXIT("Shared memory not attached. Call Attach() first.");
        }
        printf("VirtualAddr: %p\n", _start_mem);
        return _start_mem;
    }

我们执行server程序:

发现我们代码当中shmat进行虚拟地址映射失败了,因为权限拒绝!

这时候,我们应该会过来谈谈共享内存的问题了:

我们共享内存时默认创建的,perms是0,这是应该的,我们上面有一个报错是file exists,文件已经存在 ,所以共享内存在系统当中也被变相的当成是文件来看了,所以才会报文件已经存在,只要是文件,访问时就需要对应的权限,所以创建共享内存,我们除了设置构建选项之外,标志位还可以兼容对共享内存的权限控制,所以我们可以将其改为:

_shmid = shmget(k, _size, IPC_CREAT | IPC_EXCL | 0666); // 修改权限标志

但是system V版本的通信,现在的共享内存,包括之后的信号量,消息队列,虽然可以按照文件的角度去理解,但是在内核实现的时候,他和文件的关联度做得并不好,不是强相关,我们看shmid就可以发现,这个shmid是一直在增加的,不像文件描述符的重复使用,而是在操作系统内单独开了一桌,是属于system V的一套机制,和文件相关,但是不强相关,这就导致要被淘汰的可能,后面学习网络后,我们会有更好的选择。

我们可以看看共享内存的信息:

刚开始创建的共享内存没有将其和进程关联,所以nattch: 0,表示当前没有进程附加(attach)到这个共享内存段上。后面attach成功后,nattch就变为1了,因为当前进程和共享内存关联起来了。

说是进程间通信,那么不应该仅仅是server进程,和之前管道一样,我们要实现进程间通信,让另一个进程client就不需要再创建共享内存了,而是获取共享内存,然后将获取到的共享内存映射到client进程的虚拟地址空间上。这时候client也具有通信的条件了,共享内存的创建和删除已经和client进程没有关系了,该行为操作是被server进程完成的,所以我们还要再common.hpp中封装一个Get接口来获取对应共享内存(实现方法:要有相同的key,shmget的标志位仅仅是是IPC_CREAT,用来获取而已)

    void Get()
    {
        key_t k = ftok(pathname.c_str(), projid);//获取相同的key值:key是共享内存的唯一标识
        if (k < 0)
        {
            ERR_EXIT("ftok");
        }
        printf("key: 0x%x\n", k);
        _shmid = shmget(k, _size, IPC_CREAT); // 修改权限标志
        if (_shmid < 0)
        {
            ERR_EXIT("shmget");
        }
        printf("shmid: %d\n", _shmid);
    }

我们接下来就可以先对我们的common.hpp代码进行优化,减少冗余,部分成员函数私有化,保护作用:这时候就可以将common.hpp重命名为shm.hpp

优化后的代码:(有部分新增的函数接口)

shm.hpp:

#pragma once
// 防止头文件被重复包含,提高编译效率,避免重复定义等问题

#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
// 包含必要的头文件,提供标准输入输出、文件操作、系统调用、共享内存操作等功能

const int gdefaultid = -1; // 默认的共享内存标识符,表示无效值
const int gsize = 4096;    // 共享内存的默认大小
const std::string pathname = "."; // 用于生成 key 的路径名
const int projid = 0x66;  // 项目 ID,用于 ftok 函数生成 key
const int gmode = 0666;   // 共享内存的权限模式

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)
// 定义一个宏,用于在出错时打印错误信息并退出程序

#define CREATER "creater" // 定义创建者类型
#define USER "user"       // 定义用户类型

class Shm
{
private:
    // 创建共享内存的辅助函数,根据传入的标志位创建共享内存
    void CreateHelper(int flg)
    {
        printf("key: 0x%x\n", _key); // 打印生成的 key
        // 调用 shmget 创建共享内存,_key 是 ftok 生成的键值,_size 是共享内存大小,flg 是标志位
        _shmid = shmget(_key, _size, flg);
        if (_shmid < 0)
        {
            ERR_EXIT("shmget"); // 如果创建失败,调用 ERR_EXIT 宏
        }
        printf("shmid: %d\n", _shmid); // 打印创建的共享内存标识符
    }
    // 创建共享内存,使用 IPC_CREAT | IPC_EXCL | gmode 标志位,确保创建的是一个全新的共享内存
    void Create()
    {
        CreateHelper(IPC_CREAT | IPC_EXCL | gmode);
    }
    // 将当前进程的地址空间与共享内存关联
    void Attach()
    {
        _start_mem = shmat(_shmid, nullptr, 0); // 调用 shmat 函数
        if ((long long)_start_mem < 0)
        {
            ERR_EXIT("shmat"); // 如果关联失败,调用 ERR_EXIT 宏
        }
        printf("attach success\n"); // 打印关联成功的信息
    }
    // 获取共享内存,使用 IPC_CREAT 标志位,如果共享内存不存在则创建
    void Get()
    {
        CreateHelper(IPC_CREAT);
    }
    // 销毁共享内存
    void Destroy()
    {
        if (_shmid == gdefaultid)
            return;
        if (_usertype == CREATER) // 如果是创建者类型
        {
            int n = shmctl(_shmid, IPC_RMID, nullptr); // 调用 shmctl 函数删除共享内存
            if (n > 0)
            {
                printf("shmctl delete shm: %d success!\n", _shmid); // 如果删除成功,打印信息
            }
            else
            {
                ERR_EXIT("shmctl"); // 如果删除失败,调用 ERR_EXIT 宏
            }
        }
    }

public:
    // 构造函数,初始化共享内存对象
    Shm(const std::string &pathname, int projid, const std::string &usertype)
        : _shmid(gdefaultid), // 初始化共享内存标识符为默认值
          _size(gsize),      // 初始化共享内存大小为默认值
          _start_mem(nullptr), // 初始化共享内存起始地址为 nullptr
          _usertype(usertype) // 初始化用户类型
    {
        _key = ftok(pathname.c_str(), projid); // 调用 ftok 函数生成 key
        if (_key < 0)
        {
            ERR_EXIT("ftok"); // 如果生成 key 失败,调用 ERR_EXIT 宏
        }
        if (_usertype == CREATER) // 如果是创建者类型
            Create(); // 调用 Create 函数创建共享内存
        else if (_usertype == USER) // 如果是用户类型
            Get(); // 调用 Get 函数获取共享内存
        else
        {
        }
        Attach(); // 调用 Attach 函数将当前进程地址空间与共享内存关联
    }
    // 获取共享内存的虚拟地址
    void *VirtualAddr()
    {
        printf("VirtualAddr: %p\n", _start_mem); // 打印虚拟地址
        return _start_mem; // 返回共享内存的起始地址
    }
    // 获取共享内存的大小
    int Size()
    {
        return _size; // 返回共享内存的大小
    }

    ~Shm()
    {
        std::cout << _usertype << std::endl; // 析构函数中打印用户类型
        // if(_usertype == CREATER)
        // Destroy();
    }

private:
    int _shmid; // 共享内存标识符
    key_t _key; // 用于生成共享内存的 key
    int _size;  // 共享内存的大小
    void *_start_mem; // 共享内存的起始地址
    std::string _usertype; // 用户类型,创建者或用户
};

自此,我们就完成了实现通信信道,接下来就是进行进程间的正常通信了,怎么实现通信操作呢?其实很简单:

假设我们今天将共享内存当成一个大的字符串,那么我们可以利用VirtualAddr()函数接口的调用,获得映射后虚拟起始地址,使用char*进行强制类型转化:

char *men = (char *)shm.VirtualAddr();

利用强制类型转化,我们将来就可以将这个内存强转成类等。

访问这个虚拟地址就如同自己访问自己申请的对空间一样,直接用就可以了。

所以:

服务器端代码(server.cpp)

#include "shm.hpp"

int main()
{
    // 创建共享内存对象,类型为 CREATER,表示创建共享内存
    Shm shm(pathname, projid, CREATER);

    // 获取共享内存的虚拟地址
    char *mem = (char *)shm.VirtualAddr();

    // 无限循环,每隔一秒打印共享内存中的内容
    while (true)
    {
        printf("%s\n", mem);
        sleep(1);
    }

    return 0;
}

服务器端代码

  • 创建一个共享内存对象 Shm,并将其类型设置为 CREATER,表示它是共享内存的创建者。

  • 将共享内存映射到进程地址空间,并通过 VirtualAddr() 获取其虚拟地址。

  • 在一个无限循环中,每隔一秒打印共享内存中的内容。这模拟了服务器端不断读取共享内存中的数据。

客户端代码(client.cpp)

#include "shm.hpp"

int main()
{
    // 创建共享内存对象,类型为 USER,表示使用共享内存
    Shm shm(pathname, projid, USER);

    // 获取共享内存的虚拟地址
    char *mem = (char *)shm.VirtualAddr();

    // 循环写入字符 'A' 到 'Z' 到共享内存中
    for (char c = 'A'; c <= 'Z'; c++)
    {
        mem[c - 'A'] = c;
        sleep(1);
    }

    return 0;
}

客户端代码

  • 创建一个共享内存对象 Shm,并将其类型设置为 USER,表示它是共享内存的使用者。

  • 将共享内存映射到进程地址空间,并通过 VirtualAddr() 获取其虚拟地址。

  • 在一个循环中,依次将字符 'A''Z' 写入共享内存的对应位置,每次写入后暂停一秒。这模拟了客户端向共享内存中写入数据。

整体来看,这段代码展示了如何使用共享内存实现进程间通信,服务器端负责读取数据,客户端负责写入数据。

我们执行后:

我们执行发现,cilent发的消息,server可以得到了,但是我们client进程结束之后,没有给server的共享内存的析构,也就是server端没有调用shmctl:下面我们来解决一下这个BUG!!!

共享内存在创建的时候,我们要申请,进行要使用共享内存的时候,需要与共享内存进行关联,但是当我们一旦不再利用共享内存了,我们不是直接删除这个共享内存,而是要将关联的进程进行去关联,这时候,我们可以使用系统调用:shmdt 进行去关联:

    // 将当前进程的地址空间与共享内存分离
    void Detach()
    {
        int n = shmdt(_start_mem); // 调用 shmdt 函数
        if (n == 0)
        {
            printf("detach success\n"); // 如果分离成功,打印信息
        }
    }


    // 销毁共享内存
    void Destroy()
    {
        // if (_shmid == gdefaultid)
        //     return;
        Detach();                 // 先分离共享内存
        if (_usertype == CREATER) // 如果是创建者类型
        {
            int n = shmctl(_shmid, IPC_RMID, nullptr); // 调用 shmctl 函数删除共享内存
            if (n > 0)
            {
                printf("shmctl delete shm: %d success!\n", _shmid); // 如果删除成功,打印信息
            }
            else
            {
                ERR_EXIT("shmctl"); // 如果删除失败,调用 ERR_EXIT 宏
            }
        }
    }


现在,我们就实现了进程间通信。


之前使用管道实现进程间通信,进程之间的读写是采用系统调用write/read,那我们今天读写共享内存,有没有使用系统调用?

我们看上面代码就很清楚了,根本就没有采用系统调用,而是直接使用虚拟地址进行对应的操作,在读取,访问共享内存时,我们也没有采用系统调用。

所以读写共享内存,并没有出现调用系统调用!!!因为进程一旦将共享内存映射成功后,共享内存是映射在堆栈之间的,堆栈之间的,即共享区是属于用户空间的,可以让用户直接使用。

共享内存不单单是进程A和进程B的通信,如果进程B换成是磁盘呢?

一样的,磁盘文件加载到开辟好的共享内存空间,进程A就将这开辟的共享内存映射到自己的地址空间上,然后对进程代码数据作地址级别的重定向,此时就可以以用户地址的方式,访问动态库中的方法了。这就是加载动态库到内存,让进程使用到的底层原理。只不过动态库进行映射的时候,采用的系统调用不是system V这样的通信的方案,而是mmap的方式,来实现文件到共享内存的映射。

共享内存是进程间通信中,速度最快的方式:

  1. 映射之后,读写直接被对方看到;
  2. 不需要进行系统调用获取或写入内容,直接用地址的方式进行访问。

这就是共享内存应用在进程间通信的优点。

但是通信双方,没有所谓的“同步机制”(我们上面的执行./server会一直读,尽管client进程没有写入数据)会导致数据不一致(之后谈信号量时在详说)

所以,共享内存的缺点是:共享内存没有保护机制。(指的是对共享内存当中的数据的保护!)

共享内存没有保护机制,那么我们该怎么人为的进行对共享内存的保护呢?

假如实现数据成对的读取:

"AA BB CC",而不是"AA B"或者"A例A BB C",要么成对读,要么不读。

共享内存没有自己的保护机制,将来是通过信号量来进行对共享内存数据的保护的!这个方式我们现在不谈,后面再谈,那么如果我非要包共享内存保护起来,有没有其他方案可以选择呢?

是有的,就是我们之前学的命名管道,我们在两进程间使用共享内存的方式进行通信的基础上,用命名管道在将两进程关联起来,但是这个命名管道的作用不是通信功能,而是进程A写一个A不管,写两个A的话就向命名管道当中写进一个通知字符,唤醒进程B,而进程B是默认不直接读取共享内存的,而是首先要读取管道,管道为空,进程B就不做读取,当进程A写一个A不做通知,写两个A,进程A通过管道再通知B,进程B再去共享内存中获取数据,此时B进程读取数据就需要按照A进程的节奏来进行了。

我们接下来实现保护,可以继用我们命名管道的common.hpp代码(代码在中篇)(进行稍做修改),下面是添加保护机制的实现过程:

//server.cc

#include "shm.hpp"
#include "Fifo.hpp"

int main()
{
    Shm shm(pathname, projid, CREATER);

    NamedFifo fifo(PATH, FILENAME);

    FileOper readerfile(PATH, FILENAME);
    readerfile.OpenForRead();
    char *mem = (char *)shm.VirtualAddr();
    while (true)
    {
        if (readerfile.Wait()) // 默认会阻塞
        {
            printf("%s\n", mem);
        }
        else
        {
            break;
        }
    }
    readerfile.Close();
    return 0;
}



//client.cc
#include "shm.hpp"
#include "Fifo.hpp"

int main()
{
    FileOper writerfile(PATH, FILENAME);

    writerfile.OpenForWrite();

    Shm shm(pathname, projid, USER);
    char *mem = (char *)shm.VirtualAddr();
    int index = 0;
    for (char c = 'A'; c <= 'Z'; c++, index += 2)
    {
        sleep(1); // 唤醒对方之后,因为进程间本质还是各走各的,唤醒循环是继续的,先等一会,1秒的时间用来完成上次内容的读取,以致下面还需要唤醒!!!
        mem[index] = c;
        mem[index + 1] = c;
        sleep(1);
        mem[index + 2] = 0;  // 保证写入的是完整的字符串,不要受到后续乱码的影响!!!
        writerfile.Wakeup(); // 读两个到共享内存在唤醒read那边的阻塞
    }
    writerfile.Close();
    return 0;
}

最后,我们输出几个小结论:

共享内存在创建的时候,它的大小必须是4KB(4096字节)为单位创建。

但是我们如果将要开辟的空间的大小设置为4097的话,我们执行ipcs指令,发现确实是4097字节,那这是为啥?

其实我们还需要加一个前提:

在内核中,共享内存在创建的时候,它的大小必须是4KB(4096字节)为单位创建。

也就是我们设置了大小为4097字节的空间大小,实际上在内核当中是4096*2字节,也就是申请8KB的内存空间大小。但是我们申请4097字节空间的话,我们就只能使用4097字节,所以剩下的4095字节,操作系统是没有浪费掉的!!! 

完整源代码

Fifo.hpp

#pragma once
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "Comm.hpp"

#define PATH "."
#define FILENAME "fifo"

// 面向对象化
class NamedFifo
{
public:
    NamedFifo(const std::string &path, const std::string &name)
        : _path(path), _name(name)
    {
        umask(0);
        // 新建一个管道文件
        _fifoname = _path + "/" + _name;
        int n = mkfifo(_fifoname.c_str(), 0666);
        if (n < 0)
        {
            // 创建管道文件失败
            ERR_EXIT("mkfifo");
        }
        else
        {
            std::cout << "open fifo sucess" << std::endl;
        }
    }

    ~NamedFifo()
    { //  删除管道文件
        int n = unlink(_fifoname.c_str());
        if (n == 0)
        {
            // ERR_EXIT("unlink");//bug在这里,先析构fifo,导致shm的析构没有被调用
        }
        else
        {
            std::cout << "remove fifo failed" << std::endl;
        }
    }

private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
};

class FileOper
{
public:
    FileOper(const std::string &path, const std::string &name)
        : _path(path), _name(name), _fd(-1)
    {
        _fifoname = _path + "/" + _name;
    }
    void OpenForRead()
    {
        // 打开文件
        _fd = open(_fifoname.c_str(), O_RDONLY);
        if (_fd < 0)
        {
            ERR_EXIT("open");
        }
        std::cout << "open fifo sucess" << std::endl;
    }
    void OpenForWrite()
    {
        _fd = open(_fifoname.c_str(), O_WRONLY);
        if (_fd < 0)
        {
            ERR_EXIT("open");
        }
        std::cout << "open fifo sucess" << std::endl;
    }
    void Wakeup()
    {
        char c = 'c';
        int n = write(_fd, &c, 1);
        printf("尝试唤醒: %d\n", n);
    }
    bool Wait()
    {
        char c;
        int number = read(_fd, &c, 1);
        if (number > 0)
        {
            printf("唤醒成功: %d\n", number);
            return true;
        }
        return false;
    }
    void Close()
    {
        if (_fd > 0)
        {
            close(_fd);
        }
    }
    ~FileOper()
    {
    }

private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
    int _fd;
};

shm.hpp

#pragma once
// 防止头文件被重复包含,提高编译效率,避免重复定义等问题

#include "Comm.hpp"
#include <iostream>
#include <cstdio>
#include <string>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
// 包含必要的头文件,提供标准输入输出、文件操作、系统调用、共享内存操作等功能

const int gdefaultid = -1;        // 默认的共享内存标识符,表示无效值
const int gsize = 4096;           // 共享内存的默认大小
const std::string pathname = "."; // 用于生成 key 的路径名
const int projid = 0x66;          // 项目 ID,用于 ftok 函数生成 key
const int gmode = 0666;           // 共享内存的权限模式

#define CREATER "creater" // 定义创建者类型
#define USER "user"       // 定义用户类型

class Shm
{
private:
    // 创建共享内存的辅助函数,根据传入的标志位创建共享内存
    void CreateHelper(int flg)
    {
        printf("key: 0x%x\n", _key); // 打印生成的 key
        // 调用 shmget 创建共享内存,_key 是 ftok 生成的键值,_size 是共享内存大小,flg 是标志位
        _shmid = shmget(_key, _size, flg);
        if (_shmid < 0)
        {
            ERR_EXIT("shmget"); // 如果创建失败,调用 ERR_EXIT 宏
        }
        printf("shmid: %d\n", _shmid); // 打印创建的共享内存标识符
    }
    // 创建共享内存,使用 IPC_CREAT | IPC_EXCL | gmode 标志位,确保创建的是一个全新的共享内存
    void Create()
    {
        CreateHelper(IPC_CREAT | IPC_EXCL | gmode);
    }
    // 将当前进程的地址空间与共享内存关联
    void Attach()
    {
        _start_mem = shmat(_shmid, nullptr, 0); // 调用 shmat 函数
        if ((long long)_start_mem < 0)
        {
            ERR_EXIT("shmat"); // 如果关联失败,调用 ERR_EXIT 宏
        }
        printf("attach success\n"); // 打印关联成功的信息
    }
    // 将当前进程的地址空间与共享内存分离
    void Detach()
    {
        int n = shmdt(_start_mem); // 调用 shmdt 函数
        if (n == 0)
        {
            printf("detach success\n"); // 如果分离成功,打印信息
        }
    }
    // 获取共享内存,使用 IPC_CREAT 标志位,如果共享内存不存在则创建
    void Get()
    {
        CreateHelper(IPC_CREAT);
    }
    // 销毁共享内存
    void Destroy()
    {
        // if (_shmid == gdefaultid)
        //     return;
        Detach();                 // 先分离共享内存
        if (_usertype == CREATER) // 如果是创建者类型
        {
            int n = shmctl(_shmid, IPC_RMID, nullptr); // 调用 shmctl 函数删除共享内存
            if (n > 0)
            {
                printf("shmctl delete shm: %d success!\n", _shmid); // 如果删除成功,打印信息
            }
            else
            {
                ERR_EXIT("shmctl"); // 如果删除失败,调用 ERR_EXIT 宏
            }
        }
    }

public:
    // 构造函数,初始化共享内存对象
    Shm(const std::string &pathname, int projid, const std::string &usertype)
        : _shmid(gdefaultid),  // 初始化共享内存标识符为默认值
          _size(gsize),        // 初始化共享内存大小为默认值
          _start_mem(nullptr), // 初始化共享内存起始地址为 nullptr
          _usertype(usertype)  // 初始化用户类型
    {
        _key = ftok(pathname.c_str(), projid); // 调用 ftok 函数生成 key
        if (_key < 0)
        {
            ERR_EXIT("ftok"); // 如果生成 key 失败,调用 ERR_EXIT 宏
        }
        if (_usertype == CREATER)   // 如果是创建者类型
            Create();               // 调用 Create 函数创建共享内存
        else if (_usertype == USER) // 如果是用户类型
            Get();                  // 调用 Get 函数获取共享内存
        else
        {
        }
        Attach(); // 调用 Attach 函数将当前进程地址空间与共享内存关联
    }
    // 获取共享内存的虚拟地址
    void *VirtualAddr()
    {
        printf("VirtualAddr: %p\n", _start_mem); // 打印虚拟地址
        return _start_mem;                       // 返回共享内存的起始地址
    }
    // 获取共享内存的大小
    int Size()
    {
        return _size; // 返回共享内存的大小
    }
    // 获取属性信息
    void Attr()
    {
        struct shmid_ds ds;
        int n = shmctl(_shmid, IPC_STAT, &ds); // ds: 输出型参数
        printf("shm_segsz: %ld\n", ds.shm_segsz);
        printf("key: 0x%x\n", ds.shm_perm.__key);
    }

    ~Shm()
    {
        std::cout << _usertype << std::endl; // 析构函数中打印用户类型
        // if(_usertype == CREATER)
        Destroy();
    }

private:
    int _shmid;            // 共享内存标识符
    key_t _key;            // 用于生成共享内存的 key
    int _size;             // 共享内存的大小
    void *_start_mem;      // 共享内存的起始地址
    std::string _usertype; // 用户类型,创建者或用户
};

server.cc

#include "shm.hpp"
#include "Fifo.hpp"

int main()
{
    Shm shm(pathname, projid, CREATER);

    NamedFifo fifo(PATH, FILENAME);

    FileOper readerfile(PATH, FILENAME);
    readerfile.OpenForRead();
    char *mem = (char *)shm.VirtualAddr();
    while (true)
    {
        if (readerfile.Wait()) // 默认会阻塞
        {
            printf("%s\n", mem);
        }
        else
        {
            break;
        }
    }
    readerfile.Close();
    return 0;
}

client.cc

#include "shm.hpp"
#include "Fifo.hpp"

int main()
{
    FileOper writerfile(PATH, FILENAME);

    writerfile.OpenForWrite();

    Shm shm(pathname, projid, USER);
    char *mem = (char *)shm.VirtualAddr();
    int index = 0;
    for (char c = 'A'; c <= 'Z'; c++, index += 2)
    {
        sleep(1); // 唤醒对方之后,因为进程间本质还是各走各的,唤醒循环是继续的,先等一会,1秒的时间用来完成上次内容的读取,以致下面还需要唤醒!!!
        mem[index] = c;
        mem[index + 1] = c;
        sleep(1);
        mem[index + 2] = 0;  // 保证写入的是完整的字符串,不要受到后续乱码的影响!!!
        writerfile.Wakeup(); // 读两个到共享内存在唤醒read那边的阻塞
    }
    writerfile.Close();
    return 0;
}

相关文章:

  • Spring Boot 3 整合 MinIO 实现分布式文件存储
  • 算法 背包问题
  • 系统思考—组织诊断
  • Java EE 进阶:Spring MVC(2)
  • postgrel
  • Java学习--MySQL
  • leetcode日记(85)验证二叉搜索树
  • STM32 I2C驱动开发全解析:从理论到实战 | 零基础入门STM32第五十步
  • 蓝桥杯历年真题题解
  • 布朗运动(Brownian Motion):随机世界的舞者
  • C语言学习笔记-进阶(7)字符串函数3
  • 二分查找寻找旋转排序数组最小值边界条件处理
  • 【 <一> 炼丹初探:JavaWeb 的起源与基础】之 Servlet 过滤器:实现请求的预处理与后处理
  • 【GPT入门】第8课 大语言模型的自洽性
  • Mybatis Generator 使用手册
  • YCL4级python青少年人工智能水平测试复习资料
  • Java实现Consul/Nacos根据GPU型号、显存余量执行负载均衡
  • AI编程创新
  • 【机械臂】Windows 11安装Mujoco200并运行基于强化学习的多任务机械臂Meta-word基准
  • Python定时任务管理器
  • 做网站 用 云主机/教育培训网站模板
  • 新疆网站建设一条龙服务/免费发布信息的网站平台
  • 各网站封面尺寸/百度推广账户怎么开
  • 四川建设厅报名网站/办公软件速成培训班
  • 长沙网站制作公司地址/免费自助建站网站
  • 网站开发包括几个部分/个人网站注册平台