MediumSafe QThread Usage in PySide6: Signals, Cancellation, and Error Handling | by Oleksii Sylichenko | Medium
Sitemap
Oleksii Sylichenko

Oleksii Sylichenko

Press enter or click to view image in full size

Safe QThread Usage in PySide6: Signals, Cancellation, and Error Handling

9 min readJun 11, 2026

--

In this article, we will build a simple PySide6 application that executes long-running tasks in a background thread. Along the way, we will explore how to communicate with the GUI safely, handle cancellation requests, propagate errors, prevent concurrent executions, and shut down the application cleanly.

Task

Implement a long-running operation in a PySide6 application without freezing the GUI.

During the implementation, we want to address several important aspects of thread management and user interaction:

  • Start a heavy operation by clicking a button while keeping the GUI responsive.
  • Allow the user to request cancellation of the running operation.
  • Display the final result of the operation in the GUI.
  • Display intermediate progress updates while the operation is running.
  • Ensure that only one operation can be executed at a time.
  • If an error occurs during execution, report it to the GUI and properly clean up the worker thread.
  • If the application window is closed while the operation is still running, stop the worker gracefully, release all associated resources, and ensure that the application exits without hanging.

Basic Architecture

To keep responsibilities well separated, the example is organized into three main components:

  • WorkerThread: a custom thread class responsible for executing long-running operations in the background. It reports progress, delivers results, propagates errors, and supports cooperative cancellation.
  • MainWindow: the application's main window. It contains only the user interface elements and exposes the signals generated by user interactions.
  • MainWindowController: the controller responsible for the application's behavior. It connects UI events to business logic, starts and stops worker threads, handles worker signals, updates the GUI, and manages thread cleanup.
Press enter or click to view image in full size

This structure keeps each class focused on a single responsibility: the worker performs background processing, the window defines the user interface, and the controller coordinates everything. As the application grows, this separation helps keep the code organized and maintainable.

A Reusable Worker Base Class

To execute a long-running task without freezing the user interface, we need to move that work to a separate thread. Qt provides the QThread class specifically for this purpose.

In this example, we create our own worker class by inheriting from QThread. This custom class encapsulates the background operation and exposes signals that allow it to communicate safely with the GUI thread.

By running heavy processing outside the main event loop, the application remains responsive and continues to handle user interactions while the task is being executed.

QThread provides several useful methods that will be used throughout this example:

  • run(): the entry point of the thread. The long-running operation is executed inside this method.
  • requestInterruption(): requests the thread to stop by setting an internal interruption flag. The thread itself is responsible for checking this flag and terminating gracefully.
  • isInterruptionRequested(): allows the worker thread to determine whether an interruption has been requested, enabling cooperative cancellation and proper cleanup before exiting.
  • wait() and wait(timeout): block the calling thread until the worker thread finishes execution. The first variant waits indefinitely, while the second waits only up to the specified timeout.

In addition, QThread provides the finished signal, which is emitted when the thread has completed its execution. This signal can be used to perform cleanup tasks, release resources, or update the user interface once the background work has ended.

The WorkerThread class inherits from QThread and defines three signals:

  • progress: reports task progress;
  • result: communicates task results or status messages;
  • error: propagates exceptions to the GUI layer.

The run() method contains the common execution logic and exception handling. The actual task implementation is delegated to do_work(), which must be provided by subclasses.

This approach eliminates duplicated error-handling code across different worker implementations.

class WorkerThread(QThread):
progress = Signal(int)
result = Signal(str)
error = Signal(str)

def run(self) -> None:
try:
self.do_work()
except Exception as e:
self.error.emit(f"{type(e).__name__}: {e}\n{traceback.format_exc()}")

def do_work(self) -> None:
raise NotImplementedError

Specialization Through Inheritance

WorkerThread will serve as our base worker class. It contains the common infrastructure required for running background tasks, such as error handling and communication with the GUI through signals.

The actual application-specific logic will be implemented in the CustomWorker class, which inherits from WorkerThread and performs the long-running operation.

class CustomWorker(WorkerThread):

def do_work(self) -> None:
for i in range(...):
if self.isInterruptionRequested():
self.result.emit("Stopping worker...")
...
self.result.emit("Cancelled")
return

...

self.progress.emit(i)

self.result.emit("Completed")

User Interface and Controller

Next, let us move on to the user interface layer.

Our application consists of two components: the main window and its controller.

The main window is implemented by inheriting from QMainWindow. Its responsibility is limited to creating and displaying the user interface. It should not contain business logic or manage the lifecycle of worker threads.

QMainWindow provides the closeEvent() method, which we will use to address one of the requirements defined earlier: correctly stopping a running worker when the user closes the application window.

The application’s behavior is delegated to MainWindowController. The controller handles user interactions, starts and stops worker threads, processes worker signals, and updates the user interface accordingly.

An important aspect of this design is that MainWindow knows nothing about MainWindowController. This reduces coupling between the presentation layer and the application logic.

For example, when the user closes the window, the window itself should not be responsible for determining whether a worker thread is running or how it should be stopped. Instead, we introduce a dedicated signal in MainWindow. This signal is emitted from closeEvent(), allowing the controller to react appropriately and perform the necessary cleanup operations.

Get Oleksii Sylichenko’s stories in your inbox

Join Medium for free to get updates from this writer.

The window also contains the following widgets:

  • label: for displaying status messages and runtime feedback
  • progress bar: for visualizing the worker’s execution progress
  • buttons: to start a normal worker, start a worker that intentionally triggers an error, and stop the currently running worker
class MainWindow(QMainWindow):
close_event = Signal()

def __init__(self) -> None:
super().__init__()
self._setup_ui()

def _setup_ui(self) -> None:
self.label = QLabel("Ready")
self.progress_bar = QProgressBar()
self.start_button = QPushButton("Start")
self.start_err_button = QPushButton("Start with error")
self.stop_button = QPushButton("Stop")

main_layout = QVBoxLayout()

main_layout.addWidget(self.label)
main_layout.addWidget(self.progress_bar)
main_layout.addWidget(self.start_button)
main_layout.addWidget(self.start_err_button)
main_layout.addWidget(self.stop_button)

w = QWidget()
w.setLayout(main_layout)
self.setCentralWidget(w)

The Role of the Controller

MainWindowController separates application logic from presentation logic and is responsible for:

  • starting workers;
  • connecting signals and slots;
  • updating the user interface;
  • stopping active tasks;
  • cleaning up references after completion.

As a result, MainWindow remains focused solely on UI-related responsibilities.

For MainWindowController to use Qt’s signal/slot mechanism, it must inherit from QObject. This is required for three reasons:

  1. @Slot() decorator

Without QObject as a base class, methods decorated with @Slot() won’t be registered in Qt’s meta-object system. The decorator becomes effectively a no-op.

2. Parent-child ownership

super().__init__(window)

Passing window as parent means Qt will automatically destroy the controller when the window is destroyed. This memory management only works within the QObject hierarchy.

3. Future signal definitions

If the controller ever needs to emit its own signals (e.g. state_changed = Signal()), QObject inheritance is mandatory.

In PySide6, any class that participates in the signal/slot mechanism — whether by defining signals, receiving them via decorated slots, or managing object lifetime through parent-child relationships — must inherit from QObject. It is the foundation of Qt’s meta-object system.

class MainWindowController(QObject):

def __init__(self, window: MainWindow) -> None:
super().__init__(window)

self.window: MainWindow = window
self.worker: WorkerThread | None = None

self.window.close_event.connect(self.on_window_closed)

self.window.start_button.clicked.connect(self.on_start_clicked)
self.window.start_err_button.clicked.connect(self.on_start_err_clicked)
self.window.stop_button.clicked.connect(self.stop_worker)

Start worker

@Slot()
def on_start_clicked(self) -> None:
self.run_worker(CustomWorker())

def run_worker(self, worker: WorkerThread) -> None:
if self.worker is not None:
self.window.label.setText("Worker is already running")
return

self.worker = worker

worker.progress.connect(self.on_progress_updated)
worker.result.connect(self.on_result_received)
worker.error.connect(self.on_error_occurred)
worker.finished.connect(worker.deleteLater)
worker.finished.connect(self.remove_worker)

try:
worker.start()
self.window.label.setText("Worker started")
except RuntimeError as ex:
self.window.label.setText(str(ex))
logger.error("Could not start worker", exc_info=ex)

Process results

@Slot(int)
def on_progress_updated(self, value: int) -> None:
self.window.progress_bar.setValue(value)

@Slot(str)
def on_result_received(self, text: str) -> None:
self.window.label.setText(text)

@Slot(str)
def on_error_occurred(self, text: str) -> None:
self.window.label.setText("Error")
logger.error(text)

Resource Cleanup

Once the worker finishes, the following connections are used:

worker.finished.connect(worker.deleteLater)
worker.finished.connect(self.remove_worker)

deleteLater() schedules the object for safe deletion within Qt's event loop, while remove_worker() clears the controller's reference to the completed thread.

Together, these mechanisms help prevent memory leaks and accidental reuse of finished workers.

When connecting multiple slots to the same signal, Qt invokes them in the order they were connected.

Reversing the order would still work in practice — deleteLater would be called on a valid object regardless of whether self.worker is None — but it would misrepresent the intent: you would be clearing a reference to an object that has not yet been scheduled for deletion.

Handling Application Shutdown While a Worker Is Running

A special case that deserves attention is when the user closes the application window while a worker thread is still running. Without proper handling, this situation may lead to an unresponsive application or leave background threads running unexpectedly.

In the sample application, skipping the worker shutdown procedure caused the process to terminate with exit code -1073740791 (0xC0000409) on Windows, highlighting the importance of proper thread cleanup during application shutdown:

Process finished with exit code -1073740791 (0xC0000409)

To address this scenario, the following shutdown procedure is implemented:

  1. The standard window close event is intercepted by overriding QMainWindow.closeEvent().
  2. The window emits a custom signal indicating that a close request has been received.
  3. MainWindowController listens for this signal.
  4. Once the controller is notified that the window is being closed, it initiates the worker shutdown procedure.
  5. The controller first hides the window. This prevents the user from observing an unresponsive interface while the application is waiting for background processing to finish.
  6. The controller then requests interruption of the running worker thread by calling requestInterruption().
  7. After the interruption request has been sent, the controller waits for the worker to terminate by calling wait(timeout). This gives the worker an opportunity to complete its cleanup logic and exit gracefully while preventing the application from blocking indefinitely.
  8. If the worker fails to stop within the specified timeout, a warning message is written to the log, and the application proceeds with the shutdown sequence.

This approach balances two competing goals: allowing background tasks to shut down cleanly whenever possible while ensuring that the application itself does not remain stuck waiting forever for a misbehaving worker.

class MainWindow(QMainWindow):
close_event = Signal()
...
def closeEvent(self, event: QCloseEvent) -> None:
self.close_event.emit()
super().closeEvent(event)


class MainWindowController(QObject):
TIMEOUT: int = 3000 # 3s

def __init__(self, window: MainWindow) -> None:
...
self.window.close_event.connect(self.on_window_closed)

@Slot()
def on_window_closed(self) -> None:
self.window.hide()

if self.worker is not None:
self.worker.requestInterruption()

if not self.worker.wait(self.TIMEOUT):
logger.warning("Worker did not stop in time")

When a slot is executing in the main thread, Qt does not process other events for that thread until the slot returns. As a result, any other queued slots targeting objects in the main thread must wait for the current slot to finish.

In this example, remove_worker() cannot be invoked while on_window_close() is still executing. Even if the worker thread emits the finished signal during the call to wait(), the corresponding remove_worker() slot will only be executed after on_window_close() returns control back to Qt’s event loop.

Therefore, self.worker cannot become None unexpectedly while on_window_close() is performing the shutdown procedure.

This behavior relies on the following assumptions:

  • on_window_close() runs in the GUI thread;
  • remove_worker() also runs in the GUI thread;
  • worker.finished.connect(self.remove_worker) results in a queued connection, because the finished signal is emitted from a different thread.

Class Diagram

Press enter or click to view image in full size

Sequence diagrams

Start

Press enter or click to view image in full size

Run worker

Press enter or click to view image in full size

Application Exit While a Worker Is Running

Press enter or click to view image in full size

References

--

--