16. 工作队列:binder_thread 的工作队列与等待队列机制
聊到 Binder 通信,很多人只盯着数据怎么拷贝、怎么跨进程。但我觉得,真正决定通信效率的,其实是线程怎么“等”和怎么“醒”。
说白了,Binder 驱动里每个线程都有一个工作队列和一个等待队列。这两个队列,一个管“活”,一个管“睡”。我当年第一次看这部分代码时,差点被绕晕——后来自己动手改了个 Binder 线程池的 bug,才彻底搞明白。
16.1 两个队列,各司其职
每个 binder_thread 结构体里,藏着两个关键成员:
struct binder_thread {
struct binder_proc *proc;
struct list_head todo; // 工作队列
wait_queue_head_t wait; // 等待队列
// ... 其他成员
};
- todo 工作队列:存放待处理的任务。比如对端发来的 BC_TRANSACTION、BC_REPLY,或者驱动内部产生的通知。
- wait 等待队列:线程没事干时,就在这里“挂起”。等 todo 队列有活,或者超时了,再被唤醒。
你可以这么理解:等待队列是“停车场”,工作队列是“派单台”。线程没活干就停进停车场,有活就从派单台取任务。
16.2 线程的“睡眠-唤醒”循环
Binder 线程的核心逻辑,其实就是一个死循环:
// 伪代码,展示核心流程
while (1) {
// 1. 检查工作队列
if (!list_empty(&thread->todo)) {
// 有活,取一个出来处理
struct binder_work *w = list_first_entry(&thread->todo, ...);
// 处理...
continue;
}
// 2. 没活?进入等待队列
// 设置线程状态为 TASK_INTERRUPTIBLE
// 然后 schedule() 让出 CPU
wait_event_interruptible(thread->wait,
!list_empty(&thread->todo));
// 3. 被唤醒后,重新检查 todo 队列
}
嗯,这里有个细节:线程不是直接睡死的。它用的是 TASK_INTERRUPTIBLE,意味着可以被信号打断。我遇到过一个问题:某个 Binder 线程因为信号被提前唤醒,但 todo 队列还是空的,结果空转了一圈又睡回去——虽然不影响正确性,但浪费了 CPU。
关键点:等待队列的唤醒条件只有一个——todo 队列非空。驱动里通过 wake_up_interruptible() 来唤醒等待的线程。
16.3 谁往工作队列里放任务?
工作队列的“生产者”主要有三个:
| 生产者 | 场景 | 放入的任务类型 |
|---|---|---|
| 对端线程 | B 进程的线程调用 binder_transaction() | BC_TRANSACTION、BC_REPLY |
| 驱动自身 | 节点死亡通知、BR_CLEAR_DEATH_NOTIFICATION | BINDER_WORK_DEATH_NOTIFICATION |
| Binder 节点 | 节点引用计数变化、内部状态同步 | BINDER_WORK_NODE |
我记得有一次排查线上问题,发现某个服务端线程一直不处理请求。最后定位到,是驱动往 todo 队列里放了一个 BINDER_WORK_DEATH_NOTIFICATION,但客户端没及时处理,导致后续的普通事务被堵住了。说白了,工作队列是 FIFO 的,前面的任务不处理完,后面的就得等着。
16.4 等待队列的唤醒时机
什么时候会唤醒等待队列里的线程?
- 有任务入队时:调用
binder_wakeup_thread(),它会找到目标线程,然后wake_up_interruptible()。 - 线程主动退出时:调用
binder_thread_release(),会唤醒所有等待的线程,让它们有机会退出循环。 - Binder 节点被销毁时:比如服务端进程挂了,驱动会唤醒所有等待该节点响应的线程。
你想想看,如果唤醒时机不对,会发生什么?比如线程还在处理上一个事务,就被强行唤醒去处理新任务——这会导致并发问题。所以驱动里有个 binder_inner_proc_lock 来保护 todo 队列的入队和出队操作。
注意:等待队列的唤醒和任务入队是原子操作吗?不是。驱动里是先加锁入队,再解锁,然后唤醒。这中间有个时间窗口——线程可能被唤醒后,发现 todo 队列还是空的(因为另一个线程抢走了任务)。所以代码里必须用 wait_event_interruptible() 这种带条件检查的宏,而不是裸的 schedule()。
16.5 工作队列与等待队列的交互流程
我画了一张图,帮你理清整个流程:
流程其实很简单:
- 线程启动后,先检查 todo 队列。
- 有任务?处理它。处理完继续检查。
- 没任务?把自己挂到 wait 队列上,让出 CPU。
- 生产者(对端或驱动)往 todo 队列放任务,同时唤醒 wait 队列上的线程。
- 线程醒来,再次检查 todo 队列,取出任务处理。
小技巧:如果你在调试 Binder 线程卡死的问题,可以看看 /proc/binder/proc/xxx 里的线程状态。如果某个线程长期处于 waiting 状态,但 todo 队列不为空,那八成是驱动里有 bug 或者锁没释放。
16.6 我曾经踩过的坑
有一次,我在一个嵌入式设备上做 Binder 优化。设备只有 2 个核,Binder 线程池配了 4 个线程。结果发现,某个线程一直在等待队列里睡大觉,其他线程忙得要死。
查了半天,原来是驱动里 binder_wakeup_thread() 只唤醒了第一个等待的线程。如果那个线程正在处理一个耗时任务,其他线程就永远没机会被唤醒。后来我改成了 wake_up_interruptible_all(),一次性唤醒所有等待线程,让它们自己去抢任务。
嗯,这里要提醒你:唤醒所有线程虽然解决了饥饿问题,但也会带来“惊群效应”——多个线程同时醒来,只有一个能抢到任务,其他又睡回去。这在 CPU 资源紧张时是个不小的开销。
16.7 总结
工作队列和等待队列,是 Binder 线程调度的核心。一个管任务,一个管睡眠。两者配合,实现了高效的异步通信。
我个人习惯在分析 Binder 性能问题时,先看这两个队列的状态。如果等待队列里线程太多,说明任务处理不过来;如果工作队列里任务堆积,说明线程不够用。对症下药,才能调优。
说白了,Binder 的线程模型并不复杂——就是“有活干活,没活睡觉”。但驱动里那些锁、唤醒条件、队列操作,稍不注意就会出问题。希望今天的分享,能帮你少走一些弯路。
公众号:蓝海资料掘金营,微信deep3321