Search code examples
multithreadingpython-3.xpyqtqthreadevent-loop

Stop processing event-queue immediately on QThread.exit()


I am building a Qt GUI application which uses QThread/QObject combinations to act as workers that do stuff outside the main thread.

Via moveToThread, the QObject gets moved into the QThread. This way, my worker can have signals (it's a QObject) and slots which are processed in an event loop (provided by QThread).

Now I'd like to make the workers behave in a special way that they stop their thread gracefully whenever a slot in the event loop hits a Python exception.

By testing around a bit, I found that in PyQt5 an exception in a slot causes the whole application to stop, which as far as I read is an intentional change compared to PyQt4 where the excpetion was only printed but the event loop kept running. I read that it's possible to avoid that by monkeypatching your own "excepthook" to sys.excepthook, which Qt implements in a way that it stops the interpreter.

So I did that, and so far this works. Moreover, the excepthook enables me to exit() my worker when an Exception happens, for which I didn't find a better way elsewhere. I tried subclassing QThread and putting a try..except around the call to exec_() in the QThread's run() method, but it doesn't propagate the exceptions occuring in the event loop... So the only option left would be to put try..except blocks inside every single slot, which I wanted to avoid. Or did I miss something here?

Below is an MWE that demonstrates what I have so far. My problem with it is that exiting the thread does not happen immediately when an Exception occurs, demonstrated with the error slot which results in a call to thread.exit() in the excepthook. Instead all other remaining events in the threads event loop will get executed, here demonstrated by the do_work slot that I scheduled behind it. exit() just seems to schedule another event to the queue which, as soon as it is processed, then stops the event loop.

How can I get around this? Is there a way to flush the queue of the QThread's events? Can I somehow prioritize the exit?

Or maybe another completely different way to catch exceptions in slots and make the thread stop, without stopping the main program?

Code:

import sys
import time
from qtpy import QtWidgets, QtCore


class ThreadedWorkerBase(QtCore.QObject):
    def __init__(self):
        super().__init__()
        self.thread = QtCore.QThread(self)
        self.thread.setTerminationEnabled(False)
        self.moveToThread(self.thread)
        self.thread.start()

    def schedule(self, slot, delay=0):
        """ Shortcut to QTimer's singleShot. delay is in seconds. """
        QtCore.QTimer.singleShot(int(delay * 1000), slot)


class Worker(ThreadedWorkerBase):
    test_signal = QtCore.Signal(str)   # just for demo

    def do_work(self):
        print("starting to work")
        for i in range(10):
            print("working:", i)
            time.sleep(0.2)

    def error(self):
        print("Throwing error")
        raise Exception("This is an Exception which should stop the worker thread's event loop.")


#  set excepthook to explicitly exit Worker thread after Exception
sys._excepthook = sys.excepthook
def excepthook(type, value, traceback):
    sys._excepthook(type, value, traceback)
    thread = QtCore.QThread.currentThread()
    if isinstance(thread.parent(), ThreadedWorkerBase):
        print("This is a Worker thread. Exiting...")
        thread.exit()
sys.excepthook = excepthook

# create demo app which schedules some tasks
app = QtWidgets.QApplication([])
worker = Worker()
worker.schedule(worker.do_work)
worker.schedule(worker.error)    # this should exit the thread => no more scheduling
worker.schedule(worker.do_work)
worker.thread.wait()   # worker should exit, just wait...

Output:

starting to work
working: 0
working: 1
working: 2
working: 3
working: 4
working: 5
working: 6
working: 7
working: 8
working: 9
Throwing error
Traceback (most recent call last):
  File "qt_test_so.py", line 31, in error
    raise Exception("This is an Exception which should stop the worker thread's event loop.")
Exception: This is an Exception which should stop the worker thread's event loop.
This is a Worker thread. Exiting...
starting to work
working: 0
working: 1
working: 2
working: 3
working: 4
working: 5
working: 6
working: 7
working: 8
working: 9

Expectation:

The output should end after "Exiting...".


Solution

  • The Qt docs for QThread.exit are somewhat misleading:

    Tells the thread's event loop to exit with a return code.

    After calling this function, the thread leaves the event loop and returns from the call to QEventLoop::exec(). The QEventLoop::exec() function returns returnCode.

    By convention, a returnCode of 0 means success, any non-zero value indicates an error.

    Note that unlike the C library function of the same name, this function does return to the caller -- it is event processing that stops. [emphasis added]

    This suggests that after calling exit(), there will be no further processing of the thread's event-queue. But that is not what happens, because QEventLoop always calls processEvents before checking whether it should exit. This means that the event-queue will always be empty when exec() returns.

    In your example, the single-shot timer will post events to the event-queue of the receiving thread, where the connected slots will eventually be called. So no matter what you do, all those slots will be called before the thread finally quits.

    A fairly simple way to work around this is to use the requestInterruption feature with a decorator that checks to see if the slot should be called:

    def interruptable(slot):
        def wrapper(self, *args, **kwargs):
            if not self.thread.isInterruptionRequested():
                slot(self, *args, **kwargs)
        return wrapper
    
    class Worker(ThreadedWorkerBase):
        test_signal = QtCore.pyqtSignal(str)   # just for demo
    
        @interruptable
        def do_work(self):
            print("starting to work")
            for i in range(10):
                print("working:", i)
                time.sleep(0.2)
    
        @interruptable
        def error(self):
            print("Throwing error")
            raise Exception("This is an Exception which should stop the worker thread's event loop.")
    
    def excepthook(type, value, traceback):
        sys.__excepthook__(type, value, traceback)
        thread = QtCore.QThread.currentThread()
        if isinstance(thread.parent(), ThreadedWorkerBase):
            print("This is a Worker thread. Exiting...")
            thread.requestInterruption()
            thread.exit()
    sys.excepthook = excepthook