使用 PyQt 快速搭建带有 GUI 的应用(4)–多线程的使用

  • A+
所属分类:Python库介绍
摘要本文会介绍 PyQt 中的多线程的使用,防止主界面冻结。同时也会介绍在 PyQt 中的进程池与进程之间的通信,进程锁。

简介

PyQt 应用会有一个主线程来执行 event loop。但是如果你在这个线程上,执行了一个比较长时间的任务,那么主界面会被冻结,直到任务终止。在此期间,用户会无法与程序进行交互,从而会导致糟糕的用户体验。幸运的是,我们可以使用 PyQt 中的 QThread 来解决此问题。

在这一篇文章中,我们会包含以下的内容:

  • 使用 PyQt 的 QThread 来阻止主界面冻结;
  • 使用 QThreadPool 和 QRunnable 创建可重复使用的线程;
  • 使用 signals 和 slots 来管理线程之间的通行;
  • 使用 PyQt 的锁来安全的使用贡献资源;

本文的主要内容参考自,Use PyQt's QThread to Prevent Freezing GUIs

 

长时间运行的任务

我们首先来看一下长时间运行的任务。长时间运行的任务,会占用 GUI 的主线程,导致应用程序冻结,会导致用户不好的体验。我们来看一下下面的例子:

  1. import sys
  2. from time import sleep
  3. from PyQt5.QtCore import Qt
  4. from PyQt5.QtWidgets import (
  5.     QApplication,
  6.     QLabel,
  7.     QMainWindow,
  8.     QPushButton,
  9.     QVBoxLayout,
  10.     QWidget,
  11. )
  12. class Window(QMainWindow):
  13.     def __init__(self, parent=None):
  14.         super().__init__(parent)
  15.         self.clicksCount = 0
  16.         self.setupUi()
  17.     def setupUi(self):
  18.         """创建主界面
  19.         """
  20.         self.setWindowTitle("Freezing GUI")
  21.         self.resize(300, 150)
  22.         self.centralWidget = QWidget()
  23.         self.setCentralWidget(self.centralWidget)
  24.         # Create and connect widgets
  25.         self.clicksLabel = QLabel("Counting: 0 clicks", self)
  26.         self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  27.         self.stepLabel = QLabel("Long-Running Step: 0")
  28.         self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  29.         self.countBtn = QPushButton("Click me!", self)
  30.         self.countBtn.clicked.connect(self.countClicks)
  31.         self.longRunningBtn = QPushButton("Long-Running Task!", self)
  32.         self.longRunningBtn.clicked.connect(self.runLongTask)
  33.         # Set the layout
  34.         layout = QVBoxLayout()
  35.         layout.addWidget(self.clicksLabel)
  36.         layout.addWidget(self.countBtn)
  37.         layout.addStretch()
  38.         layout.addWidget(self.stepLabel)
  39.         layout.addWidget(self.longRunningBtn)
  40.         self.centralWidget.setLayout(layout)
  41.     def countClicks(self):
  42.         self.clicksCount += 1
  43.         self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
  44.     def reportProgress(self, n):
  45.         self.stepLabel.setText(f"Long-Running Step: {n}")
  46.     def runLongTask(self):
  47.         """Long-running task in 5 steps."""
  48.         for i in range(5):
  49.             sleep(1)
  50.             self.reportProgress(i + 1)
  51. app = QApplication(sys.argv)
  52. win = Window()
  53. win.show()
  54. sys.exit(app.exec())

上面的 GUI 会包含两个按钮:

  • Click me!,点击会运行一个短时间的任务。
  • Long-Running Task!,点击会运行一个长时间的任务。上面的代码中包含 sleep,所以会等 5 秒才会发生变化。我们在循环中,会去改变 label 的值。但是在实际运行中,我们发现界面上的数字没有实时改变,而是在运行结束之后,直接变为了数字 5.
使用 PyQt 快速搭建带有 GUI 的应用(4)--多线程的使用

为了解决这个主界面冻结的问题,我们后面会使用 QThread 来进行解决。

 

PyQt 中的 QThread

PyQt 中可以使用 QThread 来创建多线程的应用。PyQt 中包含两种不同的线程,分别是:

  • Main Thread:一个应用的 main thread 是一直存在的,应用的主界面就是通过这个线程显示的。当执行 .exec() 的时候开始这个线程。这个线程会管理所有的窗口,以及与主机进行通行。
  • Worker Thread:我们可以创建许多的 worker thread。work thread 可以运行长时间的任务,防止主线程被长时间的任务占用。

我们使用 QThread 来解决上面的问题。主要有下面的几个步骤:

  1. 使用 QObject 初始化一个类,将长时间的任务放在里面;
  2. 将上面的类实例化,得到 worker
  3. 实例化一个 QThreadthread = QThread()
  4. 将上面的 worker 移动到新创建的线程中,使用 .moveToThread(thread) 进行移动
  5. 连接所需要的信号和插槽,保持通信;
  6. 调用 QThread 中的 .start

我们对上面的代码进行修改,得到如下的代码(修改的地方均加上了注释):

  1. import sys
  2. from time import sleep
  3. from PyQt5.QtCore import QObject, QThread, pyqtSignal # 需要导入这些库
  4. from PyQt5.QtCore import Qt
  5. from PyQt5.QtWidgets import (
  6.     QApplication,
  7.     QLabel,
  8.     QMainWindow,
  9.     QPushButton,
  10.     QVBoxLayout,
  11.     QWidget,
  12. )
  13. # Step 1: Create a worker class
  14. class Worker(QObject):
  15.     finished = pyqtSignal() # 结束的信号
  16.     progress = pyqtSignal(int)
  17.     def run(self):
  18.         """Long-running task."""
  19.         for i in range(5):
  20.             sleep(1)
  21.             self.progress.emit(i + 1) # 发出表示进度的信号
  22.         self.finished.emit() # 发出结束的信号
  23. class Window(QMainWindow):
  24.     def __init__(self, parent=None):
  25.         super().__init__(parent)
  26.         self.clicksCount = 0
  27.         self.setupUi()
  28.     def setupUi(self):
  29.         """创建主界面
  30.         """
  31.         self.setWindowTitle("Freezing GUI")
  32.         self.resize(300, 150)
  33.         self.centralWidget = QWidget()
  34.         self.setCentralWidget(self.centralWidget)
  35.         # Create and connect widgets
  36.         self.clicksLabel = QLabel("Counting: 0 clicks", self)
  37.         self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  38.         self.stepLabel = QLabel("Long-Running Step: 0")
  39.         self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  40.         self.countBtn = QPushButton("Click me!", self)
  41.         self.countBtn.clicked.connect(self.countClicks)
  42.         self.longRunningBtn = QPushButton("Long-Running Task!", self)
  43.         self.longRunningBtn.clicked.connect(self.runLongTask)
  44.         # Set the layout
  45.         layout = QVBoxLayout()
  46.         layout.addWidget(self.clicksLabel)
  47.         layout.addWidget(self.countBtn)
  48.         layout.addStretch()
  49.         layout.addWidget(self.stepLabel)
  50.         layout.addWidget(self.longRunningBtn)
  51.         self.centralWidget.setLayout(layout)
  52.     def countClicks(self):
  53.         self.clicksCount += 1
  54.         self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
  55.     def reportProgress(self, n):
  56.         self.stepLabel.setText(f"Long-Running Step: {n}")
  57.     def runLongTask(self):
  58.         """修改复杂任务的函数
  59.         """
  60.         # Step 2: Create a QThread object
  61.         self.thread = QThread()
  62.         # Step 3: Create a worker object
  63.         self.worker = Worker()
  64.         # Step 4: Move worker to the thread
  65.         self.worker.moveToThread(self.thread)
  66.         # Step 5: Connect signals and slots
  67.         self.thread.started.connect(self.worker.run) # 通知开始
  68.         self.worker.finished.connect(self.thread.quit) # 结束后通知结束
  69.         self.worker.finished.connect(self.worker.deleteLater) # 完成后删除对象
  70.         self.thread.finished.connect(self.thread.deleteLater) # 完成后删除对象
  71.         self.worker.progress.connect(self.reportProgress) # 绑定 progress 的信号
  72.         # Step 6: Start the thread
  73.         self.thread.start()
  74.         # Final resets (结束的时候做的事)
  75.         self.longRunningBtn.setEnabled(False# 将按钮设置为不可点击
  76.         self.thread.finished.connect(
  77.             lambdaself.longRunningBtn.setEnabled(True)
  78.         )
  79.         self.thread.finished.connect(
  80.             lambdaself.stepLabel.setText("Long-Running Step: 0")
  81.         )
  82. app = QApplication(sys.argv)
  83. win = Window()
  84. win.show()
  85. sys.exit(app.exec())

在上面的代码中,我们将所有需要长时间运行的代码均放在 .runLongTask 中。循环会发出 progress 信号,该信号表示操作的进度。最后 .runLongTask() 发出 finsihed 信号表示已经处理完成。接着使用 connect 将这些信号连接到不同的插槽上。例如将 progress 连接到 reportProgress 上面,可以在 GUI 上改变数字。

最终的实验结果如下所示,可以看到点击之后,界面上数字可以实时变动:

使用 PyQt 快速搭建带有 GUI 的应用(4)--多线程的使用

 

 

重复使用线程-QRunnable 与 QThreadPool

如果我们的应用程序严重依赖多线程,那么我们会面临大量创建和销毁线程,同时需要考虑需要启动多少线程,PyQt 对此也提供了相应的解决方案,可以使用全局线程池,QThreadPool

全局线程池会根据当前 CPU 中内核数来维护和管理建议的线程数。池中的线程是可以重复利用的,从而避免了创建和销毁线程相关的开销。我们基于 QRunnable 来创建子类,并在 run 中定义需要长时间运行的代码。下面是一个例子

  1. import logging
  2. import random
  3. import sys
  4. import time
  5. from PyQt5.QtCore import QRunnable, Qt, QThreadPool
  6. from PyQt5.QtWidgets import (
  7.     QApplication,
  8.     QLabel,
  9.     QMainWindow,
  10.     QPushButton,
  11.     QVBoxLayout,
  12.     QWidget,
  13. )
  14. logging.basicConfig(format="%(message)s", level=logging.INFO)
  15. # 1. Subclass QRunnable
  16. class Runnable(QRunnable):
  17.     def __init__(self, n):
  18.         super().__init__()
  19.         self.n = n
  20.     def run(self):
  21.         # Your long-running task goes here ... (需要放在 run 这个函数里)
  22.         for i in range(5):
  23.             logging.info(f"Working in thread {self.n}, step {i + 1}/5")
  24.             time.sleep(random.randint(700, 2500) / 1000)
  25. class Window(QMainWindow):
  26.     def __init__(self, parent=None):
  27.         super().__init__(parent)
  28.         self.setupUi()
  29.     def setupUi(self):
  30.         self.setWindowTitle("QThreadPool + QRunnable")
  31.         self.resize(250, 150)
  32.         self.centralWidget = QWidget()
  33.         self.setCentralWidget(self.centralWidget)
  34.         # Create and connect widgets
  35.         self.label = QLabel("Hello, World!")
  36.         self.label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  37.         countBtn = QPushButton("Click me!")
  38.         countBtn.clicked.connect(self.runTasks)
  39.         # Set the layout
  40.         layout = QVBoxLayout()
  41.         layout.addWidget(self.label)
  42.         layout.addWidget(countBtn)
  43.         self.centralWidget.setLayout(layout)
  44.     def runTasks(self):
  45.         threadCount = QThreadPool.globalInstance().maxThreadCount() # 设置最大线程数
  46.         self.label.setText(f"Running {threadCount} Threads") # 更新 label 有多少线程
  47.         pool = QThreadPool.globalInstance()
  48.         for i in range(threadCount):
  49.             # 2. Instantiate the subclass of QRunnable
  50.             runnable = Runnable(i) # 实例化 Runnable, 使用 i 来标识当前的线程, 
  51.             # 3. Call start()
  52.             pool.start(runnable) # 在线程池中进行 start
  53. app = QApplication(sys.argv)
  54. window = Window()
  55. window.show()
  56. sys.exit(app.exec())

运算上面的代码出现如下的界面。点击 Click me! 可以看到启动了四个线程。

使用 PyQt 快速搭建带有 GUI 的应用(4)--多线程的使用

使用QThreadPoolQRunnable有一个缺点,也就是基于QRunnable的类不支持信号和插槽,因此线程间通信可能具有挑战性。

 

线程之间的通信

上面我们介绍了如何使用 QThread 来使用多线程。有的时候线程之间需要建立通信,例如将数据发送到线程,更新主界面等。PyQt 中的信号与插槽的机制可以方便的建立通信

如果多个线程同时访问同一数据或资源,并且其中至少有一个写入或修改此共享资源,那么您可能会遇到崩溃,内存或数据损坏,死锁或其他问题。我们可以使用互斥(Mutual Exclusion)来解决上面的问题。它使用锁来保护对数据和资源的访问。锁是一种同步机制,通常只允许一个线程在给定时间访问资源。例如在 PyQt 中可以使用 QMutex

下面我们来看一个例子。现在有一个银行账户存有 100 元。两个人同时从这个账户中取出 60 元,如果没有锁,那么最终账户的余额为 -20,这是不可以的。

  1. import logging
  2. import sys
  3. from time import sleep
  4. from PyQt5.QtCore import QMutex, QObject, QThread, pyqtSignal
  5. from PyQt5.QtWidgets import (
  6.     QApplication,
  7.     QLabel,
  8.     QMainWindow,
  9.     QPushButton,
  10.     QVBoxLayout,
  11.     QWidget,
  12. )
  13. logging.basicConfig(format="%(message)s", level=logging.INFO)
  14. balance = 100.00 # 账户余额
  15. mutex = QMutex() # 防止 balance 被多个线程同时访问
  16. class AccountManager(QObject):
  17.     finished = pyqtSignal() # 结束的信号
  18.     updatedBalance = pyqtSignal()
  19.     def withdraw(self, person, amount):
  20.         logging.info("%s wants to withdraw $%.2f...", person, amount)
  21.         global balance
  22.         mutex.lock() # 锁定
  23.         if balance - amount >= 0:
  24.             sleep(1)
  25.             balance -= amount
  26.             logging.info("-$%.2f accepted", amount)
  27.         else:
  28.             logging.info("-$%.2f rejected", amount)
  29.         logging.info("===Balance===: $%.2f", balance)
  30.         self.updatedBalance.emit()
  31.         mutex.unlock() # 解锁
  32.         self.finished.emit()
  33. class Window(QMainWindow):
  34.     def __init__(self, parent=None):
  35.         super().__init__(parent)
  36.         self.setupUi()
  37.         self.threads = []
  38.     def setupUi(self):
  39.         """创建 GUI 需要的代码
  40.         """
  41.         self.setWindowTitle("Account Manager")
  42.         self.resize(200, 150)
  43.         self.centralWidget = QWidget()
  44.         self.setCentralWidget(self.centralWidget)
  45.         button = QPushButton("Withdraw Money!")
  46.         button.clicked.connect(self.startThreads) # 绑定按钮
  47.         self.balanceLabel = QLabel(f"Current Balance: ${balance:,.2f}")
  48.         layout = QVBoxLayout()
  49.         layout.addWidget(self.balanceLabel)
  50.         layout.addWidget(button)
  51.         self.centralWidget.setLayout(layout)
  52.     def updateBalance(self):
  53.         """更新余额的显示
  54.         """
  55.         self.balanceLabel.setText(f"Current Balance: ${balance:,.2f}")
  56.     def createThread(self, person, amount):
  57.         """某一个人开始取钱
  58.         Args:
  59.             person (str): 人名
  60.             amount (float): 取钱的金额
  61.         """
  62.         thread = QThread()
  63.         worker = AccountManager()
  64.         worker.moveToThread(thread)
  65.         thread.started.connect(lambda: worker.withdraw(person, amount)) # 将 start 与 withdraw 连起来
  66.         worker.updatedBalance.connect(self.updateBalance)
  67.         worker.finished.connect(thread.quit)
  68.         worker.finished.connect(worker.deleteLater)
  69.         thread.finished.connect(thread.deleteLater)
  70.         return thread
  71.     def startThreads(self):
  72.         self.threads.clear()
  73.         people = {
  74.             "Alice": 60,
  75.             "Bob": 60,
  76.         }
  77.         self.threads = [self.createThread(person, amount) for person, amount in people.items()] # 为每个人创建一个线程
  78.         for thread in self.threads:
  79.             thread.start() # 开始线程
  80. app = QApplication(sys.argv)
  81. window = Window()
  82. window.show()
  83. sys.exit(app.exec())

我们运行上面的代码,会有如下的运行效果,可以看到第二个人会取钱失败,提示余额不足:

使用 PyQt 快速搭建带有 GUI 的应用(4)--多线程的使用
  • 微信公众号
  • 关注微信公众号
  • weinxin
  • QQ群
  • 我们的QQ群号
  • weinxin

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: