进程间通信的前提是:使不同进程看到同一份资源,在使用匿名管道和命名管道进行进程间通信时,同一份资源指的就是管道文件。system V作为一种通信标准,在此标准下有一套共享内存机制,即通过系统接口得到一块共享内存,这块内存就对应着通信前提中的同一份资源,让不同的进程都“看到”这块资源后,不同进程就可以进行通信,与管道一样,共享内存也是一种IPC技术。
共享内存的本质:之前在聊进程地址空间时,我们知道每个进程都有属于自己的进程空间,栈区,堆区,代码区,已初始化全局数据区…并且这些空间都是虚拟空间,虚拟空间通过页表映射到真实的物理空间。而共享内存就是调用系统接口,向系统申请的一块物理内存资源,我们只要通过系统,使进程虚拟空间的共享区与物理空间的共享内存之间建立映射关系,就代表该进程可以访问这块物理空间。同理,其他进程通过页表也能映射到这块物理空间,这样多个进程就看到了同一份资源,完成了通信的前提。
要使用一块共享内存就需要先创建一块共享内存,有创建就需要有销毁,创建了共享内存还需要使进程关联(使用)共享内存,有关联就需要有去关联。所以关于共享内存一共有四个操作,每个操作都是系统接口的调用
shmget可以向系统申请一块共享内存,其中的参数
key:用户对共享内存的唯一标识符
size:以字节为单位,表示申请空间的大小
shmflg:申请空间时的申请方式
其中有的参数令人费解,下面一个个解释这些参数的含义。一个系统下肯定存在着很多进程,这些进程中有部分进程在进行通信,通信的方式可能是共享内存,以共享内存的方式进行通信就需要使用共享内存块,对于这些位于内存上的共享内存块,操作系统就需要进行管理,所以操作系统中有着描述这些内存块的结构体,结构体的名字为struct shmid_ds
这个结构体存储了关于共享内存的信息,最后一次关联时间,创建者的pid等等…其中有一个struct ipc_perm结构体,该结构体保存了共享内存块的权限信息
其中有一个key对象,这个key就是操作系统用来识别共享内存块的唯一符号。所以这个key值是事先约定好,进程彼此间知道的符号,进程通过传递key值给系统接口,系统通过key值查找共享内存块,这就使进程可以看到同一份资源。
size是共享内存块的大小,单位为字节,size最好设置为page(4KB)的整数倍,因为系统IO的基本单位为4KB。系统以4KB为最小单位,将所有内存描述成一个个页(page),对应的结构体为struct page。所以系统分配内存是以page为基本单位进行分配的,如果申请的共享内存大小为4097(4KB + 1B),系统就要为你分配两个page,实际申请内存的大小为8192KB,但你能使用的内存大小只有4097KB,所以将共享内存的大小设置为4KB的整数倍,其实是为了方便系统的管理。
剩下一个参数shmflg,这是共享内存获取方式的参数,用来表示获取方式的参数,大多都是位图结构,可以给shmflg这个参数传递两个宏,IPC_CREAT,IPC_EXCL。其中IPC_CREAT表示如果对应key值的共享内存块不存在,就创建之。如果对应key值的共享内存块已经存在,就获取之(返回一个标识符shmid)。
IPC_EXCL表示如果对应key值的共享内存块不存在,就创建之。如果对应key值的共享内存块已经存在,就返回出错信息,也就是说,这个宏保证了获取的共享内存块一定是新分配的。但是这个宏需要和IPC_CREAT一起使用,也就是将IPC_CREAT | IPC_EXCL作为shmflg的参数。
除了shmget的参数需要解释,其返回值也需要聊一聊。如果shmget创建共享内存成功,将共享内存块的标识符shmid返回。如果shmget创建共享内存块失败,将返回-1,并设置错误码errno。这个shmid和key一样也是唯一的,那么shmid与key有什么区别? key是用户层生成的一个标识符,而shmid是系统返回的IPC资源标识符,用来访问IPC资源,如果要删除一个共享内存块,我们就需要用到shmid。
为什么要设计两个唯一的标识符呢?如果只有操作系统返回的shmid这个标识符,当前进程要怎么传递shmid给另一个进程(要传递就必须进行通信,但传递shmid的目的就是为了通信),所以只能再引入一个自己确定的标识符key,使不同进程通过事先约定好的key进行通信,这一点与命名管道相似,命名管道的通过事先约定好指定文件名,然后通过该文件进行通信。
介绍完shmget后,就是使用shmget,使用前需要有一个key值,要生成一个唯一的key,可以使用函数ftok
该函数内置了一个算法,将传入的两个参数pathname和proj_id换算成一个system V标准下的用于进程间通信的key值,通过函数的描述,我们知道只要pathname和proj_id相同,无论何时调用ftok函数,得到的key值都是相同的
所以使用共享内存之前,只要先约定好pathname和proj_id,将这两个参数固定,得到的key就是相同的,这就保证了key的唯一性,有了唯一的key值后,进程就能看到同一块共享内存,也就能进行通信了。
// Comm.hpp
#pragma once
#include
#include
#include
#include
#include
#include
using namespace std;
#define IPC_PATH "/home/cw/daily"
#define IPC_ID 0x88
int CreatKey()
{
int key = ftok(IPC_PATH, IPC_ID);
if (key != -1) // 生成key值成功
{
return key;
}
else // 生成key失败,打印错误信息
{
cerr << "ftok: " << strerror(errno) << endl;
exit(-1);
}
return -1;
}
// IpcShmCli.cc
#include "comm.hpp"
using namespace std;
int main()
{
// key的创建
int key = CreatKey();
// 创建共享内存
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
// 打印共享内存信息
printf("key: %x, creat shm: %d\n", key, shmid);
return 0;
}
// IpcShmSer.cc
#include "comm.hpp"
using namespace std;
int main()
{
// key的创建
int key = CreatKey();
// 创建共享内存
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
// 打印共享内存信息
printf("key: %x, creat shm: %d\n", key, shmid);
return 0;
}
现在有两个文件,一个客户端,一个服务器,两者通过共享内存进行进程通信。首先通过ftok函数依赖一个文件名与一个id生成一个相同的key值,然后用这个key值调用shmget函数,将共享内存块的大小设置为4096,创建方式为申请一个全新的共享内存块。两个源文件的代码目前是一样的,都是以相同的key值创建共享内内存,运行两个程序。第一个程序创建共享内存成功,第二个创建失败,原因是创建共享内存的方式决定了创建的共享内存必须是全新的,所以第二个程序运行出错。
由此可以推测,共享内存的生命周期不是跟随进程的,因为第一个进程申请了共享内存,但其退出时,共享内存的资源却没有释放,所以第二个程序以相同的key值申请共享内存失败。
共享内存的生命周期是随内核的,只有内核退出了,共享内存才会释放,也就是说重启系统可以释放申请的共享内存。除了系统的重启,还能手动释放共享内存,使用ipcs -m指令可以查找当前内核中正在使用的共享内存
使用指令ipcrm -m 描述符,可以删除指定描述符的共享内存,通过刚才程序的运行结果,我们知道申请的共享内存的shmid为8,所以ipcrm -m 8就可以删除这个共享内存块
除了手动执行指令删除共享内存,还能调用系统接口shmctl删除共享内存
这个接口可以查看或设置共享内存的一些属性,此外还可以用于删除共享内存
shmid:共享内存块的shmid
cmd:使用该函数的方式
buf:共享内存块的结构体指针,不需要可以设置为nullptr
使用shmctl删除共享内存块时,将cmd设置为IPC_RMID,buf设置为nullptr,所以shmctl(shmid, IPC_RMID, nullptr),就能删除一个共享内存块(该函数一般不会真正的删除共享内存,只有在共享内存的关联者为0时才会进行真正的删除,有点引用计数的意思,每次的删除只是对计数器-1,当计数器为0时,再进行真正的删除)
删除的使用方式:一个进程调用shmctl删除共享内存,此时的共享内存块被标记为删除,如果此时还有进程使用共享内存,系统不会删除共享内存,当没有进程使用共享内存块时,即关联数为0,系统才会释放共享内存,一个进程调用shmctl删除后,其他进程不需要再调用shmctl删除,只需要调用shmdt去关联共享内存即可。
#include "comm.hpp"
using namespace std;
int main()
{
// key的创建
int key = CreatKey();
// 创建共享内存
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
// 打印共享内存信息
printf("key: %x, creat shm: %d\n", key, shmid);
// 删除共享内存
shmctl(shmid, IPC_RMID, nullptr);
return 0;
}
运行程序,由于进程退出前调用shmctl删除共享内存块,所以每次执行程序都可以创建全新的共享内存块,而不会报错。
虽然进程创建了共享内存,但是进程并不是创建的共享内存的拥有者,该进程只是一个创建者角色,所以要使用共享内存就需要与共享内存关联,有关联的操作肯定也有去关联操作。
shmat,使进程与共享内存关联
shmid:要关联的共享内存的标识符
shmaddr:共享内存的地址,这个参数在特殊场景下使用,不使用时,传入nullptr即可
shmflg:关联共享内存的方式
对于shmflg,SHM_RDONLY表示以只读的方式关联共享内存,没有以只写的方式关联共享内存的选项,如果要以可读可写的方式关联共享内存,可以将0作为shmflg参数,0表示可读可写
所以shmat(shmid, nullptr, 0)就可以关联表示符为shmid的共享内存
shmat返回共享内存块的首地址,由于共享内存块的大小是由我们确定的,所以通过首地址+偏移量的方式,我们就能使用所有的共享内存。当shmat关联失败时,返回-1。
关联共享内存块需要当前进程有相关权限,shmget申请共享内存时可以通过shmflg设置其权限,shmflg为IPC_CREAT | IPC_EXCL表示每次申请的共享内存都是全新的,同时还能添加权限的信息,比如IPC_CREAT | IPC_EXCL | 0600表示该共享内存只对于拥有者有读和写权限。0600是八进制表示权限的方式,在设置文件权限时经常使用,对于共享内存的权限我们也可以这样设置
对于共享内存的去关联,只需要将关联共享内存时shmat返回的地址作为参数,调用shmdt函数即可。成功返回0,错误返回-1
// IpcShmCli.cc
int main()
{
// key的创建
int key = CreatKey();
// 创建共享内存,并设置其权限
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0600);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
// 打印共享内存信息
printf("key: %x, creat shm: %d\n", key, shmid);
sleep(3);
// 关联共享内存
// 由于shmat返回的地址是void*,使用时要注意类型的强转
char* shmadr = (char*)shmat(shmid, nullptr, 0);
sleep(3);
// 去关联共享内存
shmdt((void*)shmadr);
sleep(3);
// 删除共享内存
shmctl(shmid, IPC_RMID, nullptr);
return 0;
}
复制当前渠道,通过两个窗口观察共享内存的创建,关联,去关联以及删除的过程,ipcm -s 打印信息中的nattch为与共享内存关联的进程数,在当前进程关联之前,nattch为0,进程关联后变为1,去关联后又变为0
当进程与共享内存关联时,实际上就是将物理上的共享内存块地址映射到进程的虚拟地址空间上。这一点与管道不同,管道的本质是一个伪文件(内核的缓冲区),对于匿名管道,进程只有它的fd标识符,对于命名管道,进程只有它的文件名(或者说inode标识符),说白了,对于管道,进程只拥有标识符,对于共享内存,进程不仅拥有标识符,还拥有共享内存的地址在虚拟空间上的映射。两者的区别就是进程可以直接通过地址访问共享内存,而访问管道却需要通过唯一标识符,调用系统接口(类似read,write接口),在系统的帮助下完成对管道的访问。
所以共享内存的使用很简单,与数组的使用相同,或者说共享内存块本质上也能看成一个数组,由于shmget返回它的首地址,我们就能通过首地址+偏移量,然后解引用的方式使用共享内存。
在shmget函数的说明中有这样一句话
当新的共享内存被创建,它的内容会被用0初始化,这是一个细节点。
// IpcShmSer.cc创建共享内存
#include "comm.hpp"
using namespace std;
int main()
{
int key = CreatKey();
// 共享内存的获取
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
printf("server creat shm, shmid:%d, key:%d\n", shmid, key);
// 关联
char* shmadr = (char*)shmat(shmid, nullptr, 0);
printf("server attach shm, shmid:%d\n", shmid);
// 服务器读取数据
while (true)
{
printf("%s\n", shmadr);
sleep(1);
}
// 去关联
shmdt((void*)shmadr);
printf("server detach shm, shmid:%d\n", shmid);
// 共享内存的释放
shmctl(shmid, IPC_RMID, nullptr);
printf("delete detach shm, shmid:%d\n", shmid);
return 0;
}
// IpcShmCli使用共享内存
#include "comm.hpp"
using namespace std;
int main()
{
// key的创建
int key = CreatKey();
// 获取共享内存的id
int shmid = shmget(key, 4096, IPC_CREAT);
if (shmid == -1)
{
printf("key: %x, creat fail: %s\n", key, strerror(errno));
exit(-1);
}
printf("client creat shm, shmid:%d, key:%d\n", shmid, key);
// 关联共享内存
char* shmadr = (char*)shmat(shmid, nullptr, 0);
printf("client attach shm, shmid:%d\n", shmid);
// 使用
int cnt = 0;
while (cnt <= 26)
{
// 客户端写入数据
shmadr[cnt] = 'A' + cnt;
cnt++;
sleep(1);
}
// 去关联共享内存
shmdt((void*)shmadr);
printf("client detach shm, shmid:%d\n", shmid);
// 共享内存的释放
shmctl(shmid, IPC_RMID, nullptr);
printf("delete detach shm, shmid:%d\n", shmid);
return 0;
}
(由服务器创建共享内存,客户端使用共享内存,创建共享内存的步骤已经用了大量篇幅讲述,使用共享内存只是在共享内存的关联和去关联中加入代码,以类似数组的方式使用共享内存)
服务器不断地读取shmadr中的数据,而客户端会向shmadr中每隔1秒写入数据。因为shmadr中的数据已经被系统用0初始化过了,所以正常情况下,以%s打印时不用关心字符串的结束。先运行服务器程序,服务器直接开始读取shmadr中的数据,因此显示器上不断被换行符刷新,直到客户端运行,向shmadr中写入数据,显示器才读到这些数据,向显示器打印读取到的数据。以上结果表明,共享内存没有访问控制机制,关联共享内存的进程可以直接看到里面的数据,因为在每个进程看来,共享内存块都是属于自己的一块空间,每个进程可以随时向共享内存写入或读取,这样的访问是无序,不安全的。
正是由于共享内存的地址被映射到每个进程的虚拟地址空间这一特点,进程可以直接通过地址访问内存,不用像管道需要通过read和write等系统调用接口访问共享资源(使用管道还需要先将数据拷贝到进程的地址空间上,再从地址空间拷贝到管道,而共享内存位于进程的地址空间上,进程可以直接将数据拷贝到共享内存上,这样一步到位的拷贝也比管道快),可以说共享内存的通信速度是所有IPC中最快的。
我们将能被多个进程同时看到并只允许一个进程访问的资源称为临界资源,管道,共享内存只是一个共享资源。如果没有对共享资源进行任何保护使其成为临界,那么多个进程访问共享资源时,会是一种乱序的状态,多个进程的交叉读写可能导致临界资源的乱码,废弃代码等问题。所以使用共享内存时需要与其他技术进行结合,以添加访问控制,保护临界资源。
操作系统描述system V下IPC资源的结构体中含有一个struct ipc_ids结构体,该结构体中有一个指针entries,指向了一个柔性数组ipc_id_ary,该数组元素存储的数据类型为struct ipc_perm*,暂时不说struct ipc_perm,先了解在system V标准下的三个IPC技术,共享内存,信息队列以及信号量。
三种IPC资源都有对应的结构体对其进行描述,这三个结构体中有一个相同的成员,struct ipc_perm,存储IPC资源中与权限有关的信息。ipc_id_ary数组中存储就是指向struct ipc_perm结构体的指针,在练习共享内存的使用代码时,我发现了一个现象,就是申请的共享内存块的shmid一直在增加,虽然不知道为什么,但是增长的shmid其实就是数组ipc_id_ary的下标,无论你创建的IPC资源是共享内存还是消息队列,或者是信号量,它们结构体中的struct ipc_perm结构体的地址就会被保存到ipc_id_ary上,shmid作为IPC资源的struct ipc_perm在数组中的下标被返回给用户。
而struct ipc_perm成员作为IPC资源结构体中的第一个成员,其地址与结构体地址相同,通过struct ipc_perm的地址不仅可以访问struct ipc_perm结构体中的数据,还可以将其强转成struct shmid_ds*或者struct semid_ds*或者struct msqid_ds*,通过强转后的IPC资源结构体地址访问struct shmid_ds,struct semid_ds或者struct msqid_ds中的成员。
也就是说无论struct ipc_perm保存的是共享内存,消息队列还是信号量的权限,只要将其转换成对应IPC资源的指针就能访问其所在结构体的数据。又或者说struct ipc_perm只是一个基类,struct shmid_ds,struct semid_ds以及struct msqid_ds是其派生类,因为它们都具有struct ipc_perm,而通过基类struct ipc_perm的指针的强转就能访问其派生类的数据,这种操作有点像C++中的多态调用,Linux系统通过使用统一的规则(struct ipc_perm)实现对不同IPC资源(struct shmid_ds,struct semid_ds以及struct msqid_ds)的访问,而C++通过对派生类进行切片形成的基类对象,实现对不同函数的调用。