1.共享内存原理
假设现在有两个进程A和B,他们都有自己的进程地址空间,要访问时都要通过虚拟地址空间找到对应的物理空间。
我们可以在物理内存中申请一块空间,然后在虚拟内存的堆栈之间的共享区也申请一块空间。
进程A可以进程B也可以。
这样进程A和进程B就可以通过自己进程地址空间的虚拟地址,访问同一个物理内存了,这就是共享内存,此时进程A和B就可以通信了。
前面所述的所有操作都由操作系统完成,操作系统会为用户提供相应的系统调用接口,我们用系统调用完成上述工作。想要释放这个共享内存,就只需要取消关联关系,OS就会释放内存
2.shm创建和删除
shm就是share memory,shmget函数,shmget函数成功的话返回这个共享内存的标识符,失败的话返回-1.
这个函数的第二个参数size就是共享内存的大小。
第三个参数shmflg是一个标记位,常见的就两个参数就是IPC_CREAT 和 IPC_EXCL,IPC_CREAT:创建共享内存,如果不存在就创建,否则打开这个已存在的共享内存并返回。
IPC_EXCL(单独使用无意义):正确用法是和IPC_CREAT一起用,IPC_CREAT | IPC_EXCL,意思就是如果要创建的共享内存不存在,就创建,如果已存在,函数就会出错返回,所以证明只要shmget成功返回一定会是一个全新的共享内存。
两个进程用shm通信,我们就需要标识共享内存的唯一性,这个唯一性就用第一个参数key来区分。
key不是内核直接形成的,而是用户层构建并传入的,为了让要通信的进程约定一个key,这个key是什么不重要,只要能进行比较就行。
理论上这个key传什么都可以,但是为了减少key值的冲突,一般会使用算法构建来一个key,就是ftok。
这个函数就是一个用户级的算法函数,需要用户提供一个路径pathname和project_id,这个两个参数都可以随便写,成功它的返回值就是构建好的key值,失败返回-1,如果这个key还是冲突的,就更改两个参数值就行。
//Shm.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/ipc.h>
#define PATHNAME "."
#define PROJ_ID 0x66
#define SIZE 4096
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
}while(0)
class Shm
{
public:
Shm()
:_shmid(-1), _size(SIZE)
{}
void Creat()
{
key_t k = ftok(PATHNAME, PROJ_ID);
if(k < 0)
ERR_EXIT("ftok");
printf("key:0x%x\n", k); //k是十六进制的
_shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL); //创建全新的共享内存
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
~Shm(){}
private:
int _shmid;
size_t _size;
};
//server.cc文件
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Creat();
return 0;
}
第一次创建没问题,第二次创建会出错,因为我们创建时用的选项是IPC_CREAT 和 IPC_EXCL。
- ipcs -m:查看共享内存
- ipcrm -m shmid:删除共享内存,删除shm资源要指定用shmid,不是用key,因为key未来是只给内核来进行区分唯一性的,用的是shmid来进行管理共享内存的,指令本质是运行在用户空间的,只能用shmid。
创建了共享内存但是没有显示的删除,这个内存依旧会存在,证明共享内存生命周期随内核,不是随进程。
除了指令级别的删除内存,还可以用函数删除内存,shmctl是对shm进行管理的一个函数,这个函数包括了对shm删除的功能。
第一个参数shmid就是共享内存的id值,第二个参数cmd就包含了很多选项,其中IPC_RMID就是删除,第三个参数暂时不管,直接传null就行。失败返回-1。
//Shm.hpp文件
class Shm
{
public:
Shm()
:_shmid(-1), _size(SIZE)
{}
void Creat()
{
key_t k = ftok(PATHNAME, PROJ_ID);
if(k < 0)
ERR_EXIT("ftok");
printf("key:0x%x\n", k);
_shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL); //创建全新的共享内存
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
void Destroy()
{
if(_shmid >= 0)
{
int n = shmctl(_shmid, IPC_RMID, nullptr);
if(n < 0)
ERR_EXIT("shmctl");
printf("shm:%d 删除成功\n", _shmid);
}
}
~Shm(){}
private:
int _shmid;
size_t _size;
};
//server.cc文件
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Creat();
sleep(3);
shm.Destroy();
return 0;
}
3.shm与进程关联
进程调用shmat函数,就会让自己堆栈上的虚拟地址与shm的物理地址进行映射,将共享内存挂接到进程的地址空间中,at就是attach。
返回值是映射成功之后起始虚拟地址,这段shm是连续的,虚拟地址也是连续的,只要知道起始虚拟地址以及shm的大小,就可以访问shm的任意字节;失败返回-1。
第一个参数就是shmid;第二个参数是一个虚拟地址,我们可以固定地址进行挂接,我们一般不考虑,设置为null就行;第三个参数是权限相关标志位,我们用的时候设为0,表示使用共享内存的默认设置。
这里还需要多加一个类成员变量,就是这个起始虚拟地址。
//Shm.hpp文件 Shm类
void Attach()
{
_start_mem = shmat(_shmid, nullptr, 0);
if((long long)_start_mem < 0)
ERR_EXIT("shmat");
printf("Attach success\n");
}
void* VirtualAdd()
{
printf("virtual add:%p\n", _start_mem);
return _start_mem;
}
private:
int _shmid;
size_t _size;
void* _start_mem;
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Creat();
sleep(2);
shm.Attach();
sleep(2);
shm.VirtualAdd();
sleep(2);
shm.Destroy();
return 0;
}
运行时会发现权限有问题。
这是因为前面shmget函数的第三个参数我们没有设置权限,我们可以直接在这设置权限,和其他文件设置权限是一样的做法,这里直接与就行。
//创建全新的共享内存并设置权限
_shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0666);
此时我们再运行时就没问题了。
这里我们再打开一个shell然后添加一个监控脚本,脚本如下。
while :; do ipcs -m; sleep 1; done
此时权限就是我们设置好的,而且会发现nattach变成了1,nattach就是记录有多少个进程和这个共享内存关联。
4.获取共享资源
前面我们一直在server这一个进程里操作,现在我们要一个client进程通过共享内存与server进程产生联系,因为server进程已经创建好了共享内存,client进程直接获取共享内存,并且映射到自己的进程地址空间里就行了,这时,两个进程就能看到同一份资源了。
前面我们提到过一个函数shmget,这个函数的第三个参数如果是IPC_CREAT并且共享内存已经存在的情况下,这个函数会打开这个已存在的共享内存并返回。所以获取共享内存的代码如下。
void Get()
{
key_t k = ftok(PATHNAME, PROJ_ID);
if(k < 0)
ERR_EXIT("ftok");
int _shmid = shmget(k, SIZE, IPC_CREAT); //获取共享内存
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
这个代码和创建共享内存的代码Creat相比,只有shmget的第三个参数不同,其他都一样,所以我们可以对这两个函数进行调整,重复部分合并一下,写一个辅助函数,并把这个辅助函数设为私有。
//Shm.hpp文件 Shm类
private:
void CreatHelper(int Opt)
{
key_t k = ftok(PATHNAME, PROJ_ID);
if(k < 0)
ERR_EXIT("ftok");
int _shmid = shmget(k, SIZE, Opt);
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
public:
void Get() //获取共享内存
{
CreatHelper(IPC_CREAT);
}
void Creat() //创建全新的共享内存并设置权限
{
CreatHelper(IPC_CREAT | IPC_EXCL | 0666);
}
我们在client进程里就可以调用这个接口了,在client进程里获取共享内存然后映射到自己的进程地址空间里,然后我们把虚拟地址打印出来。
//client.cc文件
#include "Shm.hpp"
int main()
{
Shm shm;
shm.Get(); //获取共享内存
sleep(2);
shm.Attach(); //映射到自己的进程地址空间里
sleep(2);
shm.VirtualAdd(); //打印虚拟地址空间
sleep(2);
return 0;
}
左边两个是进程server和进程client,server先运行,创建共享空间并且attach成功,右边是监控脚本,可以看到此时这个shm的nattach变成了1,然后运行client进程,此时nattach就变成了2。
client和server的虚拟地址不一样是正常的,一样也是正常的,这个无所谓。
代码整合
我们需要多添加几个类成员变量,pathname、project_id,还有usertype用户类型,然后对代码做调整。
//Shm.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/ipc.h>
#define PATHNAME "."
#define PROJ_ID 0x66
#define SIZE 4096
#define CREAT "creat"
#define USER "user"
using namespace std;
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
}while(0)
class Shm
{
private:
void CreatHelper(int Opt)
{
_shmid = shmget(_key, SIZE, Opt);
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
void Creat() //创建全新的共享内存并设置权限
{
CreatHelper(IPC_CREAT | IPC_EXCL | 0666);
}
void Get() //获取共享内存
{
CreatHelper(IPC_CREAT);
}
void Attach()
{
_start_mem = shmat(_shmid, NULL, 0);
if((long long)_start_mem < 0)
ERR_EXIT("shmat");
printf("Attach success\n");
}
void Destroy()
{
if(_shmid >= 0)
{
int n = shmctl(_shmid, IPC_RMID, nullptr);
if(n < 0)
ERR_EXIT("shmctl");
printf("shm:%d 删除成功\n", _shmid);
}
}
public:
Shm(const string &pathname, int projid, const string &usertype)
:_shmid(-1)
, _size(SIZE)
, _start_mem(nullptr)
,_pathname(pathname)
,_projid(projid)
,_usertype(usertype)
{
_key = ftok(PATHNAME, PROJ_ID);
if(_key < 0)
ERR_EXIT("ftok");
printf("key:0x%x\n", _key);
if(usertype == CREAT)
Creat();
else if(usertype == USER)
Get();
Attach(); //挂载
}
void* VirtualAdd()
{
printf("virtual add:%p\n", _start_mem);
return _start_mem;
}
~Shm()
{
if(_usertype == CREAT) //只有创建者需要销毁
Destroy();
}
private:
key_t _key;
int _shmid;
size_t _size;
void* _start_mem;
string _pathname;
int _projid;
string _usertype;
};
现在在server端和client端就只要只要做初始化就行了,并且调用一下打印虚拟地址的接口。
//server.cc文件
#include "Shm.hpp"
int main()
{
Shm shm(PATHNAME, PROJ_ID, CREAT);
shm.VirtualAdd();
return 0;
}
//client.cc文件
#include "Shm.hpp"
int main()
{
Shm shm(PATHNAME, PROJ_ID, USER);
shm.VirtualAdd();
return 0;
}
5.进程通信
我们可以把整个共享内存看成一块,用char*类型的指针接收VirtualAdd的返回值,server进程直接按字符串打印。
#include "Shm.hpp"
int main()
{
Shm shm(PATHNAME, PROJ_ID, CREAT);
char* mem = (char*)shm.VirtualAdd();
while(true)
{
printf("%s\n", mem);
sleep(1);
}
return 0;
}
client进程就发送数据,发送A到Z的数据到共享内存。
#include "Shm.hpp"
int main()
{
Shm shm(PATHNAME, PROJ_ID, USER);
char* mem = (char*)shm.VirtualAdd();
for(char c = 'A'; c <= 'Z'; c++)
{
mem[c-'A'] = c;
sleep(1);
}
return 0;
}
先运行server.cc,此时会打印空串,因为shm里还没有东西,nattach也是1。
然后运行client,client就开始往shm里写入数据,server就开始从shm里读数据打印出来,此时nattach为2.
for循环结束时,client进程退出,server进程还在继续,nattach变成1.
我们会发现之前用管道通信的时候,写入或读取要用到系统调用的函数write和read,但是这里我们读写共享内存没有用到系统调用,而是直接读取。
- 因为我们会把共享内存映射到进程的地址空间中,堆栈之间的共享区里,而这个共享区属于用户,可以让用户直接使用。
- 管道属于文件,是内核级别的内核文件缓冲区,属于操作系统,所以要用到系统调用。
由于两个进程都把共享内存映射到自己的地址空间里,所以一个进程一写,另一个进程就能直接获取到,所以共享内存通信速度是最快的。
从上面的运行结果来看,我们会发现共享内存通信其实存在一个缺点,就是没有像管道通信那样的“同步机制”,server运行起来后直接从共享内存里读,不管client有没有写入,甚至没有client进程都可以。
这种同步机制也是一种保护机制,shm通信就没有保护机制(这里的保护不是对共享内存的保护,而是对共享内存里的数据的保护),这也是他通信速度快的原因。
比如说我们让client写成对的字母AA、BB、CC...到共享内存里,读取的时候就不一定是成对的读,有可能读成AAB、AABBC等等。
//client.cc文件
#include "Shm.hpp"
int main()
{
Shm shm(PATHNAME, PROJ_ID, USER);
char* mem = (char*)shm.VirtualAdd();
int i = 0;
for(char c = 'A'; c <= 'G'; c++, i += 2)
{
mem[i] = c;
sleep(1);
mem[i+1] = c;
sleep(1);
}
return 0;
}
模拟保护机制
我们可以让这两个进程再建立一个管道,这个管道是命名管道,而且建立这个管道的目的不是为了通信,而是为了保护共享内存里的数据。
client进程只写了一个数据的时候,不做处理,写两个A的时候,向管道里发送数据,通知server进程可以读取数据了,相当于唤醒server进程,而进程server也不能直接从共享内存里读取数据,而是先从管道里读取数据,如果管道里没有数据,证明client进程还没写完,server进程不做处理,如果管道里有数据(相当于通知server进程可以读取了),server进程才从共享内存里读数据。
此时就可以让server进程读数据的节奏跟着client写的节奏进行了,就能实现同步。
命名管道我们已经实现过了,直接拿来用就行了:【Linux】命名管道 ,对里面的Read和Wirte做一下修改。
//Fifo.hpp文件
#pragma once
#include <iostream>
#include <unistd.h>
#include <string>
#include <sys/types.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <stdio.h>
#include "comm.hpp"
using namespace std;
#define FIFO_FILE "fifo"
class NamedPipe
{
public:
NamedPipe(const string& path, const string& name)
:_path(path), _name(name)
{
_fifo_name = _path + "/" + _name;
umask(0);
int n = mkfifo(_fifo_name.c_str(), 0666);
if(n < 0)
{
ERR_EXIT("mkfifo");
}
cout << "fifo创建成功" << endl;
}
~NamedPipe()
{
int n = unlink(_fifo_name.c_str());
if(n < 0)
{
ERR_EXIT("unlink");
}
cout << "fifo删除成功" << endl;
}
private:
string _path;
string _name;
string _fifo_name;
};
class FileOper
{
public:
FileOper(const string& path, const string& name)
:_path(path), _name(name), _fd(-1)
{
_fifo_name = _path + "/" + _name;
}
void OpenForRead()
{
_fd = open(_fifo_name.c_str(), O_RDONLY);
if(_fd < 0)
{
ERR_EXIT("open");
}
cout << "fifo打开成功" << endl;
}
bool Read()
{
char c;
int n = read(_fd, &c, 1); //只要读一个字节就行
if(n > 0) return true;
else return false;
}
void OpenForWrite()
{
_fd = open(_fifo_name.c_str(), O_WRONLY); //写方式打开
if(_fd < 0)
{
ERR_EXIT("open");
}
cout << "client打开fifo成功" << endl;
}
void Write()
{
char c = 'a'; //这个字符是什么不重要,只要是一个字符就行
int n = write(_fd, &c, 1);
}
void Close()
{
if(_fd > 0) close(_fd);
}
~FileOper()
{}
private:
string _path;
string _name;
string _fifo_name;
int _fd;
};
先创建管道,然后server端从管道里读,再从共享内存里读,client进程写完两个字符再唤醒server进程。
//server.cc文件
#include "Shm.hpp"
#include "Fifo.hpp"
int main()
{
NamedPipe fifo(".", FIFO_FILE); //创建命名管道
FileOper readfile(".", FIFO_FILE);
readfile.OpenForRead(); //读方式打开
Shm shm(PATHNAME, PROJ_ID, CREAT); //创建共享内存并挂载
char* mem = (char*)shm.VirtualAdd();
while(true)
{
//先从管道里读,默认会阻塞在这里
if( readfile.Read() ) //管道里读成功了才从共享内存里读
{
printf("%s\n", mem);
}
else
break;
}
readfile.Close(); //结束时关闭文件
return 0;
}
//client.cc文件
#include "Shm.hpp"
#include "Fifo.hpp"
int main()
{
FileOper writefile(".", FIFO_FILE);
writefile.OpenForWrite(); //打开管道文件
Shm shm(PATHNAME, PROJ_ID, USER); //打开共享内存并挂载
char* mem = (char*)shm.VirtualAdd();
int i = 0;
for(char c = 'A'; c <= 'G'; c++, i += 2)
{
sleep(1);
mem[i] = c; //先向管道里写成对的字母
mem[i+1] = c;
sleep(1);
writefile.Write(); //往管道里写(唤醒server进程)
}
writefile.Close(); //关闭文件
return 0;
}
上图是只运行server没运行client,下图是运行了client。
可以看到此时就是按照一对一对的字母读取的。
因为client进程和server进程并发运行时,可能会导致client进程比server进程更先创建出共享内存,但是我们client进程里Shm shm(PATHNAME, PROJ_ID, USER); 只是获取共享内存,client进程先创建共享内存就会出现如下报错。
所以最好是把server进程创建共享内存的步骤放在最前面。
//server.cc文件
int main()
{
Shm shm(PATHNAME, PROJ_ID, CREAT); //先创建共享内存并挂载
char* mem = (char*)shm.VirtualAdd();
NamedPipe fifo(".", FIFO_FILE); //创建命名管道
FileOper readfile(".", FIFO_FILE);
readfile.OpenForRead(); //读方式打开
while(true)
{
//先从管道里读,默认会阻塞在这里
if( readfile.Read() ) //管道里读成功了才从共享内存里读
{
printf("%s\n", mem);
}
else
break;
}
readfile.Close(); //结束时关闭文件
return 0;
}
下面是其他文件源码。
//Shm.hpp文件
#pragma once
#include <iostream>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include "comm.hpp"
#define PATHNAME "."
#define PROJ_ID 0x67
#define SIZE 4096
#define CREAT "creat"
#define USER "user"
using namespace std;
class Shm
{
private:
void CreatHelper(int Opt)
{
_shmid = shmget(_key, SIZE, Opt);
if(_shmid < 0)
ERR_EXIT("shmget");
printf("shmid:%d\n", _shmid);
}
void Creat() //创建全新的共享内存并设置权限
{
CreatHelper(IPC_CREAT | IPC_EXCL | 0666);
}
void Get() //获取共享内存
{
CreatHelper(IPC_CREAT);
}
void Attach()
{
_start_mem = shmat(_shmid, NULL, 0);
if((long long)_start_mem < 0)
ERR_EXIT("shmat");
printf("Attach success\n");
}
void Destroy()
{
if(_shmid >= 0)
{
int n = shmctl(_shmid, IPC_RMID, nullptr);
if(n < 0)
ERR_EXIT("shmctl");
printf("shm:%d 删除成功\n", _shmid);
}
}
public:
Shm(const string &pathname, int projid, const string &usertype)
:_shmid(-1)
, _size(SIZE)
, _start_mem(nullptr)
,_pathname(pathname)
,_projid(projid)
,_usertype(usertype)
{
_key = ftok(PATHNAME, PROJ_ID);
if(_key < 0)
ERR_EXIT("ftok");
printf("key:0x%x\n", _key);
if(usertype == CREAT)
Creat();
else if(usertype == USER)
Get();
Attach(); //挂载
}
void* VirtualAdd()
{
printf("virtual add:%p\n", _start_mem);
return _start_mem;
}
~Shm()
{
if(_usertype == CREAT) //只有创建者需要销毁
Destroy();
}
private:
key_t _key;
int _shmid;
size_t _size;
void* _start_mem;
string _pathname;
int _projid;
string _usertype;
};
//comm.hpp文件
#include <stdio.h>
#include <stdlib.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
本次分享就到这里了,我们下篇见~