上位机开发日记 · 第 6 篇 · 并发模型:三条铁律与线程骨架
阅读时长:约 5 分钟 ·难度:进阶 ·前置知识:第 5 篇(解帧器)
数据已经能正确切出来了,现在解决"放在哪个线程里读"的问题。
1. 界面为什么会卡死
先看一段几乎所有新手都写过、也几乎必然踩坑的代码:
defon_start_clicked(self):whileTrue:# 死循环chunk=self.ser.read(4096)# 阻塞等待self.update_curve(chunk)# 更新界面运行起来,界面会瞬间变灰、按钮全部失灵。原因是 GUI 框架(Qt、Tkinter 都一样)都跑在一个事件循环里:
事件循环:取事件 → 分发处理 → 取事件 → 分发处理 → …画笔刷新、按钮点击、窗口重绘,全靠这个循环转起来。而你写的while True一旦进了某个处理函数,就再也不还给事件循环——它转不动了,界面自然死掉。
【判断】“界面卡死"从来不是界面的问题,是"主线程里干了不该干的事”。主线程唯一的职责是响应事件和重绘,它应该永远"很快回来"。
2. 唯一正确的心智模型
四类工作分到四个执行单元,各自独立、互不阻塞——数据走队列,界面走信号:
3. 三条铁律
铁律一:UI 线程只做渲染
任何sleep、read、while True、大数组运算,都不许出现在主线程。判断标准很简单:这个操作会不会超过 50 毫秒?会,就挪走。
铁律二:线程之间只用队列传数据
不要共享可变状态,不要用全局变量,不要加锁——用有界队列替代锁。
importqueue data_q=queue.Queue(maxsize=100)# 有界队列,自带背压【判断】队列的每个操作都是原子的,逻辑清晰;而"共享变量 + 锁"的代码,出问题极难复现。
铁律三:子线程不直接操作控件
必须通过信号(或队列)回到主线程。在子线程里直接改控件,可能显示正常,也可能随机崩溃——这类 bug 无法稳定复现,只能靠纪律避免。
4. 采集线程骨架
importthreadingfromPySide6.QtCoreimportQThread,SignalclassSampler(QThread):batch_ready=Signal(object)# 只传"新对象/不可变数据"link_lost=Signal(str)def__init__(self,dev,parser,interval=0.05):super().__init__()self._dev,self._parser=dev,parser self._interval=interval self._stop=threading.Event()defrun(self):whilenotself._stop.is_set():try:chunk=self._dev.read(4096,timeout=self._interval)exceptDeviceErrorase:self.link_lost.emit(str(e))ifnotself._reconnect():continuecontinueifnotchunk:continue# 超时空转,顺便检查退出标志frames=self._parser.feed(chunk)ifframes:self.batch_ready.emit(frames)defstop(self):self._stop.set()self.wait(2000)# 主窗口 closeEvent 里调用四个值得注意的地方:
| 位置 | 为什么这样写 |
|---|---|
while not self._stop.is_set() | 有退出路径,线程能停 |
timeout=0.05 | 阻塞等待不吃 CPU,同时每 50 ms 有机会检查退出标志 |
if not chunk: continue | 超时返回空是正常情况,不是错误 |
stop()+wait() | 从写这个类的第一行起就要配套写stop() |
5. 关于 Qt 信号的一个关键事实
【事实】Qt 的信号槽在跨线程时,自动使用队列连接(QueuedConnection):信号发出时,Qt 把一个事件投递到接收者所在线程的事件队列,槽函数在接收者线程里执行。
这意味着两件事:
batch_ready的槽函数会在主线程里被调用 → 在槽里操作控件是安全的;- 参数的传递是异步的 → 投递后不一定立即执行。
由此引出一条实践规则:
【判断】信号参数只传"新对象"或不可变数据——list、tuple、bytes、numpy 数组。不要传正在被其他线程继续修改的对象(比如复用的缓冲区)。队列投递是异步的,传可变对象等于在线程间共享内存,后果不可预测。
6. 推送还是拉取
数据从子线程到界面,有两种模式:
| 模式 | 做法 | 问题 |
|---|---|---|
| 推送 | 每切出一帧就emit一次 | 高频时事件队列堆积,界面反而更卡 |
| 拉取 | 子线程只写缓冲区;主线程用QTimer定时取最新快照 | 刷新频率恒定,不受数据突发影响 |
【判断】推荐拉取模式。具体做法是:子线程把数据放进环形缓冲(或队列),主线程用QTimer每 30~50 ms 主动取一次、画一次。
好处是:数据来得再快,界面刷新频率都是恒定的。界面性能从"取决于设备速率"变成"由我控制",这是可控性的巨大提升。第 8 篇会展开讲具体实现。
7. GIL 与何时该用多进程
【事实】CPython 有全局解释器锁(GIL):同一时刻只有一个线程执行 Python 字节码。所以"多线程做纯计算"不能并行加速——但 IO 阻塞时会释放 GIL,因此采集线程 + 处理线程这种 IO 型分工是有效的。
选择依据:
| 场景 | 方案 | 理由 |
|---|---|---|
| 通讯 IO 等待为主(低速) | 多线程 | 瓶颈在等待,不在计算 |
| 单帧计算耗时 > 采集间隔 | 独立进程(multiprocessing) | 绕开 GIL,真正并行 |
| 极端高吞吐解析 | 编译扩展(Cython / Nuitka)或下沉到下位机 | Python 解释开销成为瓶颈 |
【判断】判断自己属于哪种,不需要猜——量一下"处理一帧耗时"和"帧间隔"这两个数。处理耗时接近或超过帧间隔,就必须考虑多进程了。
8. 本篇小结
| 记住这一条 | 说明 |
|---|---|
| 卡死的根因在主线程 | 事件循环被阻塞,不是界面框架的问题 |
| 一个线程一件事 | UI / 采集 / 处理 / 写盘,各管各的 |
| 队列替代锁 | 不共享可变状态,不加锁 |
| 信号参数只传新对象 | 跨线程投递是异步的 |
| 推荐拉取模式 | 主线程定时取快照,刷新频率可控 |
| 先写 stop() | 有停止路径的线程才是可用的线程 |
| GIL 不挡 IO | IO 分工有效,计算密集才要上多进程 |
9. 动手练习
定位你项目里的阻塞点:搜索所有
while True、sleep、read(、join(,检查它们是否出现在主线程(通常是MainWindow的方法、按钮回调、QTimer槽)里。每找到一个,标为待重构。把线程类写完整:拿上面
Sampler的骨架,给你自己的设备写一个采集类。要求写完之后,不接界面也能用命令行跑起来(回顾第 2 篇的分层检验标准)。量化一次:在你现有代码里加两行计时,打印"处理一帧耗时"和"帧到达间隔"。这两个数字直接告诉你需不需要多进程。
上一篇:第 5 篇 · 通讯(下):手写一个解帧器
下一篇:第 7 篇 · 数据与存储:格式、批量落盘与时间戳
——数据活着进来了,接下来要确保它活到明天。