Qt objects, events, and platform plugins

Trace Qt object ownership, event delivery and platform integration.

14 min readChapter 132 of 168Intermediate → advanced
In this chapter

Before you start#

Read userspace threads and allocation and Wayland surfaces. This chapter studies Qt base v6.8.0 and Qt Wayland v6.8.0 as separately pinned projects. The console exercise uses the locally installed Qt Core version, which may differ, and creates no GUI application or display connection.

Learning goals#

Follow QObject ownership, signal delivery and deferred deletion across threads. Explain why a QThread object's affinity differs from the thread executing its run function. Separate Qt's application and window abstractions from platform and graphics plugins, and design cleanup for queued work and shutdown.

Source walkthrough#

QObject event delivery and platform
QObject event delivery and platformEmitter: Signal emitted in a particular thread → Receiver: Direct call or queued delivery depends on connection and affinity (direct or queued delivery). GUI application: Requests a platform integration → QPA plugin: Connects to a supported window-system backend (load matching plugin)direct or queueddeliveryload matching pluginEmitterSignal emitted in aparticular threadGUI applicationRequests a platformintegrationReceiverDirect call or queueddelivery depends onconnection and affinityQPA pluginConnects to a supportedwindow-system backend
EmitterSignal emitted in a particular thread
ReceiverDirect call or queued delivery depends on connection and affinity
GUI applicationRequests a platform integration
QPA pluginConnects to a supported window-system backend
Read the text version
  • Emitter: Signal emitted in a particular thread → Receiver: Direct call or queued delivery depends on connection and affinity (direct or queued delivery)
  • GUI application: Requests a platform integration → QPA plugin: Connects to a supported window-system backend (load matching plugin)
Conceptual model. Thread affinity and platform plugins are separate boundaries; this is not a captured application.

A QObject provides a common object model with parent-child ownership, a runtime meta-object, properties, signals and slots. A parent normally deletes its children when it is destroyed; removing or changing a parent changes that ownership relationship subject to thread-affinity restrictions. A child pointer is not an independent lifetime guarantee. If a stack-allocated child is also parented, construction and destruction order must avoid having the parent delete an object whose own automatic destruction will later occur. Ownership trees solve many ordinary cleanup cases but do not eliminate cycles or ownership relationships in external libraries.

The meta-object system records information used for property access, signal and slot connections, type information and other runtime operations. Many classes use generated meta-object code produced by Qt's build tools. A property is a named interface with declared reading, writing and notification behavior; a property change is not automatically a cross-thread transaction. Signals describe events and connections route them to slots or functors. A connection has a sender, receiver or context, callback and connection type. Its lifetime and the lifetimes of data captured by a lambda are related but distinct.

A direct connection invokes the slot synchronously in the thread emitting the signal. The caller does not resume until the slot returns. A queued connection posts an event for delivery in the receiver's affinity thread; that thread needs a running event loop to dispatch it. A blocking queued connection waits for the receiver to process the call and can deadlock if used within the same thread or when the receiver depends on the blocked thread. A queued call needs arguments that Qt can copy or otherwise marshal under the applicable meta-type rules. A pointer argument copies the pointer value, not the pointed-to object's lifetime.

The blocking branch in the same function does more than call the slot. In src/corelib/kernel/qobject.cpp, Qt base v6.8.0, SPDX LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only (surrounding code omitted):

c++
            } else if (c->connectionType == Qt::BlockingQueuedConnection) {
                if (receiverInSameThread) {
                    qWarning("Qt: Dead lock detected while activating a BlockingQueuedConnection: "
                    "Sender is %s(%p), receiver is %s(%p)",
                    sender->metaObject()->className(), sender,
                    receiver->metaObject()->className(), receiver);
                }

The same-thread test compares the current thread with the receiver's thread identifier, and the warning names a deadlock. The excerpt does not show the wait that follows, where the code posts a call event and calls semaphore.acquire(). The warning reports the problem; it does not prevent the wait.

Four connection types, one emission
Direct
The slot runs synchronously in the emitting thread, and the emitter resumes only after it returns.
Queued
An event is posted for the receiver thread, which runs the slot when its event loop dispatches it.
Auto
Decided at emission: queued only when the emitting thread differs from the receiver thread.
BlockingQueued
The emitter waits on a semaphore; a same-thread call triggers a deadlock warning.
Source map. Source map of the connection-type branches in qobject.cpp (Qt base v6.8.0), simplified; a teaching comparison, not a captured delivery trace.

For Qt::AutoConnection, Qt decides between direct and queued behavior using the thread currently emitting the signal and the receiver's thread affinity at emission time. It is not determined solely by the sender object's affinity. A signal emitted from a worker function on an object whose affinity remains in the main thread can still originate in the worker thread. If the receiver belongs to the main thread, auto delivery is normally queued. This distinction prevents the common but incorrect assumption that the sender's parent or creation thread decides where every slot executes. See Qt base v6.8.0 qobject.cpp for the implementation and documented connection behavior.

The decision for Qt::AutoConnection is in the same emission loop. In src/corelib/kernel/qobject.cpp, Qt base v6.8.0, SPDX LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only (surrounding code omitted):

c++
            bool receiverInSameThread;
            if (inSenderThread) {
                receiverInSameThread = currentThreadId == td->threadId.loadRelaxed();
            } else {
                // need to lock before reading the threadId, because moveToThread() could interfere
                QMutexLocker lock(signalSlotLock(receiver));
                receiverInSameThread = currentThreadId == td->threadId.loadRelaxed();
            }


            // determine if this connection should be sent immediately or
            // put into the event queue
            if ((c->connectionType == Qt::AutoConnection && !receiverInSameThread)
                || (c->connectionType == Qt::QueuedConnection)) {
                queued_activate(sender, signal_index, c, argv);
                continue;

The comparison uses currentThreadId, captured when the signal is emitted, against the receiver's thread identifier; it is not the sender's affinity. The excerpt does not show the direct-call branch or the queue internals, so it shows the decision but not the order in which the slot finally runs.

One AutoConnection emit from a worker
One AutoConnection emit from a worker1. Worker thread to Signal emission: emit result(42). 2. Signal emission to Signal emission: compare currentThreadId with receiver thread. 3. Signal emission to Receiver event queue: thread differs: queue QMetaCallEvent. 4. Signal emission to Worker thread (return): emit returns without waiting. 5. Receiver thread loop to Receiver event queue: event loop takes the posted event. 6. Receiver event queue to Receiver thread loop: slot runs in the receiver thread.Worker threadSignal emissionReceiver event queueReceiver thread loopemit result(42)comparecurrentThreadIdwith receiverthreadthread differs:queue QMetaCallEventemit returns withoutwaitingevent loop takes theposted eventslot runs in thereceiver thread
  1. Worker threadSignal emission

    emit result(42)

  2. Signal emissionSignal emission

    compare currentThreadId with receiver thread

  3. Signal emissionReceiver event queue

    thread differs: queue QMetaCallEvent

  4. Signal emissionWorker thread

    Return. emit returns without waiting

  5. Receiver thread loopReceiver event queue

    event loop takes the posted event

  6. Receiver event queueReceiver thread loop

    slot runs in the receiver thread

Time runs downward. Dashed arrows show returns.

Read the text version
  1. Worker thread → Signal emission: emit result(42)
  2. Signal emission → Signal emission: compare currentThreadId with receiver thread
  3. Signal emission → Receiver event queue: thread differs: queue QMetaCallEvent
  4. Signal emission → Worker thread (return): emit returns without waiting
  5. Receiver thread loop → Receiver event queue: event loop takes the posted event
  6. Receiver event queue → Receiver thread loop: slot runs in the receiver thread
Source map. Source map of QMetaObject activation in qobject.cpp (Qt base v6.8.0), simplified to one receiver; a teaching model, not a captured trace.

Affinity and moving objects

A QObject normally belongs to the thread in which it was created. Its queued events are dispatched by that thread's event loop. Objects with a parent must share its affinity; moving an object also concerns its children. A QThread instance is itself a QObject and usually lives in the thread that created it. Its run method executes in the managed thread. Slots on the QThread object therefore do not automatically run in the managed thread merely because the object represents it. A common design creates a parentless worker QObject, moves it to a QThread before starting work, and communicates through queued signals or invocations.

From Qt base v6.8.0 src/corelib/kernel/qobject.cpp, QObject::moveToThread, file license LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only; earlier same-thread check and later checks omitted:

c++
    if (d->parent != nullptr) {
        qWarning("QObject::moveToThread: Cannot move objects with a parent");
        return false;
    }

The condition checks the object's parent pointer. If a parent exists, the warning describes the rejected move and the function returns false; the caller must not assume the affinity changed. The surrounding implementation also rejects widgets and certain binding situations, and checks the current thread's relationship to the object with documented exceptions. A valid design tests the return value where available, changes ownership deliberately, and does not attempt to move a live widget into a worker. A child is not a way to smuggle an object into an unrelated thread.

An event loop is necessary for normal queued delivery, timers and deferred deletion in its thread. A worker that blocks indefinitely in a slot can prevent queued stop requests from running. Conversely, a thread with no event loop cannot be assumed to dispatch arbitrary queued calls. A long-running worker should have a cooperative cancellation mechanism appropriate to its work, and shutdown must not wait for a slot that can only execute on a thread already blocked in that wait. Locks protecting shared data must have a consistent order; queued delivery is not itself a lock protecting every object the callback can reach.

Deferred deletion and callbacks

deleteLater schedules deferred deletion rather than immediately destroying the object. The event loop and thread's state determine when that event is processed. Calling it after the relevant event loop has stopped can leave cleanup delayed or require a documented thread-finish path; it is not a universal guarantee that deletion already occurred before the next statement. Nested event loops have specific deferred-delete semantics, so do not assume any nested dispatch immediately performs every deferred deletion. Directly deleting a QObject from a different thread while it handles events can be unsafe; arrange for deletion in an appropriate context.

From Qt base v6.8.0 src/corelib/kernel/qobject.cpp, QObject::deleteLater, with preceding diagnostics and later event posting omitted; the same dual commercial/LGPL/GPL file license applies:

c++
    auto eventListLocker = QCoreApplicationPrivate::lockThreadPostEventList(this);
    if (!eventListLocker.threadData)
        return;

The helper locks the relevant posted-event list and obtains thread data. If no thread data is available, this path returns without proceeding to the later deferred-delete posting. The surrounding code uses that lock to guard the flag which suppresses repeated requests; its own comment notes a possible bit-field race in other accesses. The excerpt is evidence of a guarded scheduling path, not a promise that every deleteLater immediately destroys the object. Qt base qcoreapplication.cpp implements posted event delivery and event-loop integration.

A connection with a QObject context is automatically disconnected when the sender or context is destroyed. This protects against invoking a new context-bound callback on an already destroyed context under the connection's rules. It does not automatically make arbitrary captured raw pointers safe, nor does disconnect wait for a callback already executing in another thread. A queued event may already have been posted; Qt's receiver/event cleanup and connection semantics determine whether it is delivered. A captured pointer to a different object can dangle even if the connection context survives. Prefer an ownership strategy that keeps needed data alive, or use QPointer and check it in the appropriate thread, while respecting that a check and later access need suitable thread coordination.

A queued result and a destroyed receiver

Consider a worker computing a value 42. The worker emits a result signal in its worker thread. The receiver is a controller with main-thread affinity, so an auto connection queues the call. Before the main thread dispatches that event, the controller is destroyed. A connection whose context is the controller is disconnected during destruction, and the program must not assume the callback updates the vanished controller. The worker's result storage still needs an owner; for example, a copied integer needs no special cleanup, while a heap result should be owned by a value type or a clearly defined smart pointer.

Now consider a lambda connected without an appropriate context that captures a raw pointer to that controller. If the sender remains alive, a later signal can invoke the lambda with a dangling pointer. Merely requesting worker cancellation does not make a previously queued or concurrently executing callback disappear. The safe design binds the callback to an appropriate context and separately ensures any other captured objects remain valid. If shared mutable data is involved, synchronize it and define when the worker is finished. This timeline is an explanatory model; the practice program below demonstrates a smaller, local console event loop rather than this entire race.

For shutdown, stop accepting new requests, signal or otherwise request the worker to stop, allow its in-flight work to finish or cooperate with interruption, and arrange worker cleanup in the appropriate thread. Ask the thread's event loop to quit only after work that depends on that loop can complete. Wait for the managed thread from a thread that is not required to process the awaited completion. Finally release the QThread object and any owned result state. The exact pattern depends on whether the worker uses an event loop, blocking operations, and the documented finished and deferred-deletion semantics. Calling quit does not forcibly interrupt arbitrary blocking I/O or a long-running computation.

Qt platform integration

QCoreApplication supports the core event loop without creating graphical windows. QGuiApplication adds graphical integration, and QWindow is a Qt window abstraction. The Qt Platform Abstraction, QPA, supplies platform-specific integration through plugins. A Wayland plugin communicates with a Wayland compositor; an XCB plugin communicates with an X server. Platform selection, installed plugin binaries, display availability, negotiated interfaces and build features determine whether initialization succeeds. A missing or incompatible plugin can prevent a GUI application from starting; changing a process-wide environment variable is not a necessary step for the console exercise.

Qt Wayland is a separate project from Qt base. Its v6.8.0 qwaylandintegration.cpp creates platform integration and depends on a working Wayland display connection for that backend. It is not a universal implementation of every Qt application: an application may use XCB or another platform. Plugin compatibility depends on the Qt build and supported version/ABI, not just a matching file name. A platform integration may provide input, clipboard, window and screen services, but this chapter does not inspect a user's session or clipboard.

The platform plugin and graphics rendering backend are different decisions. A Wayland window can use a renderer supported by that build and platform; the existence of a Wayland plugin does not prove that Vulkan, OpenGL, software rendering, explicit synchronization or HDR is available. Similarly, a QWindow object is not itself a compositor buffer or a physical monitor. Creation or exposure of a window does not prove a frame was presented. Errors during display connection, plugin loading, window creation or graphics initialization require releasing whichever objects were successfully created before failure. The precise cleanup path is platform-specific.

Values, copies, and thread safety

Qt value classes such as QString use implicit sharing in many operations: copying can share backing data until a write causes detachment. This can make value passing efficient, including passing an owned copy through a queued call. It does not make concurrent unsynchronized mutation of the same QString object safe. Two threads using distinct value objects under the class's documented reentrancy guarantees differ from two threads racing on the same variable. A captured reference to a local string can become invalid before a queued callback runs even if copying the string would have been safe.

The same distinction applies to queued arguments containing pointers. A queued copy of an integer 42 owns its value. A queued copy of a pointer to an object only preserves the address; it does not keep the pointee alive unless an independent ownership mechanism does so. Choose value types or explicit shared ownership where appropriate, document which thread destroys the last owner, and avoid retaining a GUI object on a worker just to avoid deciding its lifetime. A reference count can keep an object alive while still leaving its thread affinity unchanged.

Practice#

This program constructs a QCoreApplication, posts a zero-delay callback and exits. It does not create a GUI application, load a QPA display plugin, or contact a session service. It requires a C++ compiler and Qt 6 Core development files.

sh
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
cat > "$tmpdir/main.cpp" <<'CXX'
#include <QCoreApplication>
#include <QTimer>
#include <cstdio>
int main(int argc, char **argv) {
    QCoreApplication app(argc, argv);
    int count = 0;
    QTimer::singleShot(0, &app, [&] {
        ++count;
        std::printf("callback count=%d\n", count);
        app.quit();
    });
    int result = app.exec();
    return result == 0 && count == 1 ? 0 : 1;
}
CXX
if command -v c++ >/dev/null && pkg-config --exists Qt6Core; then
  c++ "$tmpdir/main.cpp" -o "$tmpdir/main" $(pkg-config --cflags --libs Qt6Core) && "$tmpdir/main"
else
  printf '%s\n' 'Qt 6 Core development files or compiler unavailable'
fi
rm -rf "$tmpdir"
trap - EXIT

Expected output is callback count=1. The callback context and captured counter outlive this bounded event loop. A counterexample is capturing a reference to a local variable that returns before an asynchronously delivered callback; the reference then dangles. If the compiler or Qt Core is absent, use the supplied 42 timeline: mark the worker emission, queued event, controller destruction, connection cleanup and result ownership. This is source-based reasoning, not evidence that the full race was executed.

Knowledge check#

  1. Which thread determines AutoConnection delivery together with receiver affinity?
  2. Why does a QThread object's slot not automatically run in its managed thread?
  3. What happens when moveToThread is called on an object with a parent in the quoted path?
  4. Why is deleteLater not an immediate destruction guarantee?
  5. What does a context-bound lambda protect, and what does it leave to the application?

Answers#

  1. The thread currently emitting the signal is compared with the receiver's affinity. The sender object's affinity alone is insufficient.
  2. The QThread object usually belongs to its creator thread; its run function executes in the managed thread. QObject slots follow connection and affinity rules.
  3. The function warns and returns false. The caller cannot treat the object's affinity as changed.
  4. It posts deferred work subject to event-loop and thread conditions. The object can remain alive until the appropriate event is handled or the relevant documented cleanup occurs.
  5. The context's destruction disconnects the associated connection under Qt's rules. Other captured raw pointers and concurrently executing callbacks still require explicit lifetime and synchronization design.

Checkpoint#

Submit the queued 42 timeline, a worker shutdown sequence and a diagram showing QCoreApplication, GUI application, QPA plugin and compositor or X server. Pass when the timeline distinguishes emission thread from sender affinity and the shutdown avoids waiting on a blocked required event loop. If not, reread the two QObject source paths and retry. This is practice, not certification.

Sources#

The pinned sources are QObject implementation, core event delivery, Qt Wayland integration, and Qt platform abstraction documentation. Their availability and runtime behavior depend on the installed build and environment.

A little further beneath the surface.

Check your understanding with the checkpoint. Marking a chapter as read saves a bookmark, not a proficiency assessment.