第 13 章:并发与退出——让每个阻塞点都能醒来
“设置 stopped=true”并不能停止一个卡在
accept()、recv()、条件变量或 MediaCodec dequeue 的线程。scrcpy 的并发设计围绕一个问题展开:退出请求到来时,每个阻塞点由什么机制唤醒?
一、线程地图
一次完整运行可能包含:
| 线程 | 主要阻塞点 | 唤醒/停止机制 |
|---|---|---|
| SDL 主线程 | SDL_WaitEvent | push quit/error event |
| server thread | ADB 进程、connect/accept、stop cond | sc_intr_interrupt + cond signal |
| video demuxer | socket recv | socket shutdown/interruption |
| audio demuxer | socket recv | socket shutdown/interruption |
| controller sender | cond / socket send | stopped + cond signal / socket close |
| controller receiver | socket recv | socket close |
| recorder | cond | stopped + cond signal |
| video regulator | cond/timed wait | stopped + cond signal |
| V4L2 | cond | stopped + cond signal |
| USB/AOA | libusb event/queue | backend interrupt |
| Android video/audio/control | MediaCodec/AudioRecord/socket | reset/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。
可中断包装器的模式是:
注册 current socket/process
→ 如果此前已 interrupted,立刻失败
→ 执行阻塞调用
→ 清除 current另一线程调用 interrupt 时置位,并 shutdown socket 或 terminate process。实现见 intr.c 与 net_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 不足以唤醒系统内部阻塞。可靠系统需要正常路径,也需要有界等待后的强制收敛。
十一、新增并发组件的检查表
- 谁拥有对象内存?
- start 失败时哪些子资源已创建?
- producer 与 consumer 是否可能互相等待?
- stop 能否唤醒所有 cond/socket/codec 阻塞?
- callback 在哪个线程执行?
- 是否把 UI 操作转回主线程?
- join 前是否已阻止新任务进入?
- destroy 时是否仍可能被回调?
- 对端不配合时是否有 timeout/watchdog?
十二、本章小结
scrcpy 的并发可靠性来自可中断阻塞、分离 stop/join/destroy、集中事件汇聚、显式所有权和有界 watchdog。下一章解释另一类复杂性:设备端不是正常 APK,却要调用大量 Android 系统能力,它如何用 FakeContext、反射 wrapper 和品牌 workaround 跨越版本差异。