Binder原理:进程间通信基础

讲Binder优化之前,咱得先把地基打牢。很多做Android开发的朋友,用Binder用得挺溜,但一问到「用户态和内核态到底怎么切换的」、「序列化到底干了什么事」,就有点含糊了。这不行,真遇到性能问题,你连瓶颈在哪都找不到。

我记得刚接触Binder那会儿,也觉得它就是「调用远程方法」而已。直到有一次线上问题:系统服务响应超时,我顺着调用栈一路追下去…嗯,才发现自己对Binder的理解只浮在API层面。今天咱们就把它铺开,把用户态、内核态、共享内存、序列化这几个核心概念讲透。

用户态与内核态:谁在干活?

Android的进程跑在用户态。用户态说白了就是「你写的代码」在跑,权限有限。比如你不能直接读写物理内存、不能直接操作硬件。内核态就不一样了,它拥有最高权限,能访问所有硬件资源。

Binder通信,本质上是两个用户态进程(比如App和SystemServer)要交换数据。但两个进程的内存空间是隔离的,不能互相访问。怎么办?必须经过内核。Binder驱动跑在内核态,充当「中间人」。

关键理解:

  • 发起方(客户端)在用户态调用Binder API
  • 数据先复制到内核空间(由Binder驱动完成)
  • 内核再通知目标进程(服务端),把数据从内核空间复制到它的用户空间
  • 一次Binder IPC,至少经历 一次用户态→内核态切换 + 一次内核态→用户态切换

为什么会多一次拷贝?别急,下面会讲共享内存来优化。

共享内存:Binder的「偷懒」之道

传统IPC(比如管道、Socket)通常需要两次数据拷贝:先从发送方用户态拷到内核态,再从内核态拷到接收方用户态。Binder默认也是两次拷贝——但它在某些场景下可以做到 一次拷贝。

怎么做到的?通过内存映射(mmap)。Binder驱动在内核空间维护一块缓冲区,同时把这缓冲区映射到用户空间。说白了,就是一块物理内存,内核和用户进程都能看到。

我个人的理解:

想象一下,你(客户端)想把一箱货交给对面楼的人(服务端)。传统做法是:你把货搬到自己楼下(用户态→内核态),再搬到他楼下(内核态→用户态)。Binder的做法是:你们俩共享一个地下仓库(mmap),你直接把货放在仓库里,他直接去拿,中间只需要一次「开门通知」。

不过要注意,不是所有Binder调用都能享受一次拷贝。只有数据量小于Binder缓冲区大小(通常1MB可以调)且使用Binder内核驱动内部的mmap机制才能实现。我自己在优化系统服务时,就遇到过因为传输大图片导致频繁两次拷贝的问题,后来改成用共享内存加文件描述符传递,才压下去延迟。

序列化与反序列化:数据怎么「打包」?

进程间通信,数据不能直接传对象,因为对象引用在另一个进程里没意义。你得把对象「拍平」成二进制字节流,这叫序列化。另一端收到二进制流后,再恢复成对象,这叫反序列化。

Android里常见的序列化方案有:

方案 特点 我踩过的坑
Parcelable Android原生,效率高,需手写实现 字段顺序不能乱改,不然反序列化会崩
Serializable Java原生,简单但慢,反射开销大 在Binder里用Serializable?性能直接腰斩
Protocol Buffers 跨语言,压缩率高 需要维护.proto文件,调试不方便

Binder内部强制要求使用Parcelable。你写的AIDL接口,编译器自动生成Parcelable的序列化代码。但要注意:AIDL默认传输的是Parcelable的浅拷贝。如果对象内部有大量数组或嵌套对象,每次序列化都会遍历所有字段,这个开销不可忽视。

我曾经优化过一个系统服务,每次调用要传输一个包含1000个元素的List。反序列化时,Binder驱动逐个解包,光这个步骤就占了整个IPC时间的40%。后来我把List改成文件描述符+共享内存,才把响应时间从50ms压到5ms。

Binder IPC完整流程一览

咱们把上面讲的内容串起来,看一张图就清楚多了:

Binder IPC 核心通信流程 进程A(客户端) 用户态 1. 调用Binder API 序列化数据(Parcel) 发起 ioctl 系统调用 内核态 Binder驱动 2. 将数据从用户态拷贝到内核缓冲区 (一次拷贝 / 两次拷贝取决于mmap) 进程B(服务端) 用户态 3. 内核唤醒服务端线程 数据从内核拷贝到用户态 反序列化,执行调用 系统调用 返回响应 通知目标进程 mmap共享内存区(内核缓冲区映射到用户空间) 优化时关注:数据大小 < 1MB时优先使用mmap,可减少一次拷贝 序列化 & 反序列化 Parcelable → 字段逐个写入/读取 避免在Binder中传输大对象数组 常见性能瓶颈 数据拷贝次数多 | 序列化开销大 回调频繁 | binder线程池阻塞

我曾经踩过的坑:

  • 在AIDL接口里传了一个很大的Bitmap对象,序列化直接把binder线程耗尽,系统服务响应超200ms
  • 用了Parcelable但没注意成员变量里的List大小,反序列化耗时跟数据量呈线性增长
  • 以为mmap能解决一切,忽略了Binder缓冲区大小限制(默认1MB),传大文件时直接TransactionTooLargeException

你想想看,Binder一顿操作下来,用户态到内核态两次切换是逃不掉的。但数据拷贝次数能优化。我们做性能优化时,首要目标就是减少拷贝、减少序列化开销。后面几章我会具体聊这些优化手段,今天先把基础打牢。

小结一下:

  • 用户态/内核态:Binder通信必须经过内核驱动,这是安全的基石
  • 共享内存mmap:让内核缓冲区映射到用户空间,有机会做到一次拷贝
  • 序列化/反序列化:Parcelable是Binder的标配,注意控制数据量
  • 性能关键点:数据拷贝次数、序列化字段数量、binder线程池负载

记住这些概念,后面分析具体案例时,你就能一眼看出问题在哪。我就是这样一步步从「调了接口但没效果」变成「看一眼堆栈就知道瓶颈」的。你也一定可以。