Home > Blockchain >  Worker thread does not respond to slot calls from main thread
Worker thread does not respond to slot calls from main thread

Time:07-26

For my project based on Python and Qt I wanted to move expensive calculations and functions providing server/client functions into separate threads, to unfreeze my GUI. While leaving them running, I still want them to check periodically if there is new data from the main thread. For testing, I therefore implemented the following demo code:

import sys
from time import sleep
import shiboken6

from PySide6.QtCore import Qt, QObject, QThread, Signal, Slot, QTimer
from PySide6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

class Worker(QObject):
    finished = Signal()
    progress = Signal(int)

    def __init__(self):
        super().__init__()
        self.print_to_console_plz = False

    @Slot()
    def print_on_console_while_running(self):
        self.print_to_console_plz = True
        print("Set print_to_console to true")

    def run(self):
        timer = QTimer()
        for i in range(5):
            sleep(0.9)
            timer.start(100)
            if self.print_to_console_plz:
                print("Hello World from worker")
                self.print_to_console_plz = False
            self.progress.emit(i   1)
        self.finished.emit()

class Window(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.clicksCount = 0
        self.initWorker()
        self.setupUi()

    def initWorker(self):
        self.thread = QThread()
        # Step 3: Create a worker object
        self.worker = Worker()
        # Step 4: Move worker to the thread
        self.worker.moveToThread(self.thread)
        # Step 5: Connect signals and slots
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)
        self.worker.finished.connect(self.worker.deleteLater)
        self.thread.finished.connect(self.thread.deleteLater)
        self.worker.progress.connect(self.reportProgress)

    def setupUi(self):
        self.setWindowTitle("Freezing GUI")
        self.resize(300, 150)
        self.centralWidget = QWidget()
        self.setCentralWidget(self.centralWidget)
        # Create and connect widgets
        self.clicksLabel = QLabel("Counting: 0 clicks", self)
        self.clicksLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.stepLabel = QLabel("Long-Running Step: 0")
        self.stepLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.clicksToConsoleLabel = QLabel("Click here to print to console", self)
        self.clicksToConsoleLabel.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
        self.countBtn = QPushButton("Click me!", self)
        self.countBtn.clicked.connect(self.countClicks)
        self.ToConsoleBttn = QPushButton("Print to console!", self)
        self.ToConsoleBttn.clicked.connect(self.worker.print_on_console_while_running)
        self.longRunningBtn = QPushButton("Long-Running Task!", self)
        self.longRunningBtn.clicked.connect(self.runLongTask)
        # Set the layout
        layout = QVBoxLayout()
        layout.addWidget(self.clicksLabel)
        layout.addWidget(self.countBtn)
        layout.addStretch()
        layout.addWidget(self.clicksToConsoleLabel)
        layout.addWidget(self.ToConsoleBttn)
        layout.addStretch()
        layout.addWidget(self.stepLabel)
        layout.addWidget(self.longRunningBtn)
        self.centralWidget.setLayout(layout)

    def countClicks(self):
        self.clicksCount  = 1
        self.clicksLabel.setText(f"Counting: {self.clicksCount} clicks")

    def reportProgress(self, n):
        self.stepLabel.setText(f"Long-Running Step: {n}")

    def runLongTask(self):
        """Long-running task in 5 steps."""
        # Step 6: Start the thread
        if not shiboken6.isValid(self.thread):
            self.initWorker()
            self.ToConsoleBttn.clicked.connect(self.worker.print_on_console_while_running)
        self.thread.start()

        # Final resets
        self.longRunningBtn.setEnabled(False)
        self.thread.finished.connect(
            lambda: self.longRunningBtn.setEnabled(True)
        )
        self.thread.finished.connect(
            lambda: self.stepLabel.setText("Long-Running Step: 0")
        )

app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())

My main aim was to let the "expensive" function run in the worker thread while counting up, but it should still check periodically if there is new data available (represented by a call to print_on_console_while_running). To avoid having the run-function blocking everything while it is executed, I also introduced a QTimer as non-blocking timer.

Still, regardless, whenever I press on the button with "Print to console!" while the worker is executing the run-function, I always get "Set print_to_console to true" printed after the run-function has finished, and not during execution, indicating that the run-function still blocks the execution of everything else.

What am I doing wrong here, and how can I send data from the main thread to the worker thread while still executing the run-function?

CodePudding user response:

The problem is caused by the fact that the slot is in the receiver thread, so Qt automatically uses a QueuedConnection:

The slot is invoked when control returns to the event loop of the receiver's thread. The slot is executed in the receiver's thread.

Since the thread is occupied with the execution of run(), print_on_console_while_running will be called only as soon as run() returns.

A possible solution is to force a direct connection:

The slot is invoked immediately when the signal is emitted. The slot is executed in the signalling thread.

    self.ToConsoleBttn.clicked.connect(
        self.worker.print_on_console_while_running, Qt.DirectConnection)

In this way, the slot is immediately called and the variable is instantly set.

Another, common approach (as long as the thread doesn't need an actual event loop) is to directly subclass QThread and just override its run().

Since the QThread is the handler of the thread (no moveToThread is required), any connection made to any of its functions/slots will be in the same thread in which it was created (so, normally, the main thread), and only the run() will be executed in the separate thread, which means that implementing a print_on_console_while_running in that QThread subclass will always use a direct connection automatically.

Note that if you intend to start again the thread after its finished, you shouldn't need to delete and recreate it again. Also note that that QTimer you're creating is completely useless, not only because it doesn't do anything when it times out, but mostly because time.sleep would prevent its processing. Finally, it's usually better to avoid lambdas for thread connections, especially if the object is going to be destroyed.

CodePudding user response:

Another way of looking at this, is that the problem is caused by your worker running a blocking loop, which stops all event processing within its own thread. This will affect incoming queued signals and timers started within the worker thread, which both need to post events to the worker thread's event-queue. So you've effectively moved a small part of the freezing behaviour from the gui-thread to the worker-thread!

(Note that if you comment out the signal connection that deletes the worker, any "Set print_to_console to true" messages will be belatedly printed, since the worker can no longer block its own thread after its finished).

If you want to continue using queued signals and thread-local timers, you can periodically enforce processing of thread-local events from within your blocking loop. For timers to work (somewhat) precisely, this obviously means you must do this more frequently than the timers are scheduled to timeout. So something like this should work as expected:

class Worker(QObject):
    finished = Signal()
    progress = Signal(int)

    def __init__(self):
        super().__init__()
        self.print_to_console_plz = False

    @Slot()
    def print_on_console_while_running(self):
        self.print_to_console_plz = True
        print("Set print_to_console to true")

    @Slot()
    def timeout_handler(self):
        print('timeout:', QTime.currentTime().toString('HH:mm:ss.z'))

    def run(self):
        timer = QTimer()
        timer.start(100)
        timer.timeout.connect(self.timeout_handler)
        for i in range(50):
            sleep(0.05)
            QApplication.processEvents()
            if self.print_to_console_plz:
                print("Hello World from worker")
                self.print_to_console_plz = False
            self.progress.emit(i   1)
        self.finished.emit()
  • Related