[Qt/PyQt] 记录QThread使用的一次坑

由Jeza Chen 发表于 October 25, 2023

在PyQt开发使用QThread的过程中,发现如果在worker(QObject对象)通过调用moveToThread移动到新的线程前,如果worker中有信号连接到了主线程的槽函数,那么这个槽函数即便会在worker移动到新线程之后,仍然在主线程中执行,而不是在worker所在的新线程中执行。这样会使得运行在主进程上的GUI程序在worker执行时会被阻塞,直到worker执行完毕。

问题重现

import sys
import time
from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton


class Worker(QObject):
    finished = pyqtSignal()

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

    def run(self):
        print("Worker thread id: {}".format(int(QThread.currentThreadId())))
        for i in range(10):
            print(i)
            time.sleep(1)
        self.finished.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.button = QPushButton(self, text="Text")
        self.setCentralWidget(self.button)

        self.worker = Worker()
        self.worker.finished.connect(self.on_worker_finished)
        self.worker_thread = QThread()
        self.worker_thread.started.connect(self.worker.run)  # !!! 1
        self.worker.moveToThread(self.worker_thread)  # !!! 2
        print("Main thread id: {}".format(int(QThread.currentThreadId())))

    @pyqtSlot()
    def on_worker_finished(self):
        print("Worker finished")

    def startWorker(self):
        self.worker_thread.start()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    window.startWorker()
    sys.exit(app.exec_())

运行程序可以看到,GUI程序中(包括其中的按钮)无法响应鼠标事件,直到worker执行完毕。通过命令行输出也可以看到,worker.run方法所输出的线程id和主线程id是一样的,这意味着worker.run方法是在主线程中执行的,从而阻塞了GUI程序的运行。

稍微调整下代码顺序如下:

# 前面的代码省略 

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.button = QPushButton(self, text="Text")
        self.setCentralWidget(self.button)

        self.worker = Worker()
        self.worker_thread = QThread()
        self.worker.moveToThread(self.worker_thread)  # !!! 2

        self.worker.finished.connect(self.on_worker_finished)
        self.worker_thread.started.connect(self.worker.run)  # !!! 1
        print("Main thread id: {}".format(int(QThread.currentThreadId())))

# 后面的代码省略

此时可以看到,GUI程序可以正常响应鼠标事件,且命令行输出也可以看到,worker.run方法所输出的线程id和主线程id是不一样的,此时worker.run方法才是真正地异步执行的。

问题分析

为什么会出现这种情况呢?我们可以研究下信号和槽的连接方式,比如:

  • Auto Connection(默认):如果信号在接收对象具有密切关系的线程中发出,则行为与Direct Connection相同。否则,行为与Queued Connection相同。连接类型在信号发出时确定。
  • Direct Connection:当信号发出时,该槽会立即被调用。槽在发射器的线程中执行,该线程不一定是接收器的线程。
  • Queued Connection:当控制返回到接收者线程的事件循环时,将调用该槽。该槽在接收者的线程中执行。

我们注意到,在第一份代码中,worker.run方法是在worker.moveToThread调用前连接到了worker_thread.started信号,由于连接的时候位于主线程,因此该槽所在的线程属于主进程,通过Auto Connectionstarted信号绑定。因此started信号发出后,将以Queued Connection的连接类型(注意连接类型在信号发出时确定)通知给槽,worker.run方法会在主线程执行,阻塞了GUI程序。

因此,需要在绑定信号和槽的时候,确保槽所在的线程是目前worker所在的线程,即确保workermoveToThread调用位于和QThread信号的连接之前。

总结

  • 槽所在的线程由信号连接(connect)时所在的线程决定,而不是信号发出时所在的线程,也不是所在对象的线程。

  • 如果connect不指定连接类型,那么连接类型由信号发出时所在的线程决定

参考