【Linux】进程间通信(三)System V 共享内存完全指南:原理、系统调用与 C++ 封装实现
文章目录
- 共享内存
- 创建共享内存系统调用
- 利用共享内存实现进程间通信
- 创建共享内存(共享内存生命周期)
- 辨析key和shmid用法
- 删除共享内存
- 映射(挂接)共享内存(设置共享内存权限)
- 移除共享内存的挂接
- 获取共享内存
- 通信实现
- 源码
- 获取共享内存属性信息
- 共享内存的特征
共享内存
(基于文件+内存块的进程间通信方案)
我们先大致认识一下共享内存,后面再解释原理。共享内存其实就是物理内存中的一块空间,它会映射到进程地址空间的共享区中。
1、共享内存不同于管道只能在两个进程之间通信,它可以让多个进程同时进行通信。
2、它的大致操作顺序是先在内存中开辟共享内存空间,然后建立物理内存到进程虚拟内存的映射,通信完毕后再回收共享内存。
3、共享内存由操作系统直接管理,和我们之前学的进程、文件一样,学习共享内存就是学习它的各种系统调用接口。内存中会存在大量的共享内存,OS要管理它们也要遵循先描述再组织,所以OS中也一定有维护共享内存的内核数据结构,后面小编再细讲。

创建共享内存系统调用
我们先看创建共享内存的系统调用接口:

第二个参数是要创建的共享内存的大小,一般是4096字节的整数倍,因为操作系统内核会自动对共享内存的大小作4kb对齐,比如你要4097个字节,os也会为你开辟8192个字节的空间(但是你自己实际只能用4097字节)。具体原因等到线程部分再讲。
第三个参数是标志位,本质是一个宏或者位图,在介绍之前小编先讲一个结论,我们在最开始讲解进程间通信的时候就说过,进程间通信是让不同的进程之间能看到同一份资源,所以不能让每个进程都创建资源,而是让其中一个进程创建资源,其他进程获取这个资源,这样实现进程间通信(匿名管道是父进程创建,子进程获取,命名管道是server创建,client获取)。下面介绍用的最多的两个标志位:
IPC_CREAT:如果key索引的共享内存不存在,则会创建一个新的共享内存,索引为key,如果key索引的共享内存已经存在,则会获取该共享内存。 IPC_EXCL:单独使用没有意义。 IPC_CREAT |
IPC_EXCL:如果key索引的共享内存不存在,则会创建一个新的共享内存,索引为key,如果key索引的共享内存已经存在,则会出错返回。
如果shmget调用成功,会返回一个标识创建或者获取到的共享内存的内存标识符(shmid),失败返回-1,内存标识符有点类似于文件描述符,但是它们本质不是一个东西,后面讲原理时再细说。
第一个参数小编放到最后讲,因为它是重点,这是我们理解共享内存机制最重要的步骤。
1、我们知道要进行进程间进行通信需要让不同的进程看到同一份资源,那一个进程创建好共享内存后如何让其他进程看到呢?这就需要一个能唯一标识共享内存的变量(好比命名管道的路径+文件名),这个变量就是shmget的第一个参数键值key。
2、这个参数只能由用户自己传递,不能让OS自动生成,因为OS自动生成后只有创建共享内存的进程能拿到,如果要让其他进程看到就需要通过进程间通信传递,但是我们现在就是在利用共享内存实现进程间通信,所以这里就是一个悖论,就像人类不能左脚踩右脚上天一样。
3、用户自己约定好一个key值后就可以让其他进程拿着这个key值获取唯一的共享内存,从而实现进程间通信。
4、key值的生成理论上可以由用户自由指定,但是linux还是为我们提供了一个库函数ftok,用于获取key值,可以把它简单理解成一个随机数生成函数,但是当它的参数固定后它生成的随机数也就固定了,所以用户可以约定好ftok的两个参数,让不同的进程都调用ftok并传入约定好的参数就能得到唯一标识共享内存的键值key了。从而就可以拿着唯一的键值访问唯一的共享内存了。
(但是实际上不同的共享内存之间键值是可能冲突的,所以IPC_CREAT、 IPC_EXCL标志位,出错了就需要将ftok的参数改一改,重新获取键值)

5、键值key用于唯一标识共享内存,所以key会被存入共享内存的内核数据结构中,如下所示:

利用共享内存实现进程间通信
创建共享内存(共享内存生命周期)
我们下面就直接封装共享内存了:
//Shm.hpp
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string>
#include <stdio.h>std::string gpathname = ".";
int gproj_id = 0x66;
int gdefaultsize = 4096;class SharedMemory
{
public:SharedMemory(int size = gdefaultsize) : _key(0), _size(size), _shmid(-1){}bool Create(){// 获取键值key_key = ftok(gpathname.c_str(), gproj_id);if (_key < 0){perror("ftok");return false;}printf("形成键值成功: 0x%x\n", _key); // 16进制形式打印// 根据key创建共享内存_shmid = shmget(_key, _size, IPC_CREAT | IPC_EXCL); // 创建新的共享内存if (_shmid < 0){perror("shmget");return false;}printf("创建共享内存成功,shmid:%d\n", _shmid); return true;}~SharedMemory(){}private:key_t _key; // 共享内存的键值int _size; // 共享内存的大小int _shmid; // 共享内存的内存标识符
};
//server.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Create();sleep(1);return 0;
}
运行结果:

我们可以看到第一次创建共享内存时成功了,但是后面继续创建却失败了,失败原因是共享内存已经存在了。
这里小编需要引出一个共享内存的特点,我们知道进程退出后一般资源会被自动释放,所以我们只对常驻内存的进程,比较关心它们的内存泄漏问题。
但是共享内存的生命周期不是随进程,它是随OS内核的,所以当第一个server进程退出后,键值为0x6601279a的共享内存还在内存中,自然后面getshm就会报错了。
下面是几组有关共享内存的指令: ipcs -m :列出系统中所有 System V 共享内存。(-m 表示共享内存)

ipcrm -m + shmid :删除指定shmid的共享内存。
辨析key和shmid用法
key:只给内核使用,存入shm的内核数据结构中,用来标识 shm 的唯一性。
shmid:给用户使用,用来进行访问 shm。
删除共享内存
我们前面只介绍了如何用指令删除共享内存,除此之外我们还需要在代码层面删除共享内存,这就需要另一个系统调用shmctl:

共享内存也和进程、文件一样,也分为管理共享内存的内核数据结构(属性)+内存块,shmcrl就是主要用来修改共享内存属性的,或者对共享内存整体做删除。
第一个参数是待控制共享内存的内存描述符。
第二个参数是要对共享内存进行什么操作,需要传入对应的宏,我们要删除共享内存就传入 IPC_RMID (remove imediately),并把第三个参数用来设置或者获取共享内存的属性,删除操作就直接设为nullptr。
//class SharedMemorybool Removeshm(){int n = shmctl(_shmid, IPC_RMID, nullptr);if(n < 0){perror("shmctl");return false;}std::cout << "删除shm成功" << std::endl;return true;}
映射(挂接)共享内存(设置共享内存权限)
我们创建共享内存后不能直接对shmid进行读写,因为它不是真正的文件描述符,还需要把共享内存挂接到虚拟内存中,挂接共享内存的系统调用如下(at表示attach):

它会将指定共享内存挂接到调用shmat的进程的进程地址空间中,需要注意shmat失败时返回的是 void* 类型的-1,所以在判断shmat是否出错把返回值和-1进行比较时要把返回值进行强转,但是不能直接强转为int,因为当前linux系统默认是64位的,指针类型是8字节,所以不能把8字节的void*强转为4字节的int,因为可能导致指针数据截断(高位数据丢失),所以最好的做法是把指针强转为intptr_t类型,它是专门为 “存储指针的整数表示” 设计的类型,大小与指针一致。
其他shmat的注意事项都在图中有说明。
//class SharedMemorybool Attach(){_start_addr = shmat(_shmid, nullptr, 0);if ((intptr_t)_start_addr == -1){perror("shmat");return false;}std::cout << "将共享内存挂接到指定进程的地址空间" << std::endl;return true;}
但是现在进行挂接时系统会提示没有权限,这是因为我们在创建共享内存时没有对它设置权限,权限默认为0,虽然没有权限可以正常创建删除共享内存,但是一但访问共享内存就会报错,比如这里的挂接操作。所以我们需要在创建共享内存时就设置权限(操作shmget的第三个参数):
bool Create(){// 获取键值key_key = ftok(gpathname.c_str(), gproj_id);if (_key < 0){perror("ftok");return false;}printf("形成键值成功: 0x%x\n", _key); // 16进制形式打印// 根据key创建共享内存_shmid = shmget(_key, _size, IPC_CREAT | IPC_EXCL | 0666); // 创建新的共享内存if (_shmid < 0){perror("shmget");return false;}printf("创建共享内存成功,shmid:%d\n", _shmid);return true;}
现在我们来用server调用一下主体逻辑:
//server.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Create();sleep(3);sm.Attach();sleep(3);sm.Removeshm();sleep(3);return 0;
}
运行结果:

移除共享内存的挂接
上面程序还差最后一步:移除共享内存的挂接,这就完成了对共享内存生命周期的整体管理。
移除操作系统调用:

//class SharedMemorybool Detach(){int n = shmdt(_start_addr);if (n < 0){perror("shmdt");return false;}std::cout << "将指定共享内存从进程地址空间中移除" << std::endl;return true;}
获取共享内存
我们上面已经完成了server对共享内存的管理工作,那么要通信也需要client获取到server创建的共享内存,其实client整体管理工作和server类似,只是不需要自己创建、回收共享内存。
这里server的Create和client的Get的大致逻辑类似,只有shmget第三个参数标志位的传递有区别,所以我们把创建\获取共享内存的核心逻辑写成CreateHelper,让Create和Get传不同的标志位调用CreateHelper就行了。
//Shm.hpp
class SharedMemory
{
private:bool CreateHelper(int flags){// 获取键值key_key = ftok(gpathname.c_str(), gproj_id);if (_key < 0){perror("ftok");return false;}printf("形成键值成功: 0x%x\n", _key); // 16进制形式打印// 根据key创建共享内存_shmid = shmget(_key, _size, flags); // 创建新的共享内存if (_shmid < 0){perror("shmget");return false;}printf("创建共享内存成功,shmid:%d\n", _shmid);return true;}public:SharedMemory(int size = gdefaultsize): _key(0), _size(size), _shmid(-1), _start_addr(nullptr){}bool Create(){return CreateHelper(IPC_CREAT | IPC_EXCL | 0666);}bool Get(){//存在则获取return CreateHelper(IPC_CREAT);}bool Removeshm(){int n = shmctl(_shmid, IPC_RMID, nullptr);if (n < 0){perror("shmctl");return false;}std::cout << "删除shm成功" << std::endl;return true;}bool Attach(){_start_addr = shmat(_shmid, nullptr, 0);if ((intptr_t)_start_addr == -1){perror("shmat");return false;}std::cout << "将共享内存挂接到指定进程的地址空间" << std::endl;return true;}bool Detach(){int n = shmdt(_start_addr);if (n < 0){perror("shmdt");return false;}std::cout << "将指定共享内存从进程地址空间中移除" << std::endl;return true;}~SharedMemory(){}private:key_t _key; // 共享内存的键值int _size; // 共享内存的大小int _shmid; // 共享内存的内存标识符void *_start_addr; // 共享内存挂接到虚拟内存的起始地址
};
//server.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Create();sleep(3);sm.Attach();sleep(3);sm.Detach();sleep(3);sm.Removeshm();sleep(3);return 0;
}
//client.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Get();sleep(3);sm.Attach();sleep(3);sm.Detach();sleep(3);return 0;
}
至此我们已经把信道创建完毕,接下来就进行通信了。
通信实现
现在共享内存已经映射到进程地址空间的共享区了,那么用户就可以直接拿着共享内存的虚拟起始地址访问共享内存进行读写通信了,就像使用malloc开辟的空间一样,也就是说访问共享内存是不需要系统调用的,相反管道通信访问的是内核级文件,所以需要系统调用read、write。
下面我们实现通信功能模块,在SharedMemory中实现两个接口:pushchar对共享内存作写入,popchar读取共享内存的数据。
因为读端和写端都需要各自有一个读写标志位,所以定义两个成员变量:_windex,_rindex,还需要一个成员变量num记录共享内存中的数据总数,以可以在读写的时候检测num,以免共享内存已经满了还往内存中写数据,或者共享内存已经空了还读取共享内存中的数据。但是这里不能直接在SharedMemory中定义一个整型变量num,因为读进程和写进程是独立的,互相都不能看到和影响对方的num,所以小编这里的实现思路是把共享内存块中的前四个字节预留给整型变量num,剩余的空间用于传输数据。所以还需要两个成员变量:int、*类型_num指向共享内存的最开头,char*类型的_start_char指向内存块数据区的开头,也就是_num往后偏移4个字节。(因为我们传输字符数据所以用char*类型)
源码
//shm.hpp
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string>
#include <stdio.h>std::string gpathname = ".";
int gproj_id = 0x66;
int gdefaultsize = 4096;class SharedMemory
{
private:bool CreateHelper(int flags){// 获取键值key_key = ftok(gpathname.c_str(), gproj_id);if (_key < 0){perror("ftok");return false;}printf("形成键值成功: 0x%x\n", _key); // 16进制形式打印// 根据key创建共享内存_shmid = shmget(_key, _size, flags); // 创建新的共享内存if (_shmid < 0){perror("shmget");return false;}printf("创建共享内存成功,shmid:%d\n", _shmid);return true;}public:SharedMemory(int size = gdefaultsize): _key(0), _size(size - 4) // 刨除内存块前四个存储num整型变量的字节空间,_shmid(-1), _start_addr(nullptr), _num(nullptr), _start_char(nullptr), _windex(0), _rindex(0){}bool Create(){return CreateHelper(IPC_CREAT | IPC_EXCL | 0666);}bool Get(){// 存在则获取return CreateHelper(IPC_CREAT);}bool Removeshm(){int n = shmctl(_shmid, IPC_RMID, nullptr);if (n < 0){perror("shmctl");return false;}std::cout << "删除shm成功" << std::endl;return true;}bool Attach(){_start_addr = shmat(_shmid, nullptr, 0);if ((intptr_t)_start_addr == -1){perror("shmat");return false;}std::cout << "将共享内存挂接到指定进程的地址空间" << std::endl;_num = (int *)_start_addr;_start_char = (char *)_start_addr + sizeof(int);return true;}bool Detach(){int n = shmdt(_start_addr);if (n < 0){perror("shmdt");return false;}std::cout << "将指定共享内存从进程地址空间中移除" << std::endl;return true;}// 传输端void pushchar(char ch){if ((*_num) >= _size)return;*(_start_char + _windex++) = ch;*(_start_char + _windex) = '\0';_windex %= _size;std::cout << "ch: " << ch << " _windex " << _windex << std::endl;(*_num)++;}// 接受端void popchar(char *ch) // 输出型参数{if ((*_num) <= 0)return;*ch = *(_start_char + _rindex++);_rindex %= _size;(*_num)--;}~SharedMemory(){}private:key_t _key; // 共享内存的键值int _size; // 共享内存的大小int _shmid; // 共享内存的内存标识符void *_start_addr; // 共享内存挂接到虚拟内存的起始地址int *_num; // 指向共享内存块的前4个字节开头,表示共享内存中的字符总数char *_start_char; // 若共享内存总内存为4096,则它指向后4092字节的首位置int _windex; // 读位置(把文件当作数组,此为数组下标)int _rindex; // 写位置
};
//server.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Create();sleep(3);sm.Attach();sleep(3);//通信(接收端)while(true){char ch;sm.popchar(&ch);printf("server get a char: %c\n", ch);sleep(1);}sm.Detach();sleep(3);sm.Removeshm();sleep(3);return 0;
}
//client.cpp
#include "Shm.hpp"int main()
{SharedMemory sm;sm.Get();sleep(3);sm.Attach();sleep(3);//通信(输出端)char ch = 'A';for(; ch <= 'Z'; ch++){sm.pushchar(ch);sleep(1);}sm.Detach();sleep(3);return 0;
}
获取共享内存属性信息
因为共享内存的属性是在操作系统内核的,所以获取共享内存属性也需要系统调用,一般我们用shmctl获取属性:

这和删除共享内存是用的同一个系统调用接口,只不过要获取属性信息第二个参数要传IPC_STAT,示例如下:
void PrintAttr(){struct shmid_ds ds;int n = shmctl(_shmid, IPC_STAT, &ds);if(n < 0){perror("shmctl");return;}printf("size: %ld\n", ds.shm_segsz);printf("key: 0x%x\n", ds.shm_perm.__key);}
共享内存的特征
1、共享内存生命周期随内核,除非手动删除或OS退出,否则共享内存一直存在。
2、共享内存是所有ipc中最快的,因为一方把数据通过虚拟地址写进内存,另一方不需要任何操作就可以马上看到。其一是因为共享内存不用像管道一样把数据拷贝到内核。其二是共享内存实现通信时不需要任何系统调用,不像管道需要read、write。
3、共享内存没有同步、互斥机制来对多个进程的访问进行协同,例如有同步机制是管道在写端打开但没有写数据时读端是会被阻塞的,而共享内存在写端没有写数据时会一直读取,不会阻塞,这就可能造成一端数据还没写全另一端就把数据读走了。
第三点是有解决办法的,比如利用前面用匿名管道写的进程池,我们可以把共享内存挂接到所有进程的进程地址空间中,父进程负责往共享内存中写数据,当父进程写完毕后就可以利用匿名管道向特定子进程发送信息让指定进程读共享内存中的数据,利用管道的同步机制间接让共享内存具有同步性。
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

