system V是一种进程间通信策略,它包括共享内存,消息队列以及信号量。
共享内存区是最快的IPC(进程间通信)形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据。
- 共享内存也是由操作系统维护的共享资源。
两个进程的PCB各自维护着一个进程地址空间。当两个进程要进行通信时:
所以说,共享内存就是让不同的进程,看到同一块内存块。
在维持通信关系中,还涉及到几个概念:
- 挂接:将内存中创建好的内存块映射到进程的地址空间中。
- 去关联:不想通信时,取消进程和内存的映射关系。
注意: 去关联后,共享内存仍然存在,只是和去关联的进程没有了映射关系。
该系统调用接口就是用来在内存中创建共享内存的。它们的参数意义非凡,下面本喵来详细解释一下。
关于共享内存,首先需要理解几件事情:
共享内存多了,就需要有一个标识,让要通信的进程找到正确的共享内存。
key值就是共享内存的标识,让想要通信的进程双方看到同一块公共资源。
系统中既然存在很多个共享内存,操作系统势必要将它们管理起来,管理也是使用先描述再组织的方式。
管理共享内存并不是在管理内存块本身,而是在管理共享内存对应的结构体:
struct shm
{
key_t key;
size_t size;
//....
}
结构体类似与上面代码,包含许多共享内存的属性,最重要的是,结构体中有key值。
- 每创建一个共享内存,就会创建一个结构体对象,并且赋一个不同的key值。
- 所以这个key值就代表着一块唯一的共享内存。
问题又来了,怎么保证这个key值是唯一的呢?
从shmget函数的声明中可以看到,这个key值是我们传给操作系统的,也就是用我们传的key值来标定共享内存。
函数ftok()
这就需要用到另一个函数ftok(),来生成一个独一无二的key值。
- pathname:文件路径名,可以随意写,一般我们都写成当前路径"."。
- proj_id:项目ID,同样可以自定义,但是不能为0。
- 返回值:独一无二的key值。
该函数会根据我们传的路径名和项目id值生成一个key值,具体实现是通过一些算法实现的,我们不需要在意,只需要得到key值就行。
所以,在开辟共享内存之前,必须先使用ftok函数来生成一个独一无二的key值,这样才能保证我们内存块的标识是唯一的。
当两个进程通过key值和共享内存挂接起来后,就可以进行通信了。
结论:key值就是用来标识共享内存的唯一性的。
size
size是用来指定开辟的共享内存是多大的,以字节为单位。
操作系统在开辟共享内存的时候是以4KB为单位的。每次开辟的共享内存,最小也是4KB的。
- 假设我们指定4097字节大小的共享内存,但是在内存中实际开辟的共享内存是2*4KB的。
- 但是在使用的时候只能使用4097字节的空间,剩下的空间用户无法使用,操作系统也不会用,就浪费掉了。
所以,即使用不了那么大的空间,我们也要指定4KB的整数倍。
shmflg
这是一个标志位,和之前使用的open用法相似,也是一个int类型的数据,根据比特位不同,用法也不同。
常用的两个选项:
- IPC_CREAT:创建共享内存,如果不存在,创建新的,如果存在,获取相关信息。
- IPC_EXCL:无法单独使用,必须与其他标志组合使用。
IPC_CREAT | IPC_EXCL:创建共享内存,如果不存在,则创建,如果存在,错误返回。
IPC_CREAT | IPC_EXCL是专门给用户使用的,就是为了保证创建的共享内存是一块新的内存块。
返回值
- 创建成功返回共享内存的标识符,注意不是key值。
- 创建失败,返回-1。
来看看返回的值是什么样子:
- shmid也是连续的小整数,它和文件描述符fd一样,也是让用户使用的。
- 但是它和文件描述符代表的意义又不一样。
又存在一个问题,为什么返回的不是key值,而是shmid呢?key值也是唯一的啊。
在设计上是可以的,都是唯一的数字,用户在语言层和在系统使用的标识相同是没有问题的。
所以说,用户层使用shmid而不是key值是为了让用户层和系统层解耦。
结论:shmid是供用户使用的共享内存标识符。
shmget系统调用就是用来让通信双方获取同一块共享内存的。
刚刚我们使用shmget时创建了五个共享内存:
此时,进程早已经结束了,但是共享内存还是存在,没有随进程的结束而消失。
所以说,共享内存的生命周期随内核,不随进程。
如果不想要这几个共享内存了,怎么删除呢?同样有指令:
- 指令:ipcrm -m shimd
- 功能:删除指定shimd标识的共享内存。
此时所有的共享内存就被删除了,但是需要我们手动的一个个去删除。
由于指令也是shell上运行的进程,也是属于用户层,所以操作共享内存时,使用的时shmid,而不是key值。
用命令行的形式未免也太麻烦了,所以有系统调用shmctl也可以用来删除共享内存,而且是自动的。
- shmid:获取共享内存后返回的标识符。
- cmd:指定控制共享内存的方式。
- buf:描述共享内存的数据结构指针。
cmd:IPC_RMID
删除是最常用的选项。
key值和shmid也打印了,说明共享内存创建成功了,但是使用ipcs查看时,发现什么都没有,证明shmctl将创建的共享内存删除了。
cmd:IPC_STAT
上图所示就是共享内存的数据结构struct_shmid_ds,也就是用来描述共享内存的数据结构,这只是其中的一部分。
该结构体的第一个成员变量是struct ipc_prem shm_prem,具体内容如红色箭头所指。可以看到,key值就在这里,共享内存就是通过它来标识唯一性的。
当cmd是IPC_STAT时,我们就可以获取到共享内存的属性:
cmd可以传的值有很多,分别对应这不同的操作,但是最常用的还是删除。
shmat
shmat是让进程和共享内存挂接:
- shmid:创建共享内存后返回的标识符。
- shmaddr:指定共享内存映射到进程地址空间中的地址,一般设置成NULL,让系统自动来设置。
- shmflg:不用管它是啥,直接给0。
- 返回值:共享内存映射到进程地址空间中的地址。不成功返回-1,但是是void*类型的。
让该进程挂接共享内存,挂接成功打印映射到进程地址空间中的起始地址。
在运行的时候发现报错了,说我们没有权限,再查看共享内存,发现确实是创建了,但是共享内存的权限是0,也就是我们谁都不能访问。
解决办法就让给共享内存开发相应的权限:
- 在创建共享内存的时候,让其开发对拥有者的读写权限。
- 将0600和IPC_CREAT已经IPC_EXCL或在一起。
此时便挂接成功了,并且成功打印出了共享内存映射在进程地址空间中的起始地址。
shmdt
shmdt是让进程和共享内存去关联。
- shmaddr:要去关联的共享内存映射在进程地址空间中的起始地址。
- 返回值:成功返回0,不成功返回-1。
nattch值
在进程和共享内存挂接后,查看共享内存信息,发现这个共享内存的nattch变成了1。
- nattch:表示和这块共享内存挂接的进程数量。
可以看到,在计时到之前,nattch的值是1,等计时到了,nattch的值变成了0,此时就将该进程和共享内存去关联了。
注意: 去关联后,共享内存还在,只是和进程没有联系了。
共享内存方式的进程间通信,用到的系统调用为shmget,shmctl,shmat,shmdt四个。
使用共享内存的方式,实现进程server和client之间的通信:
shmget,shmctl,shmat,shmdt四个系统调用通信双方都会使用,而且会对返回值进行严格判断,所以我们将这些共用的代码放在一个头文件中。
comm.h:
使用ftok生成key值,如果成功的话,返回key值,失败的话之间退出进程。
server方需要负责维护共享内存,所以就由它来创建,将创建的具体过程封装在一个函数里,只需要调用创建函数,传入一个key值就可以创建。
创建成功返回共享内存标识符shmid,失败的话退出进程。
client方只需获取共享内存的shmid即可,同样将具体实现封装,只需要调用获取函数,传入一个key值就可以,获取成功返回共享内存标识符,失败退出进程。
挂接共享内存时,同样将具体实现封装起来,只需要调用挂接函数,传一个shmid即可,挂接成功返回映射后的虚拟地址,失败则退出进程。
- (long long)memp == -1L解释说明:
- memp是一个void类型的指针,即使失败返回的-1也是void的,所以在判断时要将其强转为整形。
- Linux是64位的机器,所以指针的大小是8个字节,所以为了不发生数据截断,需要强转成对应8个字节的long long类型。
- 常量-1默认是int类型的,加一个后缀L成为-1L就表示这是一个long long类型的常量。
- 此时相同类型的数据才可以进行判断。
将具体判断细节放在函数中,使用的时候只需要调用函数即可,不用再判断。若去关联失败则进程退出。
调用shmctl的具体传参细节在函数中实现,删除的时候只需要调用删除函数即可,删除失败则退出进程。
comm.h头文件中的各个函数,都是为了通信双方更方便通信而设计的。
server.cpp和client.cpp:
通信双方的通信框架都是按照:
双方数据传送:
发送方一秒发送一次,接收方一秒接收一次。
- 必须先执行server,因为server负责维护共享内存,只有共享内存创建了才能进行通信。
- 在client没有发数据之前,server就开始读取数据了,但此时什么都没有。
- 当client开始发送时,server才能读取到数据。
可以看到,使用共享内存通信时,不像管道那样,通信双方是通过访问管道这个公共资源来实现数据交换的。
而是双方各自访问各自的虚拟地址就可以。
- 操作系统会通过各自的页表与共享内存建立联系。
- 所以双方各自访问自己进程地址空间中映射的起始地址时,就相当于访问到了同一块共享内存。
这样来看,双方各自管自己的就行,不用考虑对方,所以这种通信方式比较简单。
共享内存的优势:
考虑一个问题,通信双方一方写入,一方读取,采用管道和共享内存分别发生了几次数据拷贝。
管道:
一共需要四次。
- 键盘->写入端进程地址空间->管道->写出端进程地址空间->显示器
共享内存:
- 键盘->共享内存(写入写出端进程地址空间)->显示器
由于共享内存在双方各自的进程地址空间中都有映射,相当于三者是一个整体。
*如果考虑用户层缓冲区的话,两种方式各自再增加两次拷贝。
当通信的数据量非常大时,共享内存的方式大大减少了拷贝次数。
共享内存的劣势:
可以看到,结果非常混乱,两个进程想读就读,想写就写,这样就会导致数据混乱,通信的数据不准确。因为双方并不知道对方的存在。
- 原因是共享内存通信方式没有同步和互斥机制。
因为没有对公共资源的保护机制,所以就会导致通信混乱。
如果非要使用共享内存的方式,可以在通信双方之间再加入管道,利用管道的互斥机制来实现共享内存的互斥。有兴趣的小伙伴可以去试试。
共享内存的特征:
我们知道,操作系统中不仅有一块共享内存,它们是通过key值来进行唯一性标识的。
操作系统也要管理这些共享内存,上面本喵讲过采用的是先描述后组织的方式。
描述就是使用上图所示的结构体对象来描述的,该结构体中包含了共享内存的所有属性。
将这些描述共享内存的结构体对象组织成某一种数据结构,例如链表,这样操作系统就将共享内存管理起来了。
- 数据结构中存放的是描述共享内存结构体对象的指针。
这里这个指针有点特殊:
由于结构体的地址和结构体中第一个成员的地址在数值上相等,所以在访问结构体的时候,将第一个成员的指针强转成结构体类型的指针就可以。
消息队列的公共资源是链表结构。
通信双方不会和消息队列进行挂接,而是像管道一样,访问内存中的消息队列。
当发送方有数据发送时,将数据先打包成一个节点,然后尾插到内核中的消息队列中去。
当接收方接收数据时,从队列头部开始去找所需要的节点,然后进行解包得到数据。
- 消息队列和普通队列不一样,不是严格按照先进先出的规则。
- 读取方可以跳过队头寻找自己需要的数据。
- 但是相同的数据,必须先读取靠近队头的。
如上图中,当读取方需要的是香蕉,但是队头是苹果,此时就可以跳过苹果,读取香蕉,并且靠近队头的香蕉先被读取。
几乎和共享内存一样,本喵就不详细介绍了。
msgget()
- key:和共享内存一样,也需要生成,是消息队列唯一性的标识符。
- msgflg:和共享内存一样,可以是IPC_CREAT或者IPC_EXCL或者是二者的组合。
- 返回值:返回消息队列的标识符,供用户层使用。
msgctl()
- 第一个成员变量的类型是struct ipc_perm,变量名是msg_perm,结构类型和共享内存的一样。
只要是采用system V的通信策略,描述共享资源的结构体都是这个结构,第一个变量类型都是struct ipc_perm。
msgsnd()
- msgid:消息队列标识符
- msgp:要发送数据所在的数组,元素类型是 struct msgbuf
- msgsz:要发生的数据大小,以字节为单位
- msgflg:创建标记,如果使用IPC_NOWAIT,失败就会立即返回。
0:阻塞发送
IPC_NOWAIT:非阻塞发送- 返回值:失败返回-1,成功返回0
在发送数据之前,需要先将数据进行打包:
struct msgbuf
{
long mtype; //数据类型,必须大于0
char mtext[1];//要发送的数据
};
在发送的时候,需要将数据进行打包,按照上面的规则。
msgrcv()
- msgid:消息队列标识符
- msgp:接收的数据后要存放的地址
- msgsz:要接收的数据大小
- msgtype:发送方设定的数据类型标识
- 0:读取队列中的第一条消息(不在乎当前队列头元素是什么消息类型,将他当作普通队列来处理)。
- 大于0(约定值):读取队列中类型为msgtyp 的第一条消息。(就是读取对列元素中第一个香蕉)
- 小于0:读取队列中最小类型小于或等于msgtyp 绝对值的第一条消息。
- msgflg:创建标记,如果指定IPC_ NOWAIT,获取失败会立刻返回
- 0:阻塞接收
- NOWAIT:非阻塞接收
- 返回值:成功返回读取数据的字节数,失败返回-1。
信号量本质上就是资源计数器,能够保证多个进程之间访问临界资源,执行临界区代码时。
- 临界资源:多个进程都可以访问到的资源(例如:同一块内存)。
- 临界区:访问临界资源时的代码,所在区域称之为临界区。
如上图,将一大块公共资源划分成了多个小块的公共资源,假设一个有100个小块。
当进程想要访问某一小块资源的时候,首先要进行的就是申请信号量,一旦申请成功,信号量就会减1。
当这个进程访问完这块小资源后,需要将资源释放,这时候信号量就会加1。
当信号量为0的时候,进程就不能再申请了。
此时再来看,所有进程在访问公共资源之前,都必须申请信号量,而申请信号量的前提是所有进程都能看到同一个信号量,所以这个信号量本身就是公共资源。
既然信号量是公共资源,就必须在很多进程对它进行PV操作时保证自身的安全。
- 试想,当一个进程正在申请,但是这个进程申请的比较慢,还有几个其他进程也在申请,但是申请的快。
- 后面几个进程把信号量都申请完了,当第一个进程申请完成以后,发现信号量没了,此时就会出错。
当然这是一种极端情况,在PV操作的时候,可能会因为时序问题,导致信号量有中间状态,从而导致数据不一致。
所以为了保证信号量的安全性:
- 原子性:要么不做,要做就做完。
- 也就是,信号量有互斥机制保护,当一个进程在申请信号量的时候,其他要申请信号量的进程处于阻塞状态。
信号量的具体使用,在后面用到的时候本喵会详细讲解,在这里只需要了解这些就可以。
重点介绍了system V通信策略的共享内存方式,包括它的原理及应该,至于消息队列和信号量仅做了解就行,在后面用到的时候本喵会详细讲解。