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.
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:
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:
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);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.
The rules, with the warnings Qt 6 prints when you break them:
moveToThreadcan 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:
#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:
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).
#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.
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:
#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.
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::setProgressValuealready rate-limits the progress signals aQFutureWatchersees. - 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:
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 acondition_variable_anywakes onrequest_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::jointhen throwsstd::system_error(resource_deadlock_would_occur); compareworker_.get_id()withstd::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::jthreaddeclared 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 ininvokeMethodguarantees no queued result reaches a destroyed viewer.
#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'stsanpreset). Test the Qt facade with QtTest and the offscreen platform:
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());
}QT_QPA_PLATFORM=offscreen ctest --test-dir <build-dir> -R viewer --output-on-failureIn the project
Ungraded practice in projects/scene-viewer/app/ (stage 2); the evidence is
your own build and test output.
- Read
app/backend/latest_wins.hppand compare it with your build task'sRenderScheduler: pending slot, generations, stop tokens, delivery outside the lock, shutdown joining the worker. - 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, targetviewer_backend_tests) and run them in a loop. - Follow a camera drag through
SceneController:orbitBy→requestRender→LatestWins::submit→splat::renderwith the stop token →onRenderedchecking the generation. Why can't a stale frame be shown? - 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.