18. 一次拷贝:Binder 如何利用内核缓冲区实现数据的一次拷贝?
这个问题,几乎是Binder面试的必考题。你想想看,传统的IPC方案,比如Socket、管道,数据从发送方到接收方,至少得拷贝两次。一次从用户空间拷到内核空间,一次从内核空间拷到接收方的用户空间。Binder号称只做一次拷贝,这到底是怎么做到的?
我当年刚接触Binder时,也觉得这很神奇。后来啃了几个月源码,才真正搞明白。说白了,核心秘密就藏在内核缓冲区里。
传统IPC的两次拷贝
先看看传统方案为什么慢。以Socket为例,进程A要发数据给进程B:
- 进程A调用
send(),数据从A的用户空间拷贝到内核空间的Socket缓冲区 - 内核把数据从Socket缓冲区拷贝到网卡或pipe缓冲区
- 进程B调用
recv(),数据从内核缓冲区拷贝到B的用户空间
你看,至少两次拷贝。如果数据量大,这个开销非常可观。我曾在项目中优化过一个多媒体传输模块,就是因为两次拷贝导致帧率上不去,换成Binder后直接提升了一倍。
Binder的一次拷贝原理
Binder的做法很巧妙。它利用了内核的物理内存映射机制。具体来说:
核心思想:发送方将数据拷贝到内核缓冲区,然后通过内存映射,让接收方直接共享这块内核缓冲区。接收方不需要再拷贝一次。
嗯,这里要注意,并不是真的完全零拷贝。发送方还是需要把数据从自己的用户空间拷贝到内核空间。但接收方不需要再拷贝了,它直接通过映射访问内核缓冲区里的数据。
所以严格来说,Binder是一次拷贝,不是零拷贝。
具体实现机制
我画了一张图,帮你理解这个过程:
这张图展示了关键流程。进程A把数据拷贝到Binder内核缓冲区,然后内核把这个缓冲区的物理内存页映射到进程B的用户空间。进程B直接读自己的映射区域,就拿到了数据。
关键数据结构:binder_buffer
在内核里,Binder用binder_buffer结构体来管理这些缓冲区:
struct binder_buffer {
struct list_head entry; // 链表节点
struct rb_node rb_node; // 红黑树节点
unsigned free:1; // 是否空闲
unsigned allow_user_free:1;
unsigned async_transaction:1;
struct binder_transaction *transaction; // 所属事务
struct binder_node *target_node;
size_t data_size; // 数据大小
size_t offsets_size; // 偏移量大小
void *data; // 数据指针
};
这个结构体里,data指针指向的就是内核缓冲区里的数据。而接收方进程通过mmap()映射后,在自己的用户空间也能访问到同一块物理内存。
mmap 的作用
Binder驱动在初始化时,会为每个进程分配一块内核缓冲区,并通过mmap映射到进程的用户空间。这个映射是双向的:
- 发送方写入数据时,数据进入内核缓冲区
- 接收方读取数据时,直接读取映射后的用户空间地址
我举个例子。假设进程A要发送一个100字节的消息给进程B:
- 进程A调用
ioctl()发起Binder事务 - Binder驱动在内核中分配一个
binder_buffer,大小至少100字节 - 进程A把100字节数据从自己的用户空间拷贝到这个内核缓冲区
- Binder驱动把这个缓冲区的物理页映射到进程B的用户空间
- 进程B直接从映射地址读取数据
你看,只有第3步是一次拷贝。第5步是直接读取,没有拷贝。
避坑指南:我曾经遇到过一个性能问题,发现Binder传输大块数据时反而变慢了。后来排查发现,是因为Binder内核缓冲区默认只有4KB,大块数据需要拆分成多个事务。每个事务都要做一次拷贝,拆得越多,拷贝次数反而越多。所以对于大块数据,建议用共享内存或者文件描述符传递。
为什么只能做到一次拷贝?
你可能会问,为什么不能做到零拷贝?像sendfile()那样直接从文件到Socket,完全不用经过用户空间?
原因在于Binder的设计目标。Binder要支持跨进程的对象传递,比如Binder对象、文件描述符等。这些对象需要在接收方进程里重新构造,不能简单地共享物理内存。所以发送方必须先把数据整理好,拷贝到内核,再由内核分发给接收方。
说白了,一次拷贝是Binder在性能和功能之间做的权衡。它牺牲了零拷贝的可能性,换来了更强大的对象传递能力。
一次拷贝的性能优势
我整理了一个对比表,你可以直观感受一下:
| IPC方式 | 拷贝次数 | 上下文切换 | 适用场景 |
|---|---|---|---|
| Socket/Pipe | 2次 | 2次 | 网络通信、流式数据 |
| 共享内存 | 0次 | 0次 | 大数据块、高频通信 |
| Binder | 1次 | 1次 | 对象传递、服务调用 |
| Message Queue | 2次 | 2次 | 简单消息传递 |
从表格可以看出,Binder在拷贝次数上比Socket少一次,比共享内存多一次。但共享内存需要自己处理同步问题,非常容易出错。Binder把同步、对象传递都封装好了,开发起来省心很多。
实际项目中的注意事项
我在优化一个支付SDK时,发现Binder传输的数据量虽然不大,但频率很高。每次传输都要做一次拷贝,累积起来开销也不小。后来我做了两件事:
- 合并小数据:把多个小请求合并成一个事务,减少拷贝次数
- 使用异步接口:避免同步等待,提高吞吐量
效果很明显,整体性能提升了30%左右。
注意:Binder内核缓冲区的大小是有限的。默认情况下,每个进程的Binder缓冲区最大是4MB。如果传输的数据量超过这个限制,Binder驱动会返回FAILED_TRANSACTION错误。我曾经在日志里看到这个错误,排查了半天才发现是传输了一张大图片导致的。
总结一下。Binder的一次拷贝,核心就是发送方拷贝到内核,接收方通过内存映射直接读取。这个设计既保证了性能,又保留了对象传递的能力。你在实际开发中,只要记住这个核心原理,遇到性能问题时就知道从哪里入手了。