在 PyQt 中正确删除带有 QThread 的控件

发布日期     浏览量 114514

#PyQt

对含有自定义线程的控件运行 deleteLater() 易引发难以修复的错误,本文介绍了避免出现错误的方法

前言

自从我在 PyQt-SiliconUI 上写了模态弹窗这一功能后,有一个 bug 就一直没被修复: 当弹窗内部有一个 QThread 在运行时,直接对弹窗运行 deleteLater() 会直接引发程序的崩溃,并且不会显示任何错误信息。

直到今天,我才终于确定,这个问题源自控件被删除后,其线程不会立即退出,因此可能访问到非法内存,导致程序崩溃。捣鼓了很久终于修复了这个包浆的 bug。

具体场景

SiLongPressButton 是一个按钮类,继承自 QPushButton,具有一个线程 thread,线程的 run() 方法在循环中发射按钮的一个信号。 以下是这个场景的简化代码:

SiLongPressButton

class SiLongPressButton(ABCPushButton):
    longPressed = pyqtSignal()

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.thread = LongPressThread(self)
        self.thread.ticked.connect(self.update_progress)  # 线程控制按钮更新进度
        
    def update_progress(self, p):
        # 更新进度的代码
        ...

LongPressThread

class LongPressThread(QThread):
    ticked = pyqtSignal(float)

    def run(self):
        while (...):
            ...
            self.ticked.emit(self.progress)  # 发射 ticked 信号,以控制按钮更新进度
            ...

我注意到这个 bug 的特点:只要 SiLongPressButtonthread 还在运行中,这时对 SiLongPressButton 的实例运行 deleteLater(),程序就会崩溃,没有错误信息。

没有错误信息导致这个问题特别棘手,这也是为什么这玩意我拖了俩月才修复。

尝试修复

分析一下,最可能出问题的地方就在于控件被删除后,控件并没有自动停止线程,因此线程还在运行,有点像那个没头苍蝇

因此,我们要尝试在控件被删除之前停止线程。

尝试1:terminate() 方法

这并非一个好主意,具体的实现方法是在运行 deleteLater() 前从外部调用线程对象的 terminate() 方法,即:

def deleteMyWidget(self):
    self.long_press_button.thread.terminate()
    self.deleteLater()

好消息是这个方法是奏效的,解决了之前的崩溃问题,但使用该方法会导致线程非正常退出导致的内存溢出和其他潜在问题,这是 qt 文档的解释:

Warning: This function is dangerous and its use is discouraged. The thread can be terminated at any point in its code path. Threads can be terminated while modifying data. There is no chance for the thread to clean up after itself, unlock any held mutexes, etc. In short, use this function only if absolutely necessary.

实测的确存在一些难以预料的问题,例如在反复执行删除操作后,整个程序会异常卡顿,我也不知道为什么,但显然这并非最好的解决方案。

解决:wait() 与 flag 方法

换一种思路:既然借助 terminate() 的“外力”无法正常关闭线程,那就使用 flag 让线程自己退出。

实现也很简单,在 run() 的循环体中添加判断条件,如果 flag 为真就 return

而在外部,需要线程停止时,我可以修改 flag 为真,并等待线程返回,这里就需要用到 wait() 方法。实现如下:

def deleteMyWidget(self):
    self.long_press_button.thread.flag = True
    self.long_press_button.thread.wait()
    self.deleteLater()

这样,我们设置 flag 为真后,使用 wait() 方法等待线程的 run() 返回,以此保证下面运行 deleteLater() 时线程肯定已经正常退出。

最后

这个难缠的 bug 终于修复了,给我高兴坏了,所以我写了这篇文章 XD。修复时的我:

发电

在 Astro 框架下 Markdown 中使用 Katex 渲染数学公式

这是最后一篇文章