目录
- 1. 原理
- 2. 编码通信
- 2.1 创建共享内存
- 2.2 shmat && shmdt && shmctl
- 2.3 通信
- 3. 共享内存的特性
- 3.1 共享内存的属性
- 3.2 加入管道实现同步机制
前面的文章介绍了管道通信,其中包括匿名管道、命名管道。这篇文章介绍另一种进程间通信的方式 ----- 共享内存。
但是必须确定一点,即无论用哪种方式实现进程间通信,本质都是让不同的进程先看到同一份资源!
1. 原理
共享内存是操作系统提供的一种进程间通信的方案,它不像匿名管道通信,局限于进程之间的关系。换言之,不具备任何关系的进程之间,都能通过共享内存进行通信。
共享内存是由操作系统在物理内存中直接开辟的一块内存空间,因为这是操作系统,所以它有权限可以直接修改进程的页表,然后把这块共享内存的地址与虚拟内存映射起来,映射到进程A 地址空间中的共享区域,最后再给用户层返回该共享内存的起始地址(虚拟地址)即可。
如果有进程 B 想要与进程 A 进行通信,只需要将同一块共享内存通过页表映射到进程 B 的共享区中即可。后续就可以通过页表映射,访问同一块内存。这就完成了让不同进程看到同一份资源的工作!
所以创建共享内存,总结为三个步骤
- 申请内存
- 挂接到进程地址空间
- 返回起始地址
后续想要释放共享内存,那么只需要先去关联(即与创建共享内存的第二步相反的操作),再释放内存空间即可。
-
关于共享内存的所有操作是进程 直接 完成的吗?
肯定不是, 基本操作系统允许用户自己创建共享内存,那么用户肯定只能通过类似 malloc / new 这样的接口去创建,申请出来的内存空间最终只有自己这个进程能够看到(因为进程具有独立性),达不到进程通信的基本要求。所以诸如申请内存等一系列操作,都是由操作系统来完成的。
换言之,在进程通信需要创建共享内存这件事上,进程(代表用户)是需求方,操作系统是执行方,因此执行方需要向需求方提供一些系统接口,来满足用户的通信需求,因此用户只能通过系统调用。
在操作系统中存在几十上百个进程,可能有很多个进程都需要创建共享内存来通信,因此操作系统中就可能存在很多个共享内存,那么操作系统就需要对共享内存进行管理!而管理的本质就是先用内核结构体对共享内存的诸多属性进行描述,再用特定的数据结构将各个结构体对象组织进来,对共享内存的管理就转变为对数据结构的管理!
与 struct file 相似的是,当多个进程打开同一个文件,在 struct file 内部会维护一个计数器,只有当引用计数为0时,才会释放 struct file 和文件的相关数据。在共享内存的描述结构体中,也会维护一个引用计数的属性。
2. 编码通信
2.1 创建共享内存
-
要通信,得先有通道,因此需要先创建共享内存。
NAME shmget - allocates a System V shared memory segment SYNOPSIS #include <sys/ipc.h> #include <sys/shm.h> int shmget(key_t key, size_t size, int shmflg); RETURN VALUE On success, a valid shared memory identifier is returned. On errir, -1 is returned, and errno is set to indicate the error 参数分析: key: 不同进程对共享内存的标识,用于访问同一块共享内存,具有唯一性 size: 创建共享内存的大小(单位为字节) shmflg: 与 open 接口的 flags 参数相似,以比特位标志的方式进行传递,例如 IPC_CREAT 和 IPC_EXCL,并且一样可以叠加使用 ret: 共享内存标识符(创建成功的返回值) IPC_CREAT: 如果申请的共享内存存在,则创建,否则获取这个共享内存并返回。 IPC_EXCL: 这个选项不单独使用。 IPC_CREAT | IPC_EXCL: 如果申请的共享内存存在,则创建,否则出错返回。(这么用的目的是确保申请的共享内存一定是刚创建出来的)
-
问题一:如何保证不同进程看到的是同一个共享内存?在创建共享内存时又如何得知这个共享内存是否已经存在?
要想搞清楚这个问题,就需要搞清楚 shmget 接口中的 key 参数。
操作系统中可能存在多个共享内存,为了保证通信双方进程看到的是同一块共享内存,因此引入 key 参数,这个数字操作系统中必须具有唯一性,能够让不同进程进行唯一性标识。(不管共享内存是否已经创建,只要是通信,那么进程就需要用这个 key 与共享内存建立连接。遍历系统中所有的共享系统,比对 key 值,如果不存在相同的,创建共享内存,存在的话,那么直接与这个共享内存建立连接即可,可以理解为 key 是进程之间的一种暗号)。
因此只要第一个进程通过 key 创建了一块共享内存,后续进程只要拿着同一个key,就能够与第一个进程看到同一个共享内存,然后建立通信信道。有了 key,就解决了上述的两个问题,比对 key 值,可以保证看到的是同一个资源,遍历共享内存比对的同时,也是在解决该共享内存是否存在的问题
-
所以 key 在哪??
key 是进程间通信的 “暗号”,而共享内存 = 内存块 + 描述结构体对象,内存块肯定只是用于存储用户通信数据,那么 key 只能在共享内存的描述结构体对象中了。即首次创建共享内存时,key 值肯定是要被设置到内核结构中的,这样后续进程才能够拿着 key 在内核中与所有共享内存做比对。
在讲命名管道的通信时,我们就解决过类似的问题,如何保证不同的进程看到的是同一个管道? ---- 每个管道文件具有唯一的路径,在该路径下具有唯一的文件名,因此能够精准的让不同进程看到同一个管道。命名管道通信的本质,不是进程间看到了同一个管道文件,而是它们看到了具有唯一性的标识! 只要不同进程看到具有唯一性的东西,那么这个问题就能够得到保证。
-
所以如何获取 key ?
NAME ftok - convert a pathname and a project identifier to a System V IPC key SYNOPSIS #include <sys/types.h> #include <sys/ipc.h> key_t ftok(const char *pathname, int proj_id); RETURN VALUE On success, the generated key_t value is returned. On failure -1 is returned with errno indicating the error as for the stat(2) system call. 分析: 一个生成 key 的系统调用,根据 pathname 和 proj_id 这两个参数的值加上特定算法进行数值计算得到 key 值 pathname 和 proj_id 都是由用户自由的,但 ftok 生成的 key 可能会出现冲突的问题 pathname 代表的就是路径,本来就具有唯一性,因此冲突的概率并不大,如果冲突了,那么换一个 proj_id 即可。 因此后续进程只要调用 ftok,并且使用同一个 pathname 和 proj_id,那么计算得到的 key 值也一定是相同的, 这样就能够与其它进程访问同一个共享内存了。
-
这个函数是有可能调用失败的,可能是系统内存不足,申请失败,也可能是 key 值冲突。搞那么一圈,让用户自己指定 pathname 和 proj_id,最后生成的 key 还冲突了,那为什么操作系统不直接为用户生成 key 值呢??
如果操作系统自动为用户生成一个 key,但是操作系统不知道用户要哪几个进程通信,后续其它进程调用 ftok 生成 key 时,操作系统怎么知道这个进程是要与前面生成 key 的那个进程通信的,如果不知道,操作系统如何给你生成一样的 key,无法生成一样的 key,其它进程如何与前面的进程进行通信。
所以不是技术上无法实现,是这件事就必须由用户来做,只有用户才知道哪些进程要进行通信。与其说 key 值是由用户生成的,不如说是由用户约定的!通过约定好同样的参数,这样就一定生成同一个 key 值,再拿着一样的 key 值就可以创建 / 获取到同一个共享内存,然后进行通信。
-
-
编码实现:
#include<iostream> #include<cstring> #include<string> #include<sys/ipc.h> #include<sys/shm.h> #include<sys/types.h> #include "log.hpp" using namespace std; Log log; // 自定义的日志输出 // 共享内存的大小一般给4096的整数倍 // 假如size=4097,操作系统实际分配的是4096*2的大小(但是用户看不到,看到的还是4097) // 这是因为操作系统在分配内存时,是以一个页大小(即4096 bytes)为单位分配的,方便虚拟内存到物理内存的映射。 const int size = 4096; const string pathname = "/home/outlier"; const int proj_id = 0x6666; key_t GetKey() { key_t key = ftok(pathname.c_str(), proj_id); if(key == -1) { log(Fatal, "fotk error: %s", strerror(errno)); exit(1); } log(Info, "fotk success: 0x%x", key); return key; } int GetShareMemory(int shmflg) { key_t key = GetKey(); int shmid = shmget(key, size, shmflg); if(shmid == -1) { log(Fatal, "create share error: %s", strerror(errno)); exit(2); } log(Info, "create share success: %d", shmid); return shmid; } int CreateShm() { return GetShareMemory(IPC_CREAT|IPC_EXCL|0666); // 666是共享内存的权限 } int Getshm() { return GetShareMemory(IPC_CREAT); }
-
我们可以看到,第一遍执行,成功创建了共享内存,但第二遍的时候,却显示文件已经存在,这就表面了,共享内存的生命周期是随内核的,用户不主动关闭,共享内存就不会被释放,除非内核重启或者用户手动释放。
icps -m
可以查看系统中已存在的共享内存信息。icprm -m shmid
根据 shmid 释放共享内存(为什么是根据 shmid,因为在释放共享内存这件事上,是用户层面做的事情,只要涉及是用户层,操作共享内存时,一切都是根据 shmid,key 值只是操作系统内核用于标定共享内存的唯一性而已,可以理解为 key 只是内核用于比对共享内存,创建和获取共享内存,仅此而已,之后的关于共享内存的任何操作都跟 key 无关了,因为后续操作都是用户层做的,那么就需要根据 shmid, shmid 是进程内(强调进程)用来表示资源的唯一性。
2.2 shmat && shmdt && shmctl
NAME
shmat, shmdt - System V shared memory operations
SYNOPSIS
#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg); // 把创建出来的共享内存挂接到指定进程的进程地址空间中。
参数分析:
shmid:共享内存标识符(即shmget的返回值)
shmaddr:让共享内存挂接到进程地址的哪个位置,一般设置null,让操作系统决定即可。
shmflg:设置该进程挂接共享内存的权限,可设置为 SHM_RDONLY(只读) 等,设置为 0 代表按照共享内存的默认权限挂接。
返回值: 挂接到进程地址空间的地址。
NAME
shmat, shmdt - System V shared memory operations
SYNOPSIS
#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr); // 去关联
参数分析:
shmaddr:把 shmat 共享内存挂接后返回的地址传入即可。原理与 malloc / new 相似,只需要传入一段内存空间的起始地址,
共享内存描述结构体对象中记录了共享内存的大小,因此起始地址 + 大小即可释放一段连续的空间。
NAME
shmctl - System V shared memory control
SYNOPSIS
#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数分析:
shmid:共享内存标识符(即shmget的返回值)
shmid_ds:记录共享内存诸多属性的结构体,通过共享内存进行进程通信的本质就是在操作共享内存,操作无法就是获取、修改、删除共享内存内部的属性。
cmd:指明要怎么操作共享内存的属性,其可以设置为 IPC_STAT(拷贝把共享内存的属性) / IPC_RMID(将共享内存标记为删除) 等
struct shmid_ds {
struct ipc_perm shm_perm; /* Ownership and permissions */ // 共享内存的权限
size_t shm_segsz; /* Size of segment (bytes) */ // 大小
time_t shm_atime; /* Last attach time */ // 最后一次挂接的时间
time_t shm_dtime; /* Last detach time */ // 最后一次取消挂接的时间
time_t shm_ctime; /* Last change time */ // 最后一次修改共享内存属性的时间
pid_t shm_cpid; /* PID of creator */ // 创建共享内存的进程pid
pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */ // 最后挂接共享内存的进程pid
shmatt_t shm_nattch; /* No. of current attaches */ // 挂接共享内存的进程数量
...
};
// processA
int main()
{
int shmid = CreateShm(); // 创建共享内存
char* shmaddr = (char*)shmat(shmid, nullptr, 0); // 共享内存挂接进程地址空间
shmdt(shmaddr); // 去关联
shmctl(shmid, IPC_RMID, nullptr); // 删除共 享内存时不需要关注其属性,因此第三个参数为null
return 0;
}
// processB
int main()
{
int shmid = Getshm(); // 第二个进程只需要获取共享内存,不需要创建,也不需要删除
char* shmaddr = (char*)shmat(shmid, nullptr, 0); // 共享内存挂接进程地址空间
shmdt(shmaddr); // 去关联
return 0;
}
到这里,共享内存还是没有开始通信!上面的这一切,调了一堆的系统调用,诸如 shmget、shmat、shmctl,只是在建立信道和释放共享内存,仅此而已。
2.3 通信
与管道通信不同的是,共享内存通信,不需要调用系统接口进行读写数据。因为共享内存已经挂接到指定进程的进程地址空间了,已经属于这个进程的空间了,进程可以直接访问自己进程地址空间内的任何空间!换言之,一旦有了共享内存,共享内存挂接到进程地址空间之后,就可以直接把当作该进程的内存空间来使用!
// processA
int main()
{
...
while(1)
{
// 直接像读取自己new出来的空间内的数据一样的读取共享内存即可!
cout << "[cilent]# " << shmaddr << endl;
sleep(1);
}
...
}
// processB
int main()
{
...
while(1)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
}
...
}
3. 共享内存的特性
- 共享内存没有同步互斥之类的保护机制。 即上面看到的,当 processB 还没开始写入数据时,processA 并不会像管道通信那样,在读取时阻塞等待写方,而是自顾自的读取,如果没有手动的在每一回合的读写后清空共享内存的数据,那么读方会继续重复读取数据。即不管你写不写,我都读,不管你读不读,我都写。
- 共享内存是所有进程间通信速度最快的。 因为它不需要做拷贝数据的工作,例如管道通信时,读端需要先调用 read 把数据读到用户缓冲区,再从用户缓冲区读取数据;写端则需要先把数据写到用户缓冲区,再调 write 写到管道中。read 和 write 到用户缓冲区的本质就是拷贝数据。
- 共享内存内部的数据,由用户自己维护。 即如果不手动清空内部数据,即便完成了一回合通信,内部的数据依旧在,下次读取时还能读取到上次的数据。
3.1 共享内存的属性
struct shmid_ds { //
struct ipc_perm shm_perm; /* Ownership and permissions */ // 共享内存的权限是一个结构体!
size_t shm_segsz; /* Size of segment (bytes) */
time_t shm_atime; /* Last attach time */
time_t shm_dtime; /* Last detach time */
time_t shm_ctime; /* Last change time */
pid_t shm_cpid; /* PID of creator */
pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */
shmatt_t shm_nattch; /* No. of current attaches */
...
};
struct ipc_perm { // 共享内存权限
key_t __key; /* Key supplied to shmget(2) */ // 其中就包含获取共享内存的 key
uid_t uid; /* Effective UID of owner */
gid_t gid; /* Effective 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 */ // icps -m 看到的共享内存的权限
unsigned short __seq; /* Sequence number */
};
把共享内存的部分属性打印出来见一见:
// 核心代码
struct shmid_ds shmds;
shmctl(shmid, IPC_STAT, &shmds); // IPC_STAT:将共享内存的数据拷贝到 shmds 结构体中
cout << "shm size: " << shmds.shm_segsz << endl;
cout << "shm size: " << shmds.shm_nattch << endl;
cout << "shm __key: " << shmds.shm_perm.__key << endl;
cout << "shm mode: " << shmds.shm_perm.mode << endl;
3.2 加入管道实现同步机制
共享内存的同步机制一般是通过信号来实现的,这里使用管道实现,只不过是为了演示,共享内存是可以做到同步性的。
// 新增代码
// comm.hpp
#define FIFO_FILE "./myfifo"
#define MODE 0664
class Init
{
public:
Init()
{
int n = mkfifo(FIFO_FILE, MODE);
if(n == -1) exit(1);
}
~Init()
{
int m = unlink(FIFO_FILE); // unlink 删除文件的系统调用
if(m == -1) exit(2);
}
};
// processA
int main()
{
...
Init init;
int fd = open(FIFO_FILE, O_RDONLY);
if(fd < 0) exit(3);
while(1)
{
char c;
ssize_t s = read(fd, &c, 1); // 写入方没有写入操作时,进程阻塞在read,通信便有了同步性
if(s == 0) break; // 当写端退出时,借助管道,读端也能够自己break退出
else if(s < 0) break;
// 直接像读取自己new出来的空间内的数据一样的读取共享内存即可!
cout << "[cilent]# " << shmaddr << endl;
sleep(1);
}
...
}
// processB
int main()
{
...
int fd = open(FIFO_FILE, O_WRONLY);
if(fd < 0) exit(1);
while(1)
{
cout << "Please Enter@ ";
fgets(shmaddr, 4096, stdin);
write(fd, "c", 1); // 引入管道通信只是为了保证共享内存通信的同步性
}
...
}
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!