1

The majority of guides on QThread seem to focus on deleting the QThread after its QObject finishes. I would like to preserve QThread and QObject and reuse them when I need them again. This also means that I need to be more careful when managing their lifecycle because the Qt-python binding and their memory management can easily lead to some unexpected behaviour.

I am looking for the best practice to bind the lifecycle of the QThread and QObject to the GUI (QMainWindow, QFRame, etc...) which should perform proper clean-up during its destruction. I have prepared a minimal working example but maybe the community can point out its flaws and improve on it.

import sys
from PyQt6 import QtCore, QtWidgets
import time

class MyWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("MyWindow")
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) # this is OK
        
        self.button = QtWidgets.QPushButton("Start thread")
        self.setCentralWidget(self.button)
        
        self.worker = MyObject()
        self.thread = MyThread()
        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.run)
        self.worker.finished.connect(self.thread.quit)
        # self.worker.finished.connect(self.worker.deleteLater) # for singleshot task
        # self.thread.finished.connect(self.thread.deleteLater) # for singleshot task

        # self.thread.start() # for singleshot task
        self.button.clicked.connect(self.thread.start)
        
    def __del__(self):
        print("MyWindow __del__ entered")
        self.thread.quit()
        print("MyWindow __del__ quit issued, waiting on thread")
        self.thread.wait()
        print("MyWindow __del__ thread returned")
        self.worker.deleteLater()
        self.thread.deleteLater()
        print("MyWindow __del__ quit")
        
    # def closeEvent(self, qCloseEvent):
    #     qCloseEvent.accept() # Also leads to crashes
    
class MyObject(QtCore.QObject):
    finished = QtCore.pyqtSignal()
    
    def __init__(self):
        super().__init__()
        
    def run(self):
        print("MyObject run entered")
        time.sleep(1)
        self.finished.emit()
        print("MyObject run quit")

class MyThread(QtCore.QThread):
    def __init__(self):
        super().__init__()
    
    def run(self):
        print("MyThread run entered")
        self.exec()
        print("MyThread run quit")
        
if __name__ == '__main__':
    app = QtCore.QCoreApplication.instance()
    if app is None:
        app = QtWidgets.QApplication(sys.argv)
    mainGui = MyWindow()
    mainGui.show()
    # app.aboutToQuit.connect(app.deleteLater) # Causes crashes
    app.exec()

This code produces the following result:

In [1]: runfile(...)
MyObject run entered
MyObject run quit
MyThread run entered
MyThread run quit
MyObject run entered
MyWindow __del__ entered
MyWindow __del__ quit issued, waiting on thread
MyWindow __del__ thread returned
MyWindow __del__ quit
MyObject run quit
MyThread run entered

In [2]: runfile(...)
MyThread run quit

This indicates that the thread was not deleted properly. Also, it surprised me that the __del__ function is not blocked on calling thread.wait(), so the thread survives the QWidget. But at least it does not crash after multiple calls.

2 Answers2

2

If you're going to re-use the thread, there's no point in doing any explicit clean-up, since Python/Qt will handle that automatically when the program exits. What you should instead do is add some kind of stop() method to your worker class, so you can terminate the thread gracefully in the close-event of the main-window:

class MyWindow(QtWidgets.QMainWindow):
    ...

    def closeEvent(self, event):
        self.worker.stop()    
        self.thread.quit()
        self.thread.wait()

There is no need to implement __del__ or call deleteLater() in your example. More generally, explict clean-up is usually only necessary for objects that are deleted during normal runtime (i.e. prior to program exit), so as to avoid memory leaks.

ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • Thank you for the answer and the edit! What exactly would you put into `worker.stop()`? I could think of a call to `self.thread().requestInterruption()` maybe? – Károly Csurilla Aug 25 '21 at 18:25
  • No - you cannot interrupt the thread, because it's blocked by the worker. You must directly interrupt the worker itself. For sequential processing with a loop, you can use a simple flag (e.g. `while self._running: ...`) which can be toggled by the stop method. However, if the task is CPU-bound and cannot be broken up into chunks, there will be literally *no way* to stop it, because python does not support true concurrency (due to the GIL). For cases like that, multithreading is the wrong approach and you must use multiprocessing instead. – ekhumoro Aug 25 '21 at 19:30
  • PS: there's a working example [here](https://stackoverflow.com/a/39999236/984421) that should make things clearer. It uses a mutex to protect the flag, but that's not strictly necessary if only one thread is reading/writing it - a bare attribute will work just a s well (as shown in [this example](https://stackoverflow.com/a/68938869/984421)). – ekhumoro Aug 26 '21 at 13:01
0

I am answering my question to give an example application that motivated the reusable thread architecture and also iterate on @ekhumoro's answer. On the main thread you enqueue messages and start the other thread which processes these messages in the queue. You don't have to check if the thread is already running because QThread.start() is idempotent. You retain the variable of the same thread at all times not to induce garbage-collection crashes. Periodically checking QThread.isInterruptionRequested() helps us terminate it gracefully.

import sys, collections
from PyQt6 import QtCore, QtWidgets

class LoggerWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Logger window")
        self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
        
        self.button = QtWidgets.QPushButton("Start thread")
        self.setCentralWidget(self.button)
        
        self.message_queue = collections.deque()
        
        self.worker = LoggerWindow.Worker(self.message_queue)
        self.thread = QtCore.QThread()
        self.worker.moveToThread(self.thread)
        self.thread.started.connect(self.worker.process_messages)
        
        self.button.clicked.connect(self.enqueue_message)
    
    @QtCore.pyqtSlot(bool)
    def enqueue_message(self, checked):
        self.message_queue.appendleft(len(self.message_queue) + 1)
        self.thread.start()
        
    def closeEvent(self, qCloseEvent):
        self.thread.requestInterruption()
        self.thread.quit()
        self.thread.wait()
        qCloseEvent.accept()
    
    class Worker(QtCore.QObject):
        def __init__(self, message_queue):
            super().__init__()
            self.message_queue = message_queue
            
        @QtCore.pyqtSlot()    
        def process_messages(self):
            while self.message_queue and not self.thread().isInterruptionRequested():
                self.thread().sleep(1)
                print(f"Processing message: {self.message_queue.pop()}/{len(self.message_queue)}")
            print("Finished processing!")
            self.thread().quit()
        
if __name__ == '__main__':
    app = QtCore.QCoreApplication.instance()
    if app is None:
        app = QtWidgets.QApplication(sys.argv)
    mainGui = LoggerWindow()
    mainGui.show()
    app.exec()