7

使用 PyQt 快速搭建带有 GUI 的应用(4)–多线程的使用 | 文艺数学君

 3 years ago
source link: https://mathpretty.com/13641.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

摘要本文会介绍 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. class Window(QMainWindow):
  12.     def __init__(self, parent=None):
  13.         super().__init__(parent)
  14.         self.clicksCount = 0
  15.         self.setupUi()
  16.     def setupUi(self):
  17.         """创建主界面
  18.         self.setWindowTitle("Freezing GUI")
  19.         self.resize(300, 150)
  20.         self.centralWidget = QWidget()
  21.         self.setCentralWidget(self.centralWidget)
  22.         # Create and connect widgets
  23.         self.clicksLabel = QLabel("Counting: 0 clicks", self)
  24.         self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  25.         self.stepLabel = QLabel("Long-Running Step: 0")
  26.         self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  27.         self.countBtn = QPushButton("Click me!", self)
  28.         self.countBtn.clicked.connect(self.countClicks)
  29.         self.longRunningBtn = QPushButton("Long-Running Task!", self)
  30.         self.longRunningBtn.clicked.connect(self.runLongTask)
  31.         # Set the layout
  32.         layout = QVBoxLayout()
  33.         layout.addWidget(self.clicksLabel)
  34.         layout.addWidget(self.countBtn)
  35.         layout.addStretch()
  36.         layout.addWidget(self.stepLabel)
  37.         layout.addWidget(self.longRunningBtn)
  38.         self.centralWidget.setLayout(layout)
  39.     def countClicks(self):
  40.         self.clicksCount += 1
  41.         self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
  42.     def reportProgress(self, n):
  43.         self.stepLabel.setText(f"Long-Running Step: {n}")
  44.     def runLongTask(self):
  45.         """Long-running task in 5 steps."""
  46.         for i in range(5):
  47.             sleep(1)
  48.             self.reportProgress(i + 1)
  49. app = QApplication(sys.argv)
  50. win = Window()
  51. win.show()
  52. 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. # Step 1: Create a worker class
  13. class Worker(QObject):
  14.     finished = pyqtSignal() # 结束的信号
  15.     progress = pyqtSignal(int)
  16.     def run(self):
  17.         """Long-running task."""
  18.         for i in range(5):
  19.             sleep(1)
  20.             self.progress.emit(i + 1) # 发出表示进度的信号
  21.         self.finished.emit() # 发出结束的信号
  22. class Window(QMainWindow):
  23.     def __init__(self, parent=None):
  24.         super().__init__(parent)
  25.         self.clicksCount = 0
  26.         self.setupUi()
  27.     def setupUi(self):
  28.         """创建主界面
  29.         self.setWindowTitle("Freezing GUI")
  30.         self.resize(300, 150)
  31.         self.centralWidget = QWidget()
  32.         self.setCentralWidget(self.centralWidget)
  33.         # Create and connect widgets
  34.         self.clicksLabel = QLabel("Counting: 0 clicks", self)
  35.         self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  36.         self.stepLabel = QLabel("Long-Running Step: 0")
  37.         self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  38.         self.countBtn = QPushButton("Click me!", self)
  39.         self.countBtn.clicked.connect(self.countClicks)
  40.         self.longRunningBtn = QPushButton("Long-Running Task!", self)
  41.         self.longRunningBtn.clicked.connect(self.runLongTask)
  42.         # Set the layout
  43.         layout = QVBoxLayout()
  44.         layout.addWidget(self.clicksLabel)
  45.         layout.addWidget(self.countBtn)
  46.         layout.addStretch()
  47.         layout.addWidget(self.stepLabel)
  48.         layout.addWidget(self.longRunningBtn)
  49.         self.centralWidget.setLayout(layout)
  50.     def countClicks(self):
  51.         self.clicksCount += 1
  52.         self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")
  53.     def reportProgress(self, n):
  54.         self.stepLabel.setText(f"Long-Running Step: {n}")
  55.     def runLongTask(self):
  56.         """修改复杂任务的函数
  57.         # Step 2: Create a QThread object
  58.         self.thread = QThread()
  59.         # Step 3: Create a worker object
  60.         self.worker = Worker()
  61.         # Step 4: Move worker to the thread
  62.         self.worker.moveToThread(self.thread)
  63.         # Step 5: Connect signals and slots
  64.         self.thread.started.connect(self.worker.run) # 通知开始
  65.         self.worker.finished.connect(self.thread.quit) # 结束后通知结束
  66.         self.worker.finished.connect(self.worker.deleteLater) # 完成后删除对象
  67.         self.thread.finished.connect(self.thread.deleteLater) # 完成后删除对象
  68.         self.worker.progress.connect(self.reportProgress) # 绑定 progress 的信号
  69.         # Step 6: Start the thread
  70.         self.thread.start()
  71.         # Final resets (结束的时候做的事)
  72.         self.longRunningBtn.setEnabled(False) # 将按钮设置为不可点击
  73.         self.thread.finished.connect(
  74.             lambda: self.longRunningBtn.setEnabled(True)
  75.         self.thread.finished.connect(
  76.             lambda: self.stepLabel.setText("Long-Running Step: 0")
  77. app = QApplication(sys.argv)
  78. win = Window()
  79. win.show()
  80. 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. logging.basicConfig(format="%(message)s", level=logging.INFO)
  14. # 1. Subclass QRunnable
  15. class Runnable(QRunnable):
  16.     def __init__(self, n):
  17.         super().__init__()
  18.         self.n = n
  19.     def run(self):
  20.         # Your long-running task goes here ... (需要放在 run 这个函数里)
  21.         for i in range(5):
  22.             logging.info(f"Working in thread {self.n}, step {i + 1}/5")
  23.             time.sleep(random.randint(700, 2500) / 1000)
  24. class Window(QMainWindow):
  25.     def __init__(self, parent=None):
  26.         super().__init__(parent)
  27.         self.setupUi()
  28.     def setupUi(self):
  29.         self.setWindowTitle("QThreadPool + QRunnable")
  30.         self.resize(250, 150)
  31.         self.centralWidget = QWidget()
  32.         self.setCentralWidget(self.centralWidget)
  33.         # Create and connect widgets
  34.         self.label = QLabel("Hello, World!")
  35.         self.label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
  36.         countBtn = QPushButton("Click me!")
  37.         countBtn.clicked.connect(self.runTasks)
  38.         # Set the layout
  39.         layout = QVBoxLayout()
  40.         layout.addWidget(self.label)
  41.         layout.addWidget(countBtn)
  42.         self.centralWidget.setLayout(layout)
  43.     def runTasks(self):
  44.         threadCount = QThreadPool.globalInstance().maxThreadCount() # 设置最大线程数
  45.         self.label.setText(f"Running {threadCount} Threads") # 更新 label 有多少线程
  46.         pool = QThreadPool.globalInstance()
  47.         for i in range(threadCount):
  48.             # 2. Instantiate the subclass of QRunnable
  49.             runnable = Runnable(i) # 实例化 Runnable, 使用 i 来标识当前的线程, 
  50.             # 3. Call start()
  51.             pool.start(runnable) # 在线程池中进行 start
  52. app = QApplication(sys.argv)
  53. window = Window()
  54. window.show()
  55. 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. logging.basicConfig(format="%(message)s", level=logging.INFO)
  13. balance = 100.00 # 账户余额
  14. mutex = QMutex() # 防止 balance 被多个线程同时访问
  15. class AccountManager(QObject):
  16.     finished = pyqtSignal() # 结束的信号
  17.     updatedBalance = pyqtSignal()
  18.     def withdraw(self, person, amount):
  19.         logging.info("%s wants to withdraw $%.2f...", person, amount)
  20.         global balance
  21.         mutex.lock() # 锁定
  22.         if balance - amount >= 0:
  23.             sleep(1)
  24.             balance -= amount
  25.             logging.info("-$%.2f accepted", amount)
  26.         else:
  27.             logging.info("-$%.2f rejected", amount)
  28.         logging.info("===Balance===: $%.2f", balance)
  29.         self.updatedBalance.emit()
  30.         mutex.unlock() # 解锁
  31.         self.finished.emit()
  32. class Window(QMainWindow):
  33.     def __init__(self, parent=None):
  34.         super().__init__(parent)
  35.         self.setupUi()
  36.         self.threads = []
  37.     def setupUi(self):
  38.         """创建 GUI 需要的代码
  39.         self.setWindowTitle("Account Manager")
  40.         self.resize(200, 150)
  41.         self.centralWidget = QWidget()
  42.         self.setCentralWidget(self.centralWidget)
  43.         button = QPushButton("Withdraw Money!")
  44.         button.clicked.connect(self.startThreads) # 绑定按钮
  45.         self.balanceLabel = QLabel(f"Current Balance: ${balance:,.2f}")
  46.         layout = QVBoxLayout()
  47.         layout.addWidget(self.balanceLabel)
  48.         layout.addWidget(button)
  49.         self.centralWidget.setLayout(layout)
  50.     def updateBalance(self):
  51.         """更新余额的显示
  52.         self.balanceLabel.setText(f"Current Balance: ${balance:,.2f}")
  53.     def createThread(self, person, amount):
  54.         """某一个人开始取钱
  55.         Args:
  56.             person (str): 人名
  57.             amount (float): 取钱的金额
  58.         thread = QThread()
  59.         worker = AccountManager()
  60.         worker.moveToThread(thread)
  61.         thread.started.connect(lambda: worker.withdraw(person, amount)) # 将 start 与 withdraw 连起来
  62.         worker.updatedBalance.connect(self.updateBalance)
  63.         worker.finished.connect(thread.quit)
  64.         worker.finished.connect(worker.deleteLater)
  65.         thread.finished.connect(thread.deleteLater)
  66.         return thread
  67.     def startThreads(self):
  68.         self.threads.clear()
  69.         people = {
  70.             "Alice": 60,
  71.             "Bob": 60,
  72.         self.threads = [self.createThread(person, amount) for person, amount in people.items()] # 为每个人创建一个线程
  73.         for thread in self.threads:
  74.             thread.start() # 开始线程
  75. app = QApplication(sys.argv)
  76. window = Window()
  77. window.show()
  78. sys.exit(app.exec())

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

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

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK