文章目录(Table of Contents)
简介
PyQt 应用会有一个主线程来执行 event loop。但是如果你在这个线程上,执行了一个比较长时间的任务,那么主界面会被冻结,直到任务终止。在此期间,用户会无法与程序进行交互,从而会导致糟糕的用户体验。幸运的是,我们可以使用 PyQt
中的 QThread
来解决此问题。
在这一篇文章中,我们会包含以下的内容:
- 使用 PyQt 的 QThread 来阻止主界面冻结;
- 使用 QThreadPool 和 QRunnable 创建可重复使用的线程;
- 使用 signals 和 slots 来管理线程之间的通行;
- 使用 PyQt 的锁来安全的使用贡献资源;
本文的主要内容参考自,Use PyQt's QThread to Prevent Freezing GUIs。
长时间运行的任务
我们首先来看一下长时间运行的任务。长时间运行的任务,会占用 GUI 的主线程,导致应用程序冻结,会导致用户不好的体验。我们来看一下下面的例子:
- import sys
- from time import sleep
- from PyQt5.QtCore import Qt
- from PyQt5.QtWidgets import (
- QApplication,
- QLabel,
- QMainWindow,
- QPushButton,
- QVBoxLayout,
- QWidget,
- )
- class Window(QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.clicksCount = 0
- self.setupUi()
- def setupUi(self):
- """创建主界面
- """
- self.setWindowTitle("Freezing GUI")
- self.resize(300, 150)
- self.centralWidget = QWidget()
- self.setCentralWidget(self.centralWidget)
- # Create and connect widgets
- self.clicksLabel = QLabel("Counting: 0 clicks", self)
- self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
- self.stepLabel = QLabel("Long-Running Step: 0")
- self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
- self.countBtn = QPushButton("Click me!", self)
- self.countBtn.clicked.connect(self.countClicks)
- self.longRunningBtn = QPushButton("Long-Running Task!", self)
- self.longRunningBtn.clicked.connect(self.runLongTask)
- # Set the layout
- layout = QVBoxLayout()
- layout.addWidget(self.clicksLabel)
- layout.addWidget(self.countBtn)
- layout.addStretch()
- layout.addWidget(self.stepLabel)
- layout.addWidget(self.longRunningBtn)
- self.centralWidget.setLayout(layout)
- def countClicks(self):
- self.clicksCount += 1
- self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
- def reportProgress(self, n):
- self.stepLabel.setText(f"Long-Running Step: {n}")
- def runLongTask(self):
- """Long-running task in 5 steps."""
- for i in range(5):
- sleep(1)
- self.reportProgress(i + 1)
- app = QApplication(sys.argv)
- win = Window()
- win.show()
- sys.exit(app.exec())
上面的 GUI 会包含两个按钮:
Click me!
,点击会运行一个短时间的任务。Long-Running Task!
,点击会运行一个长时间的任务。上面的代码中包含sleep
,所以会等 5 秒才会发生变化。我们在循环中,会去改变 label 的值。但是在实际运行中,我们发现界面上的数字没有实时改变,而是在运行结束之后,直接变为了数字 5.
为了解决这个主界面冻结的问题,我们后面会使用 QThread
来进行解决。
PyQt 中的 QThread
PyQt
中可以使用 QThread
来创建多线程的应用。PyQt
中包含两种不同的线程,分别是:
- Main Thread:一个应用的 main thread 是一直存在的,应用的主界面就是通过这个线程显示的。当执行
.exec()
的时候开始这个线程。这个线程会管理所有的窗口,以及与主机进行通行。 - Worker Thread:我们可以创建许多的 worker thread。work thread 可以运行长时间的任务,防止主线程被长时间的任务占用。
我们使用 QThread
来解决上面的问题。主要有下面的几个步骤:
- 使用
QObject
初始化一个类,将长时间的任务放在里面; - 将上面的类实例化,得到
worker
; - 实例化一个
QThread
,thread = QThread()
; - 将上面的
worker
移动到新创建的线程中,使用.moveToThread(thread)
进行移动 - 连接所需要的信号和插槽,保持通信;
- 调用
QThread
中的.start
;
我们对上面的代码进行修改,得到如下的代码(修改的地方均加上了注释):
- import sys
- from time import sleep
- from PyQt5.QtCore import QObject, QThread, pyqtSignal # 需要导入这些库
- from PyQt5.QtCore import Qt
- from PyQt5.QtWidgets import (
- QApplication,
- QLabel,
- QMainWindow,
- QPushButton,
- QVBoxLayout,
- QWidget,
- )
- # Step 1: Create a worker class
- class Worker(QObject):
- finished = pyqtSignal() # 结束的信号
- progress = pyqtSignal(int)
- def run(self):
- """Long-running task."""
- for i in range(5):
- sleep(1)
- self.progress.emit(i + 1) # 发出表示进度的信号
- self.finished.emit() # 发出结束的信号
- class Window(QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.clicksCount = 0
- self.setupUi()
- def setupUi(self):
- """创建主界面
- """
- self.setWindowTitle("Freezing GUI")
- self.resize(300, 150)
- self.centralWidget = QWidget()
- self.setCentralWidget(self.centralWidget)
- # Create and connect widgets
- self.clicksLabel = QLabel("Counting: 0 clicks", self)
- self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
- self.stepLabel = QLabel("Long-Running Step: 0")
- self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
- self.countBtn = QPushButton("Click me!", self)
- self.countBtn.clicked.connect(self.countClicks)
- self.longRunningBtn = QPushButton("Long-Running Task!", self)
- self.longRunningBtn.clicked.connect(self.runLongTask)
- # Set the layout
- layout = QVBoxLayout()
- layout.addWidget(self.clicksLabel)
- layout.addWidget(self.countBtn)
- layout.addStretch()
- layout.addWidget(self.stepLabel)
- layout.addWidget(self.longRunningBtn)
- self.centralWidget.setLayout(layout)
- def countClicks(self):
- self.clicksCount += 1
- self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
- def reportProgress(self, n):
- self.stepLabel.setText(f"Long-Running Step: {n}")
- def runLongTask(self):
- """修改复杂任务的函数
- """
- # Step 2: Create a QThread object
- self.thread = QThread()
- # Step 3: Create a worker object
- self.worker = Worker()
- # Step 4: Move worker to the thread
- self.worker.moveToThread(self.thread)
- # Step 5: Connect signals and slots
- self.thread.started.connect(self.worker.run) # 通知开始
- self.worker.finished.connect(self.thread.quit) # 结束后通知结束
- self.worker.finished.connect(self.worker.deleteLater) # 完成后删除对象
- self.thread.finished.connect(self.thread.deleteLater) # 完成后删除对象
- self.worker.progress.connect(self.reportProgress) # 绑定 progress 的信号
- # Step 6: Start the thread
- self.thread.start()
- # Final resets (结束的时候做的事)
- self.longRunningBtn.setEnabled(False) # 将按钮设置为不可点击
- self.thread.finished.connect(
- lambda: self.longRunningBtn.setEnabled(True)
- )
- self.thread.finished.connect(
- lambda: self.stepLabel.setText("Long-Running Step: 0")
- )
- app = QApplication(sys.argv)
- win = Window()
- win.show()
- sys.exit(app.exec())
在上面的代码中,我们将所有需要长时间运行的代码均放在 .runLongTask
中。循环会发出 progress
信号,该信号表示操作的进度。最后 .runLongTask()
发出 finsihed
信号表示已经处理完成。接着使用 connect
将这些信号连接到不同的插槽上。例如将 progress
连接到 reportProgress
上面,可以在 GUI
上改变数字。
最终的实验结果如下所示,可以看到点击之后,界面上数字可以实时变动:
重复使用线程-QRunnable 与 QThreadPool
如果我们的应用程序严重依赖多线程,那么我们会面临大量创建和销毁线程,同时需要考虑需要启动多少线程,PyQt 对此也提供了相应的解决方案,可以使用全局线程池,QThreadPool
。
全局线程池会根据当前 CPU
中内核数来维护和管理建议的线程数。池中的线程是可以重复利用的,从而避免了创建和销毁线程相关的开销。我们基于 QRunnable
来创建子类,并在 run
中定义需要长时间运行的代码。下面是一个例子:
- import logging
- import random
- import sys
- import time
- from PyQt5.QtCore import QRunnable, Qt, QThreadPool
- from PyQt5.QtWidgets import (
- QApplication,
- QLabel,
- QMainWindow,
- QPushButton,
- QVBoxLayout,
- QWidget,
- )
- logging.basicConfig(format="%(message)s", level=logging.INFO)
- # 1. Subclass QRunnable
- class Runnable(QRunnable):
- def __init__(self, n):
- super().__init__()
- self.n = n
- def run(self):
- # Your long-running task goes here ... (需要放在 run 这个函数里)
- for i in range(5):
- logging.info(f"Working in thread {self.n}, step {i + 1}/5")
- time.sleep(random.randint(700, 2500) / 1000)
- class Window(QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setupUi()
- def setupUi(self):
- self.setWindowTitle("QThreadPool + QRunnable")
- self.resize(250, 150)
- self.centralWidget = QWidget()
- self.setCentralWidget(self.centralWidget)
- # Create and connect widgets
- self.label = QLabel("Hello, World!")
- self.label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
- countBtn = QPushButton("Click me!")
- countBtn.clicked.connect(self.runTasks)
- # Set the layout
- layout = QVBoxLayout()
- layout.addWidget(self.label)
- layout.addWidget(countBtn)
- self.centralWidget.setLayout(layout)
- def runTasks(self):
- threadCount = QThreadPool.globalInstance().maxThreadCount() # 设置最大线程数
- self.label.setText(f"Running {threadCount} Threads") # 更新 label 有多少线程
- pool = QThreadPool.globalInstance()
- for i in range(threadCount):
- # 2. Instantiate the subclass of QRunnable
- runnable = Runnable(i) # 实例化 Runnable, 使用 i 来标识当前的线程,
- # 3. Call start()
- pool.start(runnable) # 在线程池中进行 start
- app = QApplication(sys.argv)
- window = Window()
- window.show()
- sys.exit(app.exec())
运算上面的代码出现如下的界面。点击 Click me!
可以看到启动了四个线程。
使用QThreadPool
和QRunnable
有一个缺点,也就是基于QRunnable
的类不支持信号和插槽,因此线程间通信可能具有挑战性。
线程之间的通信
上面我们介绍了如何使用 QThread 来使用多线程。有的时候线程之间需要建立通信,例如将数据发送到线程,更新主界面等。PyQt 中的信号与插槽的机制可以方便的建立通信。
如果多个线程同时访问同一数据或资源,并且其中至少有一个写入或修改此共享资源,那么您可能会遇到崩溃,内存或数据损坏,死锁或其他问题。我们可以使用互斥(Mutual Exclusion)来解决上面的问题。它使用锁来保护对数据和资源的访问。锁是一种同步机制,通常只允许一个线程在给定时间访问资源。例如在 PyQt
中可以使用 QMutex
。
下面我们来看一个例子。现在有一个银行账户存有 100
元。两个人同时从这个账户中取出 60
元,如果没有锁,那么最终账户的余额为 -20
,这是不可以的。
- import logging
- import sys
- from time import sleep
- from PyQt5.QtCore import QMutex, QObject, QThread, pyqtSignal
- from PyQt5.QtWidgets import (
- QApplication,
- QLabel,
- QMainWindow,
- QPushButton,
- QVBoxLayout,
- QWidget,
- )
- logging.basicConfig(format="%(message)s", level=logging.INFO)
- balance = 100.00 # 账户余额
- mutex = QMutex() # 防止 balance 被多个线程同时访问
- class AccountManager(QObject):
- finished = pyqtSignal() # 结束的信号
- updatedBalance = pyqtSignal()
- def withdraw(self, person, amount):
- logging.info("%s wants to withdraw $%.2f...", person, amount)
- global balance
- mutex.lock() # 锁定
- if balance - amount >= 0:
- sleep(1)
- balance -= amount
- logging.info("-$%.2f accepted", amount)
- else:
- logging.info("-$%.2f rejected", amount)
- logging.info("===Balance===: $%.2f", balance)
- self.updatedBalance.emit()
- mutex.unlock() # 解锁
- self.finished.emit()
- class Window(QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setupUi()
- self.threads = []
- def setupUi(self):
- """创建 GUI 需要的代码
- """
- self.setWindowTitle("Account Manager")
- self.resize(200, 150)
- self.centralWidget = QWidget()
- self.setCentralWidget(self.centralWidget)
- button = QPushButton("Withdraw Money!")
- button.clicked.connect(self.startThreads) # 绑定按钮
- self.balanceLabel = QLabel(f"Current Balance: ${balance:,.2f}")
- layout = QVBoxLayout()
- layout.addWidget(self.balanceLabel)
- layout.addWidget(button)
- self.centralWidget.setLayout(layout)
- def updateBalance(self):
- """更新余额的显示
- """
- self.balanceLabel.setText(f"Current Balance: ${balance:,.2f}")
- def createThread(self, person, amount):
- """某一个人开始取钱
- Args:
- person (str): 人名
- amount (float): 取钱的金额
- """
- thread = QThread()
- worker = AccountManager()
- worker.moveToThread(thread)
- thread.started.connect(lambda: worker.withdraw(person, amount)) # 将 start 与 withdraw 连起来
- worker.updatedBalance.connect(self.updateBalance)
- worker.finished.connect(thread.quit)
- worker.finished.connect(worker.deleteLater)
- thread.finished.connect(thread.deleteLater)
- return thread
- def startThreads(self):
- self.threads.clear()
- people = {
- "Alice": 60,
- "Bob": 60,
- }
- self.threads = [self.createThread(person, amount) for person, amount in people.items()] # 为每个人创建一个线程
- for thread in self.threads:
- thread.start() # 开始线程
- app = QApplication(sys.argv)
- window = Window()
- window.show()
- sys.exit(app.exec())
我们运行上面的代码,会有如下的运行效果,可以看到第二个人会取钱失败,提示余额不足:
- 微信公众号
- 关注微信公众号
- QQ群
- 我们的QQ群号
评论