第 18 章

多线程

本章共 5 个小节 · PySide6 Basic Tutorial
本章要点:
  1. QThread类的常见用法;
  2. 线程池(QThreadPool);
  3. 互斥锁(QMutex);
  4. 条件等待(QWaitCondition)。
18.1

单线程与多线程的比较

应用程序进程在初始化时会创建一个默认线程(也叫主线程)用于处理GUI(图形用户界面)逻辑。

如果在主线程上执行需要长时间运行的代码,会导致窗口及其所包含的组件无法接收用户输入(鼠标单击、拖动,或键盘输入),最终导致窗口无响应,甚至整个程序会崩溃,如图18-1所示。

如果把耗费时间的代码放到另一个线程上运行,那么主线程仍可以继续与用户交互,窗口组件就不会无响应了,如图18-2所示。

图 18-1、图 18-2
图 18-1 程序窗口无响应 图18-2 窗口依然可以接收用户输入
图 18-1 程序窗口无响应
图18-2 窗口依然可以接收用户输入

因此,若应用程序要处理比较耗时的任务(如下载大文件、进行大量计算),最好把它放在新线程上运行,以保证图形界面具有良好的用户体验。

18.2

QThread 类

QThread是编写多线程代码的基础类,它提供了许多底层API,灵活性高。一个QThread实例只负责管理一个线程。QThread类实例化后,新线程不会马上执行。要启动新线程请调用start方法。线程启动后会发出started信号,线程执行完毕后会发出finished信号。

QThread类支持事件循环,run方法的默认实现会调用exec方法启动事件循环。事件循环启动后会一直等待和处理事件,直到调用quit或exit方法才会结束。事件循环结束标志着线程退出。

QThread类有以下两种用法。

  1. 定义新类,从 QThread 类派生并重写 run 方法。在 run 方法中加入需要的代码逻辑。当 start方法被调用后,run方法也会被调用,从而执行自定义代码。
  1. 定义从QObject类派生的新类,将自定义的逻辑代码封装到该类中,然后用moveToThread方法将对象移动到指定线程(QThread实例)上,通过信号/槽机制激活自定义代码。

18.2.1 示例:随机变换颜色

本示例将实现每隔3秒更换一次颜色的功能,颜色是随机生成的,其代码将在新的线程上运行。本示例采用直接从QThread类派生的方式实现多线程,具体步骤如下。

  1. 定义 CustThread类,基类是 QThread。
class CustThread(QThread): # 信号 colorGen = Signal(QColor) # 执行任务的代码 def run(self): # 进入循环 while self.isInterruptionRequested() == False: # 生成3个随机整数 r, g, b = randint(0, 255), randint(0, 255), randint(0, 255) # 创建QColor 实例 c = QColor.fromRgb(r, g, b) # 发出信号 self.colorGen.emit(c) # 暂停一会儿 QThread.sleep(3)

GUI线程与其他线程之间的代码不能直接访问,但可以通过信号和槽(Signals and Slots)相互调用。

当生成新的颜色后,CustThread对象就会发送colorGen信号。GUI线程可以连接该信号,并接收新的颜色值。

CustThread类重写了run方法,实现随机生成r、g、b 3个整数,并用这 3个数据值产生新的 QColor实例。

  1. 定义DemoWindow类,派生自QWidget类,它是示例程序的主窗口。
class DemoWindow(QWidget):
  1. 实现__init__方法,初始化窗口布局。
def __init__(self, parent: QWidget = None): super().__init__(parent) # 窗口标题 self.setWindowTitle("随机变换颜色") # 窗口大小 self.resize(300, 320) # 窗口布局 rootlayout = QGridLayout() self.setLayout(rootlayout) # 网格的第一行放一个自定义Qwidget self._wg = QWidget(self) self._wg.setAutoFillBackground(True) rootlayout.addWidget(self._wg, 0, 0, 1, 3) # 网格的第二行是两个按钮 self._btnStart = QPushButton("开始", self) self._btnStop = QPushButton("停止", self) rootlayout.addWidget(self._btnStart, 1, 0) rootlayout.addWidget(self._btnStop, 1, 2) # 调整行高比例 rootlayout.setRowStretch(0, 1) # 调整列宽比例 rootlayout.setColumnStretch(0, 2) rootlayout.setColumnStretch(1, 1) rootlayout.setColumnStretch(2, 2)

窗口使用了网格布局。第一行是一个QWidget对象(_wg字段),跨三列布局;第二行放了两个按钮,用于控制新线程的启动和停止。

  1. 创建 CustThread 实例。
self._th = CustThread(self)
  1. 两个按钮的 clicked信号需要连接到CustThread 对象的方法成员上,以达到启动和停止线程任务的目的。
# 线程启动由start方法触发 self._btnStart.clicked.connect(self._th.start) # requestInterruption 方法可以停止任务 self._btnStop.clicked.connect(self._th.requestInterruption)

requestInterruption方法可以请求中断线程,并可以通过isInterruptionRequested方法返回该状态(True表示已调用了requestInterruption 方法)。上文中 run 方法的实现代码中,while循环的条件是未调用requestInterruption 方法(isInterruptionRequested方法返回 False)。

terminate方法也可以停止线程(如果此功能被禁用,可调用 setTerminationEnabled方法并向参数传递True 来开启),但最好不要使用此方法来停止线程。因为 terminate 方法不会留给线程足够的时间去完成清理工作(如保存数据),很容易造成数据损坏或不可预知的错误。

  1. 连接线程对象的started和finished信号,实现在线程状态改变后修改按钮的可用状态。
self._th.started.connect(self.changeBtnEnabled) self._th.finished.connect(self.changeBtnEnabled)

以下是 changeBtnEnabled 方法的实现代码。

def changeBtnEnabled(self): running = self._th.isRunning() self._btnStart.setEnabled(not running) self._btnStop.setEnabled(running)

isRunning方法返回 True表示线程正在运行;如果返回False表示线程已经结束。当线程运行后,禁用“开始”按钮并启用“停止”按钮;当线程停止后,禁用“停止”按钮并启用“开始”按钮。

  1. 连接 CustThread 对象的 colorGen 信号,在获得新颜色后修改 wg 的背景颜色(通过调色板实现)。
self._th.colorGen.connect(self.setColor) @Slot(QColor) def setColor(self, color: QColor): # 获取调色板 p = self._wg.palette() # 修改颜色 p.setColor(QPalette.ColorRole.Window, color) # 重新设置调色板 self._wg.setPalette(p)

setColor 方法上添加了Slot 类作为装饰器,标注它是一个槽(Slot)对象,可以与 colorGen信号连接。

运行示例程序,单击“开始”按钮,QWidget组件的背景色会不断变化,如图18-3所示。在颜色变换过程中用户仍可以进行拖动窗口或调整窗口大小等操作,这表明GUI线程没有被阻断。

图 18-3
图 18-3 开始随机变换颜色
图 18-3 开始随机变换颜色

18.2.2 示例:使用moveToThread 方法

moveToThread是QObject类公开的方法成员,允许将Qt对象从当前线程转移到另一个线程上。该对象的子级对象也会一同转移到新线程上。调用moveToThread方法的对象不能有父级对象(不能设置parent),否则无法将对象转移到新线程上。

本示例将定义一个名为MyWorker的类(以QObject为基类)用于执行耗时操作。实例化后可以将它转移到新的 QThread 对象上执行。MyWorker类的实现代码如下:

class MyWorker(QObject): # 用于报告进度的信号 reportProgress = Signal(int) # 用于报告任务完成的信号 reportComplete = Signal() @Slot(int, int) def RunTask(self, min: int, max: int): # 表示当前进度的变量 currVal = min print("正在处理……") # 进入循环 while currVal < max: # 暂停片刻 QThread.msleep(60) # 增加进度 currVal += 1 # 报告进度 self.reportProgress.emit(currVal) print("处理完毕") # 报告任务已完成 self.reportComplete.emit()

MyWorker类定义了两个信号:reportProgress 用于报告处理进度,reportComplete 用于报告任务已完成。GUI线程上的代码可以连接这两个信号,以获取任务的实时状态。

RunTask 方法应用了Slot 装饰器,可以被 GUI线程上的信号连接。当GUI线程发出信号时,RunTask方法就会调用。GUI线程不能直接调用RunTask方法,那样做会使RunTask的代码在GUI线程上执行,导致GUI线程被阻塞,程序窗口不能及时响应用户输入。因此,GUI线程必须通过信号来调用RunTask方法。

定义 TestWindow类,派生自 QWidget,作为程序的窗口类。该窗口上分别创建了进度条组件(QProgressBar)和按钮组件(QPushButton)。代码如下:

self._pb = QProgressBar(self) #设置最大/最小进度值 self._pb.setMaximum(100) self._pb.setMinimum(0) ...... #按钮 self._btn = QPushButton("多线程处理", self) ......

TestWindow 类定义 runWorker 信号,用于连接到 MyWorker 对象的 RunTask 方法。

class TestWindow(QWidget): # 信号 runWorker = Signal(int, int) ...... def __init__(self): super().__init__() ...... # 实例化线程类 self._th = QThread() # 实例化线程任务对象 self._worker = MyWorker() # 移动到新线程上 self._worker.moveToThread(self._th) # 将runWorker 信号与MyWorker 实例的RunTask方法连接 self.runWorker.connect(self._worker.RunTask)

注意,不能向MyWorker类的构造函数传递父级对象引用(如当前窗口类的实例self),否则moveToThread方法将无效。

TestWindow 类需要两个方法成员与 MyWorker 对象的 reportProgress、reportComplete 信号建立连接,代码如下:

# 连接报告任务已完成的信号 self._worker.reportComplete.connect(self.onCompleteReport) # 连接报告进度的信号 self._worker.reportProgress.connect(self.onProgressReported) ...... @Slot(int) def onProgressReported(self, p: int): # 更新进度条的当前值 self._pb.setValue(p) @Slot() def onCompleteReport(self): self._btn.setEnabled(True) self._pb.setValue(0) QMessageBox.information(self,"提示","任务已完成")

TestWindow与MyWorker 对象之间的信号连接关系如图18-4所示。

为了实现在关闭窗口时能马上终止线程,TestWindow类可以重写closeEvent方法,具体代码如下:

def closeEvent(self, event: QCloseEvent): # 在窗口关闭前强制终止线程 self._th.terminate() # 等待线程退出 self._th.wait() # 调用基类成员 super().closeEvent(event)

此处调用terminate方法强制终止线程,wait方法用于等待线程退出。

运行示例程序,单击“多线程处理”按钮,进度条开始更新,直至完成。在此过程中,用户仍可以与窗口交互,如图18-5所示。

图 18-4、图 18-5
图18-4 两个对象间的信号连接关系 图18-5 正在后台执行任务
图18-4 两个对象间的信号连接关系
图18-5 正在后台执行任务
18.3

QThreadPool

在应用程序中频繁创建和销毁QThread实例,会消耗许多不必要的时间,使性能降低;而且创建过多的QThread实例也会增加内存的占用。QThreadPool类(线程池)可重复使用已创建的线程资源,减少创建QThread实例的次数,大幅降低内存占用,提升程序效率。这一过程是由QThreadPool自动管理的,应用程序只要将包含任务代码的 QRunnable 对象传递给 QThreadPool 对象的 start 方法即可。

18.3.1 QRunnable

QRunnable用于定义需要在线程池上运行的代码。使用时需要从 QRunnable类派生,并且重写 run方法,实现自定义逻辑。

每个应用程序都有一个全局的 QThreadPool 对象,可通过 globalInstance静态方法获取其引用。调用QThreadPool.start方法并向参数传递 QRunnable 实例即可启动任务。默认行为下,QRunnable 对象执行完毕后,QThreadPool会自动将其删除。可以调用QRunnable.setAutoDelete方法来禁用自动删除功能。

18.3.2 示例:向 QThreadPool投放 3 个任务

本示例将演示向线程池(QThreadPool)放入3个做加法运算的任务。具体实现过程如下。

  1. 定义MyTask类。该类属于多继承,基类是QRunnable和QObject类,代码如下:
class MyTask(QRunnable, QObject): # 信号:报告计算结果 setResult = Signal(int) def __init__(self, parent: QObject = None): # 调用 QObject 类的构造函数 QObject.__init__(self, parent) # 调用 QRunnable 类的构造函数 QRunnable.__init__(self) # 最终数值 self._final = 0 # 禁止自动删除实例 self.setAutoDelete(False) def run(self): print(f"正在计算,当前线程ID:{current_thread().ident}") # 表示结果的变量 result = 0 # 开始累加 current = 1 while current <= self._final: result = result + current current += 1 # 暂停一下 QThread.msleep(2) # 计算完毕,设置结果 self.setResult.emit(result) # 设置参与累加运算的最终值 @Slot(int) def setFinalNumber(self, n: int): self._final = n

MyTask类是任务类,用于实现在后台线程进行加法运算。从 QObject 类派生是因为 MyTask 需要用到信号和槽与GUI线程通信。

setResult信号可以将计算结果发送给GUI线程,并显示在用户界面上。setFinalNumber方法用于设置一个整数,该整数是参与加法运算的最大值。例如,数值设置为6,程序就会计算1+2+3+4+5+6的结果。

从QRunnable类派生必须重写 run方法,进行从1开始的累加运算,一直加到_final所指定的值。

计算结束后,发出 setResult信号,通知GUI线程显示结果。

在 MyTask 的 __init__ 方法中,一定要明确调用 QRunnable 和 QObject 类的 __init__ 方法(调用基类的构造函数),否则将无法访问基类的成员。setAutoDelete(False) 表示禁止 QThreadPool 类接管 MyTask 的生命周期。如果 setAutoDelete 方法设定为 True(默认值),那么,当 run 方法执行完毕后就会删除 MyTask 实例。

  1. 从 QWidget 派生出一个名为 CustWidget 的自定义组件。该组件内包含可以输入数值的 QSpinBox组件,可显示计算结果的QLabel组件,以及可以启动后台线程进行加法运算的按钮组件。
class CustWidget(QWidget): # 信号:用于设置累加运算的最终值 setFinnalValue = Signal(int) def __init__(self, parent: QWidget = None): super().__init__(parent) # 布局 layout = QGridLayout() self.setLayout(layout) # 标签 layout.addWidget(QLabel("最大值: ", self), 0, 0) # 数值输入组件 self._spin = QSpinBox(self) self._spin.setRange(1, 10000) self._spin.setValue(5) layout.addWidget(self._spin, 0, 1) # 按钮 self._btnAct = QPushButton("计算", self) layout.addWidget(self._btnAct, 0, 2) # 显示结果的标签 self._lbRes = QLabel(self) layout.addWidget(self._lbRes, 1, 0, 1, 3) layout.setColumnStretch(1, 1) # 任务实例 self._task = MyTask() # setFinnalValue 信号连接到 _task 的 setFinalNumber 方法 self.setFinnalValue.connect(self._task.setFinalNumber) # 连接 setResult,获取计算结果 self._task.setResult.connect(self.onGetResult) # 连接按钮的 clicked 信号 self._btnAct.clicked.connect(self.onClick) @Slot(int) def onGetResult(self, res: int): self._lbRes.setText(f'计算结果:{res}') # 恢复按钮 self._btnAct.setEnabled(True) def onClick(self): # 清空上次显示的计算结果 self._lbRes.clear() # 设置最终值 self.setFinnalValue.emit(self._spin.value()) # 将 _task 放入线程池中运行 QThreadPool.globalInstance().start(self._task) # 禁用按钮 self._btnAct.setEnabled(False)

setFinnalValue信号连接MyTask对象的setFinalNumber方法,可以传递参与运算的最大数值;onGetResult方法使用了Slot装饰器,该方法将与MyTask对象的setResult信号连接,接收来自其他线程传来的计算结果。

  1. 定义DemoWindow类,它是应用程序的主窗口。
class DemoWindow(QWidget): def __init__(self): super().__init__() # 布局 rootLayout = QVBoxLayout() self.setLayout(rootLayout) # 创建3个自定义组件 wg1 = CustWidget(self) wg2 = CustWidget(self) wg3 = CustWidget(self) rootLayout.addWidget(wg1) rootLayout.addWidget(wg2) rootLayout.addWidget(wg3) def closeEvent(self, ev: QCloseEvent): # 等待所有任务完成 thPool= QThreadPool.globalInstance() if thPool.activeThreadCount() > 0: ev.ignore() return super().closeEvent(ev)

窗口类重写了基类的closeEvent方法,如果线程池上还有正在活动的线程,就阻止关闭窗口(ev.ignore()将取消close 事件)。activeThreadCount方法可以获得线程池中仍在运行的线程数。

  1. 运行示例程序,输入3个适当的整数值(建议数值不要太大),如图18-6所示。
图 18-6
图18-6 输入参与运算的最大值
图18-6 输入参与运算的最大值
  1. 先单击第一个“计算”按钮,然后等待任务完成,再单击第二个“计算”按钮。同理,第三个按钮也要等第二个任务完成再单击。此时控制台输出的内容如下:
    正在计算,当前线程ID:6932 正在计算,当前线程ID:6932 正在计算,当前线程ID:6932

    可以看到,3个任务都是在同一个线程上运行的。由于这3个任务并不是同时进行的,因此QThreadPool对象只创建了一个新线程来处理。

  1. 重新运行示例程序,输入稍大一点的数值(主要为了延长计算时间,但数值不要过大),然后单击第一个“计算”按钮启动任务。不等第一个任务完成马上单击第二个“计算”按钮,启动第二个任务。同样地,不需要等待第二个任务完成,直接启动第三个任务。控制台打印的内容如下:
    正在计算,当前线程ID:2928 正在计算,当前线程ID:2924 正在计算,当前线程ID:6632

    这一次3个任务所在线程不同,表明QThreadPool对象创建了3个新线程来运行任务。由于3个任务运行时间较长,各自占用一个线程,QThreadPool无法重新利用现有线程,所以必须为每个任务创建新线程来运行代码。

18.4

互斥锁

当多个线程同时访问某个对象时,极容易造成不一致的状态。举一个经典案例:多线程递减整数值。

某整数变量的初始值为100,doWork函数负责递减整数的值,直到整数等于0。基本代码如下:

# 全局数值 number = 100 # 递减数值 def doWork(): global number # 声明number为全局变量 while number > 0: # 稍稍暂停一下 QThread.msleep(4) # number变量递减 number = number - 1 print(f"当前数值:{number}")

注意,在上述代码中,循环条件number>0与number=number-1之间暂停了4毫秒(由QThread.msleep 方法实现)。在暂停的时间内会发生不可控的情况——其他线程执行了number=number-1。假设A、B、C 3个线程同时运行doWork函数,如果线程A在验证 while循环的条件时number变量的值为 1,那么 1>0成立,进入循环,可是当即将对number变量做递减运算时,C线程抢先一步把number减掉1,使其变成了0;B线程又抢先一步把number的值减去1,变成了-1。而A线程并不知道number已经小于0,于是又减去1,直接导致number变成了-2。显然这一结果是不符合预期的。不妨用图18-7来模拟这个过程。

图 18-7
图18-7 线程数据不同步
图18-7 线程数据不同步

要解决这个问题,就必须限制在同一时刻只允许一个线程访问(或修改)数据。当A线程获取到数据时将其锁定,B、C线程只能等待;A线程处理完毕后解锁数据,此时B线程获得数据访问权并将数据锁定,C线程只能等待解锁。

Qt应用程序可以使用QMutex类为线程上锁。QMutex类实例化后,在需要访问数据的线程上调用lock方法即可锁定资源,访问过后调用unlock方法解锁资源,使正在等待的线程可以访问数据。

前文提到的doWork函数可以修改为:

# 全局数值 number = 100 # 实例化互斥对象 mutex = QMutex() # 递减数值 def doWork(): global number # 声明number为全局变量 # 锁定数据 mutex.lock() # 处理数据…… # 解除锁定 mutex.unlock()

18.4.1 示例:将 QMutex用于多线程运算

本示例实现的功能为:用户输入一个整数(例如5),然后对该整数进行以下三次数学运算(假设N是输入的整数)。

1、N=N*2+15 2、N=(N- 5)*20 3、N = 100* (N /2- 10)

这3个计算过程分别写到3个函数中。

def _fun1(self): num = self._number QThread.msleep(3) num = num * 2 + 15 self._number = num def _fun2(self): num = self._number QThread.msleep(5) num = (num -5) * 20 self._number = num def _fun3(self): num = self._number QThread.msleep(7) num = 100 * (num / 2 - 10) self._number = num

上述3个函数中,在读取_number字段的值后暂停了若干毫秒。在这暂停过程中,number字段的值可能会被其他线程意外修改,导致错误的计算结果。

将3个函数依次放入线程池(QThreadPool)中运行。

self._threadPool.start(self._fun1) self._threadPool.start(self._fun2) self._threadPool.start(self._fun3) # 等待计算完成 self._threadPool.waitForDone() # 显示结果 self._lbRes.setText(f"{self._number}")

waitForDone方法会等待线程池中所有任务完成后才会返回,此处是等待3个函数执行结束。

假设输入整数10,预期的正确结果是29000,但程序给出的结果是-500,如图18-8所示。

图 18-8
图18-8 显示错误的计算结果
图18-8 显示错误的计算结果

接下来为程序代码加上互斥锁。

# 获取全局的QThreadPool对象引用 self._threadPool = QThreadPool.globalInstance() # 实例化互斥锁 self._mutex = QMutex()

修改3个函数的代码,在执行计算前锁定数据,计算完成后解锁,代码如下:

def _fun1(self): self._mutex.lock() ...... self._mutex.unlock() def _fun2(self): self._mutex.lock() ...... self._mutex.unlock() def _fun3(self): self._mutex.lock() ...... self._mutex.unlock()

上锁之后,在同一时刻只有一个线程可以读写number字段的值。再次运行示例程序就能得到正确的结果了,如图18-9所示。

图 18-9
图 18-9 正确的计算结果
图 18-9 正确的计算结果

18.4.2 示例:使用 QMutexLocker 类

QMutex 对象每次访问资源时都要先调用lock方法锁定数据,处理完毕后调用 unLock方法解锁数据。如果在代码量非常大的程序中,可能会频繁调用lock和unLock方法,一不小心就会出现在调用lock方法后忘记调用unLock方法的情况。

QMutexLocker类将简化上锁与解锁操作。当QMutexLocker 实例化时自动上锁;当 QMutexLocker实例被销毁时自动解锁。因此,QMutexLocker类型的变量一般声明在函数内部,在程序进入函数时给数据上锁;执行完函数体后,在退出函数时QMutexLocker变量的生命周期结束,从而解锁数据。

本示例以出售火车票为例,演示QMutexLocker类的使用。示例的完整代码如下:

# 火车票总数 total = 1000 # 售票函数 def sale(): # total 是全局变量,要先声明一下 # 否则会被视为函数本地变量,导致全局变量被覆盖 global total while total > 0: QThread.msleep(3) # 售出一张票 total = total - 1 QThread.msleep(3) # 打印剩余票数 print(f"剩余 {total} 张火车票") if __name__=="__main__": app = QCoreApplication() # 5个线程同时售票 for n in range(5): QThreadPool.globalInstance().start(sale) # 等待售票结束 QThreadPool.globalInstance().waitForDone()

上述代码中,sale函数在修改全局变量 total时是没有加互斥锁的,因此在运行后会出现剩余火车票数量为负数的情况。控制台输出如下:

剩余 2 张火车票 剩余 -1 张火车票 剩余 -1 张火车票 剩余 -3 张火车票 剩余 -3 张火车票 剩余 -3 张火车票

随后为sale函数加上互斥锁,代码的修改如下:

# 火车票总数 total = 1000 # 实例化互斥锁对象 mutex = QMutex() # 售票函数 def sale(): # total 是全局变量,要先声明一下 # 否则会被视为函数本地变量,导致全局变量被覆盖 global total # 上锁 with QMutexLocker(mutex): while total > 0: QThread.msleep(3) # 售出一张票 total = total - 1 QThread.msleep(3) # 打印剩余票数 print(f"剩余 {total} 张火车票") # 即将退出函数,自动解锁

QMutexLocker 类实现了__enter__和__exit__方法,因此支持在 with 语句中使用。

再次运行程序,就能得到正确的输出了。

.... 剩余 7 张火车票 剩余 6 张火车票 剩余 5 张火车票 剩余 4 张火车票 剩余 3 张火车票 剩余 2 张火车票 剩余 1 张火车票 剩余 0 张火车票
18.5

QWaitCondition

QWaitCondition类是线程间一种通信信号,允许一个线程向其他线程发出信号。例如,A线程可以调用wait方法等待信号;B线程调用wakeOne或wakeAll方法发出信号,唤醒A线程;A线程收到信号后会从wait方法返回并继续执行,如图18-10所示。

图 18-10
图18-10 等待条件信号示意图
图18-10 等待条件信号示意图

wait方法在调用时需要接收一个QMutex对象,并且在调用前QMutex对象必须上锁。wait方法内部会先让QMutex对象解锁,然后等待信号。wait方法在返回之前会让QMutex对象重新上锁。也就是说,QMutex 对象在 wait方法调用前后都会处于锁定状态。

唤醒等待线程的方法有两个:wakeOne方法随机唤醒一个线程;wakeAll方法将唤醒所有正在等待的线程。

18.5.1 示例:多阶段任务

本示例将启动3个线程任务。其中,第二个任务必须等第一个任务完成后才能继续;第三个任务必须等待第二个任务完成后才能继续。因此,需要创建两个QWaitCondition实例。

# 全局互斥锁 mutex = QMutex() # 代表第一阶段 step1 = QWaitCondition() # 代表第二阶段 step2 = QWaitCondition()

由于第三阶段后没有等待的线程,所以只需要两个QWaitCondition对象即可。3个线程分别运行以下3个函数。

def work1(): print("第一阶段开始") QThread.sleep(2) print("第一阶段完成") # 唤醒正在等待的线程 mutex.lock() step1.wakeOne() mutex.unlock() def work2(): # 等待第一阶段完成 mutex.lock() step1.wait(mutex) mutex.unlock() print("第二阶段开始") QThread.sleep(1) print("第二阶段完成") # 唤醒正在等待的线程 mutex.lock() step2.wakeOne() mutex.unlock() def work3(): # 等待第二阶段完成 mutex.lock() step2.wait(mutex) mutex.unlock() print("第三阶段开始") QThread.sleep(2) print("第三阶段完成")

第一阶段(work1)执行完成后,调用 step1 的 wakeOne 方法通知 work2。

第二阶段(work2)在执行任务前调用了step1.wait方法,此时 mutex对象会临时解除锁定,并等待 step1.wakeOne的调用。当等到 step1 的信号后继续执行,完成后调用 step2.wakeOne 方法,通知其他线程第二阶段完成。

第三阶段(work3)先要等待 step2.wait收到信号才返回,然后完成后面的代码。

在主线程中,通过QThreadPool类启动上述3个线程。

# 启动3个线程 pool = QThreadPool.globalInstance() pool.start(work1) pool.start(work2) pool.start(work3) # 等待所有线程结束 pool.waitForDone() print("所有任务均已完成")

pool.waitForDone方法将处于等待状态,直到3个线程都结束。上述代码用到了start方法的另一个重载,不需要实现QRunnable类,直接传递要运行的函数引用即可。该重载的声明如下:

def start(arg__1: Callable, priority: int = ...)

运行示例程序,控制台将输出以下内容。

第一阶段开始 第一阶段完成 第二阶段开始 第二阶段完成 第三阶段开始 第三阶段完成 所有任务均已完成

18.5.2 示例:等待键盘输入

本示例将演示多个线程等待同一个信号的实现方法。3个后台线程在启动后均处于等待状态。主线程会提示用户输入两个整数值。当应用程序获取到输入数值后,调用QWaitCondition对象的wakeAll方法唤醒正在等待的3个线程。随后这3个线程分别对输入的两个整数进行加、减、乘法运算,并向控制台打印计算结果。

示例程序需要定义以下全局变量。

# 全局互斥锁 mutex = QMutex() # 等待条件 numbersGot = QWaitCondition() # 用于计算的数值 number1 = 0 number2 = 0

然后定义3个函数,用于进行算术运算。这些函数将运行在线程池中。

def action1(): # 等待输入 mutex.lock() # 上锁 numbersGot.wait(mutex) # 计算 r = number1 + number2 # 打印结果 print("{0}+{1}={2}".format(number1, number2, r)) mutex.unlock() # 解锁 def action2(): # 等待输入 mutex.lock() # 上锁 numbersGot.wait(mutex) # 计算 r = number1 - number2 # 打印结果 print("{0}-{1}={2}".format(number1, number2, r)) mutex.unlock() # 解锁 def action3(): # 等待输入 mutex.lock() # 上锁 numbersGot.wait(mutex) # 计算 r = number1 * number2 # 打印结果 print("{0}*{1}={2}".format(number1, number2, r)) mutex.unlock() # 解锁

将上述3个函数放入线程池中运行。随后读取键盘输入。

# 获取全局线程池 thPool = QThreadPool.globalInstance() # 启动3个线程 thPool.start(action1) thPool.start(action2) thPool.start(action3) # 等待输入 s1 = input("请输入第一个整数:") s2 = input("请输入第二个整数:") # 此变量标志在获取用户输入时是否发生了错误 hasErr = False try: number1 = int(s1) number2 = int(s2) except: # 输入的内容可能不是整数值 print("您输入的可能不是整数") number1 = 0 number2 = 0 hasErr = True # 标记已发生错误

如果成功获取到两个整数值,就向正在等待的3个线程发出信号。

if not hasErr: # 通知等待的线程 mutex.lock() numbersGot.wakeAll() mutex.unlock() # 等待所有线程结束 thPool.waitForDone()

运行示例程序,依次输入整数10、3,控制台将输出以下结果:

10*3=30 10-3=7 10+3=13

3个线程获取的互斥锁的序列是随机的,因此3个函数的执行顺序每次运行都会不一样。有可能先进行乘法运算,再进行加法运算。