1.问题场景:
当用PyQt5开发一个GUI界面 ,后台逻辑执行时间长,界面就容易出现卡死、未响应等问题。
(GUI(Graphics User Interface),中文名称为图形用户界面)
2.问题原因:
- 在PyQt中,GUI界面本身就是一个处理事件循环的主线程,当进行耗时操作时,主线程GUI需要等待操作完成后才会响应,在等待这段时间,整个GUI就处于卡死的状态。
- 在windows下,系统会认为这个程序运行出错了,会自动显示未响应,如果这时有其他的操作,整个程序就会卡死崩溃。
3.解决办法:
另开一个线程来执行这个耗时操作(使用QThread)
(注意:
- 如果说你的新线程需要做一些跟QT相关的事情,那就使用QT的线程,大多数情况下直接使用python的线程就可以,
- 在使用 PyQt5 开发图形界面应用程序时,最好使用
QThread
而不是直接使用 Python 标准库的threading.Thread
,这样可以更好地与 Qt 的事件循环机制结合使用,避免一些潜在的问题。)
(1)使用python的线程
from threading import Thread
例子就是:
print('主线程执行代码')
# 从 threading 库中导入Thread类
from threading import Thread
from time import sleep
# 定义一个函数,作为新线程执行的入口函数
def threadFunc(arg1,arg2):
print('子线程 开始')
print(f'线程函数参数是:{arg1}, {arg2}')
sleep(5)
print('子线程 结束')
# 创建 Thread 类的实例对象
thread = Thread(
# target 参数 指定 新线程要执行的函数
# 注意,这里指定的函数对象只能写一个名字,不能后面加括号,
# 如果加括号就是直接在当前线程调用执行,而不是在新线程中执行了
target=threadFunc,
# 如果 新线程函数需要参数,在 args里面填入参数
# 注意参数是元组, 如果只有一个参数,后面要有逗号,像这样 args=('参数1',)
args=('参数1', '参数2')
)
# 执行start 方法,就会创建新线程,
# 并且新线程会去执行入口函数里面的代码。
# 这时候 这个进程 有两个线程了。
thread.start()
# 主线程的代码执行 子线程对象的join方法,
# 就会等待子线程结束,才继续执行下面的代码
thread.join()
print('主线程结束')
快速了解:
Python中QThread、Thread、Processing的比较总结_qthread和thread区别-优快云博客
具体了解:
多线程 和 多进程 - 白月黑羽 (byhy.net)
(2)使用QThread
from PyQt5.QtCore import QThread
通过继承QThread并重写run()方法的方式实现多线程代码的编写。
结构大体如下:
class Worker(QThread):
def __init__(self):
super().__init__()
def run(self):
--snip--
把耗时操作放到一个Worker线程中的run()函数下执行,在GUI类文件中绑定操作的地方,创建Worker进程实例,启动进程即可。
t = Worker()
t.start()
4.注意:
- 不要尝试在Worker开启的线程中去设置GUI界面中的控件属性,因为可能会导致未知的错误;
- Qt建议:
只在主线程中操作界面
。- 在另外一个线程直接操作界面,可能会导致意想不到的问题,比如:输出显示不全,甚至程序崩溃。
- 推荐的方法是使用自定义信号。
5.例子:
使用了线程和自定义信号。
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QTextEdit, QPushButton, QHBoxLayout
from PyQt5.QtCore import QThread, pyqtSignal, QObject
import time
class Worker(QObject):
# progress信号用于报告训练进度,传递字符串日志和当前周期。
progress = pyqtSignal(str, int)
# finished信号用于显示训练完成,中断信息。
finished = pyqtSignal(int, float) # 修改finished信号,增加损失和。
def __init__(self, epochs_per_cycle, cycles):
super().__init__()
# 设置每个周期的训练轮数和总周期数。
self.epochs_per_cycle = epochs_per_cycle
self.cycles = cycles
# _running标志用于控制训练循环的运行状态。
self._running = True
def stop(self):
# 将_running标志设置为False,用于停止训练。
self._running = False
def run(self):
self._running = True
total_train_loss = 0 # 初始化总训练损失
# 使用for循环遍历每个周期
for cycle in range(1, self.cycles + 1):
# 检查_running标志是否为True,否则中断循环。
if not self._running:
self.finished.emit(1, total_train_loss)
break
# 发射progress信号报告当前周期。
self.progress.emit(f'Cycle: {cycle}', cycle)
for epoch in range(1, self.epochs_per_cycle + 1):
if not self._running:
break
# 模拟训练时间延迟,计算并记录假设的训练损失、测试损失和测试准确率,更新进度条描述。
time.sleep(0.5) # Simulate training time
train_loss = epoch * 0.1 # Dummy train loss
total_train_loss += train_loss # 累积训练损失
test_loss = epoch * 0.2 # Dummy test loss
test_acc = epoch * 0.01 # Dummy test accuracy
log = f'Epoch: {epoch}, train_loss: {train_loss:.4f}, test_loss: {test_loss:.4f}, test_acc: {test_acc:.4f}, {int((100 * epoch) / self.epochs_per_cycle)}%'
self.progress.emit(log, cycle)
# 如果所有周期正常完成,发射finished信号传递 0 表示训练正常的全部完成,并传递总训练损失。
if self._running:
self.finished.emit(0, total_train_loss)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.initUI()
self.worker = None
self.thread = None
def initUI(self):
self.setWindowTitle("Training Progress")
# 设置窗口大小
self.resize(800, 400)
# 创建并配置文本编辑框text_edit,用于显示训练日志。
self.text_edit = QTextEdit(self)
# 将该文本编辑器设置为只读模式
self.text_edit.setReadOnly(True)
# 创建并配置开始和停止按钮,并连接相应的槽函数。
self.start_button = QPushButton("Start Training", self)
self.start_button.clicked.connect(self.start_training)
self.stop_button = QPushButton("Stop Training", self)
self.stop_button.setEnabled(False)
self.stop_button.clicked.connect(self.stop_training)
# 布局
button_layout = QHBoxLayout()
button_layout.addWidget(self.start_button)
button_layout.addWidget(self.stop_button)
layout = QVBoxLayout()
layout.addWidget(self.text_edit)
layout.addLayout(button_layout)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def start_training(self):
# 如果已有线程在运行,则停止现有线程
if self.thread is not None and self.thread.isRunning():
self.worker.stop()
# quit() 只是请求线程退出其事件循环,并不会立即停止线程。
self.thread.quit()
# wait() 是阻塞当前线程,直到目标线程完全退出。
self.thread.wait()
# 禁用开始按钮,启用停止按钮,清空日志窗口
self.start_button.setEnabled(False)
self.stop_button.setEnabled(True)
self.text_edit.clear()
# 在训练开始前,输出开始训练的信息
info_text = "We are about to start training.\n"
info_text += f"Number of cycles: {10}\n" # 这里填入训练的循环次数
info_text += f"Epochs per cycle: {5}" # 这里填入每个周期的 epochs 数
self.text_edit.append(info_text)
self.text_edit.append("Starting training...")
# 创建新线程和新Worker对象,将Worker对象移动到新线程。
self.thread = QThread()
self.worker = Worker(epochs_per_cycle=5, cycles=10)
# moveToThread 方法是 QObject 的一个方法,它用于将对象移动到另一个线程。
# Worker 对象被移动到 self.thread 所代表的新线程中,这意味着 Worker 对象中的槽函数(如 run 方法)将在线程 self.thread 中执行,而不是在主线程中。
self.worker.moveToThread(self.thread)
# 连接信号和槽:progress信号连接update_log方法,finished信号连接training_finished方法,
self.worker.progress.connect(self.update_log)
self.worker.finished.connect(self.training_finished)
# 线程启动信号连接worker.run方法。
self.thread.started.connect(self.worker.run)
# 启动线程。
self.thread.start()
# 定义stop_training方法,停止训练过程。
def stop_training(self):
# 启用开始按钮
self.stop_button.setEnabled(True)
# 禁用停止按钮,使其在训练过程中不可点击。防止用户在训练已经停止后再次点击停止按钮。
self.stop_button.setEnabled(False)
# 检查 self.worker 是否已经被实例化。如果 self.worker 不是 None,则表示训练过程正在运行。
if self.worker is not None:
# 调用worker.stop方法,停止Worker对象中的训练循环。
self.worker.stop()
# 退出并等待线程结束
self.thread.quit()
# 这将阻塞主线程,直到 QThread 完全退出。
self.thread.wait()
def update_log(self, log, cycle):
# 获取当前 QTextEdit 对象的光标位置
cursor = self.text_edit.textCursor()
# 获取当前 QTextEdit 对象中的纯文本内容。
text = self.text_edit.toPlainText()
# 将文本内容按换行符分割成一个字符串列表,每个元素表示一行。
lines = text.split('\n')
# 计算当前周期的起始行索引
# 每个周期有两行,一行显示 Cycle: 信息,另一行显示 Epoch: 信息。
# 例如,第1周期的索引为0,第2周期的索引为2,以此类推。
cycle_start_line_idx = (cycle - 1) * 2 + 4
# 检查 log 是否包含 "Cycle:" 字符串。
if "Cycle:" in log:
# 如果当前行数少于或等于周期起始行索引,说明是新周期。
if len(lines) <= cycle_start_line_idx:
lines.append(log) # 将 log 添加到 lines 列表末尾。
else: # 否则,更新现有的周期日志信息。
lines[cycle_start_line_idx ] = log
# 如果 log 中不包含 "Cycle:" 字符串,处理 Epoch: 信息。
else:
# 计算 Epoch: 信息的行索引。
epoch_log_idx = cycle_start_line_idx + 1
# 如果当前行数少于或等于 Epoch: 信息行索引,添加新行。
if len(lines) <= epoch_log_idx:
lines.append(log) # 将 log 添加到 lines 列表末尾。
# 否则,更新现有的 Epoch: 日志信息。
else:
lines[epoch_log_idx] = log # 在指定行索引处更新 log。
# 将更新后的日志内容合并成一个字符串,并设置为 QTextEdit 的文本内容。
self.text_edit.setPlainText('\n'.join(lines))
# 将光标移动到文本末尾。
cursor.movePosition(cursor.End)
# 将光标位置设置回 QTextEdit。
self.text_edit.setTextCursor(cursor)
def training_finished(self, cycle, total_train_loss):
# 中断结束
if cycle == 1:
self.text_edit.append("Training interruption !!!")
# 正常结束
elif cycle == 0:
self.text_edit.append("Training ends normally !!!")
self.text_edit.append(f"Total Training Loss: {total_train_loss:.4f}")
else:
self.text_edit.append("Abnormal end of training !!!")
# 禁用开始按钮,启用停止按钮
self.start_button.setEnabled(True)
self.stop_button.setEnabled(False)
# 主程序
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())