使用 PyQt 的 QThread 防止 GUI 冻结

2025 年 1 月 11 日 | 20 分钟阅读

在 PyQt 图形用户界面 (GUI) 程序中,事件循环和 GUI 在主执行线程上运行。如果在此线程中启动一个长时间运行的进程,您的 GUI 将变得无响应,因为它只会等到该进程完成。用户体验会很差,因为他们只能在该期间与程序交互。幸运的是,您可以使用 PyQt 的 QThread 类来解决这个问题。

本教程将教您如何

  • 使用 PyQt 的 QThread 防止 GUI 冻结
  • 使用 QRunnable 和 QThreadPool 创建可重用线程。
  • 使用信号和槽控制线程间通信
  • 采用使用 PyQt 的线程支持创建 GUI 应用程序的最佳实践,并安全地处理共享资源。

长时间运行导致 GUI 冻结的任务

GUI 编程中普遍存在的问题是,长时间运行的任务会占用主线程并导致软件冻结,这几乎总是导致糟糕的用户体验。

假设您希望“点击我!”按钮的点击总次数显示在“计数”标签中。当您单击“长时间运行任务!”按钮时,一个需要很长时间才能完成的任务将开始。您的资源密集型进程可能是文件下载、对大型数据库的查询或任何其他耗时任务。

以下是使用 PyQt 和单个操作线程对该应用程序进行编程的首次尝试

说明

  • 此冻结 GUI 应用程序中的 setupUi() 函数创建 GUI 所需的所有图形元素。“调用我!”按钮被点击以进行调用。使用 countClicks1() 更改“计数”标签的措辞以反映按钮点击次数。
  • 当“耗时任务!”按钮被点击时,runLongTask() 函数被调用,执行一个需要五秒钟才能完成的任务。这是一个虚构的任务,您在 time 的帮助下编写了它。sleep(secs) 函数会暂停调用线程的执行指定的秒数 (secs)。
  • 为了让“长时间运行步骤”标签代表操作进度,您还必须在 .runLongTask() 函数中调用 .reportProgress1()。
  • 五秒后,应用程序的界面再次更新。在 GUI 冻结期间进行了五次点击,这反映在“计数”标签中,它显示十次点击。“长时间运行里程碑”标签必须准确描述您的长时间运行项目的进度。它跳过了中间阶段,直接从 0 到 5。
  • 停止的主线程导致应用程序的图形用户界面冻结。用户的操作需要时间才能响应主线程,因为它正忙于执行耗时活动。当用户不确定应用程序是否正常运行或已崩溃时,此行为令人恼火。
  • 然而,您可以使用各种方法来解决这个问题。通过工作线程而不是应用程序的主线程执行耗时操作是一个典型的解决方案。

您将在以下部分中了解如何使用 PyQt 的集成线程支持来解决无响应或停止的 GUI 问题,并在您的应用程序中提供最佳用户体验。

多线程基础知识

您的程序有时可以分解为几个较小的作业或子程序,您可以在不同的线程中执行它们。在执行耗时操作时避免应用程序冻结可以加快您的程序或帮助您改善用户体验。

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

线程的数量是无限的。困难的部分是弄清楚要使用多少线程。当您使用可用的 I/O 密集型线程时,系统资源限制了您可以使用的线程数量。另一方面,如果您正在使用 CPU 密集型线程,那么拥有的线程数量等于或少于系统中的 CPU 核心数量将是有利的。

多线程编程是创建可以在不同线程上同时执行多个任务的程序的过程。使用此方法,多个作业理想情况下应该并发且独立地运行。然而,这并非总是可行的。软件可能无法同时运行多个线程,原因至少有以下两个:

  1. 中央处理器 (CPU)
  2. 计算机语言

例如,您无法在具有单核处理能力的计算机上同时执行多个线程。另一方面,某些单核处理器允许操作系统将处理时间分配给多个线程,以模拟并行线程执行。这给人的印象是您的线程同时运行,而它们只是一次运行一个。

另一方面,如果您有多个核心的计算机或计算机集群,则可以同时执行多个线程。您的编程语言在这种情况下起着重要作用。一些底层编程语言结构限制了多个并发线程的执行。

在这些情况下,线程并发运行的原因很少,例如

  • 由于线程之间资源共享、数据访问同步和线程执行同步所涉及的复杂性,多线程系统通常比单线程程序更难编写、维护和排除故障。这可能会导致几个问题
  • 当事件的不可预测的顺序导致应用程序的行为变得不确定时,就会发生竞争条件。它经常发生,因为两个或多个线程错误地同步了对共享资源的访问。例如,如果读写操作以错误的顺序执行,从不同线程读写存储可能会导致竞争情况。
  • 当线程耐心等待锁定资源的释放时,就会发生死锁。例如,如果一个资源被一个线程锁定并且在使用后没有释放,其他线程将不得不无限期地等待。如果线程 A 正在等待线程 B 解锁一个资源,而线程 B 正在等待线程 A 解锁另一个资源,也可能发生死锁。
  • 活锁是一种情况,其中两个或多个线程重复地对彼此的动作做出反应,导致两个线程永远等待。活锁的线程无法继续执行其特定任务,因为它们忙于对其他线程做出反应。但是,它们既没有被阻塞也没有死锁。
  • 饥饿是指进程无法获取完成其任务所需的资源。例如,如果一个进程无法获取 CPU 时间,它就无法完成其任务,因为它正在“饥饿”CPU 时间。

在开发多线程程序时,您必须注意保护您的资源免受并发写入或状态修改访问。换句话说,您必须阻止多个线程同时使用某个特定资源。

多线程编程至少通过三种不同的方式为各种应用程序提供优势

  1. 通过利用多核处理器提高应用程序的速度
  2. 将应用程序的结构精简为更易于管理的子任务
  3. 您可以通过将耗时操作转移到工作线程来保持应用程序的响应性和最新性。

在 CPython(Python 语言的 C 版本)中,线程不是同时执行的。CPython 中的全局解释器锁 (GIL) 有效地阻止了多个 Python 线程同时运行。

由于线程之间上下文切换造成的开销,这可能会严重影响使用线程的 Python 程序的性能。

PyQt 中的多线程与 QThread

PyQt 是 Qt 的一个子集,它提供了自己的框架来构建基于 QThread 的多线程应用程序。使用 PyQt 构建的应用程序可以使用两种不同类型的线程

  1. 主线程
  2. 工作线程

应用程序的主线程始终处于活动状态。应用程序及其界面从此处运行。另一方面,工作线程是否存在取决于应用程序处理的需要。例如,如果您的应用程序经常执行耗时的繁重活动,则应该有工作线程来处理此类任务并防止应用程序的 GUI 冻结。

主线程

由于它管理所有小部件和其他 GUI 元素,PyQt 程序中的主执行线程也称为 GUI 线程。当您在 Python 中启动程序时,会创建此线程。在对 QApplication 对象调用 .exec() 之后,应用程序的事件循环在此线程中执行。此线程管理您的窗口、对话框和主机操作系统交互。

在应用程序主线程上发生的每个事件或活动(包括用户在 GUI 上的操作)的默认行为是异步的,或者一个接一个地执行。因此,如果您在主线程中启动一个长时间运行的进程,应用程序必须等待它完成,这会导致 GUI 无响应。

您必须在 GUI 线程中构建和更新所有小部件,这一点至关重要。但是,您可以在工作线程中执行其他耗时进程,并使用它们的输出为应用程序的 GUI 组件提供信息。这意味着 GUI 元素将充当信息消费者,消耗来自执行实际工作的线程的数据。

工作线程

您的 PyQt 应用程序可以拥有任意数量的工作线程。工作线程是辅助执行线程,可以将耗时活动从主线程委派出去,并避免 GUI 冻结。QThread 允许创建工作线程。

通过 PyQt 的信号和槽系统与主线程连接的能力,每个工作线程都可以拥有自己的事件循环。如果一个对象是从 QObject 派生的任何类在该线程中创建的,则认为它属于或对该线程具有亲和性。它的后代也必须连接到该线程。

QThread 不是一个线程。它封装了操作系统中的一个线程。当您使用 QThread 时,实际的线程对象是通过 .start() 生成的。

QThread 为应用程序提供高级编程接口 (API) 来管理线程。通过包含信号 .started() 和 .finished(),此 API 会发出线程启动和结束的信号。它还包含 .start()、.wait() 和 .exit() 等槽和方法。

QThread vs Python 的 threading 模块

Python 标准库的 threading 模块提供了一种可靠且一致的方法来在 Python 中使用线程。此模块为多线程编程提供了高级 Python API。

通常,threading 在 Python 程序中使用。但是,如果您正在使用 PyQt 创建带有 Python 的 GUI 应用程序,您还有另一种选择。PyQt1 提供了一个完整、集成、更强大的多线程 API。

我应该在 PyQt 应用程序中使用 PyQt 的线程支持还是 Python 的线程支持?答案是视情况而定。

例如,如果您正在开发一个包含 Web 版本的 GUI 应用程序,那么 Python 的线程更有意义,因为您的后端不需要处理线程。

使用 PyQt 线程支持的优点包括:

  1. 处理线程的类与 PyQt 的其他基础设施完全集成。
  2. 工作线程拥有自己的事件循环,提供了处理事件的能力。
  3. 信号和槽可用于促进线程间通信。
  4. 如果您想与库的其余部分通信,您应该使用 Python 的线程支持;否则,使用 PyQt 的线程支持。

使用 QThread 防止 GUI 冻结

在 GUI 应用程序中将耗时活动卸载到工作线程是一种常见的做法,可以使 GUI 对用户交互保持响应。在 PyQt 中,使用 QThread 生成和管理工作线程。

通过实例化 QThread,可以提供一个并行事件循环。事件循环使线程拥有的对象能够在其槽上接收信号,并由线程执行它们。

相反,子类化 QThread 可以在没有事件循环的情况下执行并行代码。使用此策略,您可以通过显式调用 exec() 来创建事件循环。

本教程将采用第一种策略,它需要执行以下操作

  1. 子类化 QObject 以创建 Worker 对象,然后添加您的长时间运行任务。
  2. 创建新的 Worker 类实例。
  3. 启动新的 QThread 实例。
  4. 调用新创建的线程以插入 Worker 对象。
  5. moveToThread(thread)。
  6. 连接必要的槽和信号以确保线程间通信。
  7. 使用 QThread 对象的 .start() 方法。

通过遵循这些步骤,您可以将冻结的 GUI 应用程序转换为响应式 GUI 应用程序

解释: 首先,您导入一些必需的模块。然后按照前面看到的步骤进行操作。Workers 作为 QObject 的子类在步骤 1 中创建。在 Workers 中,您可以创建 finished 和 progress 信号。请注意,信号必须创建为类属性。

  • 此外,您还创建了一个名为 .runLongTask() 的方法,其中放置了执行长时间运行任务所需的所有代码。在此示例中,使用一个循环五次的 for 循环来模拟长时间运行的任务,每次迭代之间有一秒的延迟。循环还会发出 progress 信号,指示操作的进度。然后 .runLongTask() 会发出 finished 信号,表明处理已完成。
  • 您在步骤 2 到 4 中创建了一个 Workers 实例和一个 QThread 实例,作为此任务的工作区。您通过对 Worker 调用 .moveToThread() 将您的专业对象移动到字符串中,涉及将字符串作为争论。

在步骤 5 中连接以下槽和信号

  • 线程启动信号连接到 Worker 的 .runLongTask() 槽,以确保当线程启动时 .runLongTask() 将自动调用。
  • Worker 完成任务后,它会向线程的 .quit() 槽发送一个 finished 信号,从而退出线程。
  • finished 信号指示 Worker 和线程对象使用 .deleteLater() 槽删除。最后,在步骤 6 中,您使用 .start() 启动线程。

一旦线程处于活动状态,您必须执行一些重置以使应用程序持续运行。为了防止用户在任务进行时单击“长时间运行任务!”按钮,您可以禁用它。此外,您将线程的 finished 信号链接到一个 lambda 函数,该函数在调用时激活“长时间运行任务!”按钮。长时间运行步骤标签的文本在您的最终连接时重置。

启动此程序后,以下窗口将出现在您的屏幕上

Prevent Freeze GUIs By Using PyQt's QThread

QRunnable 和 QThreadPool:重用线程

如果您的 GUI 应用程序大量依赖多线程,您将遇到与创建和终止线程相关的巨大开销。因此,为了使您的应用程序高效运行,您还需要考虑在特定机器上可以启动多少个线程。值得庆幸的是,PyQt 的线程支持也为您提供了这些问题的解决方案。

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

尽管通常使用默认线程池,但 QThreadPool 提供了可重复使用的线程集合,允许您构建自己的线程池。

全局线程池维护和管理建议的线程数量,通常基于当前 CPU 的核心数量。它还负责应用程序中线程的任务排队和执行。由于池中的线程是可重用的,因此不再有创建和删除线程的开销。

您使用 QRunnable 构建任务并在线程池中执行它们。此类象征着必须执行的进程或代码行。生成和执行可运行任务涉及三个过程

通过子类化 QRunnable 来重新实现它。

  1. 使用带有任务代码的 run() 函数。
  2. 要创建可运行任务,请实例化 QRunnable 的任何子类。
  3. 调用 QThreadPool。

以二进制兼容任务作为参数,调用 start()。

任务所需的代码必须是 run()。当您调用时,您的任务会在池中可用的一个线程中启动。start()。如果池中没有可用的线程,.start() 会将作业添加到池的运行队列中。.run() 中的代码会在可用的线程中执行。

下面是一个 GUI 程序,演示如何将此过程合并到您的代码中

代码的功能如下

  • 您在第 19 到 28 行子类化 QRunnable 并重新实现它。
  • 将您想要运行的代码放入 run() 中。在本例中,您使用标准循环模拟长时间任务。当调用 logging.info() 时,会在您的终端屏幕上打印一条消息,让您知道过程的进展。
  • 第 52 行给出了可访问线程的总数量。此数字通常取决于您的 CPU 核心数量,并将因您的特定硬件而异。
  • 您在第 53 行更改标签的措辞,以反映您操作特定数量线程的能力。
  • 您在第 55 行开始一个遍历可用线程的 for 循环。
  • 您在第 57 行创建了一个 Runnable 对象并提供了循环变量 i。

值得注意的是,本教程包含一些与日志记录相关的示例。使用带有简单配置的 info(),消息会打印在屏幕上。这是必要的,因为 print() 不是线程安全函数,可能会导致您的输出混乱。日志例程是线程安全的,允许您在多线程应用程序中使用它们。

如果您使用此应用程序,您将观察到以下行为

Prevent Freeze GUIs By Using PyQt's QThread

当您单击“点击我!”按钮时,应用程序最多可以启动四个线程。程序会更新后台终端中每个线程的进度。即使您关闭应用程序,线程也会一直运行,直到它们完成各自的职责。

使用 Python,没有方法可以从外部停止 QRunnable 实例。为了解决这个问题,您可以创建一个全局布尔变量,并在 QRunnable 子类中反复检查它,以便在值为 True 时终止它们。因为 QRunnable 需要更多信号和槽支持,所以在使用 QThreadPool 和 QRunnable 时,线程间通信可能很困难。

然而,QThreadPool 会自动管理线程池,并负责可运行任务的排队和实现。

工作 QThreads 通信

如果您正在使用 PyQt 编程多个线程,您可能需要促进应用程序主线程与工作线程之间的通信。这样做可以帮助您将数据传递给线程,获取工作线程状态的反馈,适当地更新 GUI,允许用户暂停执行等等。

PyQt 的信号和槽技术提供了一种可靠且安全的通信方法,用于在 GUI 应用程序中与工作线程进行通信。

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

使用槽和信号

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

来自一个线程的信号可以连接到另一个线程中的槽。因此,您可以通过在不同的线程中运行代码来响应在一个线程或另一个线程中发出的信号。由此创建了一个安全的线程间通信方式。

因为信号也可以包含数据,如果您发出包含数据的信号,您将在连接到该信号的每个槽中接收到该数据。

在响应式 GUI 应用程序模型中,您利用信号和槽组件来布置字符串之间的对应关系。例如,您将 Workers 的进度信号连接到应用程序的 .reportProgress1() 槽。为了更新长时间运行步骤标签,.reportProgress1() 接受来自 progress 的整数值,该整数值表示长时间运行任务的进度。

PyQt 的线程间通信建立在连接不同线程中的信号和槽的基础之上。此时,尝试使用信号和槽,在响应式 GUI 应用程序中使用 QToolBar 对象而不是长时间运行步骤标签来显示操作的进度。

QMutex(保护共享数据)

在多线程 PyQt 应用程序中,QMutex 经常用于防止多个线程同时访问共享资源和数据。您将编写一个图形用户界面 (GUI) 的代码,该界面使用 QMutex 对象来防止对全局变量的并发写入访问。

您将编写一个管理银行账户的示例,其中两个人可以随时取款,以学习如何使用 QMutex。在这种情况下,您需要防止并行访问账户余额。在任何其他情况下,人们可能会取出比他们银行账户中更多的钱。

例如,假设您有一个 100 美元的账户。当两个人同时检查可用余额时,他们发现账户中有 100 美元。他们继续进行交易,因为他们相信他们可以提取 60 美元并在账户中保留 40 美元。账户将有 20 美元的赤字,这可能是一个严重的问题。

编写示例的第一步是导入必要的类、函数和模块。您还定义了两个全局变量并添加了基本日志配置

您将使用全局变量 balance 来跟踪账户的当前余额。您将使用 QMutex 对象 mutex 来保护 balance 免受并发访问。换句话说,互斥锁将阻止多个线程同时访问 balance。

下一步是开发 QObject 的子类,其中包含控制银行账户提款的逻辑。该类将被称为 AccountManager。

然后您定义 .withdraw()。您使用此技术执行以下操作

  • 使用全局语句从 .withdraw() 内部访问 balance;在 mutex 上调用 .lock() 以获取锁并防止并行访问 balance;检查账户余额是否允许提取当前金额;调用 sleep() 以模拟操作需要一些时间才能完成;并显示一条消息,标识需要取款的人。
  • 将余额减去所需的金额;显示警报以指示交易是否获批;
  • 释放锁以允许其他线程访问余额并发出 finished 信号以指示操作已完成。发出 updatedBalance1 信号以指示余额已更新。

此应用程序的输出

Prevent Freeze GUIs By Using PyQt's QThread

创建上述 GUI 的代码

每次提款时,账户余额都会扣除所需的金额。使用此技术,当前余额标签的文本会更新以反映账户余额的变化。您必须创建两个人并为他们每个人启动一个线程才能完成应用程序

首先,您将实例属性 .threads 添加到 Window 的初始化器中。为了防止线程意外超出其范围,此变量将存储一个列表。从 startThreads() 返回。为了生成两个人并为每个人生成一个线程,您定义 .startThreads()。

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

  • 如果有,请清除 .threads 中的线程以删除以前删除的任何线程。
  • 创建一个包含 Alice 和 Bob 姓名的字典。为每个人构造一个线程;使用列表推导和 .createThread(),然后在一个循环中启动线程。每个人都将尝试从银行账户中提取随机数量的现金。

您差不多完成了这最后一点代码。

在查看应用程序输出之前,以下是完整的代码以更好地理解。

交易模拟的完整程序

示例

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

输出

 
GUI:
Before Withdrawal

Prevent Freeze GUIs By Using PyQt's QThread
 
After Withdrawal

Prevent Freeze GUIs By Using PyQt's QThread
CLI:
Alice Needs to withdraw $60.80...
-$60.80 accepted
====Balance====: $39.20
Bob Needs to withdraw $96.90...
-$96.90 rejected
====Balance====: $39.20   

后台终端上的输出显示线程正在运行。在此示例中,您可以通过使用 QMutex 对象来保护和同步对银行账户余额的访问。因此,防止用户提取比其账户中当前更多的钱。

结论

在 PyQt 应用程序主线程上执行的长时间运行活动可能会导致 GUI 无法使用和冻结。这是一个经常出现在功能需求和编码中的问题,可能会导致糟糕的用户体验。通过使用 PyQt 的 QThread 创建的工作线程卸载长时间运行活动,可以在图形应用程序中轻松解决此问题。

  • 本教程中您学习了如何使用 PyQt 的 QThread 防止 GUI 程序冻结。
  • 使用 PyQt 的 QRunnable 和 QThreadPool 构建可重用的 QThread 实例。
  • 使用 PyQt 的信号和槽进行线程间通信,并使用锁类安全地使用共享资源。
  • 借助 PyQt 及其集成线程支持,您还获得了一些多线程编程的最佳实践。