The QThread Gotcha: Why the Same AutoConnection Behaves Differently for the Same "Thread"
Here’s a scenario that trips up even experienced Qt developers: you create a worker object, move it to a thread, connect a couple of signals with the default connection type, and assume both connections behave the same way because they’re both “about” the same thread. They don’t. One becomes a DirectConnection. The other becomes a QueuedConnection. Same Qt::AutoConnection, same code style, completely different execution behavior — and the reason comes down to a detail most people never think to question: the QThread object does not live in the thread it manages.
Setting the Scene
class Worker : public QObject {
Q_OBJECT
public slots:
void doWork() {
qDebug() << "doWork running on:" << QThread::currentThread();
}
};class MyThread : public QThread {
Q_OBJECT
public slots:
void onStarted() {
qDebug() << "onStarted running on:" << QThread::currentThread();
}
};MyThread thread; // created in main thread
Worker worker; // created in main thread
worker.moveToThread(&thread); // worker's affinity is now 'thread'QObject::connect(&thread, &QThread::started,
&worker, &Worker::doWork); // AutoConnection (default)QObject::connect(&thread, &QThread::started,
&thread, &MyThread::onStarted); // AutoConnection (default)thread.start();
Both connections use the default Qt::AutoConnection. Both are triggered by the exact same signal, QThread::started, emitted at the exact same moment. Intuitively, you'd expect both slots to run "in the thread," together, consistently. They don't.
What Actually Happens
doWork running on: QThread(0x...) named "thread" // ran on the worker thread — QUEUED
onStarted running on: QThread(0x...) "main" // ran on the MAIN thread — DIRECTdoWork() runs on the new worker thread, as expected. onStarted() — a slot defined inside the QThread subclass itself — runs on the main thread, not the thread it's supposedly managing. To most people encountering this for the first time, that looks backwards.
Get Anuj Gupta’s stories in your inbox
Join Medium for free to get updates from this writer.
Why This Happens: Thread Affinity Is About the Object, Not What It Represents
Qt::AutoConnection resolves to DirectConnection if sender and receiver share the same thread affinity at the moment the signal is emitted, and QueuedConnection otherwise. So the whole mystery comes down to one question: what thread does each receiver actually live in?
worker.moveToThread(&thread)explicitly reassignsworker's thread affinity tothread. From that point on,worker"lives in" the new thread as far as Qt's event system is concerned. WhenQThread::startedfires fromthread's own execution context, sender (thread) and receiver (worker) are on different threads →AutoConnectionresolves toQueuedConnection. The call gets posted toworker's thread event loop, anddoWork()correctly executes on the worker thread.threaditself — theQThreadinstance — was never moved anywhere. AQThreadobject's own thread affinity, like anyQObject, is whichever thread created it, and it stays there unless you explicitly callmoveToThread()on it. Nobody did. Sothreadstill lives in the main thread. WhenQThread::startedfires, sender (thread) and receiver (thread, i.e. itself) are the same object on the same thread →AutoConnectionresolves toDirectConnection, andonStarted()runs synchronously on the main thread, at the exact momentstart()triggers the signal.
This is the counterintuitive part worth internalizing: a QThread object represents a thread of execution, but the object itself is not "in" that thread. The code running inside run() executes on the new thread. The QThread object's own slots, unless you go out of your way to move the object itself, do not.
The Classic Mistake This Causes
A very common (and very old) Qt anti-pattern is subclassing QThread and putting your actual work logic directly in overridden slots or in run(), connecting things to the QThread object as if it were the worker:
class BadWorkerThread : public QThread {
Q_OBJECT
protected:
void run() override {
// this part is fine — run() genuinely executes on the new thread
doHeavyWork();
}
public slots:
void onSomeSignal() {
// this slot, if connected with AutoConnection from a main-thread
// signal, runs on the MAIN thread — not here, even though it's
// defined inside a QThread subclass
processResult();
}
};The confusion compounds because run() genuinely does execute on the new thread — it's the one method QThread guarantees runs there — which makes it easy to wrongly assume everything attached to that QThread object behaves the same way. It doesn't. This exact trap is a big part of why the Qt documentation and community moved firmly toward the worker-object pattern instead of subclassing QThread at all.
The Pattern That Avoids the Confusion Entirely
QThread* thread = new QThread;
Worker* worker = new Worker;
worker->moveToThread(thread);QObject::connect(thread, &QThread::started, worker, &Worker::doWork);
QObject::connect(worker, &Worker::finished, thread, &QThread::quit);
QObject::connect(thread, &QThread::finished, worker, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater);thread->start();
Here, QThread is used purely as a thread-management handle — start it, stop it, clean it up — and never as a place to put actual business logic or slots you expect to run "inside" the thread. All real work lives in Worker, which was explicitly moved with moveToThread(). Every connection's behavior becomes predictable, because there's no ambiguity left about which object lives where.
Takeaway
Qt::AutoConnection isn't inconsistent — it's doing exactly what it's documented to do: compare the sender's and receiver's actual thread affinity at emit time. The trap is assuming a QThread object shares the affinity of the thread it manages. It doesn't, unless you explicitly move it there yourself, which almost nobody does and almost nobody should. The fix isn't a special case to memorize — it's a mental model correction: QThread is a handle to a thread, not a resident of it. Once that clicks, this entire class of "why did my slot run on the wrong thread" bug stops being mysterious.








