Skillversitas

Threading and the Event Loop

The rule that governs Qt threading is the one every GUI toolkit shares: only the main thread - the thread that runs the QApplication.exec loop - may touch the widgets. The work that takes time (the file scan, the network call, the computation) runs in a worker thread, and the result reaches the UI through the signal/slot's queued connection (the moveToThread plus the signal the worker emits, the slot on the main thread). The Qt's threading model is the QThread (the thread that owns an event loop) plus the QObject that is moveToThreaded into it (the worker), and the QtConcurrent / the QThreadPool for the task-based case. This chapter covers the rule and its reason (the widget is not thread-safe), the QThread plus the worker QObject (the moveToThread pattern), the signal/slot as the marshal (the queued connection), the QThreadPool for the tasks, and the shutdown discipline.

The Rule: Only the Main Thread Touches Widgets

from PyQt5.QtWidgets import QApplication, QLabel
import sys

# the widget is NOT thread-safe: the toolkit's object model
# assumes a single owner (the main thread, the loop's thread)
# a worker thread that calls label.setText() races the main
# thread's render of the same label - the corruption is the
# intermittent crash, the garbled text

# the structure that is safe:
#   main thread  - builds the UI, runs the loop, touches
#                  the widgets
#   worker thread - does the work, touches NO widget, emits
#                   the signal the main thread's slot receives

# the anti-pattern (the crash that comes "sometimes"):
def bad_worker():
    do_long_work()
    label.setText("done")     # a WORKER thread touching a
                              # widget: the race

# the pattern (the safe form): the worker emits, the main
# thread's slot applies to the widget
# (the QThread + the moveToThread, the next section)

# the thread affinity: the QObject's thread() is the thread
# that OWNS it (the thread whose loop dispatches its events)
# the widget's thread() is the main thread (it was created
# there); the worker QObject, after the moveToThread, has
# the worker thread as its thread() - the affinity is what
# the queued connection uses to decide the delivery

The rule is the single-thread ownership: the widgets belong to the main thread (the thread that entered app.exec()), and only that thread may call their methods. The reason is the toolkit's object model - the widget's state (its text, its layout, its render) is not protected against concurrent access, and a worker thread calling setText while the main thread renders the same label is a data race, the class of bug that is intermittent (the "sometimes crashes, sometimes garbled"). The safe structure is the split: the main thread builds the UI and runs the loop and is the only one that touches the widgets, and the worker thread does the long work and touches no widget - it does the work and emits a signal, and the main thread's slot applies the result to the UI. The thread affinity is the concept under it: the QObject's thread() is the thread that owns it (the thread whose event loop dispatches its events), the widget's thread() is the main thread (created there), and the worker QObject after the moveToThread has the worker thread as its thread() - and the affinity is what the queued connection uses to decide the delivery (the next section).

The QThread and the Worker: moveToThread

from PyQt5.QtCore import (QThread, QObject, pyqtSignal,
                          pyqtSlot, QTimer)
from PyQt5.QtWidgets import QApplication, QLabel
import sys

# the worker: the QObject that does the work (NO widgets)
class Worker(QObject):
    progress = pyqtSignal(int)
    result = pyqtSignal(str)
    def __init__(self):
        super().__init__()
    @pyqtSlot()
    def work(self):
        import time
        for i in range(10):
            time.sleep(0.1)          # the unit of work
            self.progress.emit(i + 1)
        self.result.emit("the computed result")

# the setup: the worker is MOVED to the QThread (the worker
# is NOT the QThread - the QThread is the loop, the worker
# is the QObject that runs in it)
app = QApplication(sys.argv)
label = QLabel("idle")
worker = Worker()
thread = QThread()
worker.moveToThread(thread)

# the wiring: the thread's started -> the worker.work
# (the work runs in the worker thread); the worker's signals
# -> the main thread's slots (the queued connection, the
# affinity decides it)
thread.started.connect(worker.work)
worker.progress.connect(lambda v: label.setText(
    "working %d/10" % v))
worker.result.connect(lambda r: (
    label.setText(r), thread.quit()))   # the done -> quit
thread.start()
sys.exit(app.exec_())

# the QThread is the EVENT LOOP in the worker thread; the
# worker QObject, moved into it, has the worker thread as
# its thread(); the worker's signals, emitted from the
# worker thread, to the main thread's slots are QUEUED (the
# affinity: the slot's thread is not the emitting thread)
# and run on the main thread's loop - the label.setText is
# the main thread's, the thread-safety

The QThread is the event loop in the worker thread, and the worker is a QObject that is moveToThreaded into it - the worker is not the QThread (the common mistake is subclassing the QThread and putting the work in its run; the moveToThread pattern is the Qt's, because the worker QObject in the thread's loop is what the queued connection and the signal/slot assume). The setup: the Worker QObject (the progress and the result signals, the work slot that does the units and emits), the QThread, the worker.moveToThread(thread), and the wiring - the thread.started to the worker.work (the work starts when the thread's loop starts, in the worker thread), the worker.progress to the main thread's slot (the label.setText), the worker.result to the slot that shows the result and the thread.quit() (the done stops the worker's loop). The thread-safety is the queued connection: the worker's signals, emitted from the worker thread, to the main thread's slots are queued (the affinity - the slot's thread is not the emitting thread) and run on the main thread's loop, so the label.setText is the main thread's call. The worker touches no widget; it emits, and the main thread's slot applies to the UI.

The QThreadPool: The Task-Based Case

from PyQt5.QtCore import (QRunnable, QThreadPool, QThread,
                          pyqtSignal, QObject)
from PyQt5.QtWidgets import QApplication, QLabel
import sys

# the QThreadPool: the pool of worker threads for the
# TASKS (the fire-and-forget, the no-permanent-thread)
# the QRunnable is the task (the run is the work)
class Task(QRunnable):
    # the QRunnable has NO signals (it is not a QObject) -
    # the worker QObject inside carries the signal
    class Worker(QObject):
        done = pyqtSignal(object)
    def __init__(self, payload):
        super().__init__()
        self.setAutoDelete(True)
        self.worker = Task.Worker()
        self.payload = payload
    def run(self):
        result = compute(self.payload)   # the work
        self.worker.done.emit(result)

app = QApplication(sys.argv)
pool = QThreadPool.globalInstance()   # the app's pool
pool.setMaxThreadCount(4)             # the pool size

label = QLabel("idle")
def on_done(result):
    label.setText("done: %s" % result)   # the main thread

task = Task(42)
task.worker.done.connect(on_done)
pool.start(task)       # the task to a pool thread
sys.exit(app.exec_())

# the pool is for the INDEPENDENT tasks (the no shared state
# between them, the fire-and-forget); the QThread + the
# moveToThread is for the LONG worker (the one thread, the
# one worker, the progress over time)
# the choice: the many short tasks -> the pool; the one long
# worker with the progress -> the QThread + the worker

The QThreadPool is the pool of worker threads for the task-based case: the QRunnable is the task (the run is the work), the pool.start(task) hands it to a pool thread, and the setAutoDelete(True) frees the task after. The QRunnable is not a QObject (it has no signals), so the worker QObject inside it carries the signal (the done), and the done's slot (on the main thread, the queued) applies the result to the UI. The QThreadPool.globalInstance() is the app's pool (the setMaxThreadCount the size). The choice is the one the section states: the many short independent tasks go to the pool (the fire-and-forget, the no permanent thread), and the one long worker with the progress over time is the QThread plus the moveToThread worker (the next section's pattern). The pool is the task's, the QThread is the worker's, and both marshal the result to the main thread through the queued connection - the same thread-safety, the two shapes.

The Shutdown Discipline

from PyQt5.QtCore import QThread, QObject, pyqtSignal, Qt
from PyQt5.QtWidgets import QApplication
import sys

# the clean shutdown of the QThread + the worker:
#   1. the worker signals the done (the result)
#   2. the done's slot calls the thread.quit() (the worker's
#      loop stops)
#   3. the thread.wait() (the main thread blocks until the
#      worker's loop exits - the join)
#   4. the worker and the thread are deleted (the parent /
#      the deleteLater)

class Worker(QObject):
    result = pyqtSignal(str)
    def work(self):
        self.result.emit("done")

worker = Worker()
thread = QThread()
worker.moveToThread(thread)
thread.started.connect(worker.work)

def on_result(r):
    print(r)
    thread.quit()      # the step 2: the worker's loop stops

worker.result.connect(on_result)
thread.start()

# the app's close: the quit + the wait (the step 3):
# the QApplication's aboutToQuit, or the window's close:
#   thread.quit()   (if not already)
#   thread.wait()   (the join - the main thread waits for
#                   the worker's loop to exit)
# the wait is the BARRIER: the main thread does not proceed
# (and the app does not exit) until the worker is done

# the timeout: the wait(ms) - the wait with the timeout
# (the worker that does not finish in the ms is the hung
# worker; the wait(ms) returns False on the timeout, and
# the app decides the terminate (the thread.terminate, the
# LAST resort - the worker mid-work, the possible
# corruption) vs the continue)

The shutdown is the clean stop of the QThread plus the worker: the worker signals the done (the result), the done's slot calls the thread.quit() (the worker's event loop stops), the thread.wait() is the join (the main thread blocks until the worker's loop exits - the barrier the app must not skip, or the app exits with the worker mid-work), and the worker and the thread are then deleted (the parent or the deleteLater). The wait is the step that is most often skipped and the one that causes the "the app exits with the thread still running" crash - the wait is the join, and the main thread does not proceed until the worker is done. The wait(ms) is the wait with the timeout (the False on the timeout, the hung worker), and the thread.terminate is the last resort (the worker mid-work, the possible corruption) - the quit plus the wait is the clean path, and the terminate is the one the app reaches for only when the quit did not stop the worker. The discipline: the worker signals the done, the done quits the thread, the wait joins, and the app exits with the worker finished - the clean shutdown the long worker needs.

The Working Rules

  • Only the main thread touches the widgets. The worker does the work and emits the signal; the main thread's slot (the queued connection) applies to the UI. The direct widget call from the worker is the race, the intermittent crash.
  • The worker is a QObject moveToThreaded into the QThread, not a QThread subclass. The QThread is the loop in the worker thread; the worker QObject in it is what the queued connection and the signal/slot assume. The thread.started starts the work.
  • The queued connection is the marshal. The worker's signal, emitted from the worker thread, to the main thread's slot is queued (the affinity decides it) and runs on the main thread's loop - the widget touched by its owner, the thread-safety the pattern gives.
  • The QThreadPool for the many short tasks, the QThread plus the worker for the one long worker. The QRunnable is the task (the worker QObject inside carries the signal); the moveToThread worker is the long one with the progress over time. Both marshal through the queued connection.
  • The shutdown is the quit plus the wait. The worker signals the done, the done's slot calls the thread.quit(), the thread.wait() is the join the app must not skip (the wait(ms) the timeout, the terminate the last resort) - the app exits with the worker finished.

Summary

The Qt threading rule is the single-thread ownership: only the main thread (the app.exec loop's thread) may touch the widgets, because the widget's state is not thread-safe and the worker's direct call is the data race behind the intermittent crash. The pattern is the QThread (the event loop in the worker thread) plus the worker QObject moveToThreaded into it (the worker is not the QThread subclass), the thread.started starting the work, and the worker's signals (the progress, the result) to the main thread's slots as the queued connection - the affinity (the slot's thread is not the emitting thread) decides the queue, and the slot runs on the main thread's loop, so the widget is touched by its owner. The QThreadPool plus the QRunnable is the task-based case (the many short tasks, the worker QObject inside carrying the signal), and the shutdown is the quit plus the wait (the worker signals the done, the done quits the thread, the wait is the join the app must not skip, the terminate the last resort). The discipline across it: the worker does the work and emits, the main thread's slot applies to the UI, and the queued connection is the marshal that makes the widget safe.