在 消息循环 一节中,我们知道消息的投递和处理是在同一线程中,如果消息处理行为是阻塞或耗时的,这样界面就会停顿下来。我们也给大家介绍了两种解决方案:一种方法是单线程的,如果处理的是阻塞的(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 秒显示一张图片,保持界面的流畅(用多线程实现)。
我打算写一写