中级Android开发应该了解的Binder原理

4,438 阅读11分钟

一、基础概念

Linux的进程空间是相互隔离的。

Linux将内存空间在逻辑上划分为内核空间与用户空间。Linux 操作系统和驱动程序运行在内核空间,应用程序运行在用户空间,为了保证内核安全,它们是隔离的。内核空间可以访问所有内存空间,而用户空间不能访问内核空间。

用户程序只能通过系统调用陷入内核态,从而访问内核空间。系统调用主要通过 copy_to_user() 和 copy_from_user() 实现,copy_to_user() 用于将数据从内核空间拷贝到用户空间,copy_from_user() 用于将数据从用户空间拷贝到内核空间。

二、Binder解析

Binder是Android上的一种进程间通信机制,它基于Client-Server模式实现,由BinderDriver、ServiceManager、Client和Server四个模块组成。Binder相较于Socket等传统IPC方式的优势:

安全性好:为发送方添加UID/PID身份信息
性能更佳:传输过程只要一次数据拷贝,而Socket、管道等传统IPC手段都至少需要两次数据拷贝

Binder四大模块:

  • Binder Driver位于内核空间中,主要负责Binder通信的建立,以及其在进程间的传递和Binder引用计数管理/数据包的传输等。而Client与Server之间的跨进程通信则统一通过Binder Driver处理转发。

  • 对于Client来说,只需要知道自己要使用的Binder的名字,然后通过0号引用去访问ServerManager获取目标Binder的引用,得到引用后就可以像普通方法那样调用Binder实体的方法。

  • Server在生成一个Binder实体的同时会为其绑定一个别名并将别名传递给Binder Driver,Binder Driver接收后如果发现是新增的Binder,那么就会为其在内核空间中创建相应的Binder实体节点,然后Binder Driver将该节点的引用传递给ServerManager,ServerManager收到后再将该Binder的别名和引用插入到一张数据表中,这跟DNS中存储的域名到IP地址的映射原理类似。

  • ServerManager也是个标准的Server,并且在Android中约定其在Binder通信的过程中唯一标识永远是0,也就是前面提到的0号引用。

Android系统启动过程中SystemServer会向BinderDriver注册ServiceManager,BinderDriver自动为ServiceManager创建Binder实体。所有在这之后启动的应用进程都会持有这个Binder的句柄,为0号引用,即所有用户进程的0号引用都指向该Binder。ActivityManagerService、PackageManagerService等系统服务都是通过Binder机制与应用进行双向通信。

传统的IPC方式:

* 发送方先将准备好的数据存放在缓存区中
* 然后通过系统调用进入内核中,内核服务程序在内核空间分配内存,将数据从发送方缓存区复制到内核缓存区中。
* 接收方读数据时也要提供一块缓存区,内核将数据从内核缓存区拷贝到接收方提供的缓存区中。
* 这种存储-转发机制有两个缺陷:
* 首先是效率低下,需要做两次拷贝:用户空间->内核空间->用户空间。Linux使用copy_from_user()和copy_to_user()实现这两个跨空间拷贝,在此过程中如果使用了高端内存(high memory),这种拷贝需要临时建立/取消页面映射,造成性能损失。
* 其次是接收数据的缓存要由接收方提供,可接收方不知道到底要多大的缓存才够用,只能开辟尽量大的空间或先调用API接收消息头获得消息体大小,再开辟适当的空间接收消息体。两种做法都有不足,不是浪费空间就是浪费时间。

显然,Linux上的跨进程通信需要内核空间做支持。传统的跨进程通信方式有Socket、信号量、管道、内存共享等,他们都属于Linux内核,但Android上的Binder并不属于Linux内核,那么Binder如何实现IPC呢?答案是 Loadable Kernel Module查看wiki, Android利用Linux的动态内核可加载模块机制(Loadable Kernel Module,LKM),建立Binder Driver挂载为动态内核,然后通过Binder Driver以mmap的方式将内核空间与接收方的用户空间进行内存映射,于是只需要从发送方的用户空间拷贝数据到内核空间中,就实现了一次数据拷贝完成进程间通信。使用mmap建立内核空间跟用户空间的映射后,同一份物理内存,既可以在用户空间用虚拟地址访问,也可以在内核空间用虚拟地址访问。所以mmap的本质是让用户空间中的一块虚拟地址与内核空间中的一块虚拟地址指向同一块物理地址。

Android应用在进程启动之初会创建一个单例的ProcessState对象,其构造函数执行时会同时完成binder mmap,为进程分配一块内存,专门用于Binder通信。

匿名Binder

在ServiceManager中注册过的Binder都叫实名Binder。当Client与Server通过实名Binder建立好Binder连接后,Server还可以通过这个连接将新的Binder实体封装进数据包传递给Client,这个被传递的就叫做匿名Binder,匿名Binder依然会在Binder Driver中生成实体节点,但不会在ServiceManager中注册。

匿名Binder为通信双方建立起一条私密通道,只要Server没有把匿名Binder发给别的进程,别的进程就无法通过穷举或猜测等任何方式获得该Binder的引用,向该Binder发送请求。

Binder线程(参考资料)

Binder通信实际上是位于不同进程中的线程之间的通信。假如进程S是Server端,提供Binder实体,线程T1从Client进程C1中通过Binder的引用向进程S发送请求。S为了处理这个请求需要启动线程T2,而此时线程T1处于接收返回数据的等待状态。T2处理完请求就会将处理结果返回给T1,T1被唤醒得到处理结果。在这过程中,T2仿佛T1在进程S中的代理,代表T1执行远程任务,而给T1的感觉就是象穿越到S中执行一段代码又回到了C1。为了使这种穿越更加真实,驱动会将T1的一些属性赋给T2,特别是T1的优先级nice,这样T2会使用和T1类似的时间完成任务。很多资料会用‘线程迁移’来形容这种现象,容易让人产生误解。一来线程根本不可能在进程之间跳来跳去,二来T2除了和T1优先级一样,其它没有相同之处,包括身份,打开文件,栈大小,信号处理,私有数据等。

对于Server进程S,可能会有许多Client同时发起请求,为了提高效率往往开辟线程池并发处理收到的请求。怎样使用线程池实现并发处理呢?这和具体的IPC机制有关。拿socket举例,Server端的socket设置为侦听模式,有一个专门的线程使用该socket侦听来自Client的连接请求,即阻塞在accept()上。这个socket就象一只会生蛋的鸡,一旦收到来自Client的请求就会生一个蛋 – 创建新socket并从accept()返回。侦听线程从线程池中启动一个工作线程并将刚下的蛋交给该线程。后续业务处理就由该线程完成并通过这个单与Client实现交互。

可是对于Binder来说,既没有侦听模式也不会下蛋,怎样管理线程池呢?一种简单的做法是,不管三七二十一,先创建一堆线程,每个线程都用BINDER_WRITE_READ命令读Binder。这些线程会阻塞在驱动为该Binder设置的等待队列上,一旦有来自Client的数据驱动会从队列中唤醒一个线程来处理。这样做简单直观,省去了线程池,但一开始就创建一堆线程有点浪费资源。于是Binder协议引入了专门命令或消息帮助用户管理线程池,包括:

· INDER_SET_MAX_THREADS
· BC_REGISTER_LOOP
· BC_ENTER_LOOP
· BC_EXIT_LOOP
· BR_SPAWN_LOOPER

首先要管理线程池就要知道池子有多大,应用程序通过INDER_SET_MAX_THREADS告诉驱动最多可以创建几个线程。以后每个线程在创建,进入主循环,退出主循环时都要分别使用BC_REGISTER_LOOP,BC_ENTER_LOOP,BC_EXIT_LOOP告知驱动,以便驱动收集和记录当前线程池的状态。每当驱动接收完数据包返回读Binder的线程时,都要检查一下是不是已经没有闲置线程了。如果是,而且线程总数不会超出线程池最大线程数,就会在当前读出的数据包后面再追加一条BR_SPAWN_LOOPER消息,告诉用户线程即将不够用了,请再启动一些,否则下一个请求可能不能及时响应。新线程一启动又会通过BC_xxx_LOOP告知驱动更新状态。这样只要线程没有耗尽,总是有空闲线程在等待队列中随时待命,及时处理请求。

关于工作线程的启动,Binder驱动还做了一点小小的优化。当进程P1的线程T1向进程P2发送请求时,驱动会先查看一下线程T1是否也正在处理来自P2某个线程请求但尚未完成(没有发送回复)。这种情况通常发生在两个进程都有Binder实体并互相对发时请求时。假如驱动在进程P2中发现了这样的线程,比如说T2,就会要求T2来处理T1的这次请求。因为T2既然向T1发送了请求尚未得到返回包,说明T2肯定(或将会)阻塞在读取返回包的状态。这时候可以让T2顺便做点事情,总比等在那里闲着好。而且如果T2不是线程池中的线程还可以为线程池分担部分工作,减少线程池使用率。

三、mmap扩展

Linux的三种IO方式:标准IO、直接IO、mmap。

标准IO

应用程序平时使用的read()、write()都属于标准IO,在发起读写操作后其实是往内核空间的页缓存读写数据。对于写操作,系统默认是延迟写入机制,页缓存的数据会由内核在合适的时机写入磁盘。

* 用户发起 write 操作
* 操作系统查找页缓存
    a.若未命中,则产生缺页异常,然后创建页缓存,将用户传入的内容写入页缓存
    b.若命中,则直接将用户传入的内容写入页缓存
* 用户 write 调用完成
* 页被修改后成为脏页,操作系统有两种机制将脏页写回磁盘
    a.用户手动调用 fsync()
    b.由 pdflush 进程定时将脏页写回磁盘

可以看出write过程中有两次数据拷贝,第一次是从内存空间写入内核空间,第二次是内核将页缓存数据写入磁盘。

知识扩展:

相对于机械硬盘,SSD 存储还有一个“写入放大”的问题。这个问题主要和 SSD 存储的物理结构有关。
当 SSD 被全部写过一遍之后,再写入的数据是不可以直接更新,只可以通过覆盖重写,在覆盖之前需要先擦除数据。
但写入的最小单位是 Page,擦除的最小单位是 Block,而 Block 远大于 Page,所以在写入新数据时就需要先把 Block 上的数据读出来和要写入的数据合并在一起,再把 Block 擦除,最后把读出来的数据重新写入到存储上,这样导致实际写入的数据可能远远大于最开始需要写入的数据。

直接IO

应用程序直接读写磁盘。Android并没有提供直接IO的JAVA API。

mmap

mmap是操作系统中一种内存映射的方法。

内存映射:就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。

mmap通常用在有物理介质的文件系统上。使用mmap可以把文件映射到进程的地址空间,实现磁盘地址与进程虚拟空间地址的对应关系。

优点:
    * 减少系统调用。只需要一次mmap()的系统调用,建立映射关系后就可以像操作内存一样。
    * 减少数据拷贝次数。mmap()只需要一次数据拷贝。
缺点:
    * 需要占用更多的内存。

Java中提供的内存映射实现:MappedByteBuffer