作者简介:
程磊,某手机大厂系统开发工程师,阅码场荣誉总编辑,最大的爱好是钻研Linux内核基本原理。 一、进程间通信的本质
什么是进程间通信?
为什么要有进程间通信?
为什么能进程间通信?
1.1 为什么要通信
我们先拿人来做个类比,人与人之间为什么要通信,有两个原因。首先是因为你有和对方沟通的需求,如果你都不想搭理对方,那就肯定不用通信了。其次是因为有空间隔离,如果你俩在一起,对方就站在你面前,你有话直说就行了,不需要通信。此时你非要给对方打个电话或者发个微信,是不是显得非常奇怪、莫名其妙。如果你俩不在一块,还有事需要沟通,此时就需要通信了。通信的方式有点烽火、送信鸽、写信、发电报、打电话、发微信等。采取什么样的通信方式跟你的需求、通信量的大小、以及客观上能否实现有关。
同样的,软件体系中为什么会有进程间通信呢?首先是因为软件中有这个需求,比如有些任务是由多个进程一起协同来完成的,或者一个进程对另一个进程有服务请求,或者有消息要向另一方提供。其次是因为进程间有隔离,每个进程都有自己独立的用户空间,互相看不到对方,所以才需要通信。
1.2 为什么能通信
为什么能通信呢?那是因为内核空间是共享的,虽然N个进程都有N个用户空间,但是内核空间只有一个,虽然用户空间之间是完全隔离的,但是用户空间与内核空间并不是完全隔离的,他们之间有系统调用这个通道可以沟通。所以两个用户空间就可以通过内核空间这个桥梁进行沟通了。
我们再借助一副图来讲解一下。
虽然这个图是讲进程调度的,但是大家从这个图里面也能看出来进程之间为什么要通信,因为进程之间都是有空间隔离的,它们之间要想交流信息是没有办法的。但是也不是完全没有办法,好在它们都和内核是连着的,虽然它们不能随意访问内核,但是还有系统调用这个大门,进程之间可以通过一些特殊的系统调用和内核沟通从而达到和其它进程通信的目的。
二、进程间通信的框架
通过上一章的描述,我们明白了进程间为什么要通信、为什么能通信,现在我们来看看进程间通信机制该如何实现。
2.1 进程间通信机制的结构
进程间通信机制都要有两部分组成,一是存在于内核空间的通信中枢,二是存在于用户空间的通信接口,这两者的关系就好比是邮局与信纸的关系、基站与手机的关系。通信中枢提供通信机制,通信接口提供使用方法。我们使用通信接口来让通信中枢帮我们建立通信信道或者传递通信信息。
下面我们画个图看一下进程间通信机制的基本结构。
2.2 进程间通信机制的类型
进程间通信机制的类型有两种,一种是媒婆式,给你俩牵线搭桥,然后就不管了,你俩自己聊吧,另一种是保姆式,一直在中间传话。这两种模式用计算机的术语来说分别叫做共享内存式和消息传递式。共享内存式进程间通信,通信中枢建立好通信信道之后,就不再管了,通信双方之后的通信不需要通信中枢的协助。消息传递式进程间通信,通信中枢建立好通信信道之后,每次通信还都需要通信中枢的协助。共享内存式进程间通信,由于通信信息的传递不需要通信中枢的协助,所以通信双方还需要进程间同步,来保证数据读写的一致性,以避免踩踏数据或者读到垃圾数据。消息传递式进程间通信,由于通信信息是通过通信中枢传递的,所以不需要进程间同步。消息传递式进程间通信又可以分为两类,有边界消息和无边界消息。无边界消息就是字节流,发过来是一个一个的字节,要靠进程自己设计如何区分消息的边界。有边界消息的进程间通信的发送和接收都是以消息为基本单位的。
2.3 进程间通信机制的接口设计
按照通信双方的关系,可以把通信类型分为对称型通信和非对称型通信。对称型通信的双方关系是对等的,非对称型通信的双方关系是不对等的,可能是命令执行关系、客户服务关系、生产消费关系等。这种关系是通信双方逻辑上的关系,并不是进程间通信机制本身的特征。消息传递式进程间通信一般用于非对称型通信,共享内存式进程间通信一般用于对称型通信,也可以用于非对称型通信。
进程间通信机制一般要实现下面三类接口,但是有些机制不一定要这三类接口都实现。
1.如何建立通信信道,谁去建立通信信道。
2.后者如何找到并加入这个通信信道。 3.如何使用通信信道。
对于对称型通信来说,谁去建立通信信道无所谓,有一个人去建立就可以了,后者直接加入通信信道。对于非对称型通信,一般是由服务端、消费者建立通信信道,客户端、生产者则加入这个通信信道。如何建立信道呢,不同的进程间通信机制,有不同的接口来创建信道,这个在下一章讲。后者如何找到并加入前者建立的通信信道呢?一般情况是,双方通过提前约定好的信道名称找到信道句柄,通过信道句柄加入通信信道。但是有的是通过继承把信道句柄传递给对方,有的是通过其它进程间通信机制传递信道句柄,有的则是通过信道名称直接找到信道,不需要信道句柄。如何使用信道呢?对于消息传递式进程间通信来说,一般都要提供特殊的接口。对于共享内存式进程间通信来说,则不需要提供这个接口,因为就和访问普通内存一样访问共享内存就行。
三、进程间通信机制简介
前面我们对进程间通信的本质和框架有了基本的了解,下面我们来简单介绍一下Linux中的所有进程间通信机制。我们先来看一下总图。
我们先把这张图简介浏览一下。首先从大类上分,进程间通信方法可以分为3类,消息传递式、共享内存式、进程间同步。为啥这里会有进程间同步呢?进程间同步是为了同步两个进程对共享内存的读写,进程间同步也算是在两个进程间传递了信息,所以把进程间同步也放在了进程间通信中。
可以看到共享内存式机制比消息传递式机制要少,我们就先介绍共享内存式。共享内存式进程间通信的原理很简单,就是通过修改页表,使得两个虚拟进程空间的一部分虚拟内存对应到相同的物理内存上。虽然原理是一样的,但是具体怎么实现,接口怎么设计,又产生了许多不同的共享内存式进程间通信机制。
3.1 SysV共享内存
SysV共享内存是一种非常古老的共享内存方法,是在UNIX诞生早期就有的方法。SysV共享内存创建共享内存的方法是使用接口shmget,它有三个参数,分别是key、size、flag。其中key是一个整数,是表示通信信道的名称,两个进程要提前约定好key。Size代表共享内存的大小。Flag用来表示创建的行为,flag IPC_CREAT 表示如果通信信道存在就直接获取它,如果还不存在就创建它,没有IPC_CREAT的话表示只获取不创建。如果再加上IPC_EXCL的话,表示只创建,如果已经被别人创建了则返回失败。shmget返回的是共享内存的id,代表通信信道的句柄。然后拿着通信信道的句柄通过shmat接口就可以把底层的物理内存映射到本进程空间了。函数返回值就是映射到本进程虚拟内存空间的一个指针,然后就可以像访问普通内存一样读写这段内存了。任务完成之后就可以通过shmdt接口释放信道。注意这只是释放了本进程的通信信道,没有释放底层的物理内存,要释放底层物理内存的话,需要使用接口shmctl()并选择IPC_RMID操作。
3.2 POSIX共享内存
相信大家对前面的叙述都有个疑惑,用一个整数当做通信信道名称,那岂不是很容易就选重了,那不就错乱了嘛!而且如果有人恶意猜测使用你的key,你也没有办法。针对这个问题,POSIX设计出了一个新的共享内存方案,叫做POSIX共享内存,很好地解决了这个问题。POSIX共享内存使用接口shm_open来创建共享内存通信信道句柄,它的参数和open是一样的,但是它不创建磁盘文件。这样以来,我们使用的是一个路径名作为通信信道的名称,这就比一个整数key好多了,容易起名字还不容易重复。并且它的参数是和open一样的,所以它的第三个参数mode可以指定权限,这样就更安全了。shm_open的第二个参数和open的第二个参数是一样的,可以指定flag O_CREAT O_EXCL,这两个flag和前面的shmget可以达到相同的效果,你可以选择是仅加入已经信道,还是非要自己亲自创建信道,或者已有就加入没有就创建。shm_open返回的是一个fd,这个fd就是通信信道的句柄。有了这个fd,我们可以通过接口ftruncate来设置共享内存的大小。得到了信道句柄之后,我们加入信道的方式不是用的专用的方法,而是使用系统已有的接口,用的是shared mmap,这点和SysV共享内存有很大的不同。mmap之后我们就加入了信道,其返回值是本进程虚拟内存空间的指针,我们就可以像操作普通内存一样操作它了。
3.3 共享内存映射
系统调用mmap并不是专门用来做进程间通信的,它是用来做内存映射的。它的映射来源可以用文件也可以是匿名(也就是没有来源,直接分配内存并初始化为0)。它的映射方式可以是私有的,也可以是共享的。映射来源和映射方式两者一组合是四种方式。当我们使用共享映射方式的时候,正好可以用来做进程间通信。对于共享文件映射,两个进程映射相同的文件就可以达到共享内存的目的,文件名就是通信信道的名称,由名称直接加入信道,没有信道句柄。对于共享匿名映射,是通过fork之后在父子进程之间共享内存的。fork之后父子进程之间的内存本来是COW(写时复制)的,也就是说父子进程之间不会共享内存,但是被共享匿名映射的部分不会COW,而是在父子进程之间共享物理内存,这就达到了共享内存的效果。这种方法既没有信道名称也没有信道句柄,是通过继承方式直接就获得了信道。这两种共享内存的解除方法都是使用munmap函数。
3.4 Android ION
很多博客上都会介绍说ION是一个内存分配管理器,这么说既对也不对,单看ION它确实是内存分配管理器,但是我们不能单看ION,我们要把和dma-buf一起看。Dma-buf既不是dma也不是buffer,它是一个buffer sharing框架,重点是sharing。Dma-buf框架实现了进程与进程之间、进程与内核之间的内存共享方案。但是它仅仅是一个框架,本身并没有分配内存的能力。ION则在dma-buf框架的基础之上实现了内存分配管理功能,所以应该把ION与dma-buf当做是一个整体,看成是共享内存机制。ION与普通共享内存机制不同的是,它不仅仅可以在进程间共享内存,还能在进程与内核之间共享内存。ION在进程之间共享内存时,是一方通过/dev/ion的ioctl ALLOC命令创建一个fd,这个fd就是信道句柄,通过对这个fd进行mmap就可以通信了。这和POSIX共享内存的模式有点像,不同的是对方是如何得到这个fd的。POSIX共享内存是通过大家都shm_open打开相同的文件名得到了同一个信道的句柄(句柄值不一定相同,但是底层对应的信道是相同的)。ION是通过Binder向另一个进程传递fd的,Binder对fd做了特殊处理,对方收到的fd和自己的fd,数值不一定相同,但是底层对应的东西是相同的。如果直接给一个进程传递fd的值,那是没有意义的。ION和内核驱动之间共享内存有两种情况,一种是内核驱动创建了底层的物理内存然后把它包装成一个fd,通过一些系统调用传递给进程,进程对这个fd进行mmap就可以进行进程间通信了。另一种情况是进程创建了通信信道的fd,然后通过一些系统调用传递给内核驱动,内核驱动就根据这个fd找到其对应的物理内存。
ION里面有许多不同的堆,每个堆分配的物理内存区域和方式并不相同,可以在使用ION接口的时候通过指定flag来选择不同的堆。
3.5 dma-buf heaps
dma-buf heaps是ION的替代品。因为ION里面所有的堆都对应同一个设备文件/dev/ion,不同的堆是通过在接口中指定flag来选择的。那么这就存在一个问题,就是ION所有的堆对所有进程都是开放的,没法或者不太容易对不同的进程做权限限制。dma-buf heaps正好解决了这个问题,它把不同的堆分拆成了不同的设备,都在目录 /dev/dma_heap/ 下,比如 /dev/dma_heap/system 是默认的堆。这样不同的堆就可以设置不同的文件权限,还可以通过selinux进行限制,这样就大大提高了安全性。它的用法和底层逻辑与ION是一样了,这里就不再过多介绍了。值得一提的是dma-buf heaps已经合入了标准内核,而且Android也正在逐步替换ION。
3.6 匿名管道
前面说的都是共享内存式进程间通信,下面我们来说一说消息传递式进程间通信。我们先来说说无边界的消息传递式进程间通信。
匿名管道是UNIX上最早的进程间通信机制了。它的出现来源于早期的操作系统都是命令行式的,我们经常需要多个命令来协同完成一个任务。比如 ls -ef | grep process-name ,这个命令中前面命令的输出要作为后面命令的输入,中间的|竖线叫做管道符,代表像管道一样从前往后传递数据。那么这个管道符的逻辑在程序中是怎么实现的呢,就是通过匿名管道实现的。Shell在执行命令时先fork出一个子进程A,然后在子进程A中解析命令,发现命令需要执行两个程序,并通过管道连接。于是就使用匿名管道的创建接口int pipe(int fd[2]),此接口接收一个双int元素的数组作为参数。接口执行完成后返回两个fd, fd[0]是读端fd,fd[1]是写端接口。然后fork A,生成进程B,这样进程B也继承了两个fd。A和B都有两个fd是没啥意义的,于是进程A close(fd[0]),进程B close(fd[1])。然后进程A执行exec(“ls -l”),然后进程B执行exec(“grep process-name”),这样进程A就可以通过fd[1]输出数据,进程B通过fd[0]读取数据。这样就实现了进程间通信的目的。匿名管道通过通信双方的父进程创建通信句柄,然后通过fork传递给子进程。父子进程都通过file IO的方式来进行消息传递。由于是使用的file IO,所以读写的都是字节流,并没有消息边界。如果进程想要确定消息边界,需要自己想办法确定每个消息的边界,比如每个换行符代表一个消息,或者每次遇到字符串AAAAAA,代表一个新消息。
3.7 命名管道
我们可以看到匿名管道虽然很好用,但是却有一个很大的缺陷,就是只能父子进程或者亲属进程之间使用,因为要传递信道句柄fd。有没有办法扩大匿名管道的使用范围呢,有,创建命名管道。管道有了名称之后,其它进程就可以通过名称找到信道句柄从而加入信道了。命名管道的用法是,首先要使用mkfifo命令在文件系统创建一个文件,这个文件是真实的文件,但不是常规文件,而是fifo类型的文件。有个这个文件之后,通信双方的写者就可以用正常的open接口以O_WRONLY模式打开文件,读者就可以用open接口以O_RDONLY方式打开文件。然后读写双方就可以通过各自的fd读写管道了。命名管道的创建方式和匿名管道不同,但是消息传递方式是相同的。匿名管道也是无边界消息,原理同匿名管道一样。
3.8 SysV消息队列
SysV消息队列是一个有边界的消息传递式进程间通信。它的信道创建逻辑和SysV共享内存差不多。创建接口是msgget,有两个参数key和flag。Key是一个整数,是信道名称。Flag有两个,flag IPC_CREAT 表示如果通信信道存在就直接获取它,如果还不存在就创建它,没有IPC_CREAT的话表示只获取不创建。如果再加上flag IPC_EXCL的话,表示只创建,如果已经被别人创建了则返回失败。msgget返回的是消息队列的id,也就是信道的句柄。然后可以通过接口msgsnd和msgrcv来发送和接收消息,一个只能发送或者接收一个消息。当通信完成之后,可以通过接口msgctl的IPC_RMID操作来销毁消息队列。
3.9 POSIX消息队列
SysV消息队列和SysV共享内存存在的问题是一样的,于是又设计了POSIX消息队列。POSIX消息队列的创建接口是mq_open,它的参数和open是类似的。用一个字符串类型的name作为信道名称。还有一个flag参数和前面讲的flag参数是一样的,可以指定是创建信道还是加入已经的信道。返回值叫做消息队列描述符,是信道句柄。然后可以通过接口mq_send、 mq_receive来发送接收消息。当通信完成后可以通过接口mq_close来关闭信道。如果所有的进程都关闭信道了,底层信道才会被删除。
3.10 套接字
套接字是分为网络套接字和UNIX local套接字。网络套接字不仅可以在本机进行进程间通信,还能在不同的机器间进行通信。UNIX local套接字只能在本机的进程间进行通信。两者都分为流式套接字和数据报套接字,前者是无边界消息传递式进程间通信,后者是有边界消息传递式进程间通信。套接字是区分服务端和客户端的,服务端创建通信信道,客户端加入通信信道。套接字的接口这里就不介绍了,大家可以找一些网络编程相关的书籍或者博客来学习。
3.11 Android Binder
Android Binder是谷歌为Android开发的RPC,RPC是远程过程调用的意思。RPC也是一种进程间通信,但又不仅仅是进程间通信,它的使用接口表现为可以透明调用其它进程的函数。Binder的通信中枢是内核里的Binder驱动,它的用户空间接口是对虚拟设备/dev/binder的一系列ioctl命令。但是进程并不是直接使用这些ioctl命令的,而是使用谷歌封装好的libbinder库。Binder的具体细节这里就不讲了,给大家推荐两个学习博客:
http://gityuan.com/2015/10/31/binder-prepare/
这个系列博客大概有十几篇,全面详细地讲解了Binder的各个方面。
https://www.jianshu.com/p/adaa1a39a274
这是Binder的进阶学习,有3篇,通过一些提问与解答,让你对binder有更深入的理解。
3.12 信号机制
信号机制是在UNIX里面很早就存在的机制,它是内核用来处理程序运行时发生错误的一种方法,也是给进程发送一些简单特定的消息的方法,所以也可以看做是一种进程间通信机制。但是它又比较特殊,它和一般的进程间通信机制的结构都不太相同。它是不需要建立通信信道的,因为它不是典型的进程间通信,或者说它的通信信道是天然建立好的,因为它用的是pid来指定消息传递给谁。它的发送是内核发送或者进程通过kill等接口发送,指定pid就能发送给对方。对方可以设置信号处理函数来接收处理信号,也可以不设置,内核会进行默认处理。信号机制的具体细节请参看《深入理解Linux信号机制》。
3.13 伪终端
大家可能听说过终端、虚拟终端、控制台、终端模拟器、伪终端等这些词。估计大家和我一样也是对这些词一头雾水,理不清它们到底是什么意思,相互之间是什么关系。其实我对虚拟终端和控制台也不太理解,但是对终端、终端模拟器、伪终端还是比较了解的,在这里给大家讲解一下。最早的时候,一台电脑还是一台几间房子那么大的大型机,普通人根本买不起,有些大学或者科研单位或者政府机关也只能买得起一台。然后是大家每人买一个终端连接到这台电脑就可以使用了。终端就是一台显示器加一个键盘,只不过这个显示器并不是像素显示器,而是字符显示器,一屏只能显示80x25的字符。当时的程序也都是命令行程序,从终端接收输入,再把结果输出到终端。具体到程序内部来说,fd 0 对应的就是终端输入,fd 1就是终端输出。终端并不是说键盘输入的是什么它就原封不动地传给程序,而是会做一定的预处理。
后来随着技术的不断发展,计算机就变成了我们今天使用的计算机。每个人都可以买一台独立的电脑了,而且显示器也变成了像素显示器了,可以显示丰富的画面。而且很多程序的模式也从命令行模式转变成了GUI模式。但是仍然有很多程序比较适合在命令行执行,仍然保留了命令行模式。为此系统开发了一个GUI程序,叫做终端模拟器,也是我们平常说的命令行界面或者终端程序。它利用图形界面模拟了之前的终端界面,让我们看起来像是在使用终端,但是它本身是一个GUI程序。终端模拟器是怎么运行命令行程序的呢?它会使用系统的接口创建一个伪终端,伪终端分为主端和从端两部分,模拟器自己拿主端,命令行程序拿从端,这样命令行程序仿佛就像运行在终端环境里一样。我们从键盘输入的字符其实是先按照GUI程序的逻辑传递给了终端模拟器,终端模拟器再把输入传递给伪终端的主端,然后伪终端在内核里按照终端本身的逻辑进行处理,再发给伪终端从端,这样我们的命令行程序才会收到输入。命令行程序的输出先发给伪终端从端,然后再进入内核里的伪终端,然后再发给伪终端主端,然后终端模拟器才收到我们的输出,然后它再按照GUI程序的方法把输出绘制到它的窗口上,我们就看到了程序的输出。所以说伪终端可以看做是终端模拟器和命令行程序之间的进程间通信机制。
伪终端的具体实现细节还是很复杂的,想详细了解的朋友可以去看《The Linux Programming Interface》的第64章内容。
四、总结回顾
本文中我们先分析了进程间通信的本质,然后讲解了进程间通信的基本框架,最后简单介绍了Linux系统中存在的各种进程间通信机制。大家在实际的工作过程中可以根据自己的需求来选择使用哪种进程间通信机制。
参考文献: 《Understanding the Linux Kernel》 《Professional Linux Kernel Architecture》 《The Linux Programming Interface》
https://www.kernel.org/doc/html/latest/driver-api/dma-buf.html
https://lwn.net/Articles/473668/ https://lwn.net/Articles/454389/ https://lwn.net/Articles/474819/ https://elinux.org/images/a/a8/DMA_Buffer_Sharing-_An_Introduction.pdf https://blog.csdn.net/hexiaolong2009/article/details/102596744