进程间通信有3种方法:
1、管道 -- 通过文件通信
2、system V -- 通过共享内存 IPC 通信
3、POSIX -- 通过网络通信
通信的本质是“数据的拷贝”,是进程A -> “拷贝”给OS -> OS“拷贝”到进程B,其中“拷贝”需要用到空间,所以OS一点要提供一段内存区域,能够被两个进程所看到。
进程间通讯的本质:让不同的进程先看到同一份资源(内存、文件、内核缓冲区等)。
资源由谁提供,就有不同的进程通信方式。
管道 -- 匿名管道:
实现先讲解匿名管道,和它的名字一样,这个管道是没有名字的,这说明了,匿名管道只能给该进程一个看到,其他进程看不到,也就完成通信。但是父子进程之间由于继承的因素,可以看到同一份资源,所以匿名管道是带有血缘关系的管道。
但是,管道只能继续单向通信!父读子写 / 子读父写。所以我们需要关闭父进程的其中一个读写文件描述符,子进程则关闭相反的文件描述符。
既然父子都要关闭一个,为什么一开始要两个都打开再分别关闭呢?因为方便子进程继承下去,至于为什么要关闭,是因为管道只能进行单向通信。
为什么不创建全局缓冲区?因为进程间具有独立性,在父子进程中一方改写了共享数据,就会发生写时拷贝,这样大家都用的不是一个缓冲区了。
而在多执行流下(父子),看到的同一份资源叫临时文件资源!
为什么子进程写入数据时 sleep(1),父进程读取数据时也好像 sleep(1)了一样呢?因为父进程会检查管道内是否有数据,是空的就会阻塞等待子进程写入,这种现象叫做同步,有数据了在获取。
当写端缓冲区已满时,没有空间了,没有人读取,就会被挂起。相反读端没有数据读,也会被挂起,这叫做互斥与同步机制。
由此可见,管道有以下几种特点:
1、管道内部已经自动提供了互斥与同步机制;
2、如果写段关闭了,读端就会 read 到返回值0,代表文件结束;
3、如果打开文件的进程退出了,文件也会被释放;
4、管道是提供流式服务的,想写想读多少就读写多少;
5、管道是一种半双工;
6、匿名管道,适合具有血缘关系的进程间通信,比如父子进程。
SIGPIPE:
当读端关闭时,写端一直写,操作系统会认为无意义,就会把写端杀掉,这种属于异常退出,我们可以用 status & 0x7f 查看到错误码是 13,代表 SIGPIPE。
命令:
ulimit -a // 查看系统资源
可以看到管道的大小 pipe size 在 Linux 中是 65536 字节。
管道 -- 命名管道:
命名管道和匿名管道不同的是,命名管道有名字哪个,所以可以多个进程看到共享资源。
int mkfifo("filename", mode) // 创建管道
其实命名管道很简单,就算创建一个文件,通过这个文件的描述符让多个进程可以找到共享资源,但是这个创建的文件在磁盘上只是一个标识,大小是 0,不会参与 IO。是通过文件描述符,操作系统在内存上开辟了一块空间,这块空间就算共享资源。进程间通信的方式就像打开文件一样,open 后读写即可。
匿名管道和命名管道的区别:
1、匿名管道由 pipe 函数打开;
2、命名管道由 mkfifo 函数创建,打开用 open;
3、区别在于创建和打开的方式不同,一但工作完成,具有相同语义。
小知识:
问:cat test.txt | grep hello 我们常用的这条管道指令用的是什么管道?
答:匿名管道,它们的 PPID 都是 bash,都是由 bash 创建的子进程。
system V:
与管道不同,管道是基于文件的,而 system V 是 OS 特定的,它的本质是在物理内存中开辟空间,通过修改页表的映射关系,在虚拟地址空间中开辟空间。建立映射,开辟空间这些操作由 OS 完成。
简单来说,就是通过页表映射,使得进程间看到的是同一份共享资源。要开辟这个空间,需要用到特定的接口,生成对应的数值,返回对应的地址。
- int shmget(key_t key, SIZE, shmflg)
- // 对应的 key 值由函数 frok() 生成
- // SIZE 给定特定的大小
- // shmflg 表示状态,为了能够连接上,我们还要有对应的权限,所以一般要 | 0666
-
- key_t ftok(FILENAME, PROJ_ID)
- // 工程名 和 随机值
这里的 SIZE 也挺有讲究的,我们的内存中每一个页的数据是 4096 bytes,所以我们需要填写页的整数倍,否者你看到的即使是 4097,实际上也用了 4096*2 的空间,浪费了空间。
ftok() 是通过特定的算法,返回计算出的值,是该共享内存的唯一标识符。
shmflg 有多种,常见的是:
IPC_CREAT,如果共享内存存在就直接返回该共享内存,不存在则创建,一定会获得一个 shmid,但无法确认哪个是新的哪个是旧的。
IPC_EXCL,单独使用无意义,如果不存在则创建,存在则报错,常和 IPC_CREAT 组合使用。
那我们怎么知道创建了哪个共享内存?这时候就有一条重要的指令:
- ipcs //查看共享内存等信息
- ipcs -m //查看共享内存
nattch 代表挂接的数量, perms 代表权限,bytes 代表大小。
里面的 key 值可以通过打印查看是否与共享内存的 key 值相同,还有一个我们自己定义的变量 shmid 它是为了更加简单地标识出对应的共享内存,一个是内核区的,一个用户层的唯一标识符。
系统中可能存在大量的共享内存,因为进程也可能存在多个,所以 OS 也要管理共享内存,所以要先描述,在组织,为共享内存维护相关的内核数据结构,key 值就算一个好的唯一标识符。
一般创建出来的共享内存不会因为进程结束而释放,它的生命周期跟随内核,除非关机或者重启,所以这里有两种办法,一种是命令:
ipcrm -m shmid // 释放共享内存
还有一种是函数接口调用:
- int shmctl(shmid, cmd, ds)
- // 移除共享内存, cmd 填写 IPC_RMID, ds 代表数据结构,可置空
创建和移除已经完成,那怎么让进程挂接到共享内存上呢?我们来学习新的函数:
- void * shmat(shmid, shmaddr, shmflg)
- // 连接上共享内存, 第一个参数填 shmid
- // 第二个表示共享内存的地址的指针,一般我们不知道,那就填 NULL
- // 第三个上面讲过,不过这里一般为 0
-
- int shmdt(shmaddr)
- // 通过连接返回的指针,断开即可
shmat 返回的值我们用 char * 接收,就可以直接在上面输出了。
system V 的特点:
我们在读写共享内存时,并没有使用到 read 和 write,这说明了没有调用 OS 接口!这样大大提升了效率,比管道至少少了一两次拷贝。
所以,共享内存是速度最快的,因为拷贝次数少,但是不提供同步与互斥机制。
有3个结构体,shmid_ds , msqid_ds , sem_ds;它们分别代表 shmid、消息队列和信号量的结构体,它们都有一个共同的成员,叫做 struct ipc_perm。
struct ipc_perm 中有个重要的成员,就算 key 值,它是共享内存的唯一标识符,由于共享内存可能会很多,所以 OS 会开辟一个数组,struct ipc_perm arr[1024],所以我们看到的 shmid 好像是有顺序一般。访问也很方便,可以采用切片的方法访问,*((shmid_ds) *ipc_perm) 即可通过首地址访问到全部成员。
消息队列:
消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法;
每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值、特性方面。
type 是类型,msg 是数据,消息队列用链表组织起来,一开始是空,AB 都可以看到,A 类型发给 B,B 发给 A。
消息队列的结构是 msqid_ds。
信号量:
因为共享内存不提供同步与互斥机制,所以有没有一种可能,A 进程在写时,B 进程没经过允许就读了,那岂不是乱套了,所以有信号量来帮助完善机制。
信号量的本质是一个计数器,用来描述临界资源中资源数目,它分为二元信号量和多元信号量。二元信号量只有两种状态,要么操作了,要么没有,这种状态我们称之为原子性。
本次主要讲解二元信号量,因为信号量本质是一个计算器,所以必须有一个 sem 值来计算当前访问的数量,当有进程访问时,计数器 sem 值会减一,在二元计数器中,sem 值为 1,减一后就为 0,当计数器为 0 时,就说明已经没有位置给你访问了,当访问结束时,再加 1恢复原来的状态,达到了互斥的作用。就好比酒店的这间房你买了,别人今晚就无法住进这间房,第二天你还了房卡,别人就可以进这间房了。
那么要申请一个资源,必须占有这个资源吗?不是的,只要你申请信号量成功了,就一定是有你的资源。
二元信号中讲究 PV 操作,两个进程 A、B 是竞争关系,进程开始进行 P 操作,就会对 sem--,其他进程开始等待,把数据写入共享内存当中,共享内存包括代码区和临界区,再继续 V 操作,sem++,释放信号量,唤起等待进程。这种 PV 操作必须保证原子性。