CodeRigor

FREE LESSON

Background work in a Qt application

Keep the UI thread responsive: QThread workers, QtConcurrent and std::jthread backends, queued delivery, cooperative cancellation, latest-wins request coalescing, generation counters and clean shutdown while work is in flight.

An interactive viewer has one hard rule: the UI thread must get back to its event loop within a frame (16 ms at 60 Hz). Everything it does — handling a mouse move, laying out a QML item, painting — happens in that loop, one event at a time. A 200 ms render inside a slot means 200 ms in which the window can't repaint, resize, or even show the cursor change. Users call that "frozen", and on some desktops the compositor offers to kill the app.

So slow work moves to another thread, and the interesting part is everything around the move: getting requests there, getting results back to the right thread, stopping work nobody wants any more, and shutting down while a job is still running. This skill builds on Threads, jthread and cooperative cancellation and Atomics and the C++ memory model — joins, mutexes, condition variables and happens-before are used here, not re-explained — and on QObject lifetime (qt-object-lifetime) for parents, deleteLater and context objects.

Off the UI thread
The UI thread only posts the request and later shows the result; the render runs on the worker. Input events keep being handled in between.
Off the UI thread
The UI thread only posts the request and later shows the result; the render runs on the worker. Input events keep being handled in between.

1. What blocks the UI thread

Anything that waits: a long computation in a slot, file or network I/O, QFuture::waitForFinished(), QThread::wait(), joining a std::thread, locking a mutex a worker holds for a long time, or a Qt::BlockingQueuedConnection to a busy thread. The fix is never "call QCoreApplication::processEvents() in the loop" — that re-enters the event loop from inside your slot, so a second click or a window close runs in the middle of your half-finished function.

The shape of every correct design is the same: the UI thread posts a request and returns; a worker does the work; the worker posts the result back; the UI thread handles it as one more event.

2. Three ways off the UI thread

QThread + worker object QtConcurrent std::jthread backend
good for long-lived worker with state, receiving many requests one-off tasks, parallel map/filter Qt-free core libraries (splat::core), testable under TSan
requests via queued signals / invokeMethod to the worker the call itself your own queue or slot under a mutex
results via signals (queued to the UI) QFuture + QFutureWatcher signals a callback that posts with invokeMethod
cancellation requestInterruption() / your flag QPromise::isCanceled() (run-with-promise) std::stop_token
needs moc for custom signals/slots no no

QThread + worker object. A QThread is a handle to a thread; its default run() calls exec(), an event loop. You don't subclass it to add slots — the QThread object itself lives in the thread that created it, so its slots run there. Instead, move a worker object into the thread:

C++
auto* thread = new QThread(this);               // owned by the viewer, lives on the UI thread
auto* worker = new RenderWorker;                // no parent: objects with a parent can't move
worker->moveToThread(thread);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
connect(this, &Viewer::renderRequested, worker, &RenderWorker::render);   // queued: different threads
connect(worker, &RenderWorker::frameReady, this, &Viewer::showFrame);     // queued back
thread->start();

RenderWorker::render now runs on thread, inside its event loop, one request at a time. Q_OBJECT, custom signals like frameReady and the renderRequested signal need moc, which CMake runs for you with set(CMAKE_AUTOMOC ON) or qt_standard_project_setup() (this course's sandbox has no moc, so Qt code here is in fragments and the graded backend is Qt-free).

QtConcurrent. For a one-off job, the thread pool is simpler. Qt 6's run with promise gives the task a QPromise for progress, results and cancellation:

C++
QFuture<void> future = QtConcurrent::run([scene](QPromise<void>& promise) {
    promise.setProgressRange(0, scene.rows());
    for (int row = 0; row < scene.rows(); ++row) {
        if (promise.isCanceled()) return;           // QFuture::cancel() sets this
        exportRow(scene, row);
        promise.setProgressValue(row + 1);
    }
});
watcher_.setFuture(future);   // QFutureWatcher<void>: progressValueChanged / finished arrive on the UI thread
connect(cancelButton, &QPushButton::clicked, &watcher_, &QFutureWatcher<void>::cancel);
CMake
find_package(Qt6 REQUIRED COMPONENTS Core Concurrent Quick)
target_link_libraries(viewer PRIVATE Qt6::Quick Qt6::Concurrent splat::core)

A std::jthread backend. When the work belongs to a library that must not depend on Qt (a renderer used by a CLI and by Python bindings too), the scheduler is plain C++ and a thin Qt facade bridges it. That is the build task of this skill, and the version you can test under ThreadSanitizer: the system Qt isn't built with TSan, so TSan can't see Qt's internal locking and reports false races in any Qt code. Test the thread-safety of the Qt-free part; test the Qt facade with QtTest.

3. Crossing threads: queued delivery and thread affinity

Every QObject has a thread affinity — obj->thread() — the thread whose event loop delivers its events and queued calls. With the default Qt::AutoConnection, Qt decides at emit time: if the receiver lives in the emitting thread the slot is called directly; otherwise the call is queued: the arguments are copied into an event posted to the receiver's thread, and the slot runs when that event loop gets to it.

Who lives where
Affinity decides where events and queued calls run. A direct call runs on the caller's thread regardless.
Who lives where
Affinity decides where events and queued calls run. A direct call runs on the caller's thread regardless.

The rules, with the warnings Qt 6 prints when you break them:

  • moveToThread can only push an object from the thread it lives in ("QObject::moveToThread: Current thread (…) is not the object's thread (…)"), and refuses objects that have a parent ("Cannot move objects with a parent"). Children move along with their parent.
  • A parent and its children share a thread ("QObject: Cannot create children for a parent that is in a different thread.").
  • Timers belong to the object's thread ("QObject::startTimer: Timers cannot be started from another thread").
  • Delete an object in its own thread: deleteLater() is safe to call from anywhere — it posts a deferred-delete event to the object's thread.
  • QThread::wait() from the thread itself doesn't wait: it prints "QThread::wait: Thread tried to wait on itself" and returns false.

Queued delivery is just "lock a queue, push a copy, wake the owner". Here is the whole mechanism in standard C++, with main playing the UI thread's event loop:

C++
#include <condition_variable>
#include <deque>
#include <functional>
#include <mutex>
#include <print>
#include <string>
#include <thread>

class EventQueue {                       // what every QThread's event loop has
public:
    void post(std::function<void()> ev) {
        { std::lock_guard lock(m_); q_.push_back(std::move(ev)); }
        cv_.notify_one();
    }
    void run_one() {                     // what exec() does in a loop
        std::function<void()> ev;
        {
            std::unique_lock lock(m_);
            cv_.wait(lock, [this] { return !q_.empty(); });
            ev = std::move(q_.front());
            q_.pop_front();
        }
        ev();                            // outside the lock, on the owner's thread
    }
private:
    std::mutex m_;
    std::condition_variable cv_;
    std::deque<std::function<void()>> q_;
};

int main() {
    EventQueue ui;
    const auto ui_id = std::this_thread::get_id();
    std::string shown;
    {
        std::jthread worker([&] {
            std::string frame = "frame 7";             // the worker's own object
            ui.post([frame, &shown, ui_id] {           // captured by value: a copy travels
                shown = frame;
                std::println("slot on UI thread: {}", std::this_thread::get_id() == ui_id);
            });
            frame = "worker keeps scribbling";         // can't affect the posted copy
        });
        ui.run_one();
    }
    std::println("shown: {}", shown);
}

That's why queued arguments must be copyable (and, for the string-based SIGNAL()/SLOT() syntax, registered with the meta-type system), and why passing const QImage& across threads is safe: the slot's reference binds to the event's copy. QImage, QString, QList are implicitly shared, so that copy only bumps a reference count — until someone writes to it.

From a non-QObject thread such as a jthread, post with QMetaObject::invokeMethod and a context object:

C++
scheduler_ = std::make_unique<RenderScheduler>(
    render_fn,
    [this](RenderResult r) {                         // runs on the worker
        QMetaObject::invokeMethod(this, [this, r = std::move(r)] {
            if (r.generation != scheduler_->latest_generation()) return;   // overtaken in the queue
            showFrame(toQImage(r));                  // runs on the viewer's (UI) thread
        }, Qt::QueuedConnection);
    });

this as the context object means the lambda runs in the viewer's thread, and if the viewer is destroyed first the posted event is discarded with it. Never capture a raw this into work that may outlive the object without such a context (see qt-object-lifetime).

4. Cancellation is cooperative

Nothing in C++ or Qt safely stops a thread from outside (QThread::terminate kills it wherever it is — maybe holding a lock — and the docs warn against it). Instead the work checks a flag at safe points: once per tile, row or sample batch in a render loop. The three spellings:

set by checked with
std::stop_token jthread::request_stop(), stop_source::request_stop() st.stop_requested(), stop-aware condition_variable_any waits
QThread interruption thread->requestInterruption() QThread::currentThread()->isInterruptionRequested()
QPromise QFuture::cancel() promise.isCanceled()

How often to check is a trade-off: per pixel wastes time on the check, per frame makes cancellation as slow as the render. Per row or per 16×16 tile is typical — cheap (one relaxed atomic load) and responsive (well under a frame).

C++
#include <latch>
#include <print>
#include <stop_token>
#include <thread>

// Renders `rows` rows, checking the token once per row. The latches only
// make the demo deterministic: the worker pauses after row 3 until main has
// requested stop.
int render(std::stop_token st, int rows, std::latch& reached, std::latch& go) {
    int done = 0;
    for (int row = 0; row < rows; ++row) {
        if (st.stop_requested()) break;        // the safe point
        ++done;                                // ... shade row `row` ...
        if (done == 3) { reached.count_down(); go.wait(); }
    }
    return done;
}

int main() {
    std::latch reached(1), go(1);
    int done = 0;
    {
        std::jthread worker([&](std::stop_token st) { done = render(st, 100, reached, go); });
        reached.wait();
        worker.request_stop();                 // the camera moved
        go.count_down();
    }
    std::println("rendered {} of 100 rows before stopping", done);
}

Each job needs its own stop source. If all jobs shared the jthread's token, stopping a superseded render would also stop the worker; if a job's source were reused, a stop meant for job 1 could hit job 2. The scheduler creates a fresh std::stop_source per job, under its lock, and hands out its token.

5. Latest wins: coalescing camera moves

A mouse drag produces 60–120 move events a second; a CPU splat render takes 100–500 ms. A queue of requests grows without bound, and every queued view is already wrong when it's finally rendered. What the user wants is "the camera as it is now". Keep one pending slot: a new request replaces the one waiting there, and a running job is told to stop.

One pending slot
start / 5
While g1 renders, g2, g3 and g4 arrive. Each replaces the pending one; g2 and g3 are never rendered.
One pending slot
While g1 renders, g2, g3 and g4 arrive. Each replaces the pending one; g2 and g3 are never rendered.

The slot is shared state, so every access — the write in submit, the read and reset in the worker — happens under one mutex. Here is the slot on its own, used from one thread to show the bookkeeping:

C++
#include <optional>
#include <print>
#include <utility>

struct Request { int frame; };

class LatestSlot {
public:
    bool put(Request r) {                      // true if it replaced a pending one
        bool replaced = pending_.has_value();
        pending_ = r;
        coalesced_ += replaced;
        return replaced;
    }
    std::optional<Request> take() { return std::exchange(pending_, std::nullopt); }
    int coalesced() const { return coalesced_; }
private:
    std::optional<Request> pending_;
    int coalesced_ = 0;
};

int main() {
    LatestSlot slot;
    for (int frame = 1; frame <= 5; ++frame) slot.put({frame});   // five moves during one render
    auto next = slot.take();
    std::println("next: frame {}, coalesced: {}, empty after take: {}",
                 next->frame, slot.coalesced(), !slot.take().has_value());
}

6. Generation counters: never show a stale result

Stopping a superseded job saves CPU, but it doesn't guarantee anything about what is shown. The render may be on its last row when the request arrives and return a complete image; or its result may already sit in the UI's event queue. So every request gets a generation — a counter bumped under the lock in submit — and every result carries it. A result is delivered only if its generation is still the latest, and the UI checks again on receipt.

Cancellation alone isn't enough
The g1 render finishes just as g2 is submitted. Its stop request comes too late; the generation check drops the stale image.
Cancellation alone isn't enough
The g1 render finishes just as g2 is submitted. Its stop request comes too late; the generation check drops the stale image.

The comparison only works if the generation and the slot change in the same critical section: "is g still the latest?" must be asked under the lock that submit holds while bumping it. Progress reports follow the same rule — forward them only while the job is current, and tag them with the generation, so the progress bar doesn't jump between the old job's "row 90" and the new job's "row 3".

7. Progress reporting

Progress is a stream of tiny results, so it travels the same way: from the worker, tagged with the generation, posted to the UI thread. Two practical points:

  • Throttle. A per-row report at 2000 rows per frame floods the event queue. Post at most every few milliseconds or every few percent; QtConcurrent's QPromise::setProgressValue already rate-limits the progress signals a QFutureWatcher sees.
  • Drop stale ones on the worker (the job's token is stopped) and on the UI (the generation isn't the latest).

8. Clean shutdown while work is in flight

Closing the window while a render runs is where most threading bugs surface. The order that always works:

Shutdown, in order
Each step is one line of code. Steps 1–2 under the lock, 3–4 after releasing it, 5 only if you are not the worker.
Shutdown, in order
Each step is one line of code. Steps 1–2 under the lock, 3–4 after releasing it, 5 only if you are not the worker.

And the rules behind it:

  • Change the condition first, under the lock; notify after. A flag set after the notify (or outside the mutex) lets the worker wake, see nothing to do, and sleep forever — a lost wake-up — and the unlocked write is a data race.
  • Waits must be stop-aware. cv.wait(lock, st, pred) on a condition_variable_any wakes on request_stop(); cv.wait(lock, pred) does not, however much the predicate checks the token.
  • Never call out while holding your lock — callbacks, signals, and even request_stop(), which runs stop callbacks registered by the job. Decide under the lock, act after unlocking.
  • Never join from the worker. A result handler that closes the viewer runs on the worker. std::thread::join then throws std::system_error(resource_deadlock_would_occur); compare worker_.get_id() with std::this_thread::get_id() and let the destructor join later.
  • The thread is the last member. Members are destroyed in reverse order, so a std::jthread declared last is stopped and joined before the mutex, queue and callbacks it uses disappear.
  • No deliveries after destruction. Joining guarantees no callback runs after shutdown() returns; the context object in invokeMethod guarantees no queued result reaches a destroyed viewer.
C++
#include <condition_variable>
#include <latch>
#include <mutex>
#include <print>
#include <stop_token>
#include <thread>

class Worker {
public:
    explicit Worker(std::latch& done) : done_(done), thread_([this](std::stop_token st) { run(st); }) {
        constructed_.count_down();                    // thread_ is assigned: run() may use it
    }
    ~Worker() { shutdown(); }

    void shutdown() {
        if (thread_.get_id() == std::this_thread::get_id()) {
            std::println("shutdown on the worker: request stop, don't join");
            thread_.request_stop();
            return;
        }
        thread_.request_stop();
        if (thread_.joinable()) thread_.join();
        std::println("shutdown from outside: joined");
    }

private:
    void run(std::stop_token st) {
        constructed_.wait();
        shutdown();                                   // e.g. a result handler closing the app
        std::unique_lock lock(m_);
        cv_.wait(lock, st, [] { return false; });     // returns at once: stop was requested
        std::println("worker loop exits");
        done_.count_down();
    }

    std::latch& done_;
    std::latch constructed_{1};
    std::mutex m_;
    std::condition_variable_any cv_;
    std::jthread thread_;                             // last
};

int main() {
    std::latch done(1);
    {
        Worker w(done);
        done.wait();
    }
    std::println("destroyed");
}

The same order in a Qt viewer with a QThread worker: thread->requestInterruption(); thread->quit(); thread->wait(); — interrupt the running job, ask the event loop to return after the current slot, block until run() has returned; finished → deleteLater then destroys the worker in its own thread. Connect it to QCoreApplication::aboutToQuit or do it in the viewer's destructor — never leave it to static destruction after main returns, when QThread: Destroyed while thread '…' is still running aborts the app.

9. Testing it without sleeps

Background code is tested by scripting the worker, not by waiting for it:

  • Inject the expensive function (RenderFn) and make the test's version block on a latch or condition variable — "frame 1 waits until the test releases it", "frame 1 ignores its stop token" — so each interleaving is reproduced exactly.
  • Wait for outcomes with a bounded condition-variable wait (wait_for(1s, pred)): correct code never hits the bound; broken code fails instead of hanging the suite. Never assert on elapsed time.
  • Run the Qt-free scheduler under TSan (-fsanitize=thread, the project's tsan preset). Test the Qt facade with QtTest and the offscreen platform:
C++
void ViewerTest::deliversLatestFrame() {
    Viewer viewer(fakeRenderer());
    QSignalSpy spy(&viewer, &Viewer::frameShown);
    viewer.setCamera(cameraA);
    viewer.setCamera(cameraB);                  // supersedes A
    QVERIFY(spy.wait(5000));                    // runs the event loop until the signal (or 5 s)
    QCOMPARE(spy.count(), 1);
    QCOMPARE(spy.at(0).at(0).value<quint64>(), viewer.latestGeneration());
}
Shell
QT_QPA_PLATFORM=offscreen ctest --test-dir <build-dir> -R viewer --output-on-failure

In the project

Ungraded practice in projects/scene-viewer/app/ (stage 2); the evidence is your own build and test output.

  1. Read app/backend/latest_wins.hpp and compare it with your build task's RenderScheduler: pending slot, generations, stop tokens, delivery outside the lock, shutdown joining the worker.
  2. Its tests are app/tests/test_latest_wins.cpp, Qt-free on purpose. Build them with TSan (cmake -S . -B build/app-tsan -DSPLAT_SANITIZER=thread -DSPLAT_BUILD_APP=ON, target viewer_backend_tests) and run them in a loop.
  3. Follow a camera drag through SceneController: orbitBy → requestRender → LatestWins::submit → splat::render with the stop token → onRendered checking the generation. Why can't a stale frame be shown?
  4. Add one thing (with a test): a "cancel" that also stops a render already in progress at the next tile, a progress bar that resets on supersede, or a frame-time readout.

Summary

  • The UI thread posts requests and handles results; it never waits for work.
  • QThread + moved worker for long-lived workers, QtConcurrent (run with promise) for one-off jobs, a std::jthread backend for Qt-free libraries.
  • Queued delivery copies the arguments into an event for the receiver's thread; affinity decides where queued calls and events run.
  • Cancellation is cooperative: a per-job stop source, checked per row or tile.
  • Latest wins: one pending slot; generations decide what is shown.
  • Shutdown: stop accepting, drop, cancel, wake, join — under the lock only what must be, callbacks never, and never join yourself.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

QThread, thread affinity, per-thread event loops and how cross-thread signals and slots really work — the Qt half of this skill in one talk, including mixing in standard C++ threads.

What a queued connection does under the hood: the posted event, the argument copies and which thread runs the slot. The rest of the series covers QThread, worker objects and Qt vs std threading.