啃下Binder这块硬骨头(一)

1,947 阅读11分钟

Binder是一片大森林,涉及的东西很多,很容易学着学着就迷失了方向。笔者也曾经看过这部分内容,但半途而废,只留下一声感叹:太难了吧。但现在发现,还是得静下心来去啃这个骨头,因为Binder确实太重要了。

作为一名新手,刚入新手村,先从宏观的角度看Binder容易理解一点:

  1. Binder是什么?
  2. Binder由哪些元素组成?
  3. Binder的这些组成元素是怎么建立联系的?
  4. Binder的通信过程是如何实现的?

Binder的初步理解

  • 从字面理解“捆绑者”,把两个需要建立关系的对象捆绑起来。
  • 简单的说,Binder是Android中使用最广泛的进程通信机制。

这么理解还是很抽象,那么就看它想解决的问题是什么:

Binder的本质目标就是进程1希望和进程2进行互相访问,但因为它们是跨进程的,资源不能直接共享,所以需要借助Binder(驱动)来把两个进程建立关联。

Binder的原型就是这样:

binder 原型

两个进程本身各自独立,毫无关联,Binder就像一座桥梁为两个进程的通信建立了基础。

先抛出这么一个问题:

Q:操作系统中进程间的通信方式已经有很多个(管道、Socket、信号量等),为什么Android还要自己弄一个新的IPC机制呢?或者说Binder有什么过“人”之处?

我们可以带着这样的疑惑继续往下看

Binder机制有哪些元素组成

前面说到Binder是一座桥梁,那么组成这个桥梁肯定需要很多元素。

首先,Binder是基于C/S架构,它的组成非常像TCP/IP网络,主要由以下几个元素组成:

  • Binder Client -> 客户端
  • Binder Server -> 服务端
  • Binder Driver -> 路由器
  • Service Manager -> DNS

把这些元素填充到之前Binder的原型图,就会变成这个样子:

binder组成元素

ClientServer对应上面的进程1和进程2。这里先说明Server Manager也是一个独立的进程,它也会和Binder驱动进行通信。

组成元素如何建立联系

补下小知识点

为了更容易的理解后面的内容,先补充一个知识点,Linux的进程隔离、进程空间划分和系统调用

linux跨进程的概念

上面这一张图片已经表达这些知识点,是不是很简单:

1. 进程隔离: 进程1与进程2之间是内存不共享的,所以进程1没法直接访问 进程2的数据,这就是进程隔离的通俗解释。

2. 进程空间划分: 如图所示可以看到有两块内存区域:用户空间和内核空间。内核空间(Kernel)是系统内核运行的空间,用户空间(User Space)是用户程序运行的空间。为了保证安全性,它们之间是隔离的。

3. 系统调用:虽然从逻辑上进行了用户空间和内核空间的划分,但不可避免的用户空间需要访问内核资源,为了突破隔离限制,系统调用是唯一让用户访问内核空间的方式。主要通过如下两个函数实现:

copy_from_user() //将数据从用户空间拷贝到内核空间
copy_to_user() //将数据从内核空间拷贝到用户空间

Q: 设计的这么麻烦,有啥好处呢?

这样设计可以让所有的资源访问都是在内核的控制下进行的,避免了用户程序对系统资源的越权访问,提升了系统安全性和稳定性。

从图中可以看出,正是基于系统调用我们的进程间的通信(IPC)实现才有可能。那么具体的通信方式是怎么样的呢?

传统IPC通信方式

根据上面介绍的知识点,考虑一下传统的IPC方式中,数据是怎样从发送进程到达接收进程的呢?

传统的IPC通信方式

通常的做法是:

  1. 消息发送方将要发送的数据存放在内存缓存区中,
  2. 通过系统调用进入内核态。然后内核程序在内核空间分配内存,开辟一块内核缓存区,调用 copy_from_user()函数将数据从用户空间的内存缓存区拷贝到内核空间的内核缓存区中。
  3. 同样,接收方进程在接收数据时在自己的用户空间开辟一块内存缓存区,
  4. 然后内核程序调用 copy_to_user()函数将数据从内核缓存区拷贝到接收进程的内存缓存区。这样数据发送方进程和数据接收方进程就完成了一次数据传输,我们称完成了一次进程间通信。

这种传统的IPC的过程,看着还着很合理,很顺畅,毫无瑕疵的亚子。

实际上存在两个缺陷:

  • 首先是效率低下,需要做两次拷贝:一次数据传递需要经历:内存缓存区 --> 内核缓存区 --> 内存缓存区;
  • 接收数据的缓存区由数据接收进程提供,但是接收进程并不知道需要多大的空间来存放将要传递过来的数据,因此只能开辟尽可能大的内存空间或者先调用 API 接收消息头来获取消息体的大小。这两种方式不是浪费空间就是浪费时间。

时机已到,这个时候主角Binder就要登场了

Binder的设计实现

一次完整的Binder IPC通信方式通常是这样:

  1. 首先 Binder 驱动在内核空间创建一个数据接收缓存区
  2. 接着在内核空间开辟一块内核缓存区建立内核缓存区和内核中数据接收缓存区之间的映射关系,以及内核中数据接收缓存区和接收进程用户空间地址的映射关系;
  3. 发送方进程通过系统调用 copy_from_user() 将数据 copy 到内核中的内核缓存区,由于内核缓存区和接收进程的用户空间存在内存映射,因此也就相当于把数据发送到了接收进程的用户空间,这样便完成了一次进程间的通信。

binder的通信过程

对比传统的通信过程,我们可以很明显的看到不同:

Binder的IPC通信方式,它多了一个数据接收缓存区,并通过这个数据接收缓存区,分别与内核空间的内核缓存区、接收进程的用户空间建立了映射关系。这样带来的最大好处就是少了一次拷贝数据的过程,这就意味着提升了一倍的性能。不要小看这一倍的性能,在数据量大和通信次数平凡的基础上,这一切都将变得不同凡响。

内存映射mmap()

Binder IPC 机制中涉及到的内存映射通过mmap() 来实现,mmap() 是操作系统中一种内存映射的方法。内存映射简单的讲就是将用户空间的一块内存区域映射到内核空间。映射关系建立后,用户对这块内存区域的修改可以直接反应到内核空间;反之内核空间对这段区域的修改也能直接反应到用户空间。

它可以带来的好处:

  1. 减少系统调用:我们只需要一次 mmap() 系统调用,后续所有的调用像操作内存一样,而不会出现大量的 read/write 系统调用。
  2. 减少数据拷贝:普通的read()调用,数据需要经过两次拷贝;而mmap只需要从磁盘拷贝一次就可以了,并且由于做过内存映射,也不需要再拷贝回用户空间。
  3. 可靠性高:mmap 把数据写入页缓存后,跟缓存 I/O 的延迟写机制一样,可以依靠内核线程定期写回磁盘。但是需要提的是,mmap 在内核崩溃、突然断电的情况下也一样有可能引起内容丢失,当然我们也可以使用 msync 来强制同步写。

看起来 mmap 的确可以秒杀普通的文件读写,那我们为什么不全都使用 mmap 呢?

  • 虚拟内存增大。mmap 会导致虚拟内存增大,我们的 APK、Dex、so 都是通过 mmap 读取。而目前大部分的应用还没支持 64 位,除去内核使用的地址空间,一般我们可以使用的虚拟内存空间只有3GB左右。如果mmap一个1GB的文件,应用很容易会出现虚拟内存不足所导致的OOM
  • 磁盘延迟。mmap 通过缺页中断向磁盘发起真正的磁盘 I/O,所以如果我们当前的问题是在于磁盘 I/O 的高延迟,那么用 mmap() 消除小小的系统调用开销是杯水车薪的。

mmap 比较适合于对同一块区域频繁读写的情况

Binder有什么“过人之处”

到这里,我们可以解决最开始提出的问题:Binder有什么“过人之处”

其他IPC通信方式特点:

  • socket作为一款通用接口,其传输效率低,开销大,主要用在跨网络的进程间通信和本机上进程间的低速通信。
  • 消息队列和管道采用存储-转发方式,即数据先从发送方缓存区拷贝到内核开辟的缓存区中,然后再从内核缓存区拷贝到接收方缓存区,至少有两次拷贝过程。
  • 共享内存虽然无需拷贝,但控制复杂,难以使用。

而Binder:

  • 性能好:只需要一次数据拷贝,性能上仅次于共享内存
  • 稳定性高: 基于 C/S 架构,职责明确、架构清晰,因此稳定性好,是优于内存共享的。
  • 安全性强: 为每个 APP 分配 UID,进程的 UID 是鉴别进程身份的重要标志。传统的 IPC 只能由用户在数据包中填入 UID/PID,但这样不可靠,容易被恶意程序利用。

讨论:

  1. 传统的IPC机制如管道、Socket都是内核的一部分,因此通过内核支持来实现进程间通信自然是没有问题的,但Binder并不算Linux系统内核的一部分,那怎么实现进程间通信呢?

这就得益于 Linux 的动态内核可加载模块(Loadable Kernel Module,LKM)的机制;模块是具有独立功能的程序,它可以被单独编译,但是不能独立运行。它在运行时被链接到内核作为内核的一部分运行。这样,Android 系统就可以通过动态添加一个内核模块运行在内核空间,从而用户进程之间就可以通过这个Binder这个内核模块作为桥梁来实现通信。

  1. 为什么接收进程不需要内核缓存区,可以直接和Binder创建的接收缓存区进行映射,但发送进程为什么不能直接和Binder接收缓存区进行映射,如果它们也可以映射的话,不就不需要拷贝,性能不是更好?

这个疑惑是我第一次看通信过程的图就存在的,但google了这方面的问题都没有找到答案,搜出来的都是为什么只需要一次拷贝?不知道是不是因为太简单了,只有我没想到。。。

我最终想了一种解释(很可能不对),大佬们有知道的,可以在留言区分享一下:

如果发送进程和接收进程都直接和binder的缓存区进行映射,那么就等同于跨过了Linux内核区,这很明显是不合理的,进程间的架构设计是需要让进程的通信的数据需要经过内核空间管理,所以需要接收发送进程拷贝的数据。

最后

本篇博客先到这里,其实发现静下心来慢慢去学习Binder,它的内容还是很有意思的呀。

你很可能发现了Binder的通信过程少了一个元素,没错,就是Server Manager,由于它的内容比较多,计划下一篇博客再分享。