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.
This article is about a different approach: stop spelling out raw Qt dispatch primitives at every call site, and instead expose a small set of helper functions whose names already encode the policy.
This helper set is used throughout Lumis session bring-up, signal routing, and teardown, so the examples below are not hypothetical; they are the shape of the real API.
invokeMethod is not the villain
It is worth being precise here, because Qt itself is better than the usual complaint suggests.
QMetaObject::invokeMethod absolutely does support
Qt::AutoConnection. In fact, the overloads without an explicit
connection type already use Qt::AutoConnection by default. That means:
- same thread: the call runs synchronously
- different thread: the call is queued to the receiver's event loop
The Qt reference pages for
QMetaObject and
Qt::ConnectionType
are the useful source material for those dispatch semantics.
So this:
QMetaObject::invokeMethod(m_plot, "adjust_t_to_target", new_min, new_max);
already has the usual same-thread fast path.
And this:
QMetaObject::invokeMethod(
m_plot,
"adjust_t_to_target",
Qt::AutoConnection,
new_min,
new_max);
means the same thing, just more explicitly.
The important contrast is with Qt::QueuedConnection. Once you write
that, you are no longer saying "run this on the receiver thread." You
are saying "always enqueue this, even if I am already on the receiver
thread."
That distinction matters because many codebases blur two different intentions:
- "do this on the receiver thread"
- "do this later through the event loop"
Qt can represent both. The problem is that raw invokeMethod makes each
call site restate the distinction for itself.
Why a thin wrapper still leaves the hard part unsolved
The first cleanup step is usually a tiny helper:
template <class F>
void post(QObject* target, F&& fn)
{
QMetaObject::invokeMethod(target, std::forward<F>(fn), Qt::QueuedConnection);
}
That removes some syntax, but it does not really solve the design problem.
It still leaves the meaning at the call site underspecified. Does the
caller want "same-thread inline, otherwise queued"? Does it want
"always queued"? Does it want a blocking hop with a return value? A
single post(...) wrapper answers all of those questions with one blunt
policy.
It also does not make failure behavior explicit. If target is null,
the call may quietly disappear. If the queued callable throws, the
original caller is long gone. If the receiver is destroyed after the
dispatch is posted, the call site still does not tell the reader whether
that is acceptable, detectable, or fatal.
Even the more modern functor overloads of invokeMethod only solve part
of the problem. They are a real improvement over string lookup and
Q_ARG, but they still force every call site to spell out its own
dispatch contract:
QMetaObject::invokeMethod(
m_plot,
[guard = QPointer<VNM_plot>{m_plot}, new_min, new_max]() {
if (!guard) {
return;
}
guard->adjust_t_to_target(new_min, new_max);
},
Qt::QueuedConnection);
This version is type-safe, but the policy is still scattered. Queueing, guarding, argument capture, and exception behavior are all being rebuilt inline by hand.
Name the policy once
The cleaner pattern is to make the API surface carry the decision.
In vnm_qt_safe_dispatch.h, the dispatch helpers are deliberately
small, but each name means something specific:
vnm::safe_invoke(m_plot, &VNM_plot::adjust_t_to_target, new_min, new_max);
vnm::post_invoke(m_plot, &VNM_plot::adjust_t_to_target, new_min, new_max);
int source_id = vnm::blocking_invoke(
m_source_manager,
&Source_manager::create_data_source,
source_config);
auto maybe_source_id = vnm::try_blocking_invoke(
m_source_manager,
&Source_manager::create_data_source,
source_config);
Those four calls are easy to tell apart without reading an overload set
or mentally decoding a Qt::ConnectionType flag:
safe_invoke: run inline when already on the receiver thread; otherwise queue itpost_invoke: always queue itblocking_invoke: block until the receiver thread finishes and return the resulttry_blocking_invoke: same blocking behavior, but convert failure intofalseorstd::optional<T>{}
| Helper | Same thread | Different thread | Caller waits? | Failure surface |
|---|---|---|---|---|
safe_invoke | execute inline now | queue to receiver thread | no | bool |
post_invoke | queue anyway | queue to receiver thread | no | bool |
blocking_invoke | execute inline now | queue and wait | yes | direct result or hard failure |
try_blocking_invoke | execute inline now | queue and wait | yes | false or optional result |
And the two non-blocking helpers differ in exactly one branch:
safe_invoke
caller on receiver thread?
yes -> run now
no -> queue
post_invoke
caller on receiver thread?
yes -> queue
no -> queue
The point is not that these helpers are magical. Internally they still use Qt dispatch. The point is that the choice is named once, in one place, and then reused everywhere.
That has several practical benefits.
First, member-function pointers are checked at compile time. If the code
spells &VNM_plot::adjust_t_to_target, the compiler verifies that the
member exists and that the argument list matches. There is no string
lookup to fail at runtime.
Second, const-correctness becomes part of the surface. Dispatching a
non-const member on a const receiver does not quietly slip through a
runtime path; it fails to compile.
Third, queued arguments are copied or moved into the dispatch object in
a uniform way. The call site no longer needs to remember Q_ARG,
manual QMetaType constraints, or a custom capture pattern just to move
data across threads.
The code also becomes easier to read for someone who has never seen the
exact subsystem before. post_invoke reads like intent. Raw
invokeMethod reads like mechanism.
Better names make failure states legible too
For the simple member-function helpers, bool is often enough. Either
the call ran, or it was at least successfully queued.
For more advanced cases, that is not enough information. A generic callable dispatch can fail in meaningfully different ways, so the helper surface exposes an enum:
enum class Callable_dispatch_result
{
RECEIVER_NULL,
EXECUTED_INLINE,
QUEUED,
COMPLETED,
RECEIVER_DESTROYED,
QUEUE_FAILED,
CALLABLE_THROWN,
};
That is a better contract than "here is a bool, good luck."
RECEIVER_NULL and RECEIVER_DESTROYED are not the same situation.
The first means the caller started with no valid receiver. The second
means the dispatch raced a teardown path. Both may result in no useful
work being done, but they point to different bugs and deserve different
handling.
CALLABLE_THROWN matters for the same reason. If a callable throws
during an inline call or a blocking cross-thread call, the helper can
surface that cleanly. A queued fire-and-forget path has no caller stack
left to unwind into, so the only sane options are to log, assert in
debug builds, or both. Making that contract visible in the helper API is
far better than leaving it as folklore.
This is the practical reading guide for those result states:
| Result | What it usually means | What the caller usually does next |
|---|---|---|
RECEIVER_NULL | no receiver was available | fix ownership or validate earlier |
EXECUTED_INLINE | work ran immediately on this stack | treat as completed work |
QUEUED | work was accepted for later delivery | continue; effect happens asynchronously |
COMPLETED | blocking dispatch finished normally | consume the returned result |
RECEIVER_DESTROYED | target died before queued work ran | handle teardown race explicitly |
QUEUE_FAILED | Qt could not enqueue the work | surface failure; this is not a no-op |
CALLABLE_THROWN | callable raised during execution | debug the callable, not the dispatch |
Signals deserve the same treatment
The same design applies to signal emission.
safe_emit and post_emit mirror the method helpers, but the most
interesting tool here is post_emit_batch. It queues one functor onto
the emitter's thread so a related group of emissions happens in one
place and in one deliberate order.
That sounds minor until you hit a real UI update path. Imagine a worker thread that needs to notify the UI model that:
- the active instrument changed
- the visible range changed
- the derived labels changed
Posting those as three separate cross-thread hops invites accidental interleaving with other work. A batched emission keeps that sequence together:
vnm::post_emit_batch(model, [](Session_model* m) {
m->emit_instrument_changed();
m->emit_visible_range_changed();
m->emit_labels_changed();
});
Now the ordering is owned by one dispatch point instead of being spread across several independent queued events.
The awkward edge case: when caller-side thread identity is unreliable
The normal same-thread fast path compares QThread::currentThread()
with receiver->thread(). Most of the time that is exactly what you
want.
It becomes less trustworthy at awkward boundaries: a plain
std::thread, a recycled pool worker, or shutdown code that is running
outside the tidy Qt thread model the rest of the application assumes.
That is why the helper surface also provides
*_on_known_thread variants such as
safe_invoke_on_known_thread,
blocking_invoke_on_known_thread, and
dispatch_callable_on_known_thread.
Those variants accept a stored Qt::HANDLE for the receiver thread and
compare against QThread::currentThreadId() instead of relying on
QThread::currentThread(). It is a narrow tool, but when you are
debugging a dispatch path that is "usually correct except during
teardown" or "usually correct except from this pool thread," it is the
right tool.
The decision path is the same shape as the normal helper; only the "am I already on the receiver thread?" check changes:
normal path
current thread object
vs
receiver->thread()
known-thread-id path
currentThreadId()
vs
stored receiver_thread_id
That small substitution matters because it moves the fragile part into explicit data. The caller is no longer asking Qt to infer thread identity from a context where the thread object may be missing, recycled, or already half-way through teardown.
For blocking dispatch, the control flow also becomes easier to picture:
caller thread receiver thread
------------- ----------------
blocking_invoke(...)
| |
| same thread? yes -------------->| execute now
| | return result
|
| same thread? no
| enqueue work ------------------>| run callable/member
| wait -------------------------->| finish
|<--------------------------------| wake caller with result
What this pattern does not solve
This helper surface makes cross-thread Qt code easier to reason about. It does not make the underlying model disappear.
Three limitations still matter.
The receiver still needs a live event loop. Queued and blocking
dispatch only work if the target thread can service the event. If the
receiver thread is blocked or exiting, blocking_invoke can still hang.
Blocking calls still do not support reference returns. Returning a reference across this boundary is the wrong shape for the mechanism. Return by value, return a smart pointer, or redesign the API.
Queued calls still need owning arguments. QStringView,
std::string_view, and similar non-owning views remain dangerous when
the work runs later on another thread. The helpers make copying the
default, which is the right bias, but they cannot turn a dangling view
into a safe object.
There is also one subtle semantic point worth keeping in view:
safe_invoke may run immediately. That is a feature, not a bug, but it
means the helper can be reentrant. If the real requirement is "not now;
later, after control returns to the event loop," then post_invoke is
the clearer choice.
The real payoff here is not less typing. It is better code review and better maintenance.
When a reader sees safe_invoke, post_invoke, or blocking_invoke,
they do not have to reverse-engineer a local combination of
invokeMethod, connection type, capture strategy, receiver guarding,
and error policy. The name already states the contract.
That is what makes the pattern scale. Qt still provides the primitive. The helper layer turns that primitive into a vocabulary.