在 消息循环 一节中,我们知道消息的投递和处理是在同一线程中,如果消息处理行为是阻塞或耗时的,这样界面就会停顿下来。我们也给大家介绍了两种解决方案:一种方法是单线程的,如果处理的是阻塞的(IO 密集型),我们可以使用非阻塞 API 替换掉阻塞的 API,如果是耗时的(计算密集型),我们可以用多个消息投递分解耗时的行为; 另一种方法是多线程的,无论消息处理行为是阻塞的还是耗时的,我们都开启一个子线程来处理这种阻塞或耗时的行为。
这种开启一个子线程来处理阻塞或耗时行为的做法,我们叫做工作者线程。对于我们的界面程序来说,消息循环(app._exec())一直运行在主线程中,界面库要求所有的 UI 操作都需要在消息循环所在的线程中完成,所以,对界面程序来说,UI 线程也叫主线程。至于为什么界面库的消息循环必须在主线程中完成,这个是由界面库的特性决定的,同学们如果想深入的了解,可以自行查找相关资料,在此,我就不再鳌述。
我们通常需要 UI 线程和工作者线程进行通信,这样,UI 线程才能使用工作者线程处理的结果,本节课我们就来学习工作者线程和 UI 线程通信问题。
为了让大家彻底理解工作者线程和 UI 线程的知识,我们从长计议,首先,我们举例一个耗时的操作,假如我们不开启工作者线程,我们来看看界面程序会是什么样子。
import sys import time from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel class OneExample(QWidget): def __init__(self): super().__init__() self.initUI() def initUI(self): self.lbl = QLabel('等待结果中...', self) self.btn = QPushButton('开始', self) self.lbl.move(100, 100) self.btn.move(100, 150) self.btn.clicked.connect(self.startCount) self.setGeometry(600, 200, 800, 500) self.setWindowTitle('阻塞耗时例子1') self.show() def startCount(self): time.sleep(10) # 模拟处理行为需要 10 秒钟 self.lbl.setText("任务完成") app = QApplication(sys.argv) ex = OneExample() app.exec_()
上面我们用 time.sleep(10) 来模拟消息处理阻塞或耗时 10 秒钟,在这 10 秒钟期间,我们的界面处于卡死状态,在此时,我拖动了一下窗口,相当于向消息队列中投递一个 mousemove 消息,但是这个消息需要等 10 秒之后才能处理,所以界面一直未动,在我连续拖动的情况下界面竟然出现了未响应提醒。
我们采用多线程的方式,把这个阻塞或耗时的操作放到工作者线程中完成,如下例子。
import sys import time import threading from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel class TwoExample(QWidget): def __init__(self): super().__init__() self.initUI() def initUI(self): self.lbl = QLabel('等待结果中...', self) self.btn = QPushButton('开始', self) self.lbl.move(100, 100) self.btn.move(100, 150) self.btn.clicked.connect(self.startCompute) self.setGeometry(600, 200, 800, 500) self.setWindowTitle('阻塞耗时例子2') self.show() def startCompute(self): t = threading.Thread(target=self.func) t.start() def func(self): # 工作者线程启动的函数 time.sleep(10) self.lbl.setText("任务完成") app = QApplication(sys.argv) ex = TwoExample() app.exec_()
运行上面的程序后,我们发现在工作者线程做任务的 10 秒钟期间,我们可以继续给消息队列投递消息并处理,比如我们拖动窗口,窗口就平滑的移动了起来。
但是,上面的例子有一个严重的 bug,大家还记不记得,在本节一开始,我们刚刚说过,对 UI
的操作必须在主线程中,我们的代码 self.lbl.setText("任务完成"),对 label
控件的操作是在子线程中的,由于我们只是在 label
上显示了文字,程序看似运行的良好(不排除有潜在风险)。如果我们在工作者线程中给
label 上显示图片(PyQt4 会报异常,PyQt5
运作暂时良好,不排除有潜在风险),或者在工作者线程中创建资源(比如调用弹窗,绘制控件,创建控件等等)的操作,程序就会崩溃(PyQt4
和 PyQt5 都会崩溃)。下面的例子,我们就在工作线程中弹出一个对话框。
import sys import time import threading from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QMessageBox class ThreeExample(QWidget): def __init__(self): super().__init__() self.initUI() def initUI(self): self.lbl = QLabel('等待结果中...', self) self.btn = QPushButton('开始', self) self.lbl.move(100, 100) self.btn.move(100, 150) self.btn.clicked.connect(self.startCompute) self.setGeometry(600, 200, 800, 500) self.setWindowTitle('阻塞耗时例子3') self.show() def startCompute(self): t = threading.Thread(target=self.func) t.start() def func(self): # 工作者线程启动的函数 time.sleep(10) QMessageBox.information(None, "工作者线程", "老鸟python") # 非 UI 线程中创建资源 app = QApplication(sys.argv) ex = ThreeExample() app.exec_()

我们发现程序崩溃了,那工作者线程和 UI 线程正确的编程姿势应该是什么呢,大家只需要记住一点,工作者线程只需要把处理的结果发给主线程,我们对 UI 的操作都需要在主线程中进行。正确的写法如下。
import sys import time import threading from PyQt5.Qt import pyqtSignal from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QMessageBox class FourExample(QWidget): sinOut = pyqtSignal(str) def __init__(self): super().__init__() self.sinOut.connect(self.outResult) self.initUI() def initUI(self): self.lbl = QLabel('等待结果中...', self) self.btn = QPushButton('开始', self) self.lbl.move(100, 100) self.btn.move(100, 150) self.btn.clicked.connect(self.startCompute) self.setGeometry(600, 200, 800, 500) self.setWindowTitle('阻塞耗时例子4') self.show() def outResult(self, rst): # 在主线程(UI 线程)中操作 UI self.lbl.setText(rst) QMessageBox.information(None, "工作者线程", "老鸟python") def startCompute(self): t = threading.Thread(target=self.func) t.start() def func(self): # 工作者线程做运算 time.sleep(10) self.sinOut.emit("任务完成") # 把结果投递到消息队列 app = QApplication(sys.argv) ex = FourExample() app.exec_()
在工作者线程调用的函数 func 中,我们做了阻塞或耗时的运算(用
time.sleep(10) 来模拟),运算完成后,我们使用了
self.sinOut.emit("任务完成")往消息队列中投递一个消息,而我们的消息循环一直在主线程中运行,此时,消息循环函数从消息队列里取出该消息,然后调用了消息处理函数
outResult(self, rst),其中 rst 参数是工作者线程投递消息时附带的参数,该参数是工作者线程想给我们的任何东西。
我们在主线程运行的函数 outResult(self, rst) 中,做了
UI 操作(self.lbl.setText(rst) 和 QMessageBox.information(None,
"工作者线程", "老鸟python")),我们发现程序运行良好。
在上例的程序中,在同一个类 FourExample中,既有主线程调用的函数
outResult(self, rst),又有工作者线程调用的函数
func(self),这样使得我们感觉很乱,我们采用面向对象的方式来重新编写工作者线程和
UI 线程通信的案例,下面我们写一个线程类来继承 Qt 的线程类 QThread。
import sys import time from PyQt5.Qt import pyqtSignal, QThread from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel, QMessageBox class FiveExample(QWidget): sinOut = pyqtSignal(str) # 创建一个消息对象 def __init__(self): super().__init__() self.workthd = Workthread(self.sinOut) # 创建一个线程对象 self.sinOut.connect(self.outResult) # 给消息对象关联槽函数 self.initUI() def initUI(self): self.lbl = QLabel('等待结果中...', self) self.btn = QPushButton('开始', self) self.lbl.move(100, 100) self.btn.move(100, 150) self.btn.clicked.connect(self.startCompute) self.setGeometry(600, 200, 800, 500) self.setWindowTitle('阻塞耗时例子5') self.show() def outResult(self, rst): # 在主线程(UI 线程)中操作 UI self.lbl.setText(rst) QMessageBox.information(None, "工作者线程", "老鸟python") def startCompute(self): self.workthd.start() # 启动工作者线程 class Workthread(QThread): def __init__(self, sinOut): super(Workthread, self).__init__() self.sinOut = sinOut def run(self): # 工作者线程做任务 time.sleep(10) self.sinOut.emit("任务完成") app = QApplication(sys.argv) ex = FiveExample() app.exec_()
会写工作者线程和 UI 线程通信。
牢记要在主线程中对 UI 进行操作。
在 label 中每隔 3-5 秒显示一张图片,保持界面的流畅(用多线程实现)。
我打算写一写