一、System V ------ 共享内存(详解)
共享内存区是最快的 IPC 形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说,就是进程不再通过执行进入内核的系统调用来传递彼此的数据。
下面我们还需要了解进程间通信之 System V 标准下的共享内存,前面所讲的管道其实不属于 System V 标准,但是它依旧是操作系统下最原生的通信方式。
System V 标准下有最典型的三种通信方式:**共享内存、消息队列、信号量。**下面会重点谈谈共享内存,然后简单提一下消息队列,而信号量将放在后面的多线程部分再进行了解。
1、共享内存的原理
正如上面两种管道,进程间通信的第一步一定是先让不同的进程看到同一份资源,然后才是通信的过程。
进程间通信中的大部分内容都是第一步,在这之前要让不同进程看到同一份资源:匿名管道是通过父子共享文件的特征;命名管道是通过文件路径具有唯一性。
其中,这两种管道归根到底所看到的资源都是文件资源。
对于上图中的内容,我们之前已经接触过了。操作系统为了满足通信的需求,需要:
- 在物理内存上申请一块物理内存空间。
- 再把这块空间通过页表映射到共享区(也就是堆栈之间)。
- 最后,将建立映射之后的虚拟地址返回给用户。
操作系统当然可以做到这些操作,因为操作系统是软硬件资源的管理者,这并不难理解,在 C/C++ 中使用 malloc / new 时,实际上就是在堆上申请空间,最后也还是在物理内存中申请,然后再返回地址。以前 malloc / new 是为了让你这个进程私有使用,而现在是为了能够让多个进程看到同一份资源。
此时又有一个进程 B,它和进程 A 没有任何关系,虽然它们是类似的数据结构来表示进程,但是它们的代码和数据是被加载到内存中不同的位置,所以实际上它们是具有很强的独立性。举一个例子:一个全局变量,然后父进程直接打印出它的地址和值,子进程修改内容后再打印出地址和值,最后结果显示二者地址一样,值却不一样。
同样对于进程 B,操作系统也可以向物理内存申请空间,然后再映射到共享区,接着返回给进程。不过,我们要做到的是让不同的进程能够看到同一份资源,所以原理就是操作系统向物理内存申请一块空间,这块空间就叫做共享内存,再把这块空间分别映射到两个进程中 mm_struct 的共享区(这个共享区在基础 IO 中已经说过动态库是被映射到这个区域的,现在就知道了物理内存中申请的共享内存也会被映射到这块区域),然后再返回给进程,那么这两个进程就可以使用各自的虚拟地址、页表访问同一块物理内存,这就是共享内存。上述步骤一定是有对应的系统调用接口帮助我们实现。共享内存的提供者是操作系统。
操作系统内部是提供通信机制的(IPC),也就是其中有一个 ipc 模块。前面讲到操作系统申请一块内存空间,但也得是有人来告诉操作系统自己需要申请,所以本质还是进程申请的。从宏观上来看,操作系统内一定存在大量的共享内存,所有的共享内存都是进程向操作系统申请的,其中操作系统当然要管理这么多的共享内存,那应该怎么管理呢?------ 先描述,再组织(共享内存是给进程使用的,而操作系统为了管理这些共享内存,它也需要申请大量对应的数据结构来维护),共享内存 = 共享内存块 + 对应的共享内存的内核数据结构,所以操作系统对共享内存的管理就变成了对共享内存所对应的数据结构的管理。
综上所述,流程对应如下:
- 申请共享内存。
- 进程 A、B 分别挂接对应的共享内存到自己的地址空间(共享区)。
- 进程双方就能够看到同一份资源,也就可以互相通信了。
- 释放共享内存。
2、 shmget
(1)认识接口
shmget 是系统提供 来申请共享内存的一个系统接口。
key 是这个共享内存段的名字。
size 是我们想申请共享内存的大小,理论上来说是可以任意,但还是建议选择 4kb 的倍数(后面会解释原因)。
shmflg 有 IPC_CREAT 和IPC_EXCL 两个选项。前者是创建共享内存,后者单独使用并没有意义。其次,这里还可以 | 上一个八进制 方案,表示这个共享内存的权限。shmflg 由九个权限标志构成,它们的用法和创建文件时使用的 mode 模式标志是一样的
- 若同时设置 IPC_CREAT 和 IPC_EXCL,如果目标共享内存不存在,则创建;否则,出错返回。这样做的意义是如果调用 shmget 成功,那么得到的一定是全新的共享内存,因为如果它失败就出错了。所以一般这两个选项会组合使用,就可以从 0 到 1 的创建一个共享内存。
若只设置 IPC_CREAT(同 0)(没有意义),如果目标共享内存不存在,则创建;否则,则获取共享内存。
一定是一个进程设置 IPC_CREAT | IPC_EXCL,另一个进程设置 IPC_CREAT(什么叫做同时设置呢?之前我们就说过标志位用 int 太浪费了,所以这里用的是一个 bit 位来表示一种状态。如果有多个状态需要同时设置就使用 |,这里可以验证一下,结果可以看到这里 define 的是一种 8 进程数据,这里的 1 2 4 就说明用的是一串 01 序列,但只有一个 1,而且 1 的位置不一样,所以 | 就可以获取到多个标志位)。
如果共享内存已经存在了,此时就不应该再进行创建了,而是选择获取。因为如果一个创建好共享内存的进程要与另一个进程通信的话,另一个进程就只能是获得要通信进程对应的共享内存的返回值:如果成功,会返回一个合法的共享内存的标识符,类似之前学的 fd;否则,返回 -1。
它可以通过这个返回值来唯一标识这个共享内存。这个概念有点类似文件描述符,共享内存 ipc 机制也确实与文件系统有关,但 ipc 机制是操作系统另外一个独立的模块,这样的小模块还有很多,之前了解的都是一些宏观上的模块,就比如:进程管理、文件管理、内存管理、驱动管理。
如何保证两个进程看到的是同一块共享内存呢?
每个共享内存都有自己对应的数据结构 struct shm_ipc,此时通过 key 就可以进行唯一 区分(像是我们的身份证号码,更多的是强调唯一性)。其中 A 进程创建了共享内存,key 的值是 123,如果 B 进程想要和 A 进程通信,就需要遍历共享内存数据结构中的 key 值。
那么现在的问题就变成了如何保证两进程获得的是同一个 key 值呢?------ ftok。
它和 fork 很像,但是没有任何关系。它的内部不进行任何的系统调用,而是一套算法,ftok 没有任何的系统调用。它只是把第一参数的字符串和第二个参数的整数合起来形成一个唯一的 key 值。它可以按照自己的情况任意填写,但必须要保证通信的两个进程填的 key 值是一样的,这样就能够保证两个进程使用的是同一个规则来形成的 key 值。
(2)代码
A. makefile
makefile 中是可以定义变量的, makefile 中取变量要用 $()。
B. common.h
c. 必须要先保证 server.c 和 client.c 中的 ftok 获取的 key 值是一样的
ftok 本身没有任何的系统调用,key 值就是 ftok 将 PATH_NAME 和 PROJ_ID 组合形成唯一的 key 值。
D. 申请共享内存
a. ipcs
ipcs 命令默认它会查看 Message Queues(消息队列)、Shared Memory Segments(共享内存段)、Semaphore Arrays(信号量数组)相关信息。如果只想查看共享内存,则 ipcs -m,这里我们可以看到好像并没有什么共享内存,sudo 之后也没有,这时我们再打开一个共享内存。
此时 ./server,输出结果之后 server 进程当然退出了,所以它的退出码是 0。
(说明当进程运行结束,共享内存依旧存在)
此时再 ipcs -m 就可以看到 server 进程所申请的共享内存信息了。
正如上面所看到的,server 进程已经结束了,但是它所申请的 ipc 共享内存资源仍然存在。这里表达的是,所有的 Systrem V IPC 资源的生命周期都是随内核,而不随进程。这里有两种方法可以释放共享内存:
- 手动删除:操作系统进行重启或者命令行指令(
ipcrm -m shmid
释放共享内存,规范应该是由所对应的进程来调用系统接口来释放的)。- 代码删除:当进程退出时,用调用释放(有申请共享内存,自然也有释放)。
2、shmctl
(1)认识接口
既然有 shmget 来申请共享内存,那么也必须要有 shmctl 来释放共享内存,shmctl 用于控制共享内存。
- shmid是 shmget 创建共享内存成功后返回的共享内存标识码 id。
- cmd 是将要采取的动作(有三个可取值),如果想释放共享内存,那么就用 IPC_RMID 选项。
- buf 类似于上面说的共享内存的属性 struct shm_ipc,这很少使用,它指向一个保存着共享内存的模式状态和访问权限的数据结构,设置为 NULL。
(2)代码
这里的现实的 perms 就是共享内存的权限。
3、shmat
至此,我们完成了让进程在物理内存中创建好共享内存,然后释放共享内存,那么接下来还要将进程与共享内存关联。所以刚刚在查看共享内存时,nattch 就是与当前共享内存关联的进程的个数,我们可以看到,这里只是创建了共享内存,还没有任何一个进程与之关联。
(1)认识接口
shmat 是系统提供于共享内存和进程关联的一个系统接口,也就是将共享内存段连接到进程地址空间。
- shmid 是让这个进程和哪个共享内存关联,是一个共享内存标志。
- shmaddr 是一个指定连接地址,要把共享内存挂接到进程的哪个虚拟上,这里要挂接到共享区,我们直接设置为 NULL,操作系统会帮我们选择。
- shmflg 的选项是挂接的方式,我们默认填 0,它的两个可能取值是 SHM_RND 和 SHM_RDONLY。
- shmat的返回值是 void*,我们之前学的 malloc 的返回值也是 void*,虽然它们的区域不一样,但是原理类似,malloc 成功后返回值就是堆上的一块空间的起始地址,而 shmat 成功就返回关联共享内存段的起始地址。
- shmaddr 为 NULL,核心自动选择一个地址。
- shmaddr 不为 NULL 且 shmflg 无 SHM_RND 标记,则以 shmaddr 为连接地址。
- shmaddr 不为 NULL 且 shmflg 设置了 SHM_RND 标记,则连接的地址会自动向下调整为 SHMLBA 的整数倍。公式:shmaddr - (shmaddr % SHMLBA)。
- shmflg=SHM_RDONLY,表示连接操作用来只读共享内存。
(2)代码
至此就完成了关联共享内存。
4、shmdt
有 shmgat 关联,那么也有相对应的 shmdt 去关联,也就是将共享内存段与当前进程脱离。
(1)认识接口
- shmaddr 是由 shmat 所返回的指针。
shmdt 是系统提供来取消关联共享内存的一个系统接口。shmdt 和 shmat 是同一个文档下,它就更简单了,只有一个参数。shmaddr 就是刚刚获取成功的共享内存的虚拟地址。
注意 :将共享内存段与当前进程脱离不等于删除共享内存段。
(2)代码
至此就完成了去关联共享内存。
5、shmServer 和 shmClient 开始通信
至此,创建共享内存、释放共享内存、关联共享内存、去关联共享内存这几个系统接口就介绍完了。那么 shmClient 端一定比 shmServer 端更简单,因为它不用再创建共享内存,自然也就不用它来释放共享内存,但是它需要关联和去关联共享内存。
编写和完善代码,测试挂接数量由 0 - 1 - 2 - 1 - 0。
运行结果:
既然已经将物理内存映射到进程的地址空间,那么进程就可以直接使用虚拟地址直接对物理内存进行真正访问,而不再需要用 read 和 write 这些系统调用接口了。
此时,client 和 server 就看到了同一份资源,这里就可以通过指针访问共享内存了,然后 client 每隔 2 秒向共享内存写入 abcd...xyz,server 每 1 秒向共享内存读出 abcd...xyz。
毫不意外的是,这里 server 是死循环,所以只要不终止,那么最后挂接数会由 2 变为 1,不过没关系,这里只是测试。可以看到如下测试结果,server 每一秒读一次,client 每二秒写一次,client 明显写的比较慢,但是 server 并没有等 client,所以共享内存机制并没有像管道机制那样有同步机制(这里读的时候可以不休眠的读,就可以看到更明显的现象了,就是 client 2 秒写的时候,server 才不管),所以共享内存不提供任何同步与互斥的操作,双方彼此独立,这里可能就会引起一些问题,比如 client 想写 Hello,然后让 server 干净的读,但是对于共享内存机制而言,server 只能等 client 写完才可以读。
【结论】
- 只要是通信双方使用 shm,一方直接向共享内存中写入数据,另一方就可以立刻看到对方写入的数据。共享内存是所有进程间通信(IPC),是速度最快的,因为不需要过多的拷贝(不需要将数据给操作系统)。
- 共享内存缺乏访问控制,会带来并发问题 【**如果想一定程度的访问控制呢,能否实现?**能 】
对应的程序在加载的时候会自动构建全局变量,就要调用该类的构造函数 ------ 创建管道文件。
程序退出的时候,全局变量会被析构,自动调用析构函数,会自动删除管道文件。
6、总结
- 共享内存的生命周期随 OS。
- 共享内存不提供任何同步与互斥的操作,双方彼此独立。
- 共享内存是进程间通信中速度最快的。
- 相比之下,管道就很慢了,它需要写端把数据写到管道,读端再从管道读,和管道的交互至少需要两次拷贝。还不包括如果写端的数据是从 stdin 中来的,那么就要先写到用户层缓冲区。
- 共享内存没有进行同步与互斥。
为什么创建共享内存的 SIZE 要设置成 4kb 的倍数?
因为系统在分配共享内存时是按 4kb 也就是一页为单位,所以如果申请 4097byte,那么操作系统在分配时会分配 4096 + 4096,也就是 8kb。但是 ipcs -m 时也确实只是 4097,这里系统确实是分配了 8kb,但是我们能使用的就是我们所申请的。换而言之,如果申请了 4097byte,那么就有可能浪费 4095byte,所以在创建共享内存时,建议 SIZE 大小是 4kb 的整数倍。
【key & shmid】
key 是一个用户层生成的唯一键值,它的核心作用是为了区分唯一性,它不能用来进行 ipc 资源的操作。
shmid 是一个系统给我们返回的 ipc 资源标识符(其实它也是一个数组下标,用于维护 ipc 资源),用来操作对应的 ipc 资源。
这里的 key 有点类似文件的 inode 号,shmid 有点类似文件的 fd。所以我们就可以理解在代码或命令访问共享内存时使用的是 shmid,而不是 key,原因是无论是代码或命令都是用户层上的操作共享内存。
【共享内存数据结构】
下图是操作系统给我们提供的一个系统调用头文件共享内存数据结构,而系统调用本来就是操作系统提供的,所以这个数据结构基本上和内核中描述共享内存的结构类似。
struct shmid_ds 这个结构体就是我们在上面所说的 struct shm_ipc,系统中存在着大量的进程和对应的共享内存,所以每个共享内存创建出来都有这样一个结构。
简单看一下,这其中有 shm_segsz 共享内存大小,shm_atime/shm_dtime 共享内存最近挂接和去挂接时间,shm_ctime 共享内存修改时间,shm_cpid 由 pid 进程创建,shm_lpid 由 pid 进程操作,shm_nattch 有几个进程挂接到共享内存,shm_unused 未使用的共享内存等。还有一个 shm_perm,我们找一下 ipc_perm。其中我们看到了熟悉的 key、mode。
我们发现在文档中,消息队列中有 struct msqid_ds,其中也有 struct ipc_perm msg_perm;信号量中有 struct semid_ds,其中也有 struct ipc_perm msg_perm。这里只想说明,所有 System V 标准下的通信方案,都有一个描述其对应资源的结构体。
可以看到,这里的共享内存、消息队列、信号量结构体下第一行都有一个 struct ipc_perm xxx,那我们就可以定义一个数组 struct ipc_perm array[1024]; 我们都知道这里有一个嵌套结构体,假设只知道内部 obj 的地址,那么 struct A a 的地址就同 &a.obj,此时 (struct A*)&a.obj,那么就可以访问 x 和 y 了。所以 Linux 就将所有的 ipc_perm 放在一个数组中,然后 &ipc_perm 再强制类型转换成共享内存或消息队列或信号量类型。换而言之,Linux 内核的 ipc 资源可以用数组来维护,也就是说如果以后想要创建一个 ipc 资源,那么系统会给我们一个 ipc_perm,然后再给我们对应的 ipc 资源的其它属性,使用 key 值保证唯一性,然后再把数组的下标返回。
可以看到如下 Linux 内核框架图,sem_array,msg_queue,shmid_kerne 它们的第一个成员都是 xxx.perm,经过强转就可以访问 kern_ipc_perm。
二、System V ------ 消息队列(了解)
操作系统会在系统中维护一个消息队列,这个消息队列默认情况下是空的,当用户 1 创建消息队列时,就会用 key 来标识其唯一性,此时用户 2 就可以通过 key 来获取这个消息队列,那么两个用户就可以看到同一个消息队列了。然后用户 1 就可以往这个消息队列里放节点,用户 2 自然也能看到,反之也可以。这就是消息队列,消息队列 = 消息队列本身 + 为了维护消息队列内核所创建的数据结构。
- 消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法。
- 每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同类型值。
- 特性:IPC 资源必须删除,否则不会自动清除,除非重启,所以 System V IPC 资源生命周期随内核。
1、接口
msgget 获取消息队列,msgctl 释放消息队列,msgsnd 发送消息队列,msgrcv 接收消息队列。
三、System V ------ 信号量(了解)
1、相关概念的铺垫
信号量主要用于同步和互斥的,下面先铺垫一些概念。信号量跟上面的内容也有一些关联,同时也为后面的多线程部分做铺垫。
进程通信的本质是让进程看到同一份资源,当同一份资源被多进程看到时,极有可能出现当 A 进程正在对空间进行写入时,B 进程就来读取了。如果像管道那样自带同步机制倒也不会产生什么影响,实际上前面所讲的共享内存就是一种读写错乱的机制。
将多个进程同时看到的那一份资源叫做临界资源。 我们仔细观察可以看到,在 server 和 client 中访问共享内存 / 临界资源的代码实际上只有少部分几行。换而言之,造成读写数据不一致问题的可能就是这一部分代码所引起的,我们将这部分访问临界资源的代码叫做临界区 ,所以为了必免数据不一致的问题,就需要保护临界资源,即对临界区代码进行某种保护,而这某种保护就被称为互斥。
所谓互斥就是有一块空间,在任何时候有且仅能有一个进程在进行访问(生活中最典型的互斥场景就是去上洗手间),互斥本身是一种串行化 执行(也就是说,共享内存中就是因为并行读写执行才导致的数据不一致问题),而后面一般互斥是通过锁来完成的,这里可以提一种二元信号量来完成串行执行(我们也能猜到加锁和解锁是有代码的) 。所以串行化的过程本质是对临界区资源加锁和解锁,从而完成互斥操作。也就是说,client 和 server 都必须遵守 "要进入临界区就得加锁,退出临界区就得解锁" 这一原则。
这里再感性的理解一遍原子性概念,其实说白了,就是要么做了,要么没做。比如一个进程想往共享内存里写 "Hello World",写完 "Hello" 时的这个状态叫做写入中,那么在写入过程中是不能被打搅的,得等到全部写完为止。也就是说,在其他人看来,这里写入过程的状态只有两种,其一是还没写,而其二是写完了,这就是原子性。
最典型的应用就是,假设我们在农商银行里有 1000 元,在建设银行里有 500 元,然后我们想进行转帐:农商账号 -= 200;建设账号 += 200。其中,当我们从农商账号转账到建设账号的时候,系统崩溃了,此时建设银行账号还是 500 元,但是农商银行账号少了 200 元。这个现象说白了就是当某个任务正在进行时,突然因为某些原因而导致任务中断,这就叫做不是原子性。所以这个转账的过程要不就不做,要不就必须得做成功,或者转账失败了也能保证农商账号的钱不受影响,这就是原子性。
我们也可以采用互斥 的方案来保证原子性。
- 由于各个进程要求共享资源,而且有些资源需要互斥使用,那么各进程竞争使用这些资源,进程之间的这种关系就叫作进程的互斥。
- 系统中某些资源一次只允许一个进程使用,这样的资源被称为为临界资源或互斥资源。
- 在进程中涉及到互斥资源的程序段叫做临界区。
- 特性:IPC 资源必须删除,否则不会自己清除,除非重启,所以 System V IPC 资源的生命周期随内核。
2、什么是信号量
信号量也叫做信号灯。举几个例子,假设我们买了一个房子,虽然我们没住在里面,但房子依旧是是属于我们的。在宿舍的时候,虽然没躺着,但是那个床位依旧是属于我们的。我们在网上买电影票看电影,虽然还没到时间去看,但我们很清楚,到了一定的时间我们就能看到。所以现实生活中存在很多 "预定机制",因为不提前享受,所以在卖票的时候就得保证一人一座,不能超过电影院的承受能力。
这里有三个进程都要访问共享内存,这块共享内存就是这三个进程的临界资源,而要访问共享内存需要加锁,这里进程 A 先访问成功,然后解锁,紧接着进程 B,然后又进程 C,这就是互斥。
信号量的本质是一个计数器 int count(注意:这里的 int count 是错误的,先暂时这样理解,后面再详细解释),然后定义 int count = 3; 还有一段伪代码,任何进程想操作共享内存前必须先申请信号量。然后进程 A 要进来,所以 count- - 之后,count 是 2,而进程 A 要出去,也要对应进行释放信号量,也就是 count++。
这就类似于电影院的预订机制,电影院有 100 张票,我们预定了一张票,那么票数就变成 99 张,当我们看完离开电影院后,票数就变成 100 张。也就是说,申请信号量的本质就是:count- -,就是对临界资源的预定机制,count -- 后就一定要有资源给我们预留,而不是 pause,这里一共有 3 个资源,我们已经申请了一个,即使我们还没有开始访问,但最终我们也能访问,这就是一种预订,这就是信号量。所以信号量本质就是计数器,是用来描述临界资源中资源数目的计数器。
3、为什么要有信号量
有的时候进程并不是把共享内存全部使用,这里的共享内存被分为三块空间,有很多进程。如果想让不同的进程访问不同的共享内存区域,那么它们是不受影响的,但最怕的就是一个进程在访问一块空间的时候,另一个进程也来访问这块空间。这里想说明的是,进程不是对共享内存的整体进行访问,而是可能只使用共享内存中的一部分,所以只要多个进程访问的那部分共享内存是不重叠的,那么就可以并行访问。也就是说,有七八个进程,每个进程都把这个共享内存占有就是互斥,但这显然不太合理,所以允许在访问共享内存不重叠的前提下,可以允许少量进程同时访问,而这样的工作就是由信号量来完成的。
4、如何使用信号量
每个进程想对共享内存访问都必须先申请信号量,我们称之为 p 操作,而访问完之后要执行非临界区代码时要释放信号量,我们称之为 v 操作,所以信号量最重要的操作我们称之为 pv 原语。
如果同时有 5 个进程都想访问共享内存,都想对计数器进行减减操作,那么下面有两个问题。
多个进程能不能操作同一个 count 值 ?
不能,因为有写时拷贝,我们定义全局变量,甚至 malloc。无论如何,只要子进程去进行操作时,不可能减减加加去影响其它进程的,count 一开始是 3,每个进程写时拷贝都认为是 3。所以信号量 != count,因为必须保证多个进程操作的是同一个信号量。
信号量是干什么的 ?
保护临界资源的安全性。
假设还认为信号量是一个类似全局变量,且多个进程能操作一个全局变量 count,那么每个进程去执行上面的伪代码不就行了吗 ?
不行。因为申请信号量过程中需要:
- 进行 if 判断
- 内存 --> cpu
- cpu 执行计算
- cpu --> 内存。
而此时进程 A 执行判断成功后,进程 B 已经减到 0 了,进程 A 再减就是 -1,相当于给别人多分配了资源,因为它是多条语句构成,有可能会导致操作乱序,有可能会多分配资源出去,所以就不是原子性的。
- 计算是在 CPU 内的,数据存储在内存的 count 变量里面。
- CPU 在执行指令的时候,首先将内存中的数据加载到 CPU 内的寄存器中(读指令),接着进行 count--(分析和执行指令),最后将 CPU 修改完毕的 count 写回内存中。
- 执行流在执行的任何时刻都有可能会被切换。
- 寄存器只有一套,被所有的执行流共享。但是寄存器里面的数据属于每一个执行流,属于该执行流的上下文数据。
每个进程都得先申请信号量,前提是每个进程都得先看到信号量。但如果每个进程都能看到信号量时,信号量本身就是一个临界资源,所以这样就变成了信号量原本是保护临界资源的,但自己却变成了临界资源。这当然有问题,你要保护其它人的前提是先保护好自己的安全,所以上面所讲的信号量 pv 操作,它本身就是原子的,所以它被称为 pv 原语,简单点来说,就是那个计数器本身就是原子的。在同一时间内,它只允许一个进程进行操作。
实现伪代码:假设这里有若干个进程要访问临界资源,那么首先只有进程 A 先申请锁成功,然后往下执行后 count = 2 解锁,进程 A 就可以访问共享内存的一部分了。另外进程 B 也在申请锁成功,然后往下执行后 count = 1 解锁,进程 B 就可以访问共享内存的一部分了。再另外进程 C ... ... count = 0 解锁,进程 C 就可以访问共享内存的一部分了。再另外进程 D 也申请锁成功,但是因为 count = 0,代表无多余的资源,此时就 goto 跳转到 begin,重复执行,此时就用这段代码,约束了访问临界资源的进程。接着进程 A 访问完毕,然后申请锁成功,count++ 变成 1,最后解锁成功。此时进程 D 申请锁成功,count 是 1 表示有资源可以访问,然后往下执行 count = 0 解锁,进程 D 就可以访问共享内存的一部分了。
在多进程环境下,如何保证信号量被多个进程看到 ?
只要使用系统提供的一批接口,就可以保证信号量被多个进程看到。
如果信号量计数器的值是 1,此时信号量的值无非就是 1 或 0,如果我们要申请信号量,但只让我们一个进程申请成功,这种信号量叫做二元信号量,其本质就是一种互斥语义。换而言之,信号量计数器的值 大于 1,它就是多元信号量。
-
semget 中 nsems 是系统可以允许你一次创建多个信号量,底层是用数组来维护这多个信号量,所以
ipcs -s
时 ,可以发现它是一个信号量数组。 -
semctl 中 semnum 是我们想对第几个信号量进行操作。
-
semop 是需要对特定的信号量传入 sembuf 结构,这个结构如下图,sem_op 对应上面所说的 pv 操作,如果是 -1,就表示对计数器 -1,如果是 +1,就表示对计数器 +1。nsops 是想对第几个信号量操作。
- 共享内存的优点:进程间通信速度最快的。
- 共享内存的缺点:不会维护同步和互斥机制。
可以看到 System V 标准下的 ipc 共享内存机制其实蛮复杂的, 但其实共享内存又是 System V 标准下最简单的一套机制,所以当我们看到这里的时候也不难,相对更复杂的是消息队列机制,最复杂的是信号量机制。实际在公司中很少自己写这些东西,特别是消息队列和信号量,所以目前就先了解共享内存机制,知道是其底层是怎么通信的即可。