Search code examples
pythonpyqtpyqt5signals-slotsqthread

How to start work in a non-main-thread QObject


Based on the Qt docs and other examples on the web, I would have thought that the following program, which uses QThread.started signal, would start workers in non-main threads. But this is not the case, instead each work slot is called from main thread:

import time
import sys

from PyQt5.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QApplication, QPushButton, QTextEdit, QVBoxLayout, QWidget


def trap_exc_during_debug(*args):
    # when app exits, put breakpoint in next line when run in debugger, and analyse args
    pass


sys.excepthook = trap_exc_during_debug


class Checker(QObject):
    sig_step = pyqtSignal(int, str)
    sig_done = pyqtSignal(int)

    def __init__(self, id: int):
        super().__init__()
        self.__id = id

    @pyqtSlot()
    def work(self):
        thread_name = QThread.currentThread().objectName()
        thread_id = int(QThread.currentThreadId())
        print('running work #{} from thread "{}" (#{})'.format(self.__id, thread_name, thread_id))

        time.sleep(2)
        self.sig_step.emit(self.__id, 'step 1')
        time.sleep(2)
        self.sig_step.emit(self.__id, 'step 2')
        time.sleep(2)
        self.sig_done.emit(self.__id)


class MyWidget(QWidget):
    NUM_THREADS = 3

    sig_start = pyqtSignal()

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

        self.setWindowTitle("Thread Example")
        form_layout = QVBoxLayout()
        self.setLayout(form_layout)
        self.resize(200, 200)

        self.push_button = QPushButton()
        self.push_button.clicked.connect(self.start_threads)
        self.push_button.setText("Start {} threads".format(self.NUM_THREADS))
        form_layout.addWidget(self.push_button)

        self.log = QTextEdit()
        form_layout.addWidget(self.log)
        # self.log.setMaximumSize(QSize(200, 80))

        self.text_edit = QTextEdit()
        form_layout.addWidget(self.text_edit)
        # self.text_edit.setMaximumSize(QSize(200, 60))

        QThread.currentThread().setObjectName('main')
        self.__threads_done = None
        self.__threads = None

    def start_threads(self):
        self.log.append('starting {} threads'.format(self.NUM_THREADS))
        self.push_button.setDisabled(True)

        self.__threads_done = 0
        self.__threads = []
        for idx in range(self.NUM_THREADS):
            checker = Checker(idx)
            thread = QThread()
            thread.setObjectName('thread_' + str(idx))
            self.__threads.append((thread, checker))  # need to store checker too otherwise will be gc'd
            checker.moveToThread(thread)
            checker.sig_step.connect(self.on_thread_step)
            checker.sig_done.connect(self.on_thread_done)

            # self.sig_start.connect(checker.work)  # method 1 works: each work() is in non-main thread
            thread.started.connect(checker.work)  # method 2 doesn't work: each work() is in main thread
            thread.start()

        self.sig_start.emit()  # this is only useful in method 1

    @pyqtSlot(int, str)
    def on_thread_step(self, thread_id, data):
        self.log.append('thread #{}: {}'.format(thread_id, data))
        self.text_edit.append('{}: {}'.format(thread_id, data))

    @pyqtSlot(int)
    def on_thread_done(self, thread_id):
        self.log.append('thread #{} done'.format(thread_id))
        self.text_edit.append('-- Thread {} DONE'.format(thread_id))
        self.__threads_done += 1
        if self.__threads_done == self.NUM_THREADS:
            self.log.append('No more threads')
            self.push_button.setEnabled(True)


if __name__ == "__main__":
    app = QApplication([])

    form = MyWidget()
    form.show()

    sys.exit(app.exec_())

If I use a custom signal instead, it works fine. To see this, comment out the "method 2" line and uncomment the "method 1" line and repeat the run.

It would certainly be nicer to start the workers without having to create a custom signal, is there a way to do this (while sticking to the design of calling moveToThread on the workers)?

Note: The docs for QThread.started signal don't help much:

This signal is emitted from the associated thread when it starts executing

To me this implies that started would be emitted in the non-main thread, such that the work slot it is connected to would get called in the non-main thread, but this is clearly not the case. Even if my interpretation is incorrect and the signal is in-fact emitted in the main thread, the connection type is for both methods the default Qt.AutoConnection, to a slot on a QObject moved to another thread, so the started signal should be transmitted asynchronously (i.e. through each checker's QThread event loop), again clearly not the case.


Solution

  • I posted a support request to jetbrains and they promptly answered :

    It was made intentionally for a better debugging. You can uncheck setting Settings | Build, Execution, Deployment | Python Debugger > PyQt compatible and will have workers started as expected.

    Amazing!