Cross-thread Qt code has a recognizable smell: the business intent is small, but the dispatch machinery around it keeps getting larger.
Suppose a worker thread computes a new visible time range and the plot
object on the UI thread needs to apply it. The real intent is simple:
"run adjust_t_to_target on the plot's thread."
What often lands in the code is this:
QMetaObject::invokeMethod(
m_plot,
"adjust_t_to_target",
Qt::QueuedConnection,
new_min,
new_max);
Nothing here is inherently wrong. The problem is that the call site is doing more than asking for a plot update. It is also making a queueing decision, relying on string-based lookup, relying on runtime argument matching, and leaving receiver-lifetime behavior implicit.
That is manageable once. It is not manageable when the same pattern appears a few hundred times across a large Qt codebase.
The framework gives that dispatch policy one public vocabulary:
vnm::qt::post(context, task);
vnm::qt::call(context, task);
post means "admit this exact-void work to the receiver's event queue
and return." call means "run this on the receiver's affinity thread
and do not return until its result or exception is available."
Those two operations are deliberately different. Picking one is a statement about whether completion matters.
invokeMethod is not the villain
It is worth being precise here, because Qt itself is better than the usual complaint suggests.
QMetaObject::invokeMethod supports both direct and queued execution.
The Qt reference pages for
QMetaObject and
Qt::ConnectionType
are the useful source material for those primitives.
The problem is not that Qt lacks the mechanism. The problem is that raw calls make every call site rebuild the policy: connection type, argument storage, receiver assumptions, cancellation behavior, and exception handling.
The safe-dispatch surface names the application contract once. Callers
use the canonical vnm::qt namespace and choose between two questions:
- Can this work happen later, with no completion signal? Use
vnm::qt::post. - Must this operation complete, return a value, or propagate an
exception? Use
vnm::qt::call.
| Operation | Same affinity thread | Different thread | Completion surface |
|---|---|---|---|
vnm::qt::post | queue for later | queue for later | admission result only |
vnm::qt::call | execute inline | queue and block | return value or exception |
The same-thread distinction matters. post is always deferred, even
when the caller is already on the receiver's thread. call checks the
receiver's actual Qt thread affinity and executes inline when that is
the current thread.
post: always deferred, exact void, admission only
The most direct queued member call looks like this:
const vnm::qt::Post_result post_result = vnm::qt::post(
m_plot,
&VNM_plot::adjust_t_to_target,
new_min,
new_max);
The member pointer and arguments are checked at compile time. Ordinary
arguments are decayed into owned storage and consumed once when the
event runs. The member must return exactly void; a value-returning
operation is a sign that the caller probably needs call.
The callable form follows the same contract:
const vnm::qt::Post_result post_result = vnm::qt::post(
m_plot,
[plot = m_plot, range = std::move(range)]() mutable {
plot->apply_visible_range(std::move(range));
});
The callable itself must also return exactly void. It is stored once
and invoked later as an lvalue.
post is marked nodiscard because its result is part of the
operation, not decoration:
switch (vnm::qt::post(
m_plot,
&VNM_plot::adjust_t_to_target,
new_min,
new_max)) {
case vnm::qt::Post_result::QUEUED:
break;
case vnm::qt::Post_result::RECEIVER_NULL:
report_plot_dispatch_failure("plot receiver is null");
break;
case vnm::qt::Post_result::NO_THREAD_AFFINITY:
report_plot_dispatch_failure("plot receiver has no thread affinity");
break;
case vnm::qt::Post_result::SUBMISSION_FAILED:
report_plot_dispatch_failure("Qt rejected or could not store the work");
break;
}
This is intentionally more precise than a boolean. A null receiver and a receiver with no thread affinity are ownership errors. A submission failure means task or argument storage failed, or Qt rejected the queued call. None should silently masquerade as success.
The important limitation is what QUEUED means: Qt accepted the event.
It does not mean the task ran.
After admission, receiver destruction, explicit removal of posted
events, or event-loop shutdown can still cancel the work. post has no
channel back to the submitting stack after it returns, so it cannot
report that later cancellation. If the caller needs proof of execution,
the operation is not fire-and-forget; use call or design an explicit
asynchronous completion protocol.
That also explains why "I checked for QUEUED" is not a durability
guarantee. It is an admission check and nothing more.
call: completion, results, and exceptions
Use call when the caller needs the target operation to finish:
const Plot_view_state view_state = vnm::qt::call(
m_plot,
&VNM_plot::view_state);
If the caller is already on m_plot->thread(), the member runs inline.
Otherwise Qt queues it to that affinity thread and the caller blocks
until the operation completes, throws, or is cancelled before
execution.
The callable form can return a movable value:
const Session_snapshot snapshot = vnm::qt::call(
m_session_model,
[model = m_session_model] {
return model->build_snapshot();
});
Non-void results move back to the caller. Reference results are
rejected: a reference crossing a synchronous thread boundary does not
provide a safe ownership or synchronization story. Return a value or an
owning handle instead.
Target exceptions propagate to the caller. Dispatch failures throw
vnm::qt::Dispatch_error, whose code distinguishes the dispatch
failure:
try {
const Session_snapshot snapshot = vnm::qt::call(
m_session_model,
&Session_model::build_snapshot);
consume_snapshot(snapshot);
}
catch (const vnm::qt::Dispatch_error& e) {
report_session_dispatch_failure(e.code(), e.what());
}
catch (const Snapshot_error& e) {
report_snapshot_failure(e);
}
The dispatch error codes cover a null receiver, no thread affinity, submission failure, and cancellation before execution. Exceptions from the target retain their original type; callers handle them as they would for a direct call.
This synchronous behavior is useful, but it is not a shutdown
primitive. A cross-thread call is an unbounded wait. The receiver's
event loop must keep servicing events until the work executes or Qt
cancels it. If that thread is blocked waiting for the caller, has
stopped processing events, or is part-way through teardown, the two
threads can deadlock.
Do not use cross-thread call during shutdown unless the receiver's
owner provides a concrete progress guarantee. When no such guarantee
exists, restructure ownership so teardown happens on the owning thread
or use an asynchronous protocol with an explicit completion signal.
Raw receivers are a real lifetime contract
Both operations take a raw receiver pointer. The dispatch layer does
not pin the QObject's lifetime, and a QPointer check immediately
before the call cannot turn concurrent destruction into a safe race.
The required stability window differs:
- For
post, the receiver must remain alive with stable thread affinity from function entry through return. - For
call, the receiver must remain alive with stable thread affinity throughout the entire synchronous operation.
That contract must come from ownership: a join, a shutdown phase, an
owning-thread guarantee, a lock whose design permits this call, or
another boundary that actually prevents destruction and moveToThread
during the operation.
This is not just a nullability rule. A non-null pointer can still race with destruction or affinity changes. Coordinate the receiver's lifetime first, then dispatch.
Guard other queued objects with QPointer
The raw receiver contract is about the object whose event queue accepts
the work. A queued task may also refer to another QObject that is
allowed to disappear before execution. That is where a captured
QPointer is useful.
Suppose a UI-thread session controller is stable during submission, but an optional overlay may close before the queued update runs:
// Established on the overlay's owner thread while its lifetime is stable.
const QPointer<Session_overlay> overlay_guard{m_overlay};
const vnm::qt::Post_result post_result = vnm::qt::post(
m_session_controller,
[overlay = overlay_guard,
labels = std::move(labels)]() mutable {
if (!overlay) {
return;
}
overlay->apply_labels(std::move(labels));
});
Here m_session_controller still needs the normal raw receiver
guarantee while post is submitting. The captured guard answers a
different question later: does the overlay still exist when the
callback runs?
The raw m_overlay pointer must itself be stable while overlay_guard
is constructed and while that guard is copied into the lambda. Establish
the guard on the QObject's owner thread, or under synchronization that
prevents destruction, and keep that boundary through capture
construction before posting. Only then can the queued callback use the
guard to observe later destruction.
Use this pattern when the guarded object belongs to the callback's
thread and its affinity is stable. QPointer validates lifetime; it
does not authorize access from the wrong thread and does not make
concurrent submission through a raw pointer safe.
Captures must be safe to destroy on arbitrary threads
Queueing transfers more than values. It transfers their eventual destruction.
A task capture, a bound member argument, an intermediate value, a result holder, or a transported exception object may be destroyed on:
- the submitting or calling thread
- the receiver thread
- a thread that removes posted events
Every transported object therefore needs non-throwing, thread-agnostic destruction. Its destructor must not depend on a particular thread's thread-local state. A reference-counted capture also needs scrutiny: the last release may run the pointee's destructor on any of those threads.
Prefer owning values such as QString, QByteArray, std::string,
containers, and purpose-built snapshots. Non-owning types such as
QStringView, std::string_view, raw spans, and borrowed pointers are
safe only when an external lifetime contract guarantees that their
storage survives execution or cancellation.
The typed member overloads make ownership the default by decaying and
storing ordinary arguments. std::ref() opts back into reference
semantics explicitly; the referent must outlive execution or
cancellation, and the caller remains responsible for cross-thread
synchronization.
Exception behavior is part of the choice
Queued fire-and-forget work has no submitting stack left to unwind
into. vnm::qt::post is noexcept, but that guarantee begins only at
function entry. Once entered, it converts failures instead of letting
them escape:
- task and argument storage performed inside
post, plus submission problems, become a non-QUEUEDPost_result - target exceptions are contained in the Qt callback and reported by the framework
- reporting failures are contained too, with a fallback diagnostic
Evaluating arguments and constructing a lambda's captures happen before
post is entered and may throw normally. If those expressions can
throw, the caller must handle them like any other pre-call work.
The target exception does not escape the receiver's event callback, and it cannot be delivered back to the original caller.
vnm::qt::call has the opposite contract because the caller is still
waiting. It propagates the original target exception and uses
Dispatch_error for failures in the dispatch itself.
This gives a simple review rule. If code needs to react to a target
exception, it needs call or a purpose-built asynchronous result
channel. A warning from fire-and-forget work is observability, not
recovery.
Typed member calls keep the business intent visible
Both operations accept member-function pointers:
const vnm::qt::Post_result post_result = vnm::qt::post(
m_plot,
&VNM_plot::adjust_t_to_target,
new_min,
new_max);
const Query_status query_status = vnm::qt::call(
m_query_model,
&Query_model::refresh_status,
source_id);
The compiler verifies that the member belongs to the receiver's class
hierarchy and that its arguments and receiver are compatible. The
receiver pointer itself must be non-const, even when the selected
member function is const. The compile-time const check validates
member-call compatibility only; it does not admit a const receiver
pointer. No string lookup, Q_ARG, or manual metatype registration is
needed merely to carry captured C++ values across the dispatch.
Use a callable when the operation genuinely combines several steps or needs explicit capture policy. Use the member form when the intent is one typed member call; it is shorter and exposes more mistakes at compile time.
What safe dispatch should not replace
The two operations own cross-thread execution policy. They are not substitutes for every Qt event-system primitive.
Genuine timers. Use QTimer when elapsed time, a deadline,
repetition, or cancellation by timer identity is part of the
requirement. Posting work once is not a timer.
Signal lifecycle connections. A signal-slot connection expresses an ongoing relationship, connection type, automatic disconnection on QObject destruction, and often fan-out to multiple observers. Repeated one-shot dispatches do not encode that lifecycle.
Dynamic QML and metaobject tests. Direct
QMetaObject::invokeMethod is appropriate when runtime name lookup is
the behavior being used or tested, including interaction with methods
owned by a dynamic QML object. Replacing that with a C++ member pointer
would change the subject of the test.
The dividing line is intent. For owned C++ cross-thread work whose
receiver and callable are known at compile time, use vnm::qt::post or
vnm::qt::call. When timing, connection lifecycle, or dynamic
metaobject behavior is the feature, keep the Qt primitive that
expresses it.
The real payoff is not less typing. It is better code review and better maintenance.
When a reader sees vnm::qt::post, they know the work is exact-void,
always deferred, and only admitted to the queue. When they see
vnm::qt::call, they know the caller needs completion and may receive a
value or exception.
Qt still provides the machinery. The framework turns that machinery into two contracts, and the call site says which contract the work actually needs.