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 it
  • post_invoke: always queue it
  • blocking_invoke: block until the receiver thread finishes and return the result
  • try_blocking_invoke: same blocking behavior, but convert failure into false or std::optional<T>{}
HelperSame threadDifferent threadCaller waits?Failure surface
safe_invokeexecute inline nowqueue to receiver threadnobool
post_invokequeue anywayqueue to receiver threadnobool
blocking_invokeexecute inline nowqueue and waityesdirect result or hard failure
try_blocking_invokeexecute inline nowqueue and waityesfalse 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:

ResultWhat it usually meansWhat the caller usually does next
RECEIVER_NULLno receiver was availablefix ownership or validate earlier
EXECUTED_INLINEwork ran immediately on this stacktreat as completed work
QUEUEDwork was accepted for later deliverycontinue; effect happens asynchronously
COMPLETEDblocking dispatch finished normallyconsume the returned result
RECEIVER_DESTROYEDtarget died before queued work ranhandle teardown race explicitly
QUEUE_FAILEDQt could not enqueue the worksurface failure; this is not a no-op
CALLABLE_THROWNcallable raised during executiondebug 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:

  1. the active instrument changed
  2. the visible range changed
  3. 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.