零基础学Linux内核之进程间通信篇(5)_共享内存区

零基础学Linux内核系列文章目录

前置知识篇
1. 进程
2. 线程
进程间通信篇
1. IPC概述
2. 信号
3. 消息传递
4. 同步
5. 共享内存区


一、前言

最后介绍一下共享内存区的通信方式,共享内存作为效率最高的一种IPC通信机制,但其也存在缺点,就是需要进程自行维护同步与互斥。


二、前置条件

UB18 + 一点点的基础知识


三、本文参考资料

Unix卷2
百度
野火Linux教程


四、正文部分

4.1 共享内存区

4.1.1 概述

共享内存就是将内存进行共享,它允许多个不相关的进程访问同一个逻辑内存,直接将一块裸露的内存放在需要数据传输的进程面前,让它们自己使用。

可通过ipcs -m查看当前系统内的消息队列,通过 ipcrm -n [共享内存shmid] 即可删除
在这里插入图片描述
因此,共享内存是 效率最高的一种IPC通信机制,
进程不再通过执行任何进入内核的系统调用来彼此传递数据,即
不涉及内核

它可以在多个进程之间共享和传递数据,进程间需要共享的数据被放在共享内存区域,所有需要访问该共享区域的进程都要把该共享区域映射到本进程的地址空间中去(虚拟空间)
因此所有进程都可以访问共享内存中的地址, 就好像它们是由用C语言函数malloc分配的内存一样。

但是,这种共享的内存需要进程自己去维护好,如同步、互斥等工作,
比如当进程1在读取共享内存的数据时,进程2却修改了共享内存中的数据,那么必然会造成数据的混乱,进程1读取到的数据就是错误的,
因此,共享内存是属于临界资源,在某一时刻最多只能有一个进程对其操作(读/写数据),
共享内存一般不能单独使用,而要配合信号量、互斥锁等协调机制,让各个进程在高效交换数据的同时,不会发生数据践踏、破坏等意外。

共享内存的思想非常简单,进程与进程之间虚拟内存空间本来相互独立,不能互相访问的,
但是可以通过某些方式,使得相同的一块物理内存多次映射到不同的进程虚拟空间之中,这样的效果就相当于多个进程的虚拟内存空间部分重叠在一起, 如下图所示:
在这里插入图片描述
当进程1向共享内存写入数据后,共享内存的数据就变化了,那么进程2就能立即读取到变化了的数据, 而这中间并未经过内核的拷贝,因此效率极高。

  • 总的来说共享内存有以下特点:
    共享内存是进程间通信中效率最高的方式之一。
    共享内存是系统出于多个进程之间通讯的考虑,而预留的的一块内存区,因此共享内存是以传输数据为目的的。
    共享内存允许两个或更多进程访问同一块内存,当一个进程改变了这块地址中的内容的时候,其它进程都会察觉到这个更改。
    共享内存无同步无互斥。
    同一共享内存区对象内存映射到不同进程的地址空间时,起始地址可以不一样。

  • 共享内存的优缺点:
    优点:
    使用共享内存进行进程间的通信非常方便,而且函数的接口也简单,数据的共享使进程间的数据不用传送,而是直接访问内存,加快了程序的效率。
    同时,它也不像匿名管道那样要求通信的进程有一定的“血缘”关系, 只要是系统中的任意进程都可以对共享内存进行读写操作。

    缺点:
    共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段(如信号量、互斥量等)来进行进程间的同步工作。

4.1.2 fork相关(12.3)

fork对内存映射文件进行特殊处理,也就是 父进程在调用fork之前创建的内存映射关系由子进程共享
在这里插入图片描述
在这里插入图片描述
 

4.2 访问内存映射的对象

内存映射一个普通文件时,内存中映射区的大小(mmap第二个参数)通常等于该文件的大小,然而二者可不同。

若待打开文件已存在,则把它的大小截短成0,接着把该文件的大小设置成由命令行参数指定的大小,办法是 把文件读写指针移动到这个大小减去1的字节位置,然后写入1字节

filesize = atoi(argv[2]);
mmapsize = atoi(argv[3]);

fd = Open(argv[1], O_RDWR | O_CREAT | O_TRUNC, FILE_MODE);
Lseek(fd, filesize-1, SEEK_SET);
Write(fd, "", 1);

ptr = Mmap(NULL, mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
Close(fd);

假设页面大小为4096

  • 情况1:
    文件大小等于内存映射区大小,但这个大小不是页面大小的倍数
    当二者大小为5000时,可以完整读两页(0-4095 / 4096-8191),但在访问第三页时引发段错误SIGSEGV信号
    尽管将ptr[8191]设置成1,也写不到共享内存文件中,因而文件大小仍为50000
    内核允许我们读写最后一页中映射区以远部分(内核的内存保护是以页面为单位的)
    但我们写向这部分的内容不会写入文件中
    在这里插入图片描述
  • 情况2:
    内存映射区大小(15000字节)指定成大于文件大小(5000字节)
    当文件大小为5000时,可以完整读两页(0-4095 / 4096-8191),但在访问第三页时引发段错误SIGBUS信号
    –> 二者区别为SIGBUS意味着我们是在内存映射区内访问,但是已超出了底层支撑对象的大小
    内核知道被映射的底层支撑对象(即文件)大小,即使该对象描述符已关闭也一样
    内核允许我们给mmap指定一个大于该对象大小的大小参数,但我们访问不了该对象以远部分(最后一页上该对象以远的那些字节除外,5000-8191)
    在这里插入图片描述
  • 情况3:
    该方法展示了处理一个持续增长的文件的一种常用技巧
    指定一个大于该文件大小的内存映射区大小,跟踪该文件的当前大小,以确保不访问当前文件尾以远部分,然后就让该文件的大小随着往其中每次写入数据而增长
    #define	FILE	"test.data"
    #define	SIZE	32768
    
    fd = Open(FILE, O_RDWR | O_CREAT | O_TRUNC, FILE_MODE);
    ptr = Mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    
    for (i = 4096; i <= SIZE; i += 4096) {
    	printf("setting file size to %d\n", i);
    	/* ftruncate会将参数fd指定的文件大小改为参数length指定的大小。 */
    	Ftruncate(fd, i);
    	printf("ptr[%d] = %d\n", i-1, ptr[i-1]);
    }
    
    本例子表明,内核跟踪着被内存映射的底层支撑对象(本例子中为文件test.data)的大小,而且我们总是能访问在当前文件大小以内又在内存映射区以内的那些字节。
     

4.3 POSIX 共享内存

4.3.1 基本概念

我们现在把共享内存区的概念扩展到将无亲缘关系进程间共享的内存区包括在内。
Posix.1提供了两种在无亲缘关系进程间共享内存区的方法。

  1. 内存映射 文件 ( memory- mapped file):
    由open函数打开,由mmap函数把得到的描述符映射到当前进程地址空间中的一个文件。
    我们在第12章中讲述了这种技术,并给出了它在父子进程间共享内存区时的用法。
    内存映射文件也可以在无亲缘关系的进程间共享
    在这里插入图片描述
  2. 共事内存区 对象 ( shared- memory object):
    由shm_open打开一个 Posix1 IPC名字(也许是在文件系统中的一个路径名),所返回的描述符由mmap函数映射到当前进程的地址空间。

这两种技术都需要调用mmap,差别在于作为mmap的参数之一的描述符的获取手段:通过open或通过 shm_open。
图13-1展示了这个差别。Posix把两者合称为内存区对象(memory object)。
在这里插入图片描述

Posix共享内存区涉及以下两个步骤要求

  1. 指定一个名字参数调用 shm_open,以创建一个新的共享内存区对象或打开一个已存在的共享内存区对象。
  2. 调用mnap把这个共享内存区映射到调用进程的地址空间。
    两个步骤的原因在于当 Posix发明自己的共享内存区形式时,mmap已经存在。显然,单个函数完全可以做那两步工作。
    shm_open返回一个描述符(回想一下mq_open返回一个mqd_t值, sem_open返回一个指向某个sem_t值的指针)的原因是:
    mmap用于把一个内存区对象映射到调用进程地址空间的是该对象的一个已打开描述符

4.3.2 相关函数

4.3.2.1 内存映射到当前进程地址空间mmap()

该函数在Posix接口前已存在
把一个文件或是一个Posix共享内存区对象映射到调用进程的地址空间

目的:

  • 使用普通文件以提供内存映射IO
  • 使用特殊文件以提供匿名内存映射
  • 使用shn_open以提供无亲缘关系进程间的 Posix共享内存区。
  • 我们open内存映射文件后调用mmap把它映射到调用进程地址空间的某个文件
    所有I/O操作都在内核的掩盖下完成,我们只需要编写存取内存映射区中各个值的代码,绝不调用read/write/lseek
    –> 由内核在背后通过操纵页表等方式间接参与,这样就用户进程看来,I/O不再涉及系统调用
    不是所有文件都能进行内存映射,部分描述符(如socket)必须使用read/write或是变体来访问
  • 在无亲缘关系的进程间提供共享的内存区
    这种情形下,所映射文件的实际内容成了被共享内存区的初始内容,
    而且这些进程对该共享内存区所作的任何变动都复制回所映射的文件(以提供随文件系统的持续性)。
    这里假设指定了SHARED标志,它是进程间共享内存所需求的。
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
  • 参数:
    1. addr
      指定描述符fd应被映射到的进程内空间的起始地址,通常为空,表示让内核自己去选择起始地址
    2. len
      映射到调用进程地址空间中的字节数,它从被映射文件开头起第offset个字节处开始算。offset通常为0
      在这里插入图片描述
    3. prot
      内存映射区的保护参数,常见为PROT_READ | PROT_WRITE
      在这里插入图片描述
    4. flag
      SHARED / PRIVATE必须指定一个,且可有选择或上MAP_FIXED
      如果指定了PRIVATE,那么调用进程对被映射数据所作的修改只对该进程可见,而不改变其底层支撑对象(或者是一个文件对象,或者是一个共享内存区对象)。
      如果指定了SHARED,那么调用进程对被映射数据所作的修改对于共享该对象的所有进程都可见,而且确实改变了其底层支撑对象
      在这里插入图片描述
      便于移植的方式为将addr设为空指针,且不指定MAP_FIXED
      父子进程间共享内存区的方法之一为:
      –> 父调用fork前先指定MAP_SHARED调用mmap。Posix.1保证父进程中的内存映射关系存留到子进程中。且父进程所做的修改子进程可看到
  • 返回值:
    成功返描述符fd所映射到内存区的起始地址,成功返回后,fd参数可关闭,该操作对于mmap建立的映射关系没有影响
    出错返MAP_FAILED
4.3.2.2 删除映射关系munmap()

从某个进程的地址空间删除一个映射关系

int munmap(void *addr, size_t len);

	参数:
		addr是由mmap返回的地址
		len是映射区大小

再次访问这些地址将导致向调用进程产生一个SIGSEGV信号
如果使用PRIVATE标准映射的,则调用进程对它所做的变动都会被丢弃掉

4.3.2.3 同步映射后的内存修改msync()

内核的虚拟内存算法保持内存映射文件(一般在硬盘上)与内存映射区(在内存中)的同步,前提是它是一个SHARED内存区
即如果修改了处于内存映射到某个文件的内存区中某个位置的内容,那么内核将在 稍后某个时刻 相应地更新内容

若想要实时保持一致

int msync(void *addr, size_t len, int flags);

	参数:
		addr与len通常指代内存中的整个内存映射区,不过也可指定该内存区的一个子集

flags参数如下,ASYNC与SYNC必须指定一个,差别为一旦写操作已由内核排入队列,ASYNC即返回,SYNC则要等到写操作完成后才返回
在这里插入图片描述

4.3.2.4 打开一个共享内存区对象的名字shm_open()
int shm_open(const char *name, int oflag, mode_t mode);
	参数:
		oflag:若随O_PDWR指定O_TRUNC标志,且所需的共享内存区对象已存在,那么它将被截断成0长度
		mode:该参数与mq_open和sem_open不同,必须指定,不指定O_CREATE则设为0
	返回值:
		整数描述符,作为mmap第五个参数
4.3.2.5 删除一个共享内存区对象的名字shm_unlink()
int shm_unlink(const char *name);

删除一个共享内存区对象的名字,删除一个名字不会影响对于其底层支撑对象的现有引用,直到对于该对象的引用全部关闭为止。
删除一个名字仅仅防止后续的open、mq_open或sem_open调用取得成功

4.3.2.6 设置共享内存区对象的大小ftruncate()
int ftruncate(int fd, off_t length);

处理mmap时,普通文件或共享内存区对象的大小都可以通过调用ftruncate指定 / 修改
Posix就该函数对普通文件和共享内存区对象的处理的定义稍有不同。

  • 对于一个普通文件:
    如果该文件的大小 大于 length参数,额外的数据就被丢弃掉。
    如果该文件的大小 小于 length,那么该文件是否修改以及其大小是否增长是未加说明的。
    实际上对于一个普通文件,把它的大小扩展到length字节的可移植方法是:
    先1seek到偏移为length-1处,然后 write1个字节的数据。
    所幸的是几乎所有Unix实现都支持使用Truncate扩展一个文件。

  • 对于一个共享内存区对象:
    truncate把该对象的大小设置成length字节

4.3.2.7 获取共享内存区对象的信息fstat()
int fstat(int fd, struct stat *buf);

当fd指代一个共享内存区对象时,stat结构只有四个成员含有信息
在这里插入图片描述

4.3.3 操作步骤

Posix IPC名字 --> shm_open --> fd --> mmap --> addr
–> ftruncate 设置大小
–> fstat 获取状态
 

4.4 System V共享内存

SYSTEM V共享内存的实现要比POISX早且更完善,更易用,但其缺点是:内存大小在创建时确定,之后不能改变(POISX可以)
在这里插入图片描述

4.4.1 相关函数

4.4.1.1 创建共享内存函数shmget()

内核提供了shmget()函数的创建或获取一个共享内存对象,并返回共享内存标识符。函数原型如下:

int shmget(key_t key, size_t size, int shmflg);

	参数:
		key:
			标识共享内存的键值,可以有以下取值:
				0 或 IPC_PRIVATE。
					当key的取值为IPC_PRIVATE,则函数shmget()创建一块新的共享内存;
					如果key的取值为0,而参数shmflg中设置了IPC_PRIVATE这个标志,则同样将创建一块新的共享内存。
				大于0的32位整数:视参数shmflg来确定操作。
				ftok()返回值
		size:
			要创建共享内存的大小,所有的内存分配操作都是以页为单位的,所以即使只申请只有一个字节的内存, 内存也会分配整整一页。
			只有在创建时才需要指定,访问时应将该值写0
		shmflg:
			表示创建的共享内存的模式标志参数,在真正使用时需要与IPC对象存取权限mode(如0600)进行“|”运算来确定共享内存的存取权限。
			msgflg有多种情况:
				IPC_CREAT:
					如果内核中不存在关键字与key相等的共享内存,则新建一个共享内存;如果存在这样的共享内存,返回此共享内存的标识符。
				IPC_EXCL:
					如果内核中不存在键值与key相等的共享内存,则新建一个共享内存;如果存在这样的共享内存则报错。
				SHM_HUGETLB:
					使用“大页面”来分配共享内存,
					所谓的“大页面”指的是内核为了提高程序性能,对内存实行分页管理时,采用比默认尺寸(4KB)更大的分页,以减少缺页中断。
					Linux内核支持以2MB作为物理页面分页的基本单位。
				SHM_NORESERVE:
					不在交换分区中为这块共享内存保留空间。
	
	返回值:
		成功返回共享内存区标识符shmid
		当调用shmget()函数失败时将产生错误代码,有如下取值:
			EACCES:指定的消息队列已存在,但调用进程没有权限访问它
			EEXIST:key指定的消息队列已存在,而msgflg中同时指定IPC_CREAT和IPC_EXCL标志
			EINVAL:创建共享内存时参数size小于SHMMIN或大于SHMMAX。
			ENFILE:已达到系统范围内打开文件总数的限制。
			ENOENT:给定的key不存在任何共享内存,并且未指定IPC_CREAT。
			ENOMEM:内存不足,无法为共享内存分配内存。
			EACCES:没有权限。

注:
- 成功返回共享内存区标识符shmid
- 创建一个新的共享内存区时,该内存区被初始化为size字节的0
- 要创建共享内存的大小,所有的内存分配操作都是以页为单位的,所以即使只申请只有一个字节的内存, 内存也会分配整整一页。
- 在调用时已确定大小,后续不可变更

4.4.1.2 映射函数shmat()

如果一个进程想要访问这个共享内存,那么需要将其映射到进程的虚拟空间中, 然后再去访问它,那么系统提供的shmat()函数就是把共享内存区对象映射到调用进程的地址空间。
函数原型如下:

void *shmat(int shmid, const void *shmaddr, int shmflg);

	参数说明:
		shmid:
			共享内存ID,通常是由shmget()函数返回的。
		shmaddr:
			如果不为NULL,则系统会根据shmaddr来选择一个合适的内存区域, 
			如果为NULL,则系统会自动选择一个合适的虚拟内存空间地址去映射共享内存。
		shmflg:
			操作共享内存的方式:
				SHM_RDONLY:以只读方式映射共享内存。
				SHM_REMAP:重新映射,此时shmaddr不能为NULL。
				SHM_RND:自动选择比shmaddr小的最大页对齐地址
	
	返回值:
		成功返回共享内存的起始地址,这样子我们就能操作这个共享内存了

共享内存的映射有以下注意的要点:
- 成功返回共享内存的起始地址
- 共享内存只能以只读或者可读写方式映射,无法以只写方式映射
- shmat()第二个参数shmaddr一般都设为NULL,让系统自动找寻合适的地址,可移植性最好。
- 但当其确实不为空时, 那么要求SHM_RND在shmflg必须被设置,
这样的话系统将会选择比shmaddr小而又最大的页对齐地址(即为SHMLBA的整数倍)作为共享内存区域的起始地址。
如果没有设置SHM_RND,那么相应的共享内存区映射到由shmaddr参数指定的地址,则shmaddr必须是严格的页对齐地址。

4.4.1.3 解除映射函数shmdt()

shmdt()函数与shmat()函数相反,是用来解除进程与共享内存之间的映射的,在解除映射后, 该进程不能再访问这个共享内存。

int shmdt(const void *shmaddr);
		
	参数:
		shmaddr:映射的共享内存的起始地址。
	
	返回值:
		成功返回0,如果出错则返回-1,并且将错误原因存于error中

注意要点:
当一个进程终止时,它当前映射着的所有共享内存区都自动断接掉
该函数并不删除所指定的共享内存区,而只是将先前用shmat()函数映射好的共享内存脱离当前进程,共享内存还是存在于物理内存中。

4.4.1.4 获取或设置属性函数shmctl()

内核提供了shmctl()用于获取或者设置共享内存的相关属性。函数原型:

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数:
	shmid:
		共享内存标识符。
	cmd:
		函数功能的控制命令,其取值如下:
			IPC_STAT:获取属性信息,放置到buf中。
			IPC_SET:设置属性信息为buf指向的内容。
			IPC_RMID:从系统中删除由shmid标识的共享内存区并拆除它。
				删除一个共享内存区指的是使其标识符失效,这样以后针对该标识符的shmat、 shmdt和shmct1函数调用必定失败。
				拆除一个共享内存区指的是释放或回收与它对应的数据结构,包括删除存放在其上的数据,拆除操作要到该共享内存区的引用计数变为0时才进行。
				当某个shmdt调用发现所指定的共享内存区的引用计数变为0时也顺便拆除它,这就是shmctl的IPC_RMID命令先于最后一个shmdt调用发出时会发生的情形
			IPC_INFO:获得关于共享内存的系统限制值信息。
			SHM_INFO:获得系统为共享内存消耗的资源信息。
			SHM_STAT:与IPC_STAT具有相同的功能,但shmid为该SHM在内核中记录所有SHM信息的数组的下标, 因此通过迭代所有的下标可以获得系统中所有SHM的相关信息。
			SHM_LOCK:禁止系统将该SHM交换至swap分区。
						一个SHM被交换至swap分区后如果被设置了SHM_LOCK,那么任何访问这个SHM的进程都将会遇到页错误。
						进程可以通过IPC_STAT后得到的mode来检测SHM_LOCKED信息。
			SHM_UNLOCK:允许系统将该SHM交换至swap分区。
	buf:
		共享内存属性信息结构体指针,设置或者获取信息都通过该结构体,shmid_ds结构如下:
			struct shmid_ds {
				struct ipc_perm shm_perm;    /* 所有权和权限 */
				size_t          shm_segsz;   /* 共享内存尺寸(字节) */
				time_t          shm_atime;   /* 最后一次映射时间 */
				time_t          shm_dtime;   /* 最后一个解除映射时间 */
				time_t          shm_ctime;   /* 最后一次状态修改时间 */
				pid_t           shm_cpid;    /* 创建者PID */
				pid_t           shm_lpid;    /* 后一次映射或解除映射者PID */
				shmatt_t        shm_nattch;  /* 映射该SHM的进程个数 */
				...
			};
			
			struct ipc_perm {
				key_t          __key;    /* 该共享内存的键值key */
				uid_t          uid;      /* 所有者的有效UID */
				gid_t          gid;      /* 所有者的有效GID */
				uid_t          cuid;     /* 创建者的有效UID */
				gid_t          cgid;     /* 创建者的有效GID */
				unsigned short mode;     /* 读写权限 + SHM_DEST + SHM_LOCKED 标记 */
				unsigned short __seq;    /* 序列号 */
			};

4.4.2 操作步骤

使用共享内存的一般步骤是:

  1. 创建或获取共享内存ID。
  2. 将共享内存映射至本进程虚拟内存空间的某个区域。
  3. 当不再使用时,解除映射关系。
  4. 当没有进程再需要这块共享内存时,删除它。
    即 shmget() --> shmat() --> 读写操作 --> shmdt() --> shmctl()

共享内存由于其特性,与进程中的其他内存段在使用习惯上有些不同。
一般进程对栈空间分配可以自动回收,而堆空间通过malloc申请,free回收,这些内存在回收之后就可以认为是不存在了。
但是共享内存不同,用shmdt()函数解除映射后,实际上其占用的内存还在,并仍然可以使用shmat映射使用

如果不使用shmctl()函数删除这个共享内存的话,那么它将一直保留直到系统被关闭,除此之外,我们应该配合信号量去使用共享内存,避免多进程间的随意使用造成数据踩踏。
 

4.5 Posix与System V共享内存异同

相同点:

  • 两种共享内存区都是至少具有随内核的持续性,所创建的共享内存对象会一致存在,直到其所有引用都关闭并且该对象被显式删除

不同点:

  • Posix共享内存区对象的大小可在任何时刻通过调用 Truncate修改(如习题13.1中所展示的那样)
  • 而 System V共享内存区对象的大小是在调用 shmget创建时固定下来的。

五、总结

  • 共享内存
    • 共享内存是效率最高的一种IPC通信机制,传递数据不涉及内核
    • 需要各个进程单独通过调用mmap / shmat将共享内存区映射到本进程的地址空间,且需要自己去维护临界资源
    • 父进程在调用fork之前创建的内存映射关系由子进程共享
    • 内存中映射区的大小(mmap第二个参数)与映射文件大小的关系
      内核允许我们读写最后一页中映射区以远部分(内核的内存保护是以页面为单位的)
  • Posix
    • 对内存映射文件进行修改后,若不调用msync(),则内核会在一段时间后将修改在共享内存区生效
    • mmap在Posix之前已存在,故Posix接口有使用mmap接口
    • 可通过调用Truncate对共享内存区对象进行设置大小
  • SystemV
    • shmat()本质还是调用了mmap
    • 在shmget()之后就法无法修改大小
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值