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 工作队列与等待队列的交互流程

我画了一张图,帮你理清整个流程:

Binder 线程工作队列与等待队列交互流程 Binder 线程 等待队列 (wait) 工作队列 (todo) 任务生产者(对端/驱动) 无任务,挂起 被唤醒,检查 取出任务处理 放入任务 唤醒线程 线程状态 等待队列 工作队列 任务生产者

流程其实很简单:

  1. 线程启动后,先检查 todo 队列。
  2. 有任务?处理它。处理完继续检查。
  3. 没任务?把自己挂到 wait 队列上,让出 CPU。
  4. 生产者(对端或驱动)往 todo 队列放任务,同时唤醒 wait 队列上的线程。
  5. 线程醒来,再次检查 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

专注资料整理