Ⅰ. 前言
这里我们介绍的这种通信方式也就是 system V IPC
在我们后面的使用和日常见到的其实并不多,但是包括其中的共享内存、消息队列、信号量,我们如果了解共享内存其原理的话,能够更好的帮助我们了解之前我们学过的进程地址空间的概念!
至于信号量,我们后面讲多线程的时候会再次讲,我们只引入一些概念如互斥等等,而消息队列我们就只说说其原理,不会细讲!
Ⅱ. 认识共享内存
1、共享内存的原理
之前我们学过管道通信,分为匿名管道和命名管道,匿名管道通过父子进程的属性继承原理来完成父子进程看到同一份资源的目的,而命名管道则是通过路径与文件名来唯一标识管道文件,来让不同的进程之间进行通信!
而共享内存也是一样,我们得让不同的进程看到同一份资源,但是这次我们不是使用继承还是文件名路径来标识,而是通过在内存中的一段空间:共享内存区中申请一段空间,并且进程可以通过获得一个唯一的标识 ID 来获得这段共享内存的位置,当多个进程同时获得这段共享内存的时候,我们就称它们通过共享内存看到了同一份资源!这里面有许多的细节,我们一一来解释!
在 Linux 中,首先我们假设这里有两个进程分别被调度,那么它们就有各自对应的进程控制块 (PCB) 和地址空间 (mm_struct) 并且都有一个与之对应的页表,负责将进程的虚拟地址与物理地址进行映射,通过内存管理单元 (MMU) 进行管理,由于两个进程拥有独立的数据结构,所以我们可以知道其是有独立性的!如下图所示:
但是这里就要一个问题了!既然两个进程的之间的数据结构是相互独立的,我们学过进程地址空间也知道,如果它们指向了同一段物理空间,那么其中一方进行修改,是会发生写时拷贝的,并不会影响另一方,那么这样子我们如何进行通信呢 ❓❓❓
所以为了让两个毫不相干的进程能看到同一份资源,操作系统会做以下几个工作:
- 在物理内存当中申请一段共享内存空间
- 将创建好的共享内存空间通过页表映射到进程的进程地址空间(这个过程叫做挂接)
- 不同的进程通过操作各自的进程地址空间中的该段共享内存空间的虚拟地址,来操作共享内存
- 如果某个进程不想通信了,那么就将该进程与共享内存的映射取消掉(去关联),如果需要的话再将共享内存释放掉(看是否其他进程还在通信)
那么就会有人问,调用 malloc
函数不也能在内存中开辟一段空间并且和进程之间映射起来吗 ❓❓❓
答案肯定是不行的!因为我们 malloc
出来的空间,只是属于某个进程的,而进程之间具有独立性,所以其他进程是压根看不到这份资源的,就算看到了,那么也不能进行通信,因为存在写时拷贝,而共享内存是特殊的,是运行不同进程之间共同操作的一段空间!
2、linux中共享内存的数据结构
在 linux 中,共享内存也是需要被管理的,就像我们的进程控制块、文件描述符等等都是遵循一个原则:先描述、再组织!
在 Linux 内核中,每个共享内存都由一个名为 struct shmid_kernel
的结构体来管理 (shmid表示共享内存的id),而且 Linux 限制了系统最大能创建的共享内存为 128 个。通过类型为 struct shmid_kernel
结构的数组来管理,其中 struct shmid_ds
结构体用于 管理共享内存的属性信息,而 shm_segs数组
用于管理系统中所有的共享内存。
另外 struct shmid_ds
结构体中存在另一个结构体 struct ipc_perm
,它存储确定执行 IPC 操作的权限所需的信息!
这几个结构体如下:
struct shmid_kernel
{
struct shmid_ds u;
/* the following are private */
unsigned long shm_npages; /* size of segment (pages) */ // 表示共享内存使用了多少个内存页
pte_t *shm_pages; /* array of ptrs to frames -> SHMMAX */ // 指向了共享内存映射的虚拟内存页表项数组
struct vm_area_struct *attaches; /* descriptors for attaches */ //
};
static struct shmid_kernel *shm_segs[SHMMNI]; // SHMMNI等于128
struct shmid_ds {
struct ipc_perm shm_perm; /* operation perms */ // 该空间的所有权等属性,其中包括了用来唯一标识的key
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 */ // 创建该空间的进程pid
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */ // 最后一个关联或者挂接该空间的进程pid
unsigned short shm_nattch; /* no. of current attaches */ // 当前空间的挂接数
......
};
struct ipc_perm {
key_t __key; /* Key supplied to shmget(2) */ // 这个就是用来标识shmid唯一性的key
uid_t uid; /* Effective UID of owner */
gid_t gid; /* Effevtive GID of owner */
uid_t cuid; /* Effective UID of creator */
gid_t cgid; /* Effective GID of creator */
unsigned short mode; /* Permissions + SHM_DEST and SHM_LOCKED flags */
unsigned short __seq; /* Sequence number */
}
在我们讲接口之前看到这些可能会疑惑,但是在后面讲完接口调用的时候,这里对我们来说比较重要的就是这个 shmid_ds
以及 ipc_perm
中的 __key
!
3、理解共享内存的概念
从上面的讲解我们可以得到:通过让不同的进程,看到同一个内存块且进行通信的方式,叫做共享内存!
下面我们提出几个概念,为了后面我们引出共享内存申请、调用等等的内容做铺垫:
-
进程间通信,是程序员们专门设计来实现 IPC 的,这和 malloc 虽然理论和思想上差不多,但是却有完全不一样的作用,进程间通信主要是为了解决进程间共享的内存问题。
-
共享内存是一种通信方式,所有想通信的进程都可以使用。
-
操作系统中可能会同时存在很多的共享内存,就像我们以前每家每户都有一部专属电话来沟通一样。
那么第一个概念其实就是想说,既然专门设计了进程间通信,那么肯定底层会有对应的接口供我们使用!
而后面两个概念就是为了帮助我们理解这些接口参数的概念!下面我们来介绍一下这些接口!
Ⅱ. 共享内存的使用
首先我们得知道共享内存的一些特性:
- 共享内存是以覆盖写的方式
- 共享内存的声明周期随操作系统,而不是随进程(也就是说不需要使用的时候需要手动释放,不然会造成内存耗尽问题,如何释放我们下面会说)
接下来我们先来看看在 linux 下如何查看对应的 共享内存空间
、消息队列
以及 信号量
!方便我们调用接口的时候查看!
⭐ 共享内存的查看 – ipcs指令
ipcs
指令的作用是报告进程间通信设施的状态,包括共享内存、消息队列以及信号量等等!
下面我们来看一下 ipcs
的指令选项,这里只使用几个比较常见的,其他的选项可以参考下面 ipcs -help
中的!
① 列表说明
- key :用来传递给shmid的编号
- msqid :消息队列的编号
- shmid :共享内存段的编号
- semid :信号量数组的编号
- owner :创建该空间的用户
- perms :权限
- nattch :挂接数,也就是连接到共享内存的进程个数
- status :共享内存段的状态(是否有进程关联等等)
- bytes :创建的大小
- used-bytes :消息队列已使用的大小
- nsems :对应信号量数组中信号量的个数
② 查看帮助:ipcs -help
③ 三类资源查看方式
ipcs -m
:单独查看共享内存段(Shared Memory Segments)ipcs -q
:单独查看消息队列(Message Queue)ipcs -s
: 单独查看信号量数组(Semaphore Arrays)ipcs -a
或ipcs
: 查看所有的资源(设施)
④ 资源选项和输出选项可以搭配使用
这里以 -c
显示创建者和拥有者为例:ipcs -c
和 ipcs -c -s
,其它选项如 -t、-p、-l、-u、-b 也是同理的!
⑤ 通过选项 -i 打印资源的详细信息
使用 -i 选项的时候要配合 semid 或者 shmid 一起使用,而不能一起单独使用!
⭐ 共享内存段的删除 – ipcrm指令
本来这个共享内存段的删除内容是要在后面说的,这个顺序会比较合理一点,但是还是觉得不卖关子,我们先把删除解决了,并且要提一下为什么要进行删除操作!
之前我们说过一个进程如果不想使用该共享内存段了,那么就得将该进程与该共享内存的映射去掉,也就是去关联,这是为了防止我们后面做了不当的操作影响到该共享内存中的其它进程通信!
除此之外,共享内存段的生命周期是随操作系统的,而不是随进程的(System V版本的通信生命周期都是随操作系统的),也就是说就算没有进程指向该共享内存段,这个内存段也是会存在的,那么如果我们不手动对其释放,那么就要等到操作系统关闭的时候才能关闭,而共享内存段是占有内存大小的,所以这极有可能成为内存耗尽的隐患!所以当我们不想使用该段空间的时候,我们就得手动将其释放掉,而这就要借助到 ipcrm 指令了!
ipcrm -m shmid编号
注意共享内存只有在当前映射链接数为0时才会被删除释放!
那么这里可能会有人问,为什么不是删除对应的 key 编号而是 shmid 编号呢 ❓❓❓
这里我们就得搞清楚 key 和 shmid 的关系:
key 只是用来在 OS 层面上唯一标识的,不能用来管理共享内存的;
shmid 是 OS 给用户用来标识共享内存段的 id,用来在用户层进行共享内存段的管理的!
因为我们是用户,所以我们是在用户层进行操作,只能通过 shmid 编号来进行删除!
🐇 补充: ipcrm -a
表示释放所有进程间通信的资源
1、shmget函数 – 获取共享内存段标识符
#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
// 作用:得到一个共享内存标识符,或者创建一个共享内存对象并返回共享内存标识符
// 返回值:获取成功则返回一个非负数,即共享内存标识符,取决于shmflg的参数(不同操作系统返回值不同);获取失败则返回-1,并且设置错误码errno
我们来单独看看这个函数的参数:
- key:一个需要我们传递的用来保证共享内存的唯一性的(一般我们用 ftok 函数来获取,下面会讲)
- size:要创建的这段共享内存的大小(以字节为单位),设为 0 时表示只获取共享内存
- shmflg:表示获取共享内存的时候的选项标志位(与 open 等函数接口参数类似,其实就是宏,并且可以用按位或连接不同的选项)
0
:表示只取共享内存标识符,若不存在则会报错IPC_CREAT
:表示如果存在与 key 相等的共享内存空间则直接返回其内存标识符,若不存在则创建一个共享内存并返回其标识符IPC_CREAT | IPC_EXCL
:表示如果存在与 key 相等的共享内存空间则报错,若不存在则创建一个共享内存并返回其标识符(IPC_EXCL
单独使用的时候没有意义)访问权限
:注意这里我们 一般都是要或上这个访问权限的,就是这段共享内存的权限,和文件权限是一样的!如 0666、0664 等等
💥参数的细节:
这里的参数其实有多个细节,我们先来谈一下,后面就不会再细谈了!
先来谈谈这里的 size,一般来说,共享内存的大小我们都是建议是 4KB 的整数倍,因为系统分配共享内存是以 4KB 为单位的(这其实是内存划分内存块的基本单位 page,这个我们后面会讲!)
那么如果我们将其大小设为 4099 个字节而不是 4096 个字节会发生什么呢 ❓❓❓
内核在分配大小的时候会进行以 4KB 为单位向上取整,也就是 8192 字节即 8KB,但是我们会发现创建出来的共享内存打印大小的时候还是 4099 字节啊,这和我们想的不符合啊,为什么不是 8192 字节呢 ❓❓❓
其实就是因为 4099 字节只是我们申请的在共享内存段中可使用的大小,但是实际上 OS 还是会为我们在内存中申请 8192 字节大小的内存空间,但是我们只能使用 4099 字节大小的空间!
再来谈一谈这个 key,首先我们这个 shmget 函数就是为了得到一个唯一标识的共享内存段标识符,但是我们怎么保证它就是唯一的呢 ❓❓❓
其实就是通过这个 key,key 是多少不重要,最重要的是 key 要能进行唯一性标识,而这个 key 是通过我们下面会讲的 ftok 函数得到的,操作系统会将文件路径与项目标识符转化为一个 System V IPC 的 key!
2、ftok函数 – 获取唯一标识符key
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
// 作用:这个函数会根据传的路径名和id值,通过算法形成一个key值
// 返回值:成功的话返回这个得到的key值(key_t其实就是int),失败的话返回-1,并设置错误码errno
// 参数:
// pathname:自定义路径,但是必须是一个存在的路径,不一定是当前文件的路径
// proj_id:自定义id,但是必须是非0
这样子只要我们让想要通信的进程,通过相同的 pathname 和 proj_id 传给 ftok 函数,那么其返回的必定是一个相同的 key,因为底层使用的算法是不变的!再使用 shmget 函数获取共享内存段标识符,这样子就能让不同的进程看到同一份资源!
注意:key_t 类型其实就是 int 类型!
那么此时会有问题,既然 key 是一个唯一用来标识这个共享内存的位置的,那么为什么还要用 shmid 呢 ❓❓❓
我们可以先举个例子,我们一般在学校里用的是学号,在公司里用的是工号等等,为什么不直接使用我们的身份证呢,因为这样子的话即使我们的身份证被修改了,我们在学校、公司等场合用的也不是身份证,这种现象就叫做 低耦合,也是一种常见的编码思维!
我们的 key 是在内核层用来标定唯一性的,而 shmid 是在用户层来标定唯一性的,哪一天我们的操作系统可能改了,那我们用户层很多标识符就都得改了,但是如果每个软件都有各自的 shmid 的话而不是以统一的 key 为标识的话,这样子一来耦合性就降低了很多,当内核层出现问题时用户层也不必大费周章!
3、shmctl函数 – 完成对共享内存的控制(control)
#include <sys/type.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 作用:对共享内存进行控制,如删除共享内存、得到或修改共享内存的状态
// 返回值:执行成功则返回0,失败返回-1,并且设置错误码errno
其中参数为:
- shmid :要控制的共享内存的shmid
- cmd :要进行控制的选项,这里介绍三个,如下:
IPC_STAT
:得到共享内存的状态,把共享内存的 shmid_ds 结构复制到 buf 中IPC_SET
:改变共享内存的状态,把 buf 指向的 shmid_ds 结构中的 uid、gid、mode 复制到共享内存中的 shmid_ds 结构体内IPC_RMID
:删除该共享内存
- buf :是一个
struct shmid_ds*
也就是我们上面讲过的结构体用于管理共享内存的属性信息,如果我们想要获取这个共享内存的属性等则可以创建一个该类型的结构体传递过去,若不想获取任何内容的话可以直接设为 NULL。
💘注意: 共享内存的删除操作并非直接删除,而是拒绝后续映射,只有在当前映射链接数为0时,表示没有进程访问了,共享内存才会真正被删除!
4、shmat函数 – 关联共享内存(attach)
#include <sys/ipc.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
// 作用:把共享内存区对象映射到调用进程的地址空间
// 返回值:成功的话则返回关联好的共享内存的地址,失败的话返回-1,并设置错误码errno
其中参数为:
- shmid :要关联的共享内存的shmid
- shmaddr :关联共享内存挂接到指定的位置。一般我们不用关心,传NULL,交给OS自己决定一个合适的地址位置即可
- shmflg :关联共享内存的方式,若指定
SHM_RDONLY
则表示只读方式关联,否则以读写方式关联,一般传 0 即可!
💘注意:fork 后子进程继承已关联(attach)的共享内存地址。但是 exec 后该子进程与已连接的共享内存地址自动去关联(detach)。进程结束后,已关联的共享内存地址也会自动去关联(detach)。
5、shmdt函数 – 去关联(detach)
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);
// 作用:与对应shmaddr位置处的共享内存去关联
// 返回值:成功去关联则返回0,失败则返回-1,并且设置错误码errno
// 参数:shmaddr表示关联的共享内存的起始地址
💘注意:去关联不等同于删除共享内存!
⚜ 函数的使用与通信测试代码
有了上面这些函数与知识铺垫,我们大概理清一下步骤:
- 对于 server 端:
- 首先创建一个共享内存段并获得其标识符
- 通过标识符与共享内存段关联
- 进行通信
- 去关联
- 删除该共享内存段
- 对于 client 端:
- 获得共享内存段标识符
- 通过标识符与共享内存段关联
- 进行通信
- 去关联
现在我们可以把上述内容实现包装到一个 .hpp
文件中,然后 server
和 client
分别去调用即可,下面给出整体的代码:(具体现象自行上机观察)
comm.hpp
:
#ifndef _COMM_HPP_
#define _COMM_HPP_
#include <iostream>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/shm.h>
using namespace std;
// 设置两个常量pathname和proj_id,用来获取key值
const char* PATHNAME = ".";
const int PROJ_ID = 0x66;
const int MAX_SIZE = 4096; // 共享内存的大小
// 调用ftok函数获得key
key_t getKey()
{
key_t k = ftok(PATHNAME, PROJ_ID);
if(k == -1)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(1);
}
return k;
}
// 创建或者获取共享内存标识符的函数
int getShmHelper(key_t k, int flags)
{
int shmid = shmget(k, MAX_SIZE, flags);
if(shmid == -1)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(2);
}
return shmid;
}
// 为client提供获取共享内存标识符的函数
int getShm(key_t k)
{
return getShmHelper(k, IPC_CREAT);
}
// 为server提供创建共享内存标识符的函数
int createShm(key_t k)
{
// 注意这里是创建shm,所以必须也得给共享内存带上访问权限
return getShmHelper(k, IPC_CREAT | IPC_EXCL | 0600);
}
// 关联共享内存
void* attachShm(int shmid)
{
// 注意当前是64位机,所以指针大小为8字节,所以不能强转为int
void* start = shmat(shmid, nullptr, 0);
if((long long)start == -1L)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(4);
}
return start;
}
// 去关联
void detachShm(const void* shmaddr)
{
if(shmdt(shmaddr) == -1)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(5);
}
}
// 删除共享内存
void delShm(int shmid)
{
if(shmctl(shmid, IPC_RMID, nullptr) == -1)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(3);
}
}
#endif
server.cpp
:
#include "comm.hpp"
int main()
{
key_t k = getKey();
printf("0x%x\n", k);
int shmid = createShm(k);
printf("shmid: %d\n", shmid);
// 关联该共享内存
char* start = (char*)attachShm(shmid);
printf("attach success, address start: %p\n", start);
sleep(5);
// 使用
while(true)
{
sleep(1);
// 不需要读取到数组中,因为可以直接通过共享内存地址start打印出来
printf("client say: %s\n", start);
// 调用shmctl获取共享内存的属性
struct shmid_ds ds;
shmctl(shmid, IPC_STAT, &ds);
printf("获取属性: size: %d, pid: %d, myself: %d, key: 0x%x\n",\
ds.shm_segsz, ds.shm_cpid, getpid(), ds.shm_perm.__key);
}
// 去关联
detachShm(start);
sleep(5);
// 一般谁创建shm,谁删除shm!
delShm(shmid);
return 0;
}
client.cpp
:
#include "comm.hpp"
int main()
{
key_t k = getKey();
printf("key: 0x%x\n", k);
int shmid = getShm(k);
printf("shmid: %d\n", shmid);
// 关联该共享内存
char* start = (char*)attachShm(shmid);
printf("attach success, address start: %p\n", start);
sleep(3);
// 使用
pid_t id = getpid();
int cnt = 1;
const char* str = "i am client! i am talking with you!";
while(true)
{
sleep(1);
// 不需要先加载到字符数组中,直接可以通过共享内存地址写入即可
snprintf(start, MAX_SIZE, "%s : [pid: %d][cnt: %d]", str, id, cnt++);
}
// 去关联
detachShm(start);
sleep(3);
return 0;
}
makefile
:
.PHONY : all
all : shm_client shm_server
shm_client : client.cpp
g++ -o $@ $^ -std=c++11
shm_server : server.cpp
g++ -o $@ $^ -std=c++11
.PHONY : clean
clean :
rm -f shm_client shm_server
Ⅲ. 共享内存的特点
1、共享内存区是最快的 IPC 形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用(比如write、read等)来传递彼此的数据。
2、System V IPC
的 生命周期是随内核的!(就算创建 System V 资源的进程退出了,但是它申请的资源还存在)只能通过 OS 重启,或者程序员手动释放来清理资源。
3、当 client
端没有写入,甚至没有启动的时候,server
端不会像管道一样等待 client
端写入!因为 共享内存不提供任何同步或互斥机制,需要程序员自行保证数据的安全!所以不太安全!