目录
一. 共享内存实现进程间通信的原理
要实现进程间通信,就必须让相互之间进行通信的进程看到同一份资源(同一块内存空间),如通过管道实现进程间通信,本质就是让两个进程分别以读和写的方式打开同一份管道文件,一个进程向管道中写数据,另一个进程再从管道中将数据读出,这样两个进程就可以看到同一份内存空间,从而实现了进程间通信。
System V共享内存实现进程间通信的方式与管道相同,区别在于管道是基于文件的,而共享内存则是直接申请内存空间,不需用进行文件相关操作。通过System V共享内存实现通信的进程,都会使用物理内存中的同一块空间,这一块公共的物理内存空间经过通信双方进程的页表,映射到进程地址空间的共享区,通信双方进程在运行期间,拿到共享区虚拟地址,通过页表映射,就可以看到同一块物理内存,就可以实现进程间通信。
如果操作系统内有多组通过System V共享内存方式相互通信的进程处于运行状态,那么就会存在多组共享内存,操作系统需要对这些共享内存空间进行管理,管理方式为:先通过struct结构体进行描述,再利用特定的数据结构组织。
可以这样理解:共享内存 = 共享的物理内存 + 对应的内核级数据结构。
二. 共享内存相关函数
共享内存实现进程间通信的步骤可以总结为:创建共享内存 -> 共享内存与地址空间相关联 -> 通信 -> 共享内存与地址空间解绑 -> 销毁共享内存。
2.1 共享内存的获取 shmget / ftok
shmget函数:获取共享内存
头文件:#include<sys/ipc.h>、#include<sys/shm.h>
函数原型:int shmget(key_t key, size_t size, int shmflg)
函数参数:
key -- 特定共享内存的标识符
size -- 共享内存的大小
shmflg -- 共享内存获取的权限参数
返回值:创建成功返回共享内存的编号(称为shmid),失败返回-1
共享内存标识符key:OS中可能存在多个共享内存,需要保证通信双方看到同一块共享内存,因此,每个共享内存都需要一个特定的key值进行区分,这个key值是多少并不重要,只要保证它在OS中是唯一的即可。通信双方进程(Serve && Client)需要约定相同的算法,保证他们可以使用shmget获取到同一块共享内存。
ftok函数可以用于获取key值,只要调用ftok的实参相同,就会返回相同的key值。
ftok函数:获取共享内存标识符key
头文件: #include<sys/ipc.h>、#include<sys/types.h>
函数原型:key_t ftok(const char* pathname, int proj_id);
函数参数:
pathname:项目(文件)路径
proj_id:项目(文件)的id编号
返回值:成功返回特定的key值,否则返回-1。
共享内存大小size:以字节为单位,建议取页(PAGE:4096bytes)大小的整数倍,因为如果获取共享内存空间的大小不是页大小的整数倍,OS就会向上取整申请到页大小整数倍的内存空间,但是多申请的空间却不能被用户所使用。如,申请4097bytes的共享内存,OS会实际申请2*4096bytes的空间,而能被使用的只有4097bytes,剩下的都浪费掉了。
权限参数shmflg:有IPC_CREAT、IPC_EXCL、共享内存起始权限码、0这几种选项,他们之间通过竖划线 | 隔开,每个选项都有其意义。
- IPC_CREAT:如果key标识的共享内存存在,就直接将其获取,如果不存在,就创建。
- IPC_EXCL:单独使用没有任何意义,一般配合IPC_CREAT使用,IPC_CREAT | IPC_EXCL表示如果共享内存不存在就将其创建,如果存在直接报错,这样可以保证获取到的共享内存是一块全新的共享内存。
- 起始权限码:用户对于这块共享内存的使用权限,如0666就表示拥有者、所属组、其他人就具有读写权限。
- 0:只能获取已经存在的共享内存,不能创建新的,不存在就报错。
一般而言,通信双方分别以 IPC_CREAT | IPC_EXCL 和 0 的方式获取共享内存,确保一方创建全新的共享内存,另一方只能获取到该共享内存(传0阻断不存在创建新共享内存的可能)。
代码2.1以 IPC_CREAT | IPC_EXCL | 0666 的方式获取共享内存,运行代码,就可以成功获取共享内存,但是当第二次运行代码,却发现运行出错了(见图2.1),这是因为该共享内存再第一次程序运行后被创建,存在于操作系统中,IPC_CREAT | IPC_EXCL获取的共享内存一定是全新的,因此第二次运行程序会失败,删除该共享内存之后才可以再次成功运行。
结论:共享内存的生命周期是随OS内核的,而不是随进程的。
代码2.1:获取共享内存
// common.hpp -- 头文件
#pragma once
#include <iostream>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATH_NAME "."
#define PROJ_ID 0x66
#define SIZE 4096
// shmServe.cc -- 客户端代码源文件(用于接收信息)
#include "common.hpp"
int main()
{
// 获取共享内存key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k == -1)
{
perror("ftok");
exit(1);
}
// 创建共享内存
int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0666);
if(shmid == -1)
{
perror("shmget");
exit(2);
}
printf("Serve# 共享内存获取成功,shmid:%d\n", shmid);
return 0;
}
这里介绍两条指令,分别用于查看共享内存信息和删除共享内存:
- ipcs -m 指令:查看系统中所有共享内存的详细信息。
- ipcrm -m [shmid]:通过指定共享内存的shmid来删除指定的共享内存。
当然,也可以通过代码删除共享内存,本文后面会讲解。
2.2 共享内存与进程地址空间相关联 shmat
shmat函数:将共享内存关联到进程地址空间
头文件:#include<sys/types.h>、#include<sys/shm.h>
函数原型:void* shmat(int shmid, const void* shmaddr, int shmflg)
函数参数:
shmid:进行挂接的共享内存的shmid
shmaddr:指定挂接的虚拟地址(传NULL表示让OS自动选择挂接地址)
shmflg:挂接权限相关参数
返回值:若成功返回挂接到的虚拟地址,失败返回nullptr
挂接地址shmaddr参数:由于我们并不可知虚拟地址的具体使用情况,所以这个参数基本都是传NULL/nullptr来让OS自动选择虚拟地址进行关联。
挂接权限shmflg:如果传SHM_RDONLY,这表示对应共享内存空间只有读权限,传其他都是读写权限,一般shmflg都传实参0。
当共享内存与虚拟地址关联期间,使用ipcs -m指令查看共享内存属性信息,nattch就会变为1,如果通信双方都与共享内存进行了关联,那么nattch就是2。
2.3 取消共享内存与进程地址空间的关联 shmdt
shmdt函数:让共享内存与当前进程脱离
头文件:#include<sys/types.h>、#include<sys/shm.h>
函数原型:int shmdt(const char* shmaddr)
返回值:成功返回0,失败返回-1
2.4 删除共享内存 shmctl
通过共享内存控制shmctl函数(共享内存控制函数),可以删除共享内存。
删除共享内存的操作只要通信双方有一方指向即可,否则会造成重复删除。一般而言,读取信息的进程创建新的共享内存,也负责删除共享内存,遵循谁创建、谁删除的原则。
shmctl函数:控制共享内存
头文件:#include<sys/ipc.h> #include<sys/shm.h>
函数原型:int shmctl(int shmid, int cmd, struct shmid_ds* buf)
函数参数:
shmid -- 共享内存的shmid
cmd -- 控制指令,选择操作
buf -- 指向描述共享内存属性信息的结构体指针
返回值:成功返回非负数,失败返回-1
形参cmd可以选择具体的控制策略:
- IPC_STAT -- 以buf为输出型参数,获取共享内存的属性信息。
- IPC_SET -- 设置共享内存的属性为buf指向的内容。
- IPC_RMID -- 删除共享内存,此时buf传空指针NULL。
2.5 通信双方创建共享内存代码
代码2.2:头文件common.hpp -- 由通信双方共同包含
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATH_NAME "."
#define PROJ_ID 0x66
#define SIZE 4096
代码2.3:服务端代码shmServe.cc -- 用于数据读取
#include "common.hpp"
int main()
{
// 获取共享内存key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k == -1)
{
perror("Serve ftok");
exit(1);
}
printf("Serve# 成功获取key值,key:%d\n", k);
// 创建共享内存
int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0666);
if(shmid == -1)
{
perror("Sreve shmget");
exit(2);
}
printf("Serve# 共享内存获取成功,shmid:%d\n", shmid);
// 将共享内存与进程相关联
char* shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == nullptr)
{
perror("Serve shmat");
exit(3);
}
printf("Serve# 共享内存与进程成功关联,shmid:%d\n", shmid);
// 通信代码
// ... ...
// 让共享内存脱离当前进程
int n = shmdt(shmaddr);
if(n == -1)
{
perror("Serve shmdt");
exit(4);
}
printf("Serve# 共享内存成功脱离进程,shmid:%d\n", shmid);
// 删除共享内存
n = shmctl(shmid, IPC_RMID, NULL);
if(n == -1)
{
perror("Serve shmctl");
exit(5);
}
printf("Serve# 共享内存删除成功,shmid:%d\n", shmid);
return 0;
}
代码2.4:客户端代码shmClient.cc -- 用于数据发送
#include "common.hpp"
int main()
{
// 获取共享内存key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k == -1)
{
perror("Client ftok");
exit(1);
}
printf("Client# 成功获取key值,key:%d\n", k);
// 创建共享内存
int shmid = shmget(k, SIZE, 0);
if(shmid == -1)
{
perror("Client shmget");
exit(2);
}
printf("Client# 共享内存获取成功,shmid:%d\n", shmid);
// 将共享内存与进程相关联
char* shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == nullptr)
{
perror("Client shmat");
exit(3);
}
printf("Client# 共享内存与进程成功关联,shmid:%d\n", shmid);
// 通信代码
// ... ...
// 让共享内存脱离当前进程
int n = shmdt(shmaddr);
if(n == -1)
{
perror("Client shmdt");
exit(4);
}
printf("Client# 共享内存成功脱离进程,shmid:%d\n", shmid);
return 0;
}
三. 共享内存实现进程间通信
3.1 实现方法及特性
在数据输入端(shmClient),我们可以将共享内存视为一块通过malloc得来的char*指向的一段动态内存空,可以使用printf系列函数向这块空间写数据,或者将共享内存空间视为数组,使用下标的形式给每个位置赋值,这样就实现了将数据写入共享内存。
在数据读取端(shmServe),可以将共享内存视为一个大字符串,通过特定的方式,从这个大字符串中获取数据即可。
代码3.1和代码3.2实现了共享内存进程间通信的简单逻辑,在shmClient端,通过下标访问的方式,每隔3s写一次数据,在shmServe端,每隔1s读取一次数据。先运行shmServe端代码,间隔几秒后运行shmClient端代码,根据图3.1展示的运行结果,shmServe端在shmClient端开始运行之前就开始读取共享内存中的内容,在shmClient运行起来后,由于读快写慢,shmClient写入的内容在shmServe端被多次读取,可见,共享内存,没有访问控制。
结论1:共享内存没有访问控制。
代码3.1:shmClient端发送数据
// 通信代码
char ch = 'a';
int count = 0;
for(; ch <= 'c'; ++ch)
{
shmaddr[count++] = ch;
printf("write succsee# %s\n", shmaddr);
sleep(3);
}
snprintf(shmaddr, SIZE, "quit");
代码3.2:shmServe端读取数据
// 通信代码
while(true)
{
printf("[Client say]# %s\n", shmaddr);
if(strcmp(shmaddr, "quit") == 0) break;
sleep(1);
}
通过观察上面的代码我们发现,用户可以直接向共享内存中写数据和从共享内存中读取数据,不需要经过用户级缓冲区,共享内存的读或写操作最少只需要一次拷贝即可完成。而通过管道进行读写,则需要将数据预先写入或读入缓冲区,才可以写入管道文件或读出。图3.2为使用管道和共享内存的方法进行进程间通信时,读和写操作涉及的数据拷贝情况,管道通信至少要进行两次数据拷贝,而共享内存可以只进行一次数据拷贝,因此共享内存是一种高效的进程间通信手段。
结论2:共享内存进行进程间通信,通信的一方向共享内存中写入数据,通信的另一方马上就能读取到数据,不需要向操作系统中拷贝数据,共享内存是所有进程间通信方法中效率最高的。
管道通信的特性总结:
- 不具有访问控制,存在并发问题。
- 不需要向OS内核中拷贝数据,通信效率高。
3.2 为共享内存添加访问控制
通过使用命名管道加以辅助,就可以为共享内存添加访问控制,具体的实现方法和原理为:
- 在读端(shmServe)程序开始运行时创建命名管道文件,程序运行结束后管道文件销毁。
- 在写端(shmClient)向共享内存中写入数据后,向管道文件中写入任意的、少量的数据,在读端(shmServe)获取共享内存内容之前,先读取管道中的资源,如果写端没有将期望的数据全部写入共享内存,那么就不会向管道中写数据,读端就必须阻塞等待管道中被写入数据,也就无法获取共享内存中的数据。只有当写端完成向共享内存中写入一次数据,然后向管道文件中写入数据让读端读到了管道资源后,读端代码才可以继续运行,获取到共享内存中的资源。
代码3.3 ~ 3.5,为通过管道为共享内存添加访问控制的实现代码。
代码3.3:common.hpp头文件 -- 被通信双方源文件包含
#pragma once
#include <iostream>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATH_NAME "."
#define PROJ_ID 0x66
#define SIZE 4096
#define FIFO_NAME "fifo.ipc"
#define MODE 0666
// 定义类,其构造和析构函数可以创建和销毁管道文件
class Init
{
public:
Init()
{
int n = mkfifo(FIFO_NAME, MODE);
if(n == -1) perror("mkfifo");
assert(n != -1);
(void)n;
}
~Init()
{
int n = unlink(FIFO_NAME);
assert(n != -1);
(void)n;
}
};
#define READ O_RDONLY
#define WRITE O_WRONLY
// 管道文件打开函数
int OpenFifo(const char* pathname, int flags)
{
int fd = open(pathname, flags);
assert(fd != -1);
return fd;
}
// 等待函数 -- 用于读端访问控制
// 管道内没有资源时就阻塞
void Wait(int fd)
{
uint32_t temp = 0;
ssize_t sz = read(fd, &temp, sizeof(uint32_t));
assert(sz == sizeof(uint32_t));
(void)sz;
}
// 唤醒函数 -- 用于写端进程控制
// 向管道内写数据,终止读端进程的阻塞等待
void WakeUp(int fd)
{
uint32_t temp = 1;
ssize_t sz = write(fd, &temp, sizeof(uint32_t));
assert(sz == sizeof(uint32_t));
(void)sz;
}
// 管道关闭函数
void CloseFifo(int fd)
{
close(fd);
}
代码3.4:读端源文件(shmServe.cc)代码
#include "common.hpp"
// 全局类对象
// 构造和析构函数分别负责管道文件的创建和销毁
Init init;
int main()
{
// 获取共享内存key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k == -1)
{
perror("Serve ftok");
exit(1);
}
printf("Serve# 成功获取key值,key:%d\n", k);
// 创建共享内存
int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0666);
if(shmid == -1)
{
perror("Sreve shmget");
exit(2);
}
printf("Serve# 共享内存获取成功,shmid:%d\n", shmid);
// 将共享内存与进程相关联
char* shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == nullptr)
{
perror("Serve shmat");
exit(3);
}
printf("Serve# 共享内存与进程成功关联,shmid:%d\n", shmid);
// 通信代码
int fd = OpenFifo(FIFO_NAME, READ); // 只读方式打开管道文件
while(true)
{
Wait(fd); // 等待读取
printf("[Client say]# %s\n", shmaddr);
if(strcmp(shmaddr, "quit") == 0) break;
}
// while(true)
// {
// printf("[Client say]# %s\n", shmaddr);
// if(strcmp(shmaddr, "quit") == 0) break;
// sleep(1);
// }
// 让共享内存脱离当前进程
int n = shmdt(shmaddr);
if(n == -1)
{
perror("Serve shmdt");
exit(4);
}
printf("Serve# 共享内存成功脱离进程,shmid:%d\n", shmid);
// 删除共享内存
n = shmctl(shmid, IPC_RMID, NULL);
if(n == -1)
{
perror("Serve shmctl");
exit(5);
}
printf("Serve# 共享内存删除成功,shmid:%d\n", shmid);
CloseFifo(fd);
return 0;
}
代码3.5:写端源文件(shmClient.cc)代码
#include "common.hpp"
int main()
{
// 获取共享内存key值
key_t k = ftok(PATH_NAME, PROJ_ID);
if(k == -1)
{
perror("Client ftok");
exit(1);
}
printf("Client# 成功获取key值,key:%d\n", k);
// 创建共享内存
int shmid = shmget(k, SIZE, 0);
if(shmid == -1)
{
perror("Client shmget");
exit(2);
}
printf("Client# 共享内存获取成功,shmid:%d\n", shmid);
// 将共享内存与进程相关联
char* shmaddr = (char*)shmat(shmid, NULL, 0);
if(shmaddr == nullptr)
{
perror("Client shmat");
exit(3);
}
printf("Client# 共享内存与进程成功关联,shmid:%d\n", shmid);
// 通信代码
int fd = OpenFifo(FIFO_NAME, WRITE);
while(true)
{
ssize_t sz = read(0, shmaddr, SIZE); // 共享内存从键盘中读入数据(换行符也被写入)
assert(sz >= 0);
shmaddr[sz - 1] = '\0'; //末尾添加'\0'表示终止
WakeUp(fd); // 唤醒读端进程
if(strcmp(shmaddr, "quit") == 0) break;
}
// char ch = 'a';
// int count = 0;
// for(; ch <= 'c'; ++ch)
// {
// shmaddr[count++] = ch;
// printf("write succsee# %s\n", shmaddr);
// sleep(3);
// }
// snprintf(shmaddr, SIZE, "quit");
// 让共享内存脱离当前进程
int n = shmdt(shmaddr);
if(n == -1)
{
perror("Client shmdt");
exit(4);
}
printf("Client# 共享内存成功脱离进程,shmid:%d\n", shmid);
CloseFifo(fd);
return 0;
}
四. 总结
- System V共享内存实现进程间通信的底层原理是通信双方进程看到同一块内存,位于物理内存上的共享内存块,通过页表映射到通信双方的进程地址空间的共享区,通信双方拿到共享区的虚拟地址,通过页表映射,访问到同一块物理内存。
- 使用System V共享内存实现进程间通信的操作流程为:通过ftok函数获取唯一的共享内存标识符key -> 通过shmget函数获取共享内存 -> 通过shmat函数让共享内存和进程绑定 -> 【进行进程通信】-> 通过shmdt函数让共享内存和进程脱离 -> 通过shmctl删除共享内存。
- System V共享内存 进程间通信的特点为:(1)不需要向操作系统内核中拷贝数据,是所有进程间通信的方法中效率最高的。(2)没有访问控制。
- 通过管道的辅助,可以为 System V共享内存 进程间通信添加访问控制。