🏆一、进程间通信目的
1.1什么是通信
进程是具有独立性的,而我们要实现进程间通信的目标,是需要开辟空间和创造方法的。
通信目的:
1、数据传输:一个进程需要将它的数据发送给另一个进程
2、资源共享:多个进程之间共享相同的资源。
3、通知事件:一个进程需要向另一个或一组进程发送消息(例如进程终止要通知父进程)
4、进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
那么管道就是十分传统的一种解决进程间通信的方式。
在学习进程间通信之前,我们就曾见识过管道的使用。
进程通信的原因:多进程协同完成某项任务。
目前主要的进程间通信的方式有三种:POSIX(让通信过程可以跨主机) ,System V(聚焦在本地通信,主要有共享内存,消息队列,信号量),以及管道(依托于文件系统完成的一套技术方案)。
🏆二、匿名管道
2.1匿名管道的由来
说了这么多,那么管道到底是什么?管道的底层原理是什么?我们如何创建和通过管道来实现进程间通信呢?
匿名管道是基于父子进程而衍生的一种解决方案。
父进程打开一个文件后,通过fork()创建子进程,子进程继承父进程文件描述符表,这样两个进程就指向了同一个文件。
父进程通过打开的这个文件的缓冲区写入,子进程再从缓冲区读取,这种过程就是一个进程向文件当中写数据,另一个进程从文件当中读数据。这样进程间就实现了数据传输,而这个过程就是进程间通信。
这里还有一个问题,这个文件是否需要向磁盘写数据和读取数据呢?
答案是不需要!因为我们这个文件是供父子进程之间进行通信使用,而非存储到磁盘。而IO的过程是非常影响效率的。正因为它有这一特性也就意味着它和一般的文件是不同的。没错,它是由OS提供的内核级文件--->匿名管道。
管道文件和普通文件的区别:
2.2基于匿名管道再理解进程间通信
首先必须得有两个进程都能看到的部分,因为两个进程都能从公共部分写入或读取才能实现数据传输。又因为进程具有独立性,所以我们进程间要实现通信,必须由第三方提供缓冲区供进程间进行数据传输。如果由A进程提供缓冲区会因为进程的独立性导致A进程独有,B进程与之同理。所以第三方必然是操作系统(OS).
1、OS需要直接或间接给通信双方的进程提供内存空间。
2、要通信的进程必须看到一份公共的资源。
这一原理贯穿了所有的进程间通信实现方案:
所谓的不同的通信种类本质就是让进程间看到同一份资源,这份资源是OS哪一模块提供的,就是什么通信模式。
2.3匿名管道的使用
从上图我们看到,父进程先创建管道文件,父进程有管道文件的读写端,然后fork创建子进程,这样由于子进程继承父进程的文件描述符表,所以子进程也有管道的读写端。然后父进程关闭读端/写端,子进程关闭写端/读端。通过管道就能实现单向进程通信。
那么为什么父进程不关闭读端或写端后再fork()创建子进程呢?
因为父子进程关闭的读写端不一样,如果父进程关闭读端,那么子进程继承的也是关闭读端;而父进程需要读端是开启的(否则无法实现单向通信)。所以是创建完子进程后再关闭读端/写端。
OS提供的管道的接口,调用成功返回0.调用失败返回-1.
参数pipefd[2]是输出型参数,调用pipe接口的时候,OS以读和写的方式打开文件,并将读和写对应的文件描述符下标填到pipefd数组中。通过数组,把读端和写端对应的下标全部返回。就以读和写的方式打开了pipe文件。
演示:
那么输出型参数返回的数组中fds[0]和fds[1]谁是读取,谁是写入呢?
2.4演示使用匿名管道
我们如何验证我们的管道成功通信了呢?因为我们的子进程给父进程发的消息有自己的pid,而我们在父进程打印接收到的消息时,打印了父进程的pid。所以我们只需监控查看进程pid,与我们打印出的内容进行对照即可验证是否完成管道通信。
经验证,确实完成了进程间通信。
匿名管道的实际通信过程:
2.5匿名管道的几种情境下的特点
①写端慢,读端快
这句话是什么意思呢?这是一种场景,让写端输入慢(写端会sleep),而读端不设限制。比如写端(子进程)在sleep期间,父进程在做什么呢?
父进程在等待读取,首先read方法是一种阻塞式调用。当写端没有写入信息时,读端在等待读取,而非执行循环。
我们可以验证一下:将子进程(写端)代码改为在写入数据后,休眠50s
再来看父进程(读端)会有何表现:
我们可以看到,在读取过一次后,由于写端没有输入而是在sleep。读端就阻塞在read,等待读取。(如果是执行循环就应该打印"bbbbbbbbb"而非阻塞)。
这里我们发现,如果管道中没有了数据,读端再读,默认会直接阻塞当前正在读取的进程!
② 写端快,读端慢
与上一种情境相反,我们让子进程循环写入,而父进程读取端sleep。
发现会一瞬间写满pipe文件。写满后就不会再写了,此时写端就会被阻塞。
当写端快,读端慢的时候会写满管道文件,然后写端阻塞等待读端读取。
③写端先于读端关闭
如果我们写一条信息,子进程就关闭写端fd。作为读端(父进程),如果写端已经关闭,而读端已经全部读取管道文件中的数据,那么读端就会读到文件结尾。
子进程(写端)关闭,父进程读到返回值0,读端读到写端关闭结果。
④读端先于写端关闭
如果读端关闭,那么写端写的数据将不会被接收,也就是说写端写的数据将没有任何意义。
那么此时OS会自动终止写端,会给写端进程发送信号,终止写端。
我们看到子进程pid是32134,收到的终止信号是13号。那么13信号是什么意义呢?
此时我们可以总结匿名管道的四种情况和五大特征:
四种情况:
1、写端慢,读端快。读端不会循环而是阻塞在read方法,等待写端写入数据读取。
2、读端慢,写端快。写端写满管道文件,就不再写入数据,等待读端读取数据。
3、写端先于读端关闭。读端会先把管道中的数据全部读取后,读取到写端退出返回的结果,从而关闭读端,不再读取。
4、读端先于写端关闭。读端关闭,写端写入的数据不被接收,就没有了意义。OS会自动终止写端,会给写端进程发送信号,终止写端。
五大特征:
1、只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
2、管道是面向字节流的。
3、一般而言,进程退出,管道释放,所以管道的生命周期随进程。
4、内核会对管道操作进行同步和互斥。通俗点讲就是任何一个时刻,只允许一个进程向另一个进程发送消息。(涉及到加锁,等到多线程部分再说)
5、管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
🏆三、匿名管道的应用
在了解了匿名管道后,我们可以做一个小游戏。原理很简单:父进程通过管道给子进程发送一个命令操作码,子进程根据命令操作码执行对应的任务。为了提高效率我们要创建多个子进程,并且随机给每个子进程发送命令操作码,避免某个子进程执行过多任务。
上面即是原理图。
上图就是我们简单实现的一个程序。
我们运行查看一下:
我们看到代码成功执行:父进程随机挑选一个任务,将其任务码(这里转换成指针数组的下标)发送给随机一个子进程,子进程接收到任务后,执行对应的操作!
而我们实现的代码,复用性很强,耦合度低,想执行其他任务,只需改动实现函数即可。
但是我们写的代码有一个很深的bug。
父进程打开的文件,是会被子进程共享的!
如何解决这个问题呢?除了我们之前的暴力关闭所有的写端。我们还可以保存fork() 继承下来的没必要的写端到一个vector容器中,每次对子进程操作时,关闭继承下来的写端:
🏆四、命名管道
4.1命名管道
匿名管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件。
mkfifo是创建管道文件的命令。
这里以p开头的文件叫做管道文件。它的作用在于使两个不相关的进程一个可以向它写入,一个向它读取,直接完成进程间通信。
我们看到确实写入了数据,但是为什么管道文件的大小是0呢?这是因为它不把数据刷新到磁盘上,所以看到的是0.
我们已经有一份公共的资源以供读取,匿名管道是通过继承的方式让父子进程看到同一个文件, 那么命名管道是如何做到让不同的进程看到同一份资源呢?
只需让不同的进程打开指定路径(路径+文件名)的同一个文件就可以了。
4.2命名管道的使用
上面是指令级别的使用,我们编写代码时,操作系统提供有专门的接口。
我们运行一下上述代码:
成功创建管道文件。
那么我们不想要这个管道文件时,可以通过unlink接口。
这段代码是由client通过命名管道named_pipe向server写数据。
但是我们还需要注意几个细节:
1、当server运行打开命名管道,它会等待client同样打开相同管道文件后再向后运行。
2、为什么server端会多出一个换行符呢?
这是因为我们的回车键也被读取了!
我们需要手动去除:
4.3匿名管道和命名管道的区别
·匿名管道由pipe函数创建并打开。
·命名管道由mkfifo函数创建,打开用open。
·FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在他们创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
·命名管道使用的前提是双方必须都打开它(一个以读的形式,一个以写的形式),否则就会阻塞。