文章目录
- System V
- 共享内存的原理
- 管理共享内存
- shmget
- shmat
- shmdt
- shmctl
- 共享内存和管道实现进程间同步通信
前面介绍完了匿名管道和命名管道,那么本篇要引入的主题是共享内存
System V
作为进程通信部分的内容,共享内存必然有其存在的意义和价值,例如对于网络来说,有了对应的服务端和客户端,那么在服务端中有了一个用户发送的消息,这些消息都被放到了管道中,之后经过加工后,要不然会选择把信息放到数据库中,要不然会选择把内容返回到上层,这是管道的作用,而对于System V版本的共享内存来说,也有它自己的作用
对于操作系统来说,通信的场景是有很多很多种的,有的是要传输数据为目的,有的是要以传输特定数据块为目的,也有的是以进程之间进行协同控制为目的,但是不管出于什么场景,只要是通信,那么就必然意味着要有固定的通信方式,就会有对应的接口参数返回值等等,再基于一定的格式进行统一,打包整体就叫做进程之间的通信,那么现在问题是,统一的格式是什么?哪里有格式的问题呢?为了解决这样的问题,有专门的人去定标准,再去基于这些标准进行具体的视线,实现出的这通信的模式就叫做System V,也叫做系统V,所以说对于这个模式来说有很多的通信方式,例如有共享内存,消息队列,信号量等等诸多方式,但是不管是什么模式,它们的接口,参数返回值,都是基于一定的标准来实现的,具有一定的相似性,这样对于使用者来说是比较方便的
所以接下来本篇介绍的内容就是这个System V模式下的第一种通信方式–共享内存
共享内存的原理
共享内存是基于通信的目的的,那么通信的本质是让不同的进程看到同一份资源,那么现在就要将注意点转移到同一份资源这个角度,这个资源不能是和进程挂钩,必须是操作系统与来提供的,所以在前面的管道中,不管是匿名管道还是命名管道,其实都是操作系统提供的,包括文件缓冲区和文件结构体,所以对于共享内存来说也是一样的道理,首先要创建出一个资源,其次是要让不同的进程看到这份资源,这就是共享内存的基本原理
上图是前面已经讲述过很多次的一个逻辑,对于共享内存也会从这里入手进行讲解,每一个进程都有自己的进程控制块,也有自己的地址空间,这个进程地址空间最后会根据页表映射到物理内存上,而又由于缺页中断申请内存这样的机制存在,所以说在代码中申请内存的时候,其实真正的物理内存没有进行开辟,而是在地址空间中开辟好,当首次尝试访问这块空间的时候,再去触发缺页中断这个机制,来在对应的物理内存中进行开辟内存,从中也能侧面看出,操作系统具有直接在内存中申请空间的能力
所以想要实现通信的第一步已经实现了,操作系统已经在物理内存中给进程开辟好了一块资源,可以等待它们使用,第二步要通信,起码是需要两个进程,在有了进程之后,就会在堆栈之间开辟一块区域,这块区域用来实现进程之间的通信
堆栈之间的这块共享区也不是第一次接触了,在之前的动静态库中就有过提及,动态库的加载就是加载到内存中,然后映射到堆栈之间的共享区中,进行加载库的这个过程,本质上就是把物理内存中的库数据映射到堆栈之间,只不过这里操作系统做的是在物理内存中开辟一块空间,而在逻辑地址的堆栈之间开辟的是一块新的空的空间,里面没有任何数据,而加载库的过程中这块区域是有空间的
在操作系统中,在物理内存中已经申请好了一块空间,并且也映射到了共享区中,那么这个共享区的起始地址也就已经知晓,所以在上层用户就可以调用这块空间,直接对这块空间进行数据的写入等等操作
关于用户空间和内核空间
那为什么说,有了这块起始地址,就能直接访问申请的内存了呢?其实原因就在于,有起始地址,并且整个空间有多大也是清楚的,那么未来就可以通过指针的方式,向对应的缓冲区中写数据,数据就会通过页表映射到对应的物理内存中,那在操作系统内部,是如何用地址空间对于物理内存进行访问?虽然现在已经有了虚拟地址,并且也有页表进行自动转换,但是为什么有地址就可以直接访问呢?原因就在内存中的用户空间,对于管道文件,它其实是属于内核数据结构,那么所有的缓冲区文件的属性都是会在这个地址空间的这个内核区域内,想要访问就必须调用对应的系统调用,而现在创建的这个堆栈之间的共享区,是属于用户空间的,用户空间是可以直接访问的,不用对应的系统调用就可以访问,相当于是有了一块内存空间,支持随机访问
所以现在进程就和共享内存之间建立了对应的映射关系,只要创建好内存,让当前进程把内存块映射到自己的地址空间中,那此时如果有另外一个进程,想要实现进程的通信,就也要进行相同的操作来进行映射,此时这个新的进程也会获得一个虚拟地址,对于这两个进程来说,它们的虚拟地址可以相同也可以不同,到此,这两个进程就都可以使用各自地址空间内的虚拟地址,借助页表来对物理内存中进行访问,相当于是间接的借助地址空间访问同一块内存空间,这样就通过共享内存的原理达到了进程间通信的前提,叫做让不同的进程看到同一份资源
共享内存的本质,在系统层面上把内存申请好,再映射到两个进程的地址空间中,映射结束之后,此时只需要把映射在虚拟地址中的起始地址返回给用户,用户就可以通过起始地址进行访问了
谈谈释放的问题
对于共享内存的释放,只需要把虚拟地址和物理地址之间的这层关系取消掉就可以了,具体的实操来说,就是把页表清空就可以,清空了页表,虚拟空间的所有地址就都失去了对应的意义,这也就是为什么说,所有的地址的概念,都是建立在有页表的基础上,如果没有页表,所有的地址其实都没有多大的意义,而对应与malloc或者是new申请的内存,也都是在页表上申请的,而物理内存并不需要立刻映射,而是在访问的时候再借助缺页中断来填充
管理共享内存
综合上述的内容,可以得出的一个结论是,在操作系统中,一定会存在多个共享内存被创建,在操作系统中会有很多个共享内存,那操作系统当然需要对于共享内存进行管理,那管理的前提是要描述,所以在操作系统内部一定会存在管理共享内存的概念,于是就有了下面的话题:管理共享内存
在管理之前,要有的第二个概念是,对于这块空间的识别问题,如何保证两个想要通信的进程可以识别到同一块资源呢?说明共享内存被创建出来之后,一定是具有一定的识别能力,有它独特的标识,才能让另外一个进程能够找到这块内存,进而进行后面的通信工作,所以下面就要研究这个识别的东西到底是什么
- 标识由什么组成?
- 怎么传递给另外一个进程?
Linux在内部提供了很多的接口,那么就要对于这些接口进行一定的认识了
shmget
这个命令就是创建共享内存的命令,对于参数的解析来说,抛开最前面的key值,对于第二个参数size来说,这个参数的意思是要开辟的共享内存有多大,函数的返回值会返回一个标识符,也就是内存标识符,如果创建失败会返回-1,并且会设置错误码,第三个参数是选项,不再多说,主要是介绍有两个选项
第一个选项的意思是,如果这个shm不存在就创建,存在就获取,并且返回
第二个选项不会单独使用,它一般会和第一个选项组合起来使用,表示的意思是,如果shm不存在就创建,存在就会提示出错并且返回,这样的意义是可以保证每次申请的共享内存都是全新的内存
这个内存标识符实际上就是前面所说的识别问题,这个整数的作用就有些类似于文件描述符的作用,在文件的接口中,都是靠这个文件描述符来工作,同理,在共享内存的接口中也是根据这个值来运转的
key值的问题
对于key值是多少,其实没有一个明确的标准,想写多少就写多少,在创建共享内存的时候,只要让通信的这两个进程之间约定一个数字,把这个数字作为标记符,那么在创建共享内存的时候,就会把这个数字表示写到共享内存的属性中,未来另外一个进程只需要在创建好的这些共享内存中去寻找这个标识符,就能找到前面的这个共享内存,进而就可以进行通信了
这个数字的取值是有讲究的,如果出现两个共享内存的key值相同,那必然是会出现严重的问题,所以对于这个key值的取值需要做足准备,而在接口中当然是有对应的解决措施的,这个函数就叫做ftok函数:
这个函数的作用就是专门用来,把一个地址和id转换成一个key值,所以借助这个东西就能生成一个不错的key值,以来区分内存
所以此时,对于创建共享内存的这个接口的参数就都介绍结束了,下面就实践一下,创建一个共享内存:
int main()
{
int key = ftok("linux-system-and-network/shm", 1234);
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL);
// 创建失败就返回
if (shmid < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
cout << "create success" << endl;
return 0;
}
运行后,就会创建出一个共享内存了!
但是,如果再次异常会出错,提示现在已经有共享内存了,这说明一个结论,共享内存和文件不一样,打开的文件的生命周期是随进程的,进程结束这个打开的文件生命周期也就结束了,而共享内存是在System V当中单独设计出的用来进阶通信的方案,这个方案的特点是,共享内存必须让用户主动释放,也就是说如果不释放,这个内存就会一直存在,所以这里的结论是,共享内存以及未来的和通信有关的这些资源,和普通文件是不一样的,除非手动关闭,否则会一直存在
查看共享内存信息
ipcs -m
删除共享内存信息
ipcrm -m [shmid]
权限问题
在创建的时候,也可以在选项中带上权限的选项:
int main()
{
int key = ftok("linux-system-and-network/shm", 1234);
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
// 创建失败就返回
if (shmid < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
cout << "create success" << endl;
return 0;
}
开辟空间大小的问题
正常来说,开辟空间是可以随意设置的,但是建议开辟大小设置成4096字节,原因在于,在操作系统申请内存的时候,是以4096为单位进行申请的,也就是说,如果申请大小为4097,实际上申请的是两个4096字节,只不过是用户层面上只用了4097个字节,即便未来被占用了,也不会进行分配,所以未来在越界方面可能会有异常,因此建议是以4096的整数倍进行大小的分配
shmat
这个命令可以将指定的共享内存挂接到自己的地址空间中
对于第二个参数shmaddr来说,这个shmaddr默认设置成nullptr就可以了,这个参数的意义是如果想要手动把共享内存挂接地址中的一个指定起始地址处,但是如果对于地址空间的不了解的情况下,直接传参传nullptr就可以了,让操作系统来进行选择就可以了
对于第三个参数shmflag来说,这个选项代表的是挂接到共享内存中的对应方式,这个不需要进行管控,因为在创建的时候就已经有权限来进行控制了,直接设置成0就可以了
对于返回值来说,如果挂接成功后,进程就会凭空多出来一块空间,有点类似于c语言中的malloc函数,所以在用法上也和它一样,需要进行强转成需要的类型
下面做出下面的实验:
int main()
{
int key = ftok("linux-system-and-network/shm", 1234);
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
// 创建失败就返回
if (shmid < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
cout << "create success" << endl;
// 创建成功后就挂接到当前进程
cout << "开始挂接" << endl;
sleep(2);
char *s = (char*)shmat(shmid, nullptr, 0);
cout << "进程退出" << endl;
sleep(2);
return 0;
}
从中看出这也确实完成了初步的预期,在进程退出的时候会删除页表,页表清空后就随之解除了映射关系,这样共享内存也就随之没有关联的必要了,所以这里关联数就减去1即可,这里可以类比是一种引用计数,但是又不完全是,因为不会伴随着减到0而自动释放这段空间,所以只能算是继承了这样的一种思想
shmdt
这个命令的意思是去关联,其实也就是和上述的函数意思相反,要不然是在特定的空间内取消映射关系,最终的参数就是获得共享内存的起始地址,其实也就是上面这个函数的返回值,就可以用做这个函数的参数
如何理解这个过程呢?其实可以从关联的角度来讲,关联的角度就是修改页表,所以,只需要找到虚拟地址所对应的起始地址就可以了,虚拟地址的起始地址知道,并且共享内存的大小也知道,所以就可以从共享内存的起始地址释放空间,解除对应空间大小的关联关系,那么在这样的基础下,也就将整个的挂接关系都去掉了,所以就实现了解除关联的效果
int main()
{
int key = ftok("linux-system-and-network/shm", 1234);
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
// 创建失败就返回
if (shmid < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
cout << "create success" << endl;
// 创建成功后就挂接到当前进程
cout << "开始挂接" << endl;
char *s = (char*)shmat(shmid, nullptr, 0);
sleep(2);
cout << "解除关联" << endl;
shmdt(s);
sleep(1);
cout << "进程退出" << endl;
sleep(2);
return 0;
}
现象也比较简单,这里就不再展示了
shmctl
这个接口的作用就是删除指定的共享内存
在对于这个函数了解前,先从源码看一下共享内存:
这是在内核中关于共享内存的描述,上面的这个struct shmid_ds结构体中包含了的内容就有,挂接的时间,最后使用时间等等信息,而在其中的这个struct ipc_perm,其实描述的就是关于共享内存的属性信息,例如有key值和多种id的属性,而操作系统也是用这些信息来对共享内存进行管理的,操作系统对于共享内存的管理,就转换成了对于这些数据结构的管理
其实在前面用到的例如有ipcs -m命令或者是其他的删除命令,从本质上来说就是从系统中获取已经被创建的共享内存的属性,例如有共享内存的大小,最近的使用时间,有多少个挂接数,权限等等,都是从这里来的
那转回到我们的这个函数,对于这个函数来说,第一个参数不多解释,第二个参数一般是IPC_RMID,大致意思就可以理解为是立即删除的意思,第三个参数是一个结构体,这里暂时先设置为nullptr,未来有使用场景再继续补充
共享内存和管道实现进程间同步通信
有了上面的基础,对于代码进行简单的封装,可以得到如下的成品
// comm.hpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string pathname = "/home/test/linux-system-and-network/shm";
const int proj_id = 0x11223344;
const int size = 4096;
const std::string filename = "fifo";
// 利用路径名和特定id获取key值
key_t GetKey()
{
key_t key = ftok(pathname.c_str(), proj_id);
if (key < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(1);
}
return key;
}
// 将十进制转换成十六进制
std::string ToHex(int id)
{
char buffer[1024];
snprintf(buffer, sizeof(buffer), "0x%x", id);
return buffer;
}
// 按照特定选项创建对应共享内存
int CreateShmHelper(key_t key, int flag)
{
int shmid = shmget(key, size, flag);
if (shmid < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
exit(2);
}
return shmid;
}
// 以644权限创建共享内存
int CreateShm(key_t key)
{
return CreateShmHelper(key, IPC_CREAT | IPC_EXCL | 0644);
}
// 获取共享权限的shmid
int GetShm(key_t key)
{
return CreateShmHelper(key, IPC_CREAT);
}
// 创建一个命名管道
bool MakeFifo()
{
int n = mkfifo(filename.c_str(), 0666);
if (n < 0)
{
std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
return false;
}
std::cout << "mkfifo success... read" << std::endl;
return true;
}
// client.cc
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include "comm.hpp"
int main()
{
// 利用key值获取共享内存并挂接到程序共享区
key_t key = GetKey();
int shmid = GetShm(key);
char *s = (char *)shmat(shmid, nullptr, 0);
std::cout << "attach shm done" << std::endl;
int fd = open(filename.c_str(), O_WRONLY);
// 向共享内存中写入信息,并利用管道形成同步
sleep(5);
for (char c = 'a'; c <= 'z'; c++)
{
s[c - 'a'] = c;
std::cout << "write : " << c << " done" << std::endl;
sleep(1);
int code = 1;
write(fd, &code, sizeof(4));
}
// 对共享内存解除挂接,并释放管道
shmdt(s);
std::cout << "detach shm done" << std::endl;
close(fd);
return 0;
}
// server.cc
#include <iostream>
#include <cstring>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <unistd.h>
#include "comm.hpp"
// 对共享内存和管道的初始化以及释放工作
class Init
{
public:
Init()
{
bool r = MakeFifo();
if (!r)
return;
key_t key = GetKey();
std::cout << "key : " << ToHex(key) << std::endl;
sleep(3);
shmid = CreateShm(key);
std::cout << "shmid: " << shmid << std::endl;
sleep(10);
std::cout << "开始将shm映射到进程的地址空间中" << std::endl;
s = (char *)shmat(shmid, nullptr, 0);
fd = open(filename.c_str(), O_RDONLY);
}
~Init()
{
sleep(5);
shmdt(s);
std::cout << "开始将shm从进程的地址空间中移除" << std::endl;
sleep(5);
shmctl(shmid, IPC_RMID, nullptr);
std::cout << "开始将shm从OS中删除" << std::endl;
close(fd);
}
public:
int shmid;
int fd;
char *s;
};
int main()
{
// 创建共享内存和管道
Init init;
sleep(5);
// 从共享内存中读取信息
while (true)
{
// wait
int code = 0;
ssize_t n = read(init.fd, &code, sizeof(code));
if (n > 0)
{
std::cout << "共享内存的内容: " << init.s << std::endl;
sleep(1);
}
else if (n == 0)
{
break;
}
}
sleep(10);
return 0;
}
从上面的代码中可以看出,在进行进程间通信的时候,使用了一个命名管道,那这个命名管道的作用是什么呢?
这就要涉及到共享内存的同步机制了,共享内存本身是不会存在同步机制的,所以加装一个管道,可以造成的效果就是向共享内存写入数据后,必须要另外一个进程得到了共享内存的信息后,再得到管道的信息,这样就能借助管道的同步性,使得共享内存也有了一个模拟的同步机制