Re:Linux系统篇(四十)通信篇·五:SystemV标准三部曲其之一:共享内存


◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

一、什么是System V标准

  System V标准是一种IPC标准,简单的说,Linux支持了这种标准,专门设计了一个IPC模块,这里的通信原理,通信接口设计都具有相似性,方便迁移,同时减小学习成本。

  不要忘记IPC的本质是让不同的进程看到同一份资源,System V的设计也没有抛开这一点。

  System V标准下的三大通信手段,在当下工程中已经使用不多了,算是一种过时的技术。但是在考研中经常会考到,同时在面试中会提及一些原理层内容。所以请大家酌情按需阅读。

  System V三大通信手段按照重要性排列大致如下:

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

二、共享内存的原理

2.1共享内存实现看到同一份资源的原理

  进程间通信的本质是让两个不同的进程看到同一份资源,那么如何才能让两个不同的进程看到同一份资源呢?

  如下,我们有两个进程:A和B,分别在自己的虚拟地址空间的共享区(堆区和栈区之间的空白部分 )开辟一块空间。在物理内存中开辟一块共享内存。然后把这个块共享内存通过页表映射到两个进程的虚拟地址空间当中。于是两个进程就可以看到同一块内存资源了。

  其实另外一个进程可以换成动态库,这样就是动态库加载的原理了。

  磁盘中的文件拷贝到这个共享内存中,然后和进程A建立映射关系。于是进程A在它的代码段就可以进行地址重定向------这就是动态库的加载原理。唯一区别在于:

  • 进程间通信使用的是shm方案
  • 进程文件映射使用的是mmap方案

2.2共享内存被先描述再组织

  那么我们有这么多进程,可能同时存在由多组进程,都在使用不同的共享内存进行通信! OS内,多个共享内存同时存在,要不要管理共享内存呢?

  需要:先描述,再组织。于是我们预言:共享内存,一定要有对应的描述共享内存的内核结构体对象。 物理内存进程和共享内存的关系就是内核数据结构之间的关系。

2.3共享内存的系统调用

  我们上面所说的操作都是操作系统完成了,操作系统做这些事情,需要系统调用的指挥。

2.3.1共享内存的创建shmget

2.3.1.1size参数和shmfig选项

  man 2 shmget,看一看我们的创建函数的定义:

  他有三个参数,key我们先放一放,第二个参数size表示的是你想要开辟的共享内存的大小,单位是字节,第三个参数是模式类型。

  我们看一下,一共常用的有两个选项:

  • IPC_CREAT:创建共享内存,如果目标共享内存不存在,就创建,否则,打开这个已经存在的共享内存,并返回。
  • IPC_CREAT | IPC_EXCL:创建共享内存,如果目标共享内存不存在,就创建,否则,就会出错返回。(单独使用IPC_EXCL,无意义!------只要shmget成功返回,那么就一定是全新的共享内存

  这两种选项方式其实分别对应了"获取共享内存"和"创建共享内存"。

  那么问题又来了,我们的操作系统如何评估:共享内存是否存在?我们的操作系统怎么保证,两个进程一定拿到的是同一个共享内存呢?

  ------A进程创建了共享内存拿到了这个结构体的id但是没有办法给B进程。 因为进程具有独立性。

2.3.1.2key参数的构建和传递

  回顾我们的命名管道,两个进程通过约定一个相同的路径+管道文件名。使得两个进程可以看到同一个管道。 那么共享内存,他也可以约定同一把钥匙,来打开同一扇门!

不同的进程用共享内存来进行通信,Key是用来标识共享内存的唯一性的,不是内核直接形成的,而是在用户层,构建并传入给 OS 的。

  那么如何构建呢(随便写一个固然不行,在大型项目中发生冲突了这个bug你找都找不到。)?

  man 3 ftok我们看到以下接口: 这个接口在成功构建key的时候返回这个系统唯一键值(IPC Key)。构建失败的时候返回-1。

  好好好,它值得我们好好讲一讲, 第一参数:

cpp 复制代码
const char *pathname

  传递什么一个指向已经存在的文件或目录的路径名(字符串)。

  底层原理 :ftok 内部会调用 stat 系统调用来获取该文件的设备号(Device ID )和i节点号(Inode number)。

  两个关键

  • 如果传递的代码路径对应的文件不存在,ftok 会直接返回 -1。
  • 如果该文件被删除并重新创建,即使名字一样,它的 i 节点号也大概率变了,生成出来的 key 就会改变。
cpp 复制代码
int proj_id

  传递什么 :一个项目标识符(Project ID)。虽然类型是 int,但系统只会使用其中的低 8 位。

  底层原理 :它常被称作"子序号 "。当你同一组程序需要使用多个不同的 IPC 资源(比如既要一个共享内存,又要一个信号量)时,可以用相同的 pathname,但传入不同的 proj_id 来区分。

2.3.1.3共享内存创建的返回值

  shmget创建共享内存成功,那么就会返回共享内存标识符(一个整数)标识你创建的那个共享内存 ,如果失败就会返回-1。

  有人就会问了:我已经有key来标识了,还要这个共享内存标识符干嘛 (我放在了2.3.4.1讲解,排版问题)

2.3.2共享内存的关联

  创建完共享内存,进程如何得到和操作它呢?将共享内存挂接到进程的地址空间中,或者通俗的讲------建立从物理内存到虚拟地址空间的页表映射

  shmat系统调用,at是attach的缩写。

  • 第一个参数:共享内存的标识符,就是你刚才shmget得到的返回值。
  • 第二个参数:别管,直接填nullptr ,

补充 :const void *shmaddr参数的作用是虚拟地址到固定地址 进行挂接。NULL 在我们的应用开发中用不到。因为,内存的应用情况,只有操作系统是最清楚的,我们用户少管。 什么时候用到?系统开发中一些库的加载,当你必须加载到指定的位置的时候。

  • 第三个参数:也别管,直接填0 。

补充 :这个参数描述的是挂接模式,默认行为(填 0),只读挂接(SHM_RDONLY),对齐挂接(SHM_RND),一般默认行为够用了。

  • 返回值:返回共享内存在虚拟地址空间中的起始地址。
2.3.2.1共享内存的起始虚拟地址有什么用

  共享内存在物理地址中是一块连续的内存共享内存,在虚拟地址空间中是一块连续的内存。

  所以,我们只需要知道两个值:起始虚拟地址+整个共享内存的总大小 。就可以知道共享内存的每一个位置的数据。与数组、malloc的使用方法类型。

2.3.3共享内存的去关联

  那么我们的进程不想用这个共享内存了,怎么解除关联呢? shmdt系统调用。

  • 参数:返回从shmat返回的共享内存的起始地址。
  • 返回值成功:返回 0。失败:返回 -1,并设置 errno 错误码。
2.3.3.1关联与去关联------你给系统发命令,系统如何知道自己是否该删除某个共享内存?

  首先,我们的共享内存不会主动删除,即使进程被杀死后也不会立即释放------共享内存的生命周期是随内核的。随内核与随进程,我们前面也讲过。

  • 磁盘上文件的生命周期:随内核。
  • 文件描述符的生命周期:随进程。

  你给系统发命令,系统如何知道自己是否该删除某个共享内存?(删除指令放在了2.3.4,排版问题。)

直接说结论:共享内存的底层使用引用计数。进程销毁,引用计数 - -,引用计数==0 则销毁。(我们下面管它叫:关联计数)

  如何观察到这一过程?

cpp 复制代码
//一样的代码,Client和Server各自准备一份。
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>

int main() {
    key_t key = ftok(".", 0x66);
    int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
    // 1. 关联:nattch 从 0 变成 1
    char* addr = (char*)shmat(shmid, NULL, 0);
    // 此时可在另一个终端运行 ./client 模拟 nattch 变成 2
    sleep(10); 
    // 2. 去关联:nattch 减减
    shmdt(addr);
    // 3. 标记删除:若 nattch 为 0 则彻底销毁,否则变为 dest 状态
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}

2.3.4共享内存的控制

2.3.4.1指令删除共享内存

  我们上面讲到了共享内存的生命周期是随内核的,那么进程退出后,如果不显示删除,就会引发内存泄漏 。删除方式有指令法和系统调用控制法。

  指令很简单,我们一笔带过:ipcs -m 查看你的机器上现有的所有共享内存 ,看到shmid这一列,用shmrm -m shmid 删除。

不可以用key来删除共享内存。 ,为什么这么设计,Gemini给出了一个还算不错的解释:

2.3.4.2控制删除共享内存的系统调用

使用方式

cpp 复制代码
#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • shmid:共享内存的标识符(由 shmget返回的句柄)。
  • cmd :控制命令,删除操作使用宏 IPC_RMID
  • buf :由于是删除操作,不需要获取或设置结构体属性,直接传入 NULL 即可。

底层删除机制

  • 即时释放:如果当前共享内存的关联计数(nattch)为 0,调用该命令后,内核会立刻释放该物理内存。
  • 延迟释放(dest 状态) :如果当前仍有其他进程关联(nattch > 0),调用该命令后,内核只会给该内存打上一个删除标记 (在 ipcs -m 中状态变为 dest,同时 key 变为 0x00000000)。此时阻止新进程继续关联,并等待已有进程全部去关联(nattch归零)后,由内核自动完成物理销毁。
2.3.4.3获取共享内存结构体的信息
  • cmd :控制命令,获取信息使用宏 IPC_STAT
  • buf : 输出型参数。必须传入一个用户层定义好的 struct shmid_ds 结构体变量的地址。内核会将底层的内核属性数据拷贝到该结构体中。

  当使用 IPC_STAT 成功返回后,可以通过 buf 指针访问内核暴露出来的关键信息:

  • buf->shm_segsz:共享内存的大小(字节数)。
  • buf->shm_nattch:当前关联的进程数(即引用计数,图中的 nattch)。
  • buf->shm_perm.__key:创建时传入的 IPC 键值(即用户层的 key)。
  • buf->shm_atime / buf->shm_dtime:最后一次挂接(attach)与去关联(detach)的时间戳。
  • buf->shm_cpid / buf->shm_lpid:创建者进程的 PID 以及最后一次操作该共享内存的进程 PID。

2.4共享内存也有缺页中断

  我们都知道,当我们使用 malloc 申请内存时,操作系统会去搞缺页中断那一套来节省物理内存 。共享内存也一样。

  详细讲讲这个过程:

  • 调用shmget ,虚拟空间开辟,进程挂载共享内存,系统仅在虚拟内存管理结构中记录一段地址范围,页表为空,不占用物理内存;
  • 当代码第一次读写该虚拟地址时,MMU 查页表发现未映射,CPU 随即抛出缺页中断并转交内核;
  • 内核接管后会检查该位置是否已有物理页,若没有则新申请一个物理页并填入页表。
  • 若其他进程此前已触发缺页分配了物理页,则内核直接将当前进程的页表也指向这同一个物理页。

三、共享内存的完整demo代码

3.0 我们写的是一个怎样的demo代码?

3.0.1头文件包含关系

3.0.2设计思路

  我们想要让Client端写入共享内存数据,一次写一对 ,一共写26对字符。AA,BB,CC,DD。。。。。Server端读取,一次读取一对字符。

  但是共享内存有一个特点:它对输入输出没有保护机制。

3.0.3共享内存的特点(优缺点)

  • 优点 :共享内存是进程间通信中,速度最快 的方式:
    • 映射之后,读写,直接被对方看到。
    • 不需要进行系统调用获取或者写入内容通信双方(直接用指针访问),没有所谓的"同步机制"。
  • 缺点:共享内存的读端不会等待写端,读端会不断的读取共享内存中的数据,即使共享内存中没有数据。
3.0.3.1共享内存没有保护机制

对比管道 :读端会等待写端,写端没有写完,读端会处在阻塞状态我们可以说共享内存不具有原子性而管道具有。

  现象就是:我们同样用键盘写下字符串"aabbccddeeffgg",按下回车让进程读取进缓冲区,进程再将内容写入到管道/共享内存的这个微观的过程中

  • 没有保护:写进程在写入的这个微观的过程中,比如写到a就被读取进程读取a,写到b就被读取进程读取b。
  • 有保护 :写进程写入"aabbccddeeffgg" ,只有写完了,读取进程才会读取"aabbccddeeffgg"。除非一种情况不具备原子性:"写入内容超出管道的原子性写入大小"。

  没有保护的危害 :如果写端想写 aabbcc ,写到一半(写了 aab ),读端就来读,读到的就是 aab000 (假设后面是空数据)。这种现象叫数据不一致

  非要把共享内存保护起来?怎么做? 后面我们可以用信号量和互斥锁,但是我们到目前为止还没有学到,但是可以用管道来进行保护。在下面的代码中,当写端写完了,通过管道发送整型值,读端读取到后解除阻塞读取共享内存。

3.0.3.2共享内存的读写不需要系统调用

  读写共享内存,并没有出现调用系统调用 。我们的共享内存,在被映射到系统空间中的共享区属于用户空间,可以让用户直接使用。(对比管道:他是系统中的内核缓冲区。)

3.0.4共享内存必须是4KB的整数倍

  在内核中,共享内存在创建的是时候,它的大小,必须是4KB(4096)的整数倍。那我一定要跟系统对着干,我就要4097个字节的内存。他会给你在底层开辟4096*2个字节的内存,然后返回给你4097个字节的内存。为什么这样?害怕你越界访问。

  怎么查看他给我的到底有多少字节的大小?(排版问题,见2.3.4)

3.1 Client.cc

cpp 复制代码
#include "Pipe.hpp"
#include "SharedMemory.hpp"
int main(){
    try{
        SharedMemory Shm(CLIENT , MemKey ,1024*4) ; 
        Shm.AttachMemory();
        PipeOper pipeopt ;
        pipeopt.OpenForWrite(PIPE_FILE);
        char* address = Shm.GetStartMemory();
        int cnt = 26 ;
        int j = 0 ;
        for(int i = 0 ; i < cnt*2 ; i+=2 ){
            address[i] = j+65 ;
            address[i+1] = j+65 ; 
            address[i+2] = '\0';
            pipeopt.WeakUp();
            sleep(1) ;
            j++;
        }
    }
    catch(std::string _error){
        std::cout<<_error<<std::endl;
    }
    catch(...){
        std::cout<<"未知异常"<<std::endl;
    }
    return 0 ;
}

3.2 Server.cc

cpp 复制代码
#include "Pipe.hpp"
#include "SharedMemory.hpp"
int main(){
    try{
        SharedMemory Shm(SERVER, MemKey, 1024 * 4);
        Shm.AttachMemory();
        NamedPipe fifo(PIPE_FILE);
        PipeOper pipeopt;
        pipeopt.OpenForRead();
        const char* address = Shm.GetStartMemory();
        while(true){
            if(pipeopt.Wait() ==GETUP){
                printf("%s\n",address);
            }
            else if(pipeopt.Wait() == 0)
                break;
        }
        pipeopt.Close();
    }
    catch (std::string _error){
        std::cout << _error << std::endl;
    }
    catch (...){
        std::cout << "未知异常" << std::endl;
    }
    return 0;
}

3.3 SharedMemory.hpp

cpp 复制代码
#pragma once
#include "Comman.hpp"
class SharedMemory{
private:
    void CreateHelp(const int Mode ){
        _shmid = shmget(_key , _size , Mode);
        if(_shmid == -1) ERR_MEMORY("创建共享内存失败");
    }
    void CreateMemory(){ 
        umask(0);
        CreateHelp(IPC_CREAT | IPC_EXCL | 0666 ) ;
        std::cout<<"创建共享内存成功!" << std::endl ;
    }
    void GetMemory(){ 
        CreateHelp(IPC_CREAT | 0666 ) ;
        std::cout<<"获取共享内存成功!" << std::endl ;
    }
    void AttachFunc(){
        _StartMemory = (char*)shmat(_shmid , nullptr ,0 );
        if(_StartMemory == (char*)-1) 
            ERR_MEMORY("虚拟-物理内存关联失败") ;
        std::cout<<"创建虚拟-物理内存关联成功"<<std::endl ;
    }
    void DetachFunc(){
        if (_StartMemory != nullptr) {
            int n = shmdt(_StartMemory);
            if (n == -1) 
                std::cerr << "【错误】取消虚拟-物理内存关联失败: " << strerror(errno) << std::endl;
            else {
                std::cout << "取消虚拟-物理内存关联成功" << std::endl;
                _StartMemory = nullptr; 
            }
        }
    }
    void DestoryMemory(){
        int n = shmctl(_shmid, IPC_RMID, nullptr);
        if (n == -1) 
            std::cerr << "【错误】销毁共享内存失败: " << strerror(errno) << std::endl;
        else 
            std::cout << "回收共享内存成功" << std::endl;
    }
public:
    SharedMemory(int identity , int key ,int size)
        :_key(key)
        ,_size(size)
        ,_shmid(0)
        ,_StartMemory(nullptr)
        ,_identity(identity){
        if (identity == SERVER)
            CreateMemory();
        else if (identity == CLIENT)
            GetMemory();
        else ERR_MEMORY("未知身份创建共享内存") ;
    }
    void AttachMemory(){ AttachFunc(); }
    ~SharedMemory(){
        DetachFunc() ;
        if(_identity == SERVER) {
            DestoryMemory() ;
        }
    }
    char* GetStartMemory() { 
        std::cout<<"获取共享内存首地址成功"<<std::endl ;
        return _StartMemory ; 
    }
    const int GetSize(){
        std::cout<<"获取共享内存大小成功"<<std::endl ; 
        return _size ; 
    }
private:
    int _shmid ;
    const key_t _key ;
    const int _size ;
    char* _StartMemory ;
    int _identity ;
};

  为什么要设置权限位? 这是一个很别扭的设计,关于为什么我放在了SystemV的总结篇章中。

3.4 Makefile

bash 复制代码
OBJECT_1 = Server
OBJECT_2 = Client
SOURCE_1 = Server.cc
SOURCE_2 = Client.cc

ALL : $(OBJECT_1) $(OBJECT_2)
OBJECT_1 : $(SOURCE_1)
	g++ -o $@ $^
OBJECT_2 : $(SOURCE_2)
	g++ -o $@ $^
.PHONY: clean
clean : 
	rm -f $(OBJECT_1) $(OBJECT_2) fifo

3.5 Comman.hpp

cpp 复制代码
#pragma once
#include<iostream>
#include<unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>
#include<string>
#include<cstring>
#include <fcntl.h>
#include<cstdio>

#define PIPE_FILE "./fifo"

#define ERR_MEMORY(m) do{           \
    std :: string _exception = m ;  \
    throw _exception ;              \
} while(false)                      



const char* _pathname = ".";
const int object_id = 0x01 ;

const  key_t MemKey = ftok(_pathname ,object_id);

#define SERVER 1
#define CLIENT 2
#define GETUP  1

3.6 Pipe.hpp

cpp 复制代码
#pragma once
#include "Comman.hpp"
class NamedPipe{
public:
    NamedPipe(std ::string file = PIPE_FILE)
        :_file(file){
        umask(0);
        int n = mkfifo(file.c_str(), 0666);
        if (n == -1)
            ERR_MEMORY("创建管道失败");
        else
            std::cout<<"创建管道成功"<<std::endl;
    }
    ~NamedPipe(){
        int n = unlink(_file.c_str());
        if (n == -1)
            std::cerr << "【错误】销毁管道失败: " << strerror(errno) << std::endl;
        std::cout<<"销毁管道成功"<<std::endl;
    }
private:
    std ::string _file ;
};
class PipeOper{
private:
    void OpenHelp(int Mode,std ::string file = PIPE_FILE){
        _pipefd = open(file.c_str(), Mode);
        if (_pipefd == -1 && Mode == O_RDONLY)
            ERR_MEMORY("以读取方式打开管道失败");
        else if (_pipefd == -1 && Mode == O_WRONLY)
            ERR_MEMORY("以写入方式打开管道失败");
        else
            std::cout<<"以打开管道成功"<<std::endl;
    }
public:
    PipeOper(){}
    ~PipeOper(){}
    void OpenForRead(std ::string file = PIPE_FILE){ OpenHelp(O_RDONLY,PIPE_FILE); }
    void OpenForWrite(std ::string file = PIPE_FILE){ OpenHelp(O_WRONLY,PIPE_FILE); }
    void Close(){ close(_pipefd); }
    int Wait(){
        int sign = 0 ;
        int n = read(_pipefd , &sign, sizeof(sign));
        if(n == -1) 
            ERR_MEMORY("信道读取失败");
        else if(n == 0)
            return 0 ;
        // else
        //    std::cout<<"信道读取成功"<<std::endl;
        return sign ;
    }
    void WeakUp(){
        int sign = GETUP ;
        int n  = write(_pipefd , &sign , sizeof(sign));
        if(n == -1)
            ERR_MEMORY("信道写入失败");
        std::cout<<"信道写入成功"<<std::endl;
    }
private:
    int _pipefd ;
};

好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye! Linux、C++、算法持续连载中,欢迎关注WeChat Official Account 【此方的技术栈】。