As we all know,Android是基于Linux内核开发的,而市面上几乎所有的App都离开跨进程通信。可能你会说Android是通过Binder完成进程之间的通信的。但是Binder是怎么来的?为什么安卓的开发人员放着好好的Linux系统跨进程通讯方式不使用,反而创建一种新的跨进程通信方式呢?
Linux系统一共提供了六种跨进程通讯方式,我们分别讲解。
一、管道pipe
管道又分为,匿名管道和命名管道。
1、匿名管道
匿名管道是基于fork机制建立。当一个进程需要进行跨进程通讯时,如果两个进程之间存在亲缘关系,通常是指父子关系。那么,那么在fork进程的过程中,会同时复制父进程的读写管道的能力。这样两个进程都具有了读写管道的能力,Linux内核提供了一个特殊缓存区,两个进程可以同时对该缓冲区进行读写。但是,同一个进程只能读或写。如果此时将process1的写入机制和process1的读取机制同时打开,便可以完成跨进程通信机制。
值得注意的是,fork完成后的进程同时具备读写功能,如下图所示:
fork初始结果
由于匿名管道是半双工通信,所以读写功能不能同时使用,通信效率是50%。
其实,Linux内核系统并不存在所谓的管道,只是而是借助了其文件系统的file结构和VFS的索引节点inode,通过将两个 file 结构指向同一个临时的 VFS 索引节点,而这个 VFS 索引节点又指向一个物理页面而实现的。
读写功能
从图中可以看出,管道只是一种借助于文件结构的抽象,系统没有这种结构。
关于读写机制,如果process2读的时候,process1正在将数据放入管道,process2就必须等待,这就牵涉到了 同步机制的概念。系统通过对VFS 索引节点进行锁定,让读取数据的进程进入休眠等待队列。待process1写入操作完成,内存区被解锁同时process2被唤醒读取数据。同样,process2读取时,process1也必须进入等待唤醒写入。
注意:读写进程是同步操作,进程向管道中写消息时,管道另一端的读进程必须打开,否则写进程就会阻塞(默认情况下),命名管道也要遵守这个原则。
2、命名管道
但是匿名管道仅限于亲缘进程间的通信,这显然是有弊端的,系统便提供了命名管道作为解决。Linux系统提供了一种叫FIFO的特殊文件。如果进程A已读的方式打开FIFO文件,进程B以写的方式打开该 文件,系统就会在这两个文件之间建立管道。说到底,仍然是由Linux内核完成内存管理的。FIFO的好处在于文件在系统中都有自己的路径,我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接。
命名管道的安全问题:
a. 命名管道的读写规则:读操作时,会对FIFO文件设置写阻塞标记;写操作时会顿FIFO文件设置读阻塞标记。
b. 如果有多个进程同时向同一个FIFO文件写数据,而只有一个读进程读取数据时,会发生怎么样的情况呢?会造成数据块的相互交错。
为了解决这个问题,系统规定,在一个以O_WRONLY(即阻塞方式)打开的FIFO中,如果写入的数据长度小于等于PIPE_BUF,那么或者写入全部字节,或者一个字节都不写入。如果所有的写请求都是发往一个阻塞的FIFO的,并且每个写请求的数据长度小于等于PIPE_BUF字节,系统就可以确保数据绝不会交错在一起。其实这就是线程安全中的原子性操作。
但是,通信效率依旧是50%。
二、消息队列
管道是一种逻辑上的结构,而且它通信效率低、不能传递格式化数据、需要进行同步读取,这给开发人员带来了很大的不便。
消息队列则不然,它在Linux内核层拥有属于自己的格式,整个队列是链式结构,拥有写权限的进程可以向队列中写消息,拥有读权限的进程可以从队列中读消息,进程之间不必保持同步。消息队列是随内核持续的,只有在内核重起或者显示删除一个消息队列时,该消息队列才会真正被删除。
系统提供结构体msg_queue用来描述消息队列,其内部实现参考下图:
消息队列内部结构
从图中可以看到,消息队列中封装了最后一次发送消息的时间、最后一次接收消息的时间、最后一次变化时间、当前队列中的字节数、当前队列中的消息数、当前队列最大字节数,甚至还包括上一次发送消息的进程号和上一次接收消息的进程号。消息列表、接收器列表和发送器列表。很明显,通过这些信息可以完成应用层和内核层之间的通信。
通信流程参考下图:
其中,struct ipc_ids msg_ids是内核中记录消息队列的全局数据结构;struct msg_queue是每个消息队列的队列
内核访问消息队列
从上图可以看出,全局数据结构 struct ipc_ids msg_ids 可以访问到每个消息队列头的第一个成员:struct kern_ipc_perm;而每个struct kern_ipc_perm能够与指定的消息队列对应起来。因为在该结构中,有一个key_t类型成员key,而key则唯一确定一个消息队列。kern_ipc_perm结构如下:
struct kern_ipc_perm { //内核中记录消息队列的全局数据结构msg_ids能够访问到该结构;
key_t key; //该键值则唯一对应一个消息队列
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
unsigned long seq;
}
消息队列是用来封装信息的,消息的结构类型通常如下:
struct msgbuf{
long mtype;
char mtext[size];
};
mtype是消息的唯一id,mtext是消息内容,系统根据这些消息可以确定具体消息的大小,同样也根据id读取消息。
消息队列作为跨进程通信方式,允许多进程竞争读写消息,拥有独立的结构体,跟随内核的持续性,而且它规范化的消息结构,为竞争消息的进程优先级提供了方便。
但是,消息队列也有自身的局限:
- 每个消息队列的容量(所能容纳的字节数)都有限制,该值因系统不同而不同。
- 每个消息队列所能容纳的最大消息数:在redhad 8.0中,该限制是受消息队列容量制约的:消息个数要小于消息队列的容量(字节数)
注:上述两个限制是针对每个消息队列而言的,系统对消息队列的限制还有系统范围内的最大消息队列个数,以及整个系统范围内的最大消息数。
三、共享内存
顾名思义,共享内存就是系统中多个进程共用同一块内存区域。其通信原理如下图:
共享内存通信原理
说白了就是,逻辑地址指向同一块物理地址。既然是共享必然要满足内存共享的基本规则:
- 可见性
某个进程对共享内存的修改,对其他进程来说是可见的。 - 原子性
当前内存被某个进程占用的时候,其他进程不得访问共享内存进行修改操作。
基于以上两点,使用共享内存需要同步机制,但共享内存并未提供同步机制,所以需要借助于其他机制来完成,比如信号量、同步锁Mutex。
通过同步机制,在数据被写入之前不允许进程从共享内存中读取信息、不允许两个进程同时向同一个共享内存地址写入数据。 - 使用规则
要使用一块共享内存进程必须首先分配它,随后需要访问这个共享内存块的每一个进程,都必须将这个共享内存绑定到自己的地址空间中。当完成通信之后,所有进程都将脱离共享内存,并且由其中一个进程释放该共享内存块。 -
Android现状
SharedMemory可见,在API27,也就是Android8.1版本,系统专门提供了SharedMemory类供开发者使用。SharedMemory用于对匿名共享内存区的创建,映射和保护控制。
(1)优点:使用共享内存进行进程之间的通信是非常方便的,函数的接口也比较简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,加快了程序的效率。
(2)缺点:共享内存没有提供同步机制,这使得我们在使用共享内存进行进程之间的通信时,往往需要借助其他手段来保证进程之间的同步工作。
四、信号量
共享内存资源在进行同步时,通常使用信号量机制。信号量就是一个计算器,记录当前内存的使用状态。计数值为正,表示资源未被锁定,任何进程都可以访问资源;计数值为0,表示资源被锁定,想要访问它的进程只能挂起等待。
信号量的原理
信号量只能进行等待和发送两种操作,即P(sv)和V(sv):
P(sv)等待:说明资源被占用,如果sv的值大于零,执行进程就给它减1;如果它的值为零,就挂起该进程的执行
V(sv)发送:说明资源被释放,如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因等待sv而挂起,就给它加1。
举个例子,就是两个进程共享信号量sv,一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进如临界区,使sv减1。而第二个进程将被阻止进入临界区,因为当它试图执行P(sv)时,sv为0,它会挂起以等待第一个进程离开临界区并执行(sv)释放信号。
很明显,信号量跨进程通信只能携带一个bool值的信息量。
五、信号
信号有很多种,如下图:
信号分类
每一种信号都有自己对应的处理程序。信号的发送端在内核层,处理端在用户区域的应用层。比如,杀死进程信号会造成应用崩溃;停止信号会使应用退出。
这种一一对应的关系,要求信号只能处理一些单一操作,或者边缘操作。
六、套接字(socket)
套接字也是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不同计算机通过网络连接进行跨进程通信。也因为这样,套接字明确地将客户端和服务器区分开来。
也就是说,它在基于网络的通信上更擅长,其稳定性受限于网络。
Linux系统跨进程通信方式,虽各有千秋,但在面对单用户、多进程的Android系统需求时,仍捉襟见肘。因此,Binder诞生了。
网友评论