在Android开发的过程中,用到跨进程通信的地方非常非常多,我们所使用的Activity、Service等组件都需要和AMS进行跨进程通信,而这种跨进程的通信都是由Binder完成的。
甚至一个看似简单的startActivity操作,就有可能发生 7 次的跨进程通信,不信的话我就带大家走一下Activity的启动流程看看。
在日常的开发中,我们启动一个Activity通常是使用如下代码:
startActivity(Intent(this, NewActivity::class.java))
执行这行代码看似简单,其实里面运行的过程非常复杂,进行了多次IPC操作(Inter-Process Communication,进程间通信)。
通常有两种情况我们会使用到startActivity方法
第一种方式启动Activity,相对来说涉及的过程更加完整,所以我们就以这种方式来简单分析一下Activity的启动流程。我们要知道一点就是,其实桌面也是一个app且它的进程名叫Launcher进程。
下面我们来看看点击桌面图标到打开这个app的过程是怎么样的:
1、点击图标,首先会执行Activity的startActivity
方法
2、然后会执行到Instrumentation的execStartActivity
方法
3、然后会跨进程从Launcher进程进入到system_server进程,并执行AMS的startActivity
方法(IPC)
4、AMS收到启动请求后,会告知Launcher进程执行handlePauseActivity()
方法使当前Activity进入pause状态(IPC)
5、Launcher进程进入Paused状态后,会告知AMS已暂停(IPC)
6、然后AMS就会检查新的Activity是否已启动(是否已经有进程了)
7、如果没有启动,则创建一个socket
,通过socket通知Zygote进程创建一个新的应用进程(IPC)
8、应用进程创建好后,执行该进程的ActivityThread.main()
方法(这里是通过反射找到main方法的),然后再通过attachApplication()
方法将Activity的ApplicationThread给到AMS(IPC)
9、AMS收到应用进程启动成功的消息后,就会通知应用进程的ActivityThread开始Activity的生命周期(IPC)
10、Activity生命周期执行完之后,又通知AMS:可以将上一个Activity置为stop状态了(IPC)
另外,在Android开发中,一个应用就有可能会开启许多个进程,例如QQ、微信等常见的app,保守估计,都分别至少开启了8个以上的进程,
那么为什么为一个app开启这么多个进程呢?我觉得可以从以下方面考虑:
1、突破内存限制:我们知道,虚拟机分配给各个进程的运行内存是有限制的,LMK(low momery kill)也会优先回收对系统资源占用多的进程,而Android中图片是内存的大户,如果一个应用的图片非常多,是不是就可以考虑将这个应用再划分出一个进程,这样是不是就可以用更多的内存了
2、功能稳定性:例如,我们可以考虑将应用中独立且重要的模块单独开一个进程出来管理,可以保障这个纯粹的进程的稳定性。比如微信就是这么做的,微信中有个进程叫push,它是专门用来管理网络通信的,用来保证网络连接的稳定性
3、隔离风险:对于不稳定的功能,我们可以为它开启一个单独的进程,隔离这个功能可能带给我们的风险,例如用一个独立的进程来专门管理WebView,阻隔内存泄漏导致的问题
既然多进程的应用这么广泛,那么跨进程通信就显得尤为重要了,因此我们必须要对Android的跨进程通信有一定的了解。
Android中的跨进程通信机制使用的Binder机制,这是基于谷歌的一个内部项目叫openBinder所设计的,也可以说Binder是一个虚拟物理设备驱动,因为它为跨进程通信提供了共享的物理空间。Binder连接了Client、Server、Server Manager和Binder驱动程序,形成一套C/S的通信架构。
好了,说了这么多题外话,现在回归正题。
本篇文章,将带大家一起聊聊Binder机制。
在这之前,我们需要对一些内存的概念有一定的了解。
我们在程序开发中所说的内存,比如这个bitmap占用多少内存,这个对象占用多少内存等等,通常就是指虚拟内存,而虚拟内存会被映射到物理内存上或者一部分会被映射到磁盘空间上。
同时,虚拟内存,会被操作系统划分两个部分:
用户空间和内核空间是隔离的,也就是说即使用户空间的程序崩溃了,内核空间也不会受到影响。
进程的通信的关键的就是用户空间和内核空间之间的通信,那么既然用户空间和内核空间是隔离的,怎么让它们能够通信呢?
我们可以将某个进程的用户空间中需要跨进程通信的数据拷贝一份放到内核空间,然后再将内核空间中的这份数据再次拷贝给到另一个进程的用户空间,这样就完成了跨进程通信,这就是进程通信的本质。
那么,怎么完成用户空间的数据拷贝到内核空间呢?
我们可以通过syscall,即系统调用,Linux操作系统给我们提供了两个API:
其实,Linux中已经有很多种跨进程通信了,如共享内存、管道、Socket等,既然已经有这么多种方式了,为什么Android还要另外使用一套机制来单独实现跨进程通信呢?要解答这个问题,我们就不得不对Linux中已有的一些进程通信方式进行一个简单的了解,并对比它们的优劣势进行对比。
共享内存 | Socket | 管道 | |
---|---|---|---|
拷贝次数 | 0 | 2 | 2 |
特点 | 控制复杂,易用性差,需要自行处理并发同步等问题 | 基于C/S架构,传输效率低,开销大 | 效率高,但局限性大 |
安全性 | 共享内存的访问是开放的,不安全 | 访问接入点是开放的,不安全 | 一对一的关系,安全 |
1、管道:使用管道进程跨进程通信,数据需要拷贝两次,另外,进程间是一对一的关系,这种一对一的关系并不符合Android中的跨进程通信的需求
2、共享内存:所有进程共享同一份内存,使用共享内存的方式,不需要拷贝数据,但是利用共享内存来进行跨进程通信的话,控制起来非常复杂,易用性差,需要自行处理多进程并发同步等问题,另外,它最大的弊端在于它是不安全的
3、Socket:基于C/S架构,它是一对多的关系,使用Socket进行跨进程通信,需要将数据拷贝两次,性能较低,另外,Socket跨进程通信同样是不安全的
为什么说共享内存和Socket都是不安全的呢?
比如进程A需要向进程B发送数据,为了安全,进程A必要告知进程B自己的身份(pid),即进程A自己发送身份给进程B,然后由进程B来验证进程A的身份,如果验证是进程A的身份,就能正常通信。
这就存在一个问题了,那就是进程B收到的身份,有可能是被其他进程伪造出来的,而不是进程A自己的,那就使得其他进程可以通过伪造进程身份的方式来跟进程B通信,这就是一个通信不安全的问题!
通过以上的介绍,我们可以得知,管道、共享内存和Socket都不适合在Android中进行跨进程通信,因此,Android单独开发了一套机制来实现各个进程之间的通信问题,那就是Binder机制。
Binder机制是基于C/S架构实现的,使用Binder进行跨进程通信,只需要将数据拷贝一次,并且它是一种安全的跨进程通信机制。
Binder是怎么解决这个通信不安全的问题的呢?
在Binder机制中,有内核添加进程的身份标识,发送身份信息的数据包将由Linux内核来发送,不再由进程自己发送,这样就杜绝了其他进程伪造假身份来通信的可能。
那么,Binder又是怎么只进行一次数据拷贝就完成通信的呢?
在Binder机制中,某进程的用户空间中的一块内存区域vm_area_struct
,会映射到Binder驱动提供的一块物理空间(binder mmap)上,同时,内核空间中的一块内存区域vm_struct
,也会映射到物理空间 binder mmap
中。
这就使得某进程的用户空间和内核空间中的某块内存区域,共同对应了物理地址中的同一份物理空间,也就是说,这块物理地址的空间,被用户空间和内核空间共享了。
在Binder机制中的,物理地址空间和虚拟地址空间的映射关系如下图所示:
所以,为什么Binder跨进程通信只需要拷贝一次,我们再来结合这种图看,就非常容易理解了:
进程A(数据发送方)想给进程B(数据接收方)发送数据,进程A会把数据包通过系统的copy_from_user方法拷贝到内核空间的数据缓存区,注意,这块数据缓存区是映射在物理内存中的 binder mmap
这个空间中的,同时进程B的一块内存区域 vm_area_struct
也映射在物理内存中的 binder mmap
这个空间中,
那么,进程B想要取得进程A发送的数据包,实际上只需要从内核空间的数据缓存区直接取就行了。
因此,整个通信过程仅需要进行一次数据拷贝。
MMAP,memory mapping,即内存映射,Linux操作系统通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程就叫做内存映射。
在上面的图中,我们也看到,数据接收方和数据发送方是通过mmap()方法
建立内存映射的。
为什么要建立映射关系呢?
我们在用户空间中,是不能直接访问磁盘上的内容的,比如读写磁盘文件。如果用户空间想要修改某个磁盘文件中的内容的话,可以分为以下几个步骤:
1、先将磁盘文件的内容拷贝到内核空间的内存中
2、将内容从内核空间拷贝到用户空间的内存中
3、在用户空间中对文件的内容进行修改
4、修改完毕后再把数据拷贝到内核空间
5、内核空间再把数据拷贝至磁盘文件上
由此可见,如果用户空间想要修改某个磁盘文件中的内容,必须要进行四次拷贝,这种间接访问磁盘的方式效率是比较低的。
而mmap机制,就是用来解决这个问题的,通过mmap,可以将用户空间上的一块虚拟内存区域映射到物理空间上,建立这个映射关系后,如果向这块虚拟内存区域写入数据,就相当于也写入到了与其映射关联的物理空间上了,这样就提高了文件读写的效率。
mmap()方法,它的作用就是让一块虚拟内存指向一块已知的物理内存。对文件进行mmap后,会在进程的虚拟内存分配地址空间,建立映射关系,实现这样的映射关系后,就可以采用指针的方式读写操作这一段内存了。
下面我们介绍一下怎么使用mmap()方法,我们看下它是怎么定义的:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
它的各个参数的含义如下:
这里对其进行说明,首先,m_size
这个变量是指共享内存区域(映射区域)的大小,m_fd
这个变量是个指针,指向一个物理地址。返回值m_ptr
也是一个指针,指向的是与这块物理空间有映射关联的虚拟内存地址。
addr
:指定映射的虚拟内存地址,可以设置为NULL,让 Linux 内核自动选择合适的虚拟内存地址length
:共享内存区域(映射区域)的大小,单位是byteprot
:映射内存的保护模式,可选值如下:
PROT_READ
:可以被读取PROT_WRITE
:可以被写入PROT_NONE
:不可访问PROT_EXEC
:可以被执行flags
:指定映射的类型,常用的可选值如下:
MAP_SHARED
:与其它所有映射到这个文件的进程共享映射空间(可实现共享内存)MAP_PRIVATE
:建立一个写时复制(Copy on Write)的私有映射空间MAP_LOCKED
:锁定映射区的页面,从而防止页面被交换出内存MAP_FIXED
:使用指定的起始虚拟内存地址进行映射fd
:进行映射的文件句柄(文件的物理地址)offset
:文件偏移量(从文件的何处开始映射),通常设置为0我们来看下这个方法是怎么使用的:
// 打开文件
int fd = open(filepath, O_RDWR, 0644);
// 对文件进行映射
void *addr = mmap(NULL, 1024, PROT_WRITE, MAP_SHARED, fd, 0);
这里对其进行说明,首先,打开一个文件,并得到这个文件的句柄 fd(可理解为一个指针,指向一个这个文件的物理地址),
然后执行mmap()
方法,将共享内存区域(映射区域)的大小设置为1024字节,并把 fd 传入,
mmap()
方法得到的返回值 addr
,指向的是与 fd 有映射关联的虚拟内存地址。
通过这个函数调用,就使得,addr
和fd
指向了同一块内存区域(这块内存区域由Binder驱动提供)。
通过addr
这个变量指向的内存地址,我们就可以对文件进行读写操作了。
以上就是mmap的原理了,介绍至此,我们可以知道,使用 mmap
对文件进行读写操作时可以减少内存拷贝的次数,从而提高对读写文件操作的效率。