最近在做HCI的第一个作业语音识别和操作系统课设时需要用到pyqt5做前端界面,而且这两者都涉及多线程,可是操作系统刚刚学完线程的理论🥲,所以又得发扬本专业的优良传统:自学了。为了加深自己的记忆,也为了能给后来者提供参考,现在总结一下做项目需要用到的基本的多线程知识。

多线程简介

多线程的目的:

Sometimes you can divide your programs into several smaller subprograms, or tasks, that you can run in several threads. This might make your programs faster, or it might help you improve the user experience by preventing your programs from freezing while executing long-running tasks.

多线程程序通常比单线程程序更难编写、维护和调试,因为涉及到在线程之间共享资源、同步数据访问和协调线程执行方面的复杂性。这可能会导致以下几个问题:

  • 竞争条件:当应用程序的行为由于事件的不可预测顺序而变得不确定时,就会发生竞争条件。这通常是两个或多个线程在没有正确同步的情况下访问共享资源的结果。例如,如果从不同的线程中读取和写入内存,如果读取和写入操作的顺序不正确,则可能会导致竞争条件。
  • 死锁:当线程无限期地等待锁定资源被释放时,就会发生死锁。例如,如果一个线程锁定一个资源并在使用后不解锁它,则其他线程将无法使用该资源并会无限期地等待。死锁也可能发生在线程A等待线程B释放一个资源,而线程B又等待线程A释放另一个资源的情况下。两个线程将都无限期地等待。
  • 活锁:当两个或多个线程在响应彼此的动作时反复进行时,就会发生活锁。活锁线程无法在其特定任务上取得进一步进展,因为它们忙于彼此响应。然而,它们没有被阻塞或死亡。
  • 饥饿:当一个进程永远无法获得完成工作所需的资源时,就会发生饥饿。例如,如果你有一个无法获得CPU时间访问的进程,那么该进程就会饥饿于CPU时间,无法完成其工作。

Qt,因此也包括PyQt,提供了自己的基础设施来使用QThread创建多线程应用程序。PyQt应用程序可以有两种不同的线程类型:

  1. 主线程
  2. 工作线程

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

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

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

您可以在PyQt应用程序中创建尽可能多的工作线程,工作线程是次要的执行线程,您可以使用它们来卸载主线程中的长时间运行的任务并防止GUI冻结。

您可以使用QThread创建工作线程。每个工作线程都可以拥有自己的事件循环,并支持PyQt的信号和槽机制与主线程进行通信。如果您在特定的线程中创建任何从QObject类继承的对象,则该对象被认为属于或具有该线程的关联性。其子对象也必须属于相同的线程。

QThread本身并不是一个线程,而是对操作系统线程的封装。真正的线程对象是在调用QThread.start()时创建的。

QThread提供了一个高级应用程序编程接口(API)来管理线程。这个API包括一些信号,比如.started().finished(),在线程开始和结束时发出。它还包括一些方法和槽,比如.start().wait().exit().quit().isFinished().isRunning()

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

QThread OR threading?

QThread是pyqt5中用来管理多线程的模块。

Inheritance diagram of PySide2.QtCore.QThread

在Python中使用线程时,您会发现Python标准库提供了一个具有一致性和强大的解决方案,即threading模块。该模块提供了一个高级API,用于在Python中进行多线程编程。

通常,在Python应用程序中使用threading。然而,如果您使用PyQt来构建使用Python的GUI应用程序,则有另一种选择。PyQt提供了一个完整、完全集成的高级API,用于进行多线程编程。

您可能会想知道,在我的PyQt应用程序中,应该使用Python的线程支持还是PyQt的线程支持?答案是,这取决于情况。

例如,如果您正在构建一个既有GUI应用程序,又有一个Web版,那么Python的线程可能更合适,因为您的后端不会完全依赖PyQt。然而,如果您正在构建裸的PyQt应用程序,则PyQt的线程适合您。

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

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

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

QThread实现流程

在GUI应用程序中,使用线程的常见用途是将长时间运行的任务分配给工作线程,以便GUI对用户的交互保持响应。在PyQt中,您可以使用QThread来创建和管理工作线程。

根据Qt的文档,使用QThread创建工作线程有两种主要方式

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

例化QThread提供了一个并行事件循环。事件循环允许由线程拥有的对象在其槽上接收信号,并且这些槽将在线程内执行。

另一方面,子类化QThread允许在没有事件循环的情况下运行并行代码。使用这种方法,您始终可以通过显式调用exec()来创建一个事件循环。

QThread实现案例

考虑这样一段功能:实现两个按钮,按钮1🔘逐次打印1,2,3,4,5。按钮2🔘逐次打印a,b,c,d,e。

都在主线程里执行必然会卡死,各位读者应该也深有感触,不然也不会想看这篇文章了吧🥹这里就不再错误示范了,直接上多线程代码。注意多线程有两种实现方式,分别是继承QObject作为工作块,之后再创建线程,或者定义类继承QThread并覆写run方法,这里采用第二种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import sys
import time
from PyQt5.Qt import *
# 继承QThread
class Thread_1(QThread): # 线程1
def __init__(self):
super().__init__()

def run(self):
values = [1, 2, 3, 4, 5]
for i in values:
print(i)
time.sleep(0.5) # 休眠


class Thread_2(QThread): # 线程2
def __init__(self):
super().__init__()

def run(self):
values = ["a", "b", "c", "d", "e"]
for i in values:
print(i)
time.sleep(0.5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 主界面
class MyWin(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
self.setWindowTitle("test")
self.resize(500, 400)
self.btn_1 = QPushButton("button 1")
layout.addWidget(self.btn_1)
self.btn_1.clicked.connect(self.click_1)
self.btn_1.setObjectName("btn_1")

self.btn_2 = QPushButton("button 2")
self.btn_2.setObjectName("btn_2")
self.btn_2.clicked.connect(self.click_2)
layout.addWidget(self.btn_2)
self.setLayout(layout)
def click_1(self):
self.thread1 = Thread_1()
self.thread1.start()

def click_2(self):
self.thread2 = Thread_2()
self.thread2.start()


if __name__ == "__main__":
app = QApplication(sys.argv)
myShow = MyWin()
myShow.show()
sys.exit(app.exec_())

注意这里覆写run方法之后只需要在主类中创建线程对象然后调用其start方法即可。这段程序的执行结果如何呢?不会出现卡死,而且可以两个按钮执行内容交替执行。

image-20230430193922703

但是有一个严重的问题,就是当快速连续按下两次同一个按钮时,当第一次执行完毕后会将线程删除,从而导致这样的闪退:QThread: Destroyed while thread is still running。我们想实现的是在循环没有结束之前线程不允许使用,这时可以采用两种办法:线程锁🔒和信号。

先来说第一种方法,我们创建两个进程锁,然后在run方法里加锁和解锁,这样可以实现线程的互斥,就和操作系统里讲解的P&V原语一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
mutex1 = QMutex()
mutex2 = QMutex()

class Thread_1(QThread): # 线程1
def __init__(self):
super().__init__()

def run(self):
mutex1.lock()
values = [1, 2, 3, 4, 5]
for i in values:
print(i)
time.sleep(0.5) # 休眠
mutex1.unlock()

class Thread_2(QThread): # 线程2
def __init__(self):
super().__init__()

def run(self):
mutex2.lock()
values = ["a", "b", "c", "d", "e"]
for i in values:
print(i)
time.sleep(0.5)
mutex2.unlock()

但这样的策略只是防止了闪退报错而已(emm我的mac在手速很快的情况还是会闪退,难绷),而且还没达到我们的目的。

所以我们引入信号,在按钮被按下时将按钮设置为不可用,在循环结束之后传递信号使按钮可用,这样就完美实现我们的功能啦~🌹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Thread_1(QThread):  # 线程1
block_signal = pyqtSignal()

def __init__(self):
super().__init__()

def run(self):
values = [1, 2, 3, 4, 5]
for i in values:
print(i)
time.sleep(0.5) # 休眠
self.block_signal.emit()

class Thread_2(QThread): # 线程2
block_signal = pyqtSignal()
def __init__(self):
super().__init__()
def run(self):
values = ["a", "b", "c", "d", "e"]
for i in values:
print(i)
time.sleep(0.5)
self.block_signal.emit()

class MyWin(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
self.setWindowTitle("test")
self.resize(500, 400)
self.btn_1 = QPushButton("button 1")
layout.addWidget(self.btn_1)
self.btn_1.clicked.connect(self.click_1)
self.btn_1.setObjectName("btn_1")

self.btn_2 = QPushButton("button 2")
self.btn_2.setObjectName("btn_2")
self.btn_2.clicked.connect(self.click_2)
layout.addWidget(self.btn_2)
self.setLayout(layout)

def click_1(self):
self.btn_1.setEnabled(False)
self.thread1 = Thread_1()
self.thread1.block_signal.connect(lambda: self.btn_1.setEnabled(True))
self.thread1.start()

def click_2(self):
self.btn_2.setEnabled(False)
self.thread2 = Thread_2()
self.thread2.block_signal.connect(lambda: self.btn_2.setEnabled(True))
self.thread2.start()

个人不习惯继承QThread类,虽然简单直接,但是功能没有另一种实现方式广泛(个人觉得),所以这里附上继承QObject的实现方式(yysy继承QObject真的有够累的😇)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

import sys
import time
from PyQt5.Qt import *

class Thread_1(QObject): # 线程1
block_signal = pyqtSignal()

def __init__(self):
super().__init__()

def run(self):
values = [1, 2, 3, 4, 5]
for i in values:
print(i)
time.sleep(0.5) # 休眠
self.block_signal.emit()

class Thread_2(QObject): # 线程2
block_signal = pyqtSignal()
def __init__(self):
super().__init__()

def run(self):
values = ["a", "b", "c", "d", "e"]
for i in values:
print(i)
time.sleep(0.5)
self.block_signal.emit()

class MyWin(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
self.setWindowTitle("test")
self.resize(500, 400)
self.btn_1 = QPushButton("button 1")
layout.addWidget(self.btn_1)
self.btn_1.clicked.connect(self.click_1)
self.btn_1.setObjectName("btn_1")

self.btn_2 = QPushButton("button 2")
self.btn_2.setObjectName("btn_2")
self.btn_2.clicked.connect(self.click_2)
layout.addWidget(self.btn_2)
self.setLayout(layout)

self.thread1 = QThread()
self.thread2 = QThread()
def click_1(self):
# 把self.thread1的声明放这里也行
self.btn_1.setEnabled(False)
self.worker1 = Thread_1()
self.worker1.moveToThread(self.thread1)
self.thread1.started.connect(self.worker1.run)
self.worker1.block_signal.connect(lambda: self.btn_1.setEnabled(True))
self.worker1.block_signal.connect(self.thread1.quit) # 这句话非常重要,不然线程不知道啥时候结束,下一次信号就没法执行
self.thread1.start()


def click_2(self):
# 把self.thread2的声明放这里也行
self.btn_2.setEnabled(False)
self.worker2 = Thread_2()
self.worker2.moveToThread(self.thread2)
self.thread2.started.connect(self.worker2.run)
self.worker2.block_signal.connect(lambda: self.btn_2.setEnabled(True))
self.worker2.block_signal.connect(self.thread2.quit) # 这句话非常重要,不然线程不知道啥时候结束,下一次信号就没法执行

self.thread2.start()


if __name__ == "__main__":
app = QApplication(sys.argv)
myShow = MyWin()
myShow.show()
sys.exit(app.exec_())