Skip to content

第 13 章:并发与退出——让每个阻塞点都能醒来

“设置 stopped=true”并不能停止一个卡在 accept()recv()、条件变量或 MediaCodec dequeue 的线程。scrcpy 的并发设计围绕一个问题展开:退出请求到来时,每个阻塞点由什么机制唤醒?

一、线程地图

一次完整运行可能包含:

线程主要阻塞点唤醒/停止机制
SDL 主线程SDL_WaitEventpush quit/error event
server threadADB 进程、connect/accept、stop condsc_intr_interrupt + cond signal
video demuxersocket recvsocket shutdown/interruption
audio demuxersocket recvsocket shutdown/interruption
controller sendercond / socket sendstopped + cond signal / socket close
controller receiversocket recvsocket close
recordercondstopped + cond signal
video regulatorcond/timed waitstopped + cond signal
V4L2condstopped + cond signal
USB/AOAlibusb event/queuebackend interrupt
Android video/audio/controlMediaCodec/AudioRecord/socketreset/EOS、interrupt、shutdown

这张表比线程数量本身重要:任何新增线程都必须回答“谁 stop、如何醒、谁 join、资源何时 destroy”。

二、事件是跨线程错误总线

后台组件不直接退出进程,而调用 callback;callback 再 push 一个自定义 SDL event。主线程在统一 event_loop() 中把它映射成成功、断开或失败退出码。

sc_push_event_impl() 只封装 SDL_PushEvent,见 events.c。简单的价值在于所有退出最终都回到 SDL owner 线程,不会从 demuxer 回调中销毁窗口。

三、sc_intr:把阻塞对象注册为“当前可中断目标”

server 线程在不同时刻可能阻塞于 socket,也可能等待子进程,但同一时刻至多一个。sc_intr 保存:

  • atomic interrupted
  • 当前 socket 或 process;
  • 保护注册/中断竞态的 mutex。

可中断包装器的模式是:

text
注册 current socket/process
  → 如果此前已 interrupted,立刻失败
  → 执行阻塞调用
  → 清除 current

另一线程调用 interrupt 时置位,并 shutdown socket 或 terminate process。实现见 intr.cnet_intr.c

它解决的竞态

如果只存一个 stopped flag,可能发生:线程刚检查 false,退出线程置 true,随后工作线程才进入永久 accept()sc_intr_set_socket() 在同一 mutex 下同时检查 interrupted 与注册 socket,消除了这扇窗口。

四、停止、加入、销毁是三个动作

scrcpy 一贯区分:

  • stop:只请求线程停止,不等待;
  • join:等待线程真正结束;
  • destroy:释放 mutex、cond、buffer 和对象拥有数据。

为什么不合并?组合根需要先向多个相互等待的组件广播 stop,再统一 join;如果逐个 stop-and-join,前一个线程可能仍等待后一个组件动作而死锁。

controller 的三阶段 API 可见 controller.c,server 也提供对应 API。

五、条件变量循环为何总是重查谓词

所有 cond wait 都放在 while (!stopped && queue_empty) 内,因为:

  • 条件变量允许虚假唤醒;
  • signal 只表示“状态可能变化”,不是资源承诺;
  • stop 与数据到达可能同时发生;
  • 多种状态(resize slot + queue)共享一个 cond。

例如 controller sender 醒来后优先判断 stopped,其次 resize,再取普通 queue。这把停止优先级写得明确。

六、所有权转移是并发正确性的另一半

常见模式:

  • control message push 成功后,queue 获得动态字符串所有权;发送线程序列化后 destroy
  • decoder push 的 AVFrame 只在调用期间有效,跨线程 sink 必须 av_frame_ref
  • recorder 对 AVPacket 调 av_packet_ref,拥有独立引用;
  • receiver 把 clipboard char* 转移给 main-thread task,原消息不再释放它;
  • UHID output task 同时接管 data 与 task wrapper。

源码用注释标出“take ownership”,并在 debug 中用 assert 维护状态。没有垃圾回收的 C 并发系统必须把所有权当作协议字段一样对待。

七、主线程 task 的退出栅栏

后台线程可以通过 SDL_RunOnMainThread 排任务。退出时若 UI 已销毁,新 task 会 use-after-free。sc_main_thread_stop() 先在 mutex 下设置 stopped,阻止新提交,再 SDL_PumpEvents() 执行剩余 runnable,见 events.c

这是一个清晰的栅栏:stop 返回后,不会再有未处理的 main-thread task 引用旧组件。

八、为什么 screen 最晚销毁

video demuxer → decoder → screen 是同步 push 链。仅仅 screen_stop 不够,只要 demuxer 线程还活着,它就可能再进入 screen sink。清理代码先中断 socket并 join demuxer,确认 producer 消失,才 screen_join/destroy,源码甚至把这个因果写在注释中,见 scrcpy.c

这比“按字段倒序 free”更可靠,因为它依据调用可能性而非构造顺序。

九、Android 端的异步处理器协议

AsyncProcessor 统一 start(listener) / stop() / join()。Server 把 controller、audio、video 放进列表,Completion 记录剩余数量与 fatal flag:

  • 任一 fatal:请求主 Looper 退出;
  • 全部正常完成:退出;
  • finally:先 stop 全部,再 shutdown connection,再 join 全部。

对应主线见 Server.java。先 shutdown socket 可唤醒 Java 侧 IO;视频则还通过 CaptureControl.reset(TERMINATED) 给 MediaCodec signal EOS。

十、watchdog 是对设备异常的承认

客户端 stop 后先 interrupt 三条 socket,给 Android server 1 秒自行结束。若没有结束,就 terminate 设备端进程,见 server.c

这不是粗暴替代正常清理,而是最后保险:某些设备睡眠时关闭 socket 不足以唤醒系统内部阻塞。可靠系统需要正常路径,也需要有界等待后的强制收敛。

十一、新增并发组件的检查表

  1. 谁拥有对象内存?
  2. start 失败时哪些子资源已创建?
  3. producer 与 consumer 是否可能互相等待?
  4. stop 能否唤醒所有 cond/socket/codec 阻塞?
  5. callback 在哪个线程执行?
  6. 是否把 UI 操作转回主线程?
  7. join 前是否已阻止新任务进入?
  8. destroy 时是否仍可能被回调?
  9. 对端不配合时是否有 timeout/watchdog?

十二、本章小结

scrcpy 的并发可靠性来自可中断阻塞、分离 stop/join/destroy、集中事件汇聚、显式所有权和有界 watchdog。下一章解释另一类复杂性:设备端不是正常 APK,却要调用大量 Android 系统能力,它如何用 FakeContext、反射 wrapper 和品牌 workaround 跨越版本差异。

基于 scrcpy 4.1 源码的独立学习笔记