29、Binder 的性能分析:Binder 调用的延迟、吞吐量测试,以及如何优化

聊到 Binder 性能,很多人第一反应就是“比 Socket 快”。但快多少?瓶颈在哪?怎么量化?

说实话,我见过不少项目,优化全靠“感觉”。感觉卡了,就加个线程池。感觉慢了,就换共享内存。但到底是不是 Binder 的问题?没人说得清。

这一章,我们就来把 Binder 的性能底裤扒干净。我会从延迟和吞吐量两个维度入手,告诉你如何测试、如何分析、如何优化。

29.1 Binder 调用的延迟构成

一次 Binder 调用,从客户端发起,到服务端返回结果,经历了哪些阶段?

我把它拆成四个部分:

  • 用户态到内核态的切换:ioctl 系统调用,大约 0.5-1μs
  • 内核态数据拷贝:从客户端进程拷贝到内核缓冲区,再从内核缓冲区拷贝到服务端进程,约 1-3μs
  • 线程调度与唤醒:服务端线程被唤醒,上下文切换,约 2-5μs
  • 序列化与反序列化:Parcel 的读写操作,取决于数据量

核心结论:一次空 Binder 调用(无数据传递),在 Android 10 以上的设备上,延迟大约在 10-30μs 之间。这比本地函数调用慢了 1000 倍,但比 Socket 快了 10 倍以上。

为什么会这样?说白了,Binder 的设计目标就是“够用就好”。它不需要像共享内存那样零拷贝,也不需要像 Socket 那样走完整的网络协议栈。它就在中间,找到了一个平衡点。

29.2 延迟测试:如何精确测量

我习惯用两种方式测延迟:一种是写测试 App,另一种是直接改内核打 trace。

29.2.1 用户态测试方法

写一个简单的 AIDL 接口,只传递一个空方法,然后循环调用 10000 次,取平均值。

// IPerfTest.aidl
interface IPerfTest {
    void ping();
}

// 客户端测试代码
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
    mService.ping();
}
long elapsed = System.nanoTime() - start;
double avgLatency = elapsed / 10000.0 / 1000.0; // 单位 μs
Log.d("BinderPerf", "Average latency: " + avgLatency + " μs");

注意:第一次调用通常比后续调用慢很多,因为涉及 binder 节点创建、线程池初始化等。我一般会先预热 100 次,再开始正式测试。

29.2.2 内核态 trace 方法

如果你有 root 权限,或者在做系统开发,我强烈建议用 ftrace 来抓 Binder 的调度细节。

# 开启 binder 相关 trace
echo 1 > /sys/kernel/tracing/events/binder/binder_transaction/enable
echo 1 > /sys/kernel/tracing/events/binder/binder_transaction_received/enable
echo 1 > /sys/kernel/tracing/tracing_on

# 执行你的测试
# ...

# 关闭并读取 trace
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace

trace 输出里,你会看到类似这样的信息:

  binder-1234  [001] ...1  123.456: binder_transaction: transaction=789
  binder-5678  [002] ...1  123.459: binder_transaction_received: transaction=789

两个时间戳的差值,就是一次 Binder 调用的内核态耗时。我测过,这个值通常在 5-15μs 之间,剩下的时间就是用户态的序列化和调度开销。

29.3 吞吐量测试:能扛多少并发

延迟是单次调用的快慢,吞吐量是单位时间内能处理多少调用。这两个指标往往互相制约。

我遇到过最典型的场景:一个后台服务同时被 20 个前台 App 调用,结果每个 App 都感觉卡顿。这就是吞吐量瓶颈。

29.3.1 测试方法

用多个线程同时发起 Binder 调用,统计总调用次数和总耗时。

// 10 个线程并发调用
int threadCount = 10;
int callsPerThread = 1000;
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger totalCalls = new AtomicInteger(0);
long start = System.nanoTime();

for (int i = 0; i < threadCount; i++) {
    new Thread(() -> {
        for (int j = 0; j < callsPerThread; j++) {
            mService.ping();
            totalCalls.incrementAndGet();
        }
        latch.countDown();
    }).start();
}

latch.await();
long elapsed = System.nanoTime() - start;
double throughput = totalCalls.get() / (elapsed / 1_000_000_000.0);
Log.d("BinderPerf", "Throughput: " + throughput + " calls/sec");

29.3.2 典型结果

并发线程数 吞吐量(calls/sec) 平均延迟(μs)
1 ~50,000 ~20
5 ~120,000 ~42
10 ~150,000 ~67
20 ~160,000 ~125

看到没?当并发超过 10 个线程后,吞吐量增长趋缓,但延迟却急剧上升。这是因为 Binder 内核线程池默认只有 16 个线程,超过这个数就要排队等待。

我曾经踩过的坑:在一个多媒体项目中,我让 30 个线程同时通过 Binder 请求解码帧数据。结果服务端线程池被打满,新请求全部阻塞,导致 UI 线程也卡住了。后来我把 Binder 线程池上限调到了 32,才缓解了问题。

29.4 优化策略:从内核到应用层

优化 Binder 性能,我一般按这个优先级来:

  1. 减少数据量:这是最有效的优化。能传 int 就别传 String,能传 FileDescriptor 就别传 byte[]。
  2. 合并调用:把多次小调用合并成一次大调用。比如批量查询,不要一条一条查。
  3. 异步化:用 oneway 接口,或者回调方式,避免阻塞调用方。
  4. 调整线程池:根据实际并发量,调整服务端的 Binder 线程池大小。
  5. 使用共享内存:对于大块数据(比如 1MB 以上的图片),用 MemoryFile 或 Ashmem 绕过 Binder 拷贝。

29.4.1 调整 Binder 线程池

在服务端进程的入口处,可以这样设置:

// 设置 Binder 线程池最大线程数为 32
Process.setThreadPoolSize(32);

不过要注意,线程不是越多越好。线程多了,上下文切换的开销也会增加。我一般建议设置为 CPU 核心数的 2-4 倍。

29.4.2 使用 oneway 优化

如果客户端不需要等待服务端处理完成,就用 oneway:

// AIDL 定义
interface IPerfTest {
    void doHeavyWork();
    oneway void doHeavyWorkAsync(); // 异步版本
}

oneway 调用不会阻塞客户端,延迟几乎为 0。但代价是:你无法知道服务端是否处理成功。

29.5 核心知识体系

下面这张图,是我对 Binder 性能分析的总结。你可以把它当作一个检查清单:

Binder 性能分析知识体系 延迟构成 • 用户态/内核态切换 (0.5-1μs) • 内核数据拷贝 (1-3μs) • 线程调度唤醒 (2-5μs) • 序列化/反序列化 测试方法 • 用户态:System.nanoTime() • 内核态:ftrace • 延迟测试:单次调用 • 吞吐量测试:并发调用 优化策略 • 减少数据量 • 合并调用 • 异步化 (oneway) • 调整线程池 关键指标与平衡 • 空调用延迟:10-30μs(Android 10+) • 吞吐量上限:约 150,000 calls/sec(10 并发) • 优化原则:先减数据量,再调线程池,最后考虑共享内存

29.6 实战建议

最后,给你几个我实际项目中的建议:

  • 不要过早优化:先跑一次性能测试,看看 Binder 调用到底占了多少时间。很多时候,瓶颈根本不在 Binder。
  • 关注数据量:我见过有人传一个 2MB 的 JSON 字符串过 Binder,结果一次调用花了 200ms。换成 FileDescriptor 后,直接降到 5ms。
  • 监控线程池:在服务端加一个定时任务,打印当前 Binder 线程池的使用情况。如果经常满负荷,就该扩容了。
  • 考虑替代方案:如果对延迟要求极高(比如 1ms 以内),Binder 可能不是最佳选择。可以考虑用共享内存 + 原子操作,或者直接走 Socket。

我的习惯:每次上线前,我都会在压测环境跑一遍 Binder 性能测试。把延迟和吞吐量数据记录下来,作为版本对比的基线。这样一旦性能下降,我能立刻知道是哪个版本引入的问题。

嗯,Binder 性能分析就聊到这里。记住一句话:没有测量就没有优化。先搞清楚你的 Binder 调用到底有多快,再决定要不要优化、怎么优化。

公众号:蓝海资料掘金营,微信 deep3321