使用 PyQt 的 QThread 防止冻结 GUI

举报
Yuchuan 发表于 2021/08/24 16:38:17 2021/08/24
【摘要】 在 PyQt 应用程序的主线程中执行长时间运行的任务可能会导致应用程序的 GUI 冻结并变得无响应。这是GUI 编程中的常见问题,可能会导致糟糕的用户体验。使用PyQtQThread创建工作线程来卸载长时间运行的任务可以有效地解决 GUI 应用程序中的这个问题。

目录

PyQt 图形用户界面 (GUI) 应用程序具有一个运行事件循环和 GUI的执行线程。如果您在此线程中启动一个长时间运行的任务,那么您的 GUI 将冻结,直到任务终止。在此期间,用户将无法与应用程序交互,从而导致糟糕的用户体验。幸运的是,PyQt 的类允许您解决这个问题。QThread

在本教程中,您将学习如何:

  • 使用 PyQtQThread来防止 GUI 冻结
  • 创建可重复使用的线程QThreadPoolQRunnable
  • 使用信号和槽管理线程间通信
  • 使用PyQt 的锁安全地使用共享资源
  • 使用PyQt 线程支持开发 GUI 应用程序的最佳实践

为了更好地理解如何使用 PyQt 的线程,使用 PyQtPython 多线程编程的一些GUI 编程知识会有所帮助。

使用长时间运行的任务冻结 GUI

长时间运行的任务占用 GUI 应用程序的主线程并导致应用程序冻结是 GUI 编程中的一个常见问题,几乎总是会导致糟糕的用户体验。例如,考虑以下 GUI 应用程序:

PyQt 冻结 GUI 示例

假设您需要Counting标签来反映Click me!上的总点击次数按钮。单击长时间运行的任务!按钮将启动一个需要很长时间才能完成的任务。您长时间运行的任务可能是文件下载、对大型数据库的查询或任何其他资源密集型操作。

这是使用 PyQt 和单个执行线程对此应用程序进行编码的第一种方法:

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())

在这个 Freezing GUI 应用程序中,.setupUi()为GUI创建所有必需的图形组件。点击点击我!按钮调用.countClicks(),这使得计数标签的文本反映按钮点击次数。

注意: PyQt 最初是针对 Python 2 开发的,它有一个exec关键字。为了避免 PyQt 早期版本的名称冲突,在.exec_().

尽管 PyQt5 仅针对没有exec关键字的Python 3,但该库提供了两种方法来启动应用程序的事件循环:

  1. .exec_()
  2. .exec()

该方法的两种变体的工作方式相同,因此您可以在应用程序中使用其中一种。

单击长时间运行的任务!按钮调用.runLongTask(),它执行需要5几秒钟才能完成的任务。这是您使用 编码的假设任务time.sleep(secs),它将调用线程的执行暂停给定的秒数,secs

在 中.runLongTask(),您还调用.reportProgress()以使Long-Running Step标签反映操作的进度。

此应用程序是否按您的预期工作?运行应用程序并检查其行为:

PyQt 冻结 GUI 示例

当您单击单击我!按钮,标签显示点击次数。但是,如果您单击长时间运行的任务!按钮,然后应用程序变得冻结和无响应。按钮不再响应点击并且标签不反映应用程序的状态。

五秒钟后,应用程序的 GUI 再次更新。该计数标签呈现了十个点击,反映了5次点击发生而GUI是冻结的。在长时间运行的步骤标签不反映你的长期运行的操作的进度。它从零跳到五,不显示中间步骤。

注意:即使您的应用程序的 GUI 在长时间运行的任务期间冻结,该应用程序仍会注册诸如点击和击键之类的事件。在主线程被释放之前,它无法处理它们。

由于主线程阻塞,应用程序的 GUI 冻结。主线程正忙于处理一个长时间运行的任务,并且不会立即响应用户的操作。这是一个令人讨厌的行为,因为用户不确定应用程序是否正常工作或是否崩溃。

幸运的是,您可以使用一些技术来解决此问题。一个常用的解决方案是使用工作线程在应用程序的主线程之外运行长时间运行的任务。

在下面的部分中,您将学习如何使用 PyQt 的内置线程支持来解决 GUI 无响应或冻结的问题,并在您的应用程序中提供最佳的用户体验。

多线程:基础

有时,您可以将您的程序分成几个较小的子程序任务,这些子程序任务可以在多个线程中运行。这可能会使您的程序更快,或者它可以通过防止您的程序在执行长时间运行的任务时冻结来帮助您改善用户体验。

线程是一个单独的执行流程。在大多数操作系统中,线程是进程的一个组成部分,进程可以有多个线程同时执行。每个进程代表当前在给定计算机系统中运行的程序或应用程序的一个实例。

您可以根据需要拥有任意数量的线程。挑战在于确定要使用的正确线程数。如果您正在使用I/O 绑定线程,那么线程数将受到可用系统资源的限制。另一方面,如果您正在使用受CPU 限制的线程,那么您将受益于线程数量等于或小于系统中 CPU 内核的数量。

构建能够使用不同线程运行多个任务的程序是一种称为多线程编程的编程技术。理想情况下,使用这种技术,多个任务可以同时独立运行。然而,这并不总是可能的。至少有两个元素可以阻止程序并行运行多个线程:

  1. 中央处理器(CPU
  2. 编程语言

例如,如果您有一台单核 CPU 的机器,那么您就不能同时运行多个线程。但是,一些单核 CPU 可以通过允许操作系统调度多个线程之间的处理时间来模拟并行线程执行。这使您的线程看起来是并行运行的,即使它们实际上一次只运行一个。

另一方面,如果您有一台多核 CPU机器或计算机集群,那么您可能能够同时运行多个线程。在这种情况下,您的编程语言成为一个重要因素。

一些编程语言的内部组件实际上禁止了多个线程的真正并行执行。在这些情况下,线程似乎只是并行运行,因为它们利用了任务调度系统。

由于与线程之间共享资源、同步数据访问和协调线程执行相关的复杂性,多线程程序通常比单线程程序更难编写、维护和调试。这可能会导致几个问题:

  • 竞争条件是当应用程序的行为由于不可预测的事件顺序而变得不确定时。这通常是两个或多个线程在没有适当同步的情况下访问共享资源的结果。例如,如果读取和写入操作以错误的顺序执行,则从不同线程读取和写入内存可能导致竞争条件。

  • 当线程无限期地等待被锁定的资源被释放时,就会发生死锁。例如,如果一个线程锁定了一个资源并且在使用后没有解锁它,那么其他线程将无法使用该资源并无限期地等待。如果线程 A 正在等待线程 B 解锁资源,而线程 B 正在等待线程 A 解锁不同的资源,也会发生死锁。两个线程都将永远等待。

  • 活锁是两个或多个线程重复动作以响应彼此的动作的情况。活锁线程无法在其特定任务上取得进一步进展,因为它们太忙于相互响应。但是,它们并没有被阻塞或死亡。

  • 当进程永远无法访问完成其工作所需的资源时,就会发生饥饿。例如,如果您有一个无法获得 CPU 时间访问权限的进程,则该进程正在耗尽 CPU 时间并且无法完成其工作。

在构建多线程应用程序时,您需要小心保护您的资源免受并发写入或状态修改访问。换句话说,您需要防止多个线程同时访问给定资源。

广泛的应用程序至少可以通过以下三种方式从使用多线程编程中受益:

  1. 利用多核处理器使您的应用程序更快
  2. 通过将应用程序划分为更小的子任务来简化应用程序结构
  3. 通过将长时间运行的任务卸载到工作线程,使您的应用程序保持响应并保持最新状态

Python 的 C 实现(也称为CPython)中,线程不是并行运行的。CPython 有一个全局解释器锁(GIL),这是一种基本上一次只允许一个 Python 线程运行的

这会对线程化 Python 应用程序的性能产生负面影响,因为线程之间的上下文切换会产生开销。但是,Python 中的多线程可以帮助您解决在处理长时间运行的任务时应用程序冻结或无响应的问题。

PyQt 中的多线程 QThread

QtPyQt提供了自己的基础设施来使用QThread. PyQt 应用程序可以有两种不同的线程:

  1. 主线程
  2. 工作线程

应用程序的主线程始终存在。这是应用程序及其 GUI 运行的地方。另一方面,工作线程的存在取决于应用程序的处理需求。例如,如果您的应用程序通常运行需要大量时间才能完成的繁重任务,那么您可能希望有工作线程来运行这些任务并避免冻结应用程序的 GUI。

主线程

在 PyQt 应用程序中,执行的主线程也称为GUI 线程,因为它处理所有小部件和其他 GUI 组件。你通过调用.exec()你的QApplication对象来启动这个线程。主线程运行应用程序的事件循环以及您的 Python 代码。它还处理您的窗口、对话框以及与主机操作系统的通信。

默认情况下,在应用程序的主线程中发生的任何事件或任务,包括用户在 GUI 本身上的事件,都将同步运行,或者一个接一个地运行。因此,如果您在主线程中启动一个长时间运行的任务,那么应用程序需要等待该任务完成,并且 GUI 变得无响应。

请务必注意,您必须在 GUI 线程中创建和更新所有小部件。但是,您可以在工作线程中执行其他长时间运行的任务,并使用它们的结果来提供应用程序的 GUI 组件。这意味着 GUI 组件将充当消费者,从执行实际工作的线程中获取信息。

工作线程

您可以在 PyQt 应用程序中根据需要创建任意数量的工作线程。工作线程是辅助执行线程,您可以使用它从主线程卸载长时间运行的任务并防止 GUI 冻结。

您可以使用QThread. 每个工作线程都可以有自己的事件循环,并支持 PyQt 的信号和槽机制与主线程进行通信。如果您从从QObject特定线程中继承的任何类创建对象,则称该对象属于该线程,或该线程有亲缘关系。它的孩子也必须属于同一个线程。

QThread不是线程本身。它是一个操作系统线程的包装器。真正的线程对象是在您调用时创建的QThread.start()

QThread提供高级应用程序编程接口 ( API ) 来管理线程。这个API包含的信号,如.started().finished(),当线程开始和结束被发射。它还包括这样的方法和槽,如.start().wait().exit().quit().isFinished(),和.isRunning()

与任何其他线程解决方案一样,QThread您必须保护您的数据和资源免受并发或同时访问。否则你会面临很多问题,包括死锁、数据损坏等等。

使用QThreadvs Python 的threading

当谈到在 Python 中使用线程时,您会发现 Python标准库为该threading模块提供了一致且健壮的解决方案。该模块提供了一个高级 API,用于在 Python 中进行多线程编程。

通常,您将threading在 Python 应用程序中使用。但是,如果您使用 PyQt 使用 Python 构建 GUI 应用程序,那么您还有另一种选择。PyQt 为执行多线程提供了一个完整的、完全集成的、高级的 API。

你可能想知道,我应该在我的 PyQt 应用程序中使用什么,Python 的线程支持还是 PyQt 的线程支持?答案是视情况而定。

例如,如果您正在构建一个也有Web 版本的 GUI 应用程序,那么 Python 的线程可能更有意义,因为您的后端根本不会依赖 PyQt。但是,如果您正在构建裸 PyQt 应用程序,那么 PyQt 的线程适合您。

使用 PyQt 的线程支持提供以下好处:

  • 线程相关的类与 PyQt 基础设施的其余部分完全集成。
  • 工作线程可以有自己的事件循环,从而启用事件处理。
  • 可以使用信号和槽进行线程间通信

如果您要与库的其余部分交互,经验法则可能是使用 PyQt 的线程支持,否则使用 Python 的线程支持。

使用QThread防止冻结的GUI

GUI 应用程序中线程的一个常见用途是将长时间运行的任务卸载到工作线程,以便 GUI 保持对用户交互的响应。在 PyQt 中,您用于QThread创建和管理工作线程。

根据 Qt 的文档,有两种主要方法可以创建工作线程QThread

  1. QThread直接实例化并创建一个 worker QObject,然后.moveToThread()使用线程作为参数调用worker 。工作者必​​须包含执行特定任务所需的所有功能。
  2. 子类化QThread并重新实现.run()。的实现.run()必须包含执行特定任务所需的所有功能。

实例化 aQThread提供了一个并行事件循环。事件循环允许线程拥有的对象在其插槽上接收信号,这些插槽将在线程内执行。子类化QThread允许应用程序在没有事件循环的情况下运行并行代码。

Qt 社区中存在一些关于这些方法中哪一种最适合创建工作线程的争论。然而,第一种方法是Qt 社区和维护者推荐的

创建工作线程的第一种方法需要以下步骤:

  1. 通过子类化准备一个工作对象QObject,并将您的长时间运行的任务放入其中。
  2. 创建工作类的新实例。
  3. 创建一个新QThread实例。
  4. 通过调用将工作对象移动到新创建的线程中.moveToThread(thread)
  5. 连接所需的信号和槽以保证线程间通信。
  6. 调用.start()的上QThread对象。

您可以使用以下步骤将 Freezing GUI 应用程序转变为响应式 GUI 应用程序:

from PyQt5.QtCore import QObject, QThread, pyqtSignal
# Snip...

# 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):
    # Snip...
    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)
        # 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")
        )

首先,您执行一些必需的导入。然后运行之前看到的步骤。

在第 1 步中,您创建Worker了 的子类QObject。在 中Worker,您创建了两个信号,finished并且progress。请注意,您必须将信号创建为类属性

您还创建了一个名为 的方法.runLongTask(),您可以在其中放置执行长时间运行任务所需的所有代码。在此示例中,您将使用迭代次数的for循环模拟长时间运行的任务5,每次迭代有 1 秒的延迟。循环还会发出progress信号,指示操作的进度。最后,.runLongTask()发出finished信号以指出处理已完成。

在步骤 2 到 4 中,您创建 的实例QThread,它将提供运行此任务的空间,以及 的实例Worker。您可以通过调用移动你的工人对象的线程.moveToThread()worker,使用thread作为参数。

在步骤 5 中,您连接以下信号和插槽:

  • 线程的started信号给worker的.runLongTask()slot,保证当你启动线程的时候,.runLongTask()会被自动调用

  • 工作finished线程的.quit()插槽threadworker完成其工作后退出的信号

  • finished该信号.deleteLater()在两个插槽要删除的对象的工人,当工作完成线程对象

最后,在第 6 步中,您使用.start().

线程运行后,您需要进行一些重置以使应用程序的行为一致。您禁用了长时间运行的任务!按钮以防止用户在任务运行时单击它。您还可以将线程的finished信号与启用长时间运行任务lambda函数连接起来线程结束时的按钮。您的最终连接将重置Long-Running Step标签的文本。

如果您运行此应用程序,您将在屏幕上看到以下窗口:

PyQt 响应式 GUI 示例

由于您将长时间运行的任务卸载到工作线程,您的应用程序现在可以完全响应。就是这样!您已经成功地使用 PyQtQThread解决了您在前几节中看到的冻结 GUI 问题。

重用线程:QRunnableQThreadPool

如果您的 GUI 应用程序严重依赖多线程,那么您将面临与创建和销毁线程相关的大量开销。您还必须考虑在给定系统上可以启动多少个线程,以便您的应用程序保持高效。幸运的是,PyQt 的线程支持也为您提供了解决这些问题的方法。

每个应用程序都有一个全局线程池。您可以通过调用获取对它的引用QThreadPool.globalInstance()

注意:尽管使用默认线程池是一个相当普遍的选择,但您也可以通过实例化来创建自己的线程池QThreadPool,它提供了可重用线程的集合。

全局线程池通常根据您当前 CPU 中的内核数来维护和管理建议的线程数。它还处理应用程序线程中任务的排队和执行。池中的线程是可重用的,这可以防止与创建和销毁线程相关的开销。

要创建任务并在线程池中运行它们,您可以使用QRunnable. 此类表示需要运行的任务或代码段。创建和执行可运行任务的过程包括三个步骤:

  1. 使用要运行的任务的代码进行子类化QRunnable和重新实现.run()
  2. 实例化 的子类QRunnable以创建可运行的任务。
  3. 调用QThreadPool.start()与可运行的任务作为参数。

.run()必须包含手头任务所需的代码。.start()在池中的可用线程之一中启动您的任务的调用。如果没有可用线程,则将.start()任务放入池的运行队列中。当一个线程可用时,其中的代码.run()将在该线程中执行。

这是一个 GUI 应用程序,展示了如何在代码中实现此过程:

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

下面是这段代码的工作原理:

  • 在第 19 到 28 行,您对要执行的代码进行子类化QRunnable并重新实现.run()。在这种情况下,您使用通常的循环来模拟长时间运行的任务。调用logging.info()通过在终端屏幕上打印一条消息来通知您操作的进度。
  • 在第 52 行,您可以获得可用线程的数量。这个数字将取决于您的特定硬件,通常基于您的 CPU 内核。
  • 在第 53 行,您更新标签的文本以反映您可以运行的线程数。
  • 在第 55 行,您开始一个for循环,该循环在可用线程上进行迭代。
  • 在第 57 行,您实例化Runnable,将循环变量i作为参数传递以标识当前线程。然后你调用.start()线程池,使用你的可运行任务作为参数。

需要注意的是一些在本教程中使用的例子是很重要的logging.info()一个基本的配置消息打印到屏幕上。您需要这样做,因为print()它不是线程安全函数,因此使用它可能会导致输出混乱。幸运的是,中的函数logging是线程安全的,因此您可以在多线程应用程序中使用它们。

如果您运行此应用程序,您将获得以下行为:

PyQt QRunnable 示例

当您单击单击我!按钮,应用程序最多启动四个线程。在后台终端,应用程序报告每个线程的进度。如果关闭应用程序,则线程将继续运行,直到它们完成各自的任务。

QRunnable在 Python 中无法从外部停止对象。要解决此问题,您可以创建一个全局布尔变量,并从您的QRunnable子类内部系统地检查它以在您的变量变为True.

使用QThreadPooland 的另一个缺点QRunnableQRunnable不支持信号和槽,因此线程间通信可能具有挑战性。

另一方面,QThreadPool自动管理线程池并处理这些线程中可运行任务的排队和执行。池中的线程是可重用的,这有助于减少应用程序的开销。

与 Worker QThreads 通信

如果您正在使用 PyQt 进行多线程编程,那么您可能需要在应用程序的主线程和工作线程之间建立通信。这允许您获得有关工作线程进度的反馈并相应地更新 GUI,将数据发送到您的线程,允许用户中断执行,等等。

PyQt 的信号和槽机制提供了一种在 GUI 应用程序中与工作线程通信的健壮且安全的方式。

另一方面,您可能还需要在工作线程之间建立通信,例如共享数据缓冲区或任何其他类型的资源。在这种情况下,您需要确保正确保护您的数据和资源免受并发访问。

使用信号和槽

一个线程安全的对象是一个可并发多线程访问,并保证在有效状态的对象。PyQt 的信号和槽是线程安全的,因此您可以使用它们来建立线程间通信以及在线程之间共享数据。

您可以将线程发出的信号连接到线程内或不同线程内的插槽。这意味着您可以在一个线程中执行代码作为对同一线程或另一个线程中发出的信号的响应。这在线程之间建立了安全的通信桥梁。

信号也可以包含数据,因此如果您发出一个包含数据的信号,那么您将在连接到该信号的所有插槽中接收该数据。

响应式 GUI应用程序示例中,您使用了信号和插槽机制来建立线程之间的通信。例如,您将工作线程的progress信号连接到应用程序的.reportProgress()插槽。progress持有一个整数值,指示长时间运行的任务的进度,并.reportProgress()接收该值作为参数,以便它可以更新长时间运行的步骤标签。

在不同线程中建立信号槽之间的连接是 PyQt 中线程间通信的基础。在这一点上,您可以尝试使用一个QToolBar对象而不是Long-Running Step标签来显示响应式 GUI 应用程序中使用信号和槽的操作进度,这是一个很好的练习。

在线程之间共享数据

创建多线程应用程序通常需要多个线程访问相同的数据或资源。如果多个线程同时访问相同的数据或资源,并且其中至少有一个写入或修改此共享资源,那么您可能会面临崩溃、内存或数据损坏、死锁或其他问题。

至少有两种方法可以让您保护数据和资源免受并发访问:

  1. 使用以下技术避免共享状态

  2. 使用以下技术同步对共享状态的访问

如果您需要共享资源,那么您应该使用第二种方法。原子操作在单个执行步骤中执行,因此它们不能被其他线程中断。它们确保在给定时间只有一个线程会修改资源。

注意:有关 CPython 如何管理原子操作的参考,请查看哪些类型的全局值突变是线程安全的?

请注意,其他 Python 实现的行为可能有所不同,因此如果您使用不同的实现,请查看其文档以获取有关原子操作和线程安全的更多详细信息。

互斥是多线程编程中的常见模式。使用保护对数据和资源的访问,是一种同步机制,通常只允许一个线程在给定时间访问资源。

例如,如果线程 A 需要更新一个全局变量,那么它可以获取对该变量的锁。这可以防止线程 B 同时访问该变量。一旦线程 A 完成对变量的更新,它就会释放锁,线程 B 就可以访问该变量。这是基于互斥原则,它通过在访问数据和资源时让线程相互等待来强制同步访问。

值得一提的是,使用锁的成本很高,并且会降低应用程序的整体性能。线程同步强制大多数线程等待资源可用,因此您将不再利用并行执行。

PyQt 提供了一些方便的类来保护资源和数据免受并发访问:

  • QMutex是一个锁类,允许您管理互斥。您可以锁定给定线程中的互斥锁以获得对共享资源的独占访问。一旦互斥锁被解锁,其他线程就可以访问该资源。

  • QReadWriteLock类似于QMutex但区分读和写访问。使用这种类型的锁,您可以允许多个线程同时对共享资源进行只读访问。如果一个线程需要写入资源,那么所有其他线程必须被阻塞,直到写入完成。

  • QSemaphoreQMutex保护一定数量的相同资源的概括。如果一个信号量正在保护n 个资源,而您试图锁定n + 1 个资源,那么信号量就会被阻塞,从而阻止线程访问这些资源。

使用 PyQt 的锁类,您可以保护您的数据和资源并防止很多问题。下一节显示了如何QMutex用于这些目的的示例。

保护共享数据 QMutex

QMutex常用于多线程 PyQt 应用程序中,以防止多个线程并发访问共享数据和资源。在本节中,您将编写一个 GUI 应用程序,该应用程序使用QMutex对象来保护全局变量免受并发写入访问。

要了解如何使用QMutex,您将编写一个示例来管理一个银行账户,两个人可以随时从该账户中取款。在这种情况下,您需要保护帐户余额免受并行访问。否则,人们最终可能会提取比他们在银行中更多的钱。

例如,假设您有一个 100 美元的帐户。两个人同时查看可用余额,看到账户有100美元。他们每个人都认为他们可以提取 60 美元并在帐户中留下 40 美元,因此他们继续进行交易。帐户中的最终余额将为 -$20,这可能是一个重大问题。

要对示例进行编码,您将首先导入所需的模块、函数和类。您还添加了一个基本logging配置并定义了两个全局变量:

import logging
import random
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是一个全局变量,您将使用它来存储银行帐户中的当前余额。mutex是一个QMutex对象,您将使用它来防止balance并行访问。换句话说,使用mutex,您将阻止多个线程同时访问balance

下一步是创建一个子类,QObject其中包含管理如何从银行账户取款的代码。你会打电话给那个班级AccountManager

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()

在 中AccountManager,您首先定义两个信号:

  1. finished 指示类何时处理其工作。
  2. updatedBalance指示何时balance更新。

然后你定义.withdraw(). 在此方法中,您执行以下操作:

  • 显示一条消息,指出想要取款的人
  • 使用global语句balance从内部使用.withdraw()
  • 呼叫.lock()mutex获得锁,保护balance从并行访问
  • 检查账户余额是否允许提取手头的金额
  • 调用sleep()模拟操作需要一些时间才能完成
  • 将余额减少所需的金额
  • 显示消息以通知交易是否被接受
  • 发出updatedBalance信号通知余额已更新
  • 释放锁以允许其他线程访问 balance
  • 发出finished信号通知操作完成

此应用程序将显示如下窗口:

Account Manager GUI

这是创建此 GUI 所需的代码:

class Window(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setupUi()

    def setupUi(self):
        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)

当前余额标签显示帐户的可用余额。如果您点击提款!按钮,然后应用程序将模拟两个人同时尝试从帐户中取款。您将使用线程模拟这两个人:

class Window(QMainWindow):
    # Snip...
    def createThread(self, person, amount):
        thread = QThread()
        worker = AccountManager()
        worker.moveToThread(thread)
        thread.started.connect(lambda: worker.withdraw(person, amount))
        worker.updatedBalance.connect(self.updateBalance)
        worker.finished.connect(thread.quit)
        worker.finished.connect(worker.deleteLater)
        thread.finished.connect(thread.deleteLater)
        return thread

此方法包含为每个人创建线程所需的代码。在这个例子中,你将线程的started信号连接到工作线程的.withdraw(),所以当线程启动时,这个方法会自动运行。您还将工作人员的updatedBalance信号连接到名为 的方法.updateBalance()。此方法将使用当前帐户更新Current Balance标签balance

这是代码.updateBalance()

class Window(QMainWindow):
    # Snip...
    def updateBalance(self):
        self.balanceLabel.setText(f"Current Balance: ${balance:,.2f}")

每当有人提款时,帐户的余额都会减少所要求的金额。此方法更新当前余额标签的文本以反映帐户余额的变化。

要完成应用程序,您需要创建两个人并为他们每个人启动一个线程:

class Window(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setupUi()
        self.threads = []

    # Snip...
    def startThreads(self):
        self.threads.clear()
        people = {
            "Alice": random.randint(100, 10000) / 100,
            "Bob": random.randint(100, 10000) / 100,
        }
        self.threads = [
            self.createThread(person, amount)
            for person, amount in people.items()
        ]
        for thread in self.threads:
            thread.start()

首先,您将.threads一个实例属性添加到Window. 此变量将保存一个线程列表,以防止线程在.startThreads()返回后超出范围。然后您定义.startThreads()为每个人创建两个人和一个线程。

在 中.startThreads(),您执行以下操作:

  • 清除线程.threads如果有的话删除已已毁线程
  • 创建一个包含两个人的字典,AliceBob。每个人都会尝试从银行账户中提取随机数量的钱
  • 使用列表理解为每个人创建一个线程,并.createThread()
  • for循环中启动线程

有了这最后一段代码,您就快完成了。您只需要创建应用程序和窗口,然后运行事件循环:

app = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(app.exec())

如果您从命令行运行此应用程序,那么您将获得以下行为:

客户经理示例

后台终端中的输出显示线程工作。QMutex在本示例中使用对象可以保护银行帐户余额并同步对其的访问。这可以防止用户提取超过可用余额的金额。

PyQt 中的多线程:最佳实践

在 PyQt 中构建多线程应用程序时,您可以应用一些最佳实践。这是一个非详尽列表:

  • 避免在 PyQt 应用程序的主线程中启动长时间运行的任务。
  • 使用QObject.moveToThread()QThread对象来创建工作线程。
  • 使用QThreadPoolQRunnable如果你需要管理的工作线程池。
  • 使用信号和槽来建立安全的线程间通信。
  • 使用QMutexQReadWriteLockQSemaphore来防止线程同时访问共享数据和资源。
  • 确保在完成线程之前解锁或释放QMutexQReadWriteLock、 或QSemaphore
  • 在具有多个return语句的函数中释放所有可能执行路径中的锁。
  • 不要尝试从工作线程创建、访问或更新 GUI 组件或小部件。
  • 不要尝试将QObject具有父子关系的 a移动到不同的线程。

如果您在 PyQt 中使用线程时始终应用这些最佳实践,那么您的应用程序将不太容易出错并且更加准确和健壮。您将防止出现数据损坏、死锁、竞争条件等问题。您还将为您的用户提供更好的体验。

结论

在 PyQt 应用程序的主线程中执行长时间运行的任务可能会导致应用程序的 GUI 冻结并变得无响应。这是GUI 编程中的常见问题,可能会导致糟糕的用户体验。使用PyQtQThread创建工作线程来卸载长时间运行的任务可以有效地解决 GUI 应用程序中的这个问题。

在本教程中,您学习了如何:

  • 使用 PyQtQThread来防止 GUI 应用程序冻结
  • 创建可重用的QThread对象与PyQt的的QThreadPoolQRunnable
  • 使用信号和槽为间通信在PyQt的
  • 使用共享资源的安全与PyQt的的锁类

您还了解到,适用于多线程编程与PyQt的和其内置的线程支持的一些最佳做法。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。