I found today’s problem by dragging a window. The repaint stopped, the title bar sulked, and a progress label remained at 12 percent while the application did several seconds of perfectly respectable work.

The work was running from a QTimer slot. I had assumed that using a timer made it asynchronous. It does not. A timer merely arranges for a callback to be delivered by the event loop; the callback still runs on that loop’s thread. If the slot computes for five seconds, paint events wait five seconds. So do mouse events and, more importantly, the user’s patience.

The quick diagnostic was wonderfully primitive:

void Scanner::scanNext()
{
    qDebug() << "enter scanNext";
    scanEverything();
    qDebug() << "leave scanNext";
}

The pause sat between those messages. Splitting the operation into bounded pieces fixed the immediate problem:

void Scanner::scanNext()
{
    for (int i = 0; i != 50 && hasMore(); ++i)
        scanOne();

    if (hasMore())
        QTimer::singleShot(0, this, SLOT(scanNext()));
}

A zero-duration timer does not promise instant execution. It puts more work back into event processing, allowing pending paints and input to run between batches. The batch size is deliberately boring: small enough to keep the interface alive, large enough not to drown in scheduling overhead.

I prefer this incremental approach when the job naturally divides into independent records. It keeps all GUI objects on their owning thread and makes cancellation straightforward. The caveat is that a single expensive record still blocks everything. In that case the computation belongs on a worker thread, with results sent back rather than widgets touched directly.

Calling QCoreApplication::processEvents() inside the loop is tempting, but it permits other callbacks to enter while the current operation is unfinished. That can expose partially updated state or let the user start the same action twice. Returning to the event loop between explicit batches gives me a cleaner boundary: each batch leaves the object consistent before yielding.

That boundary also gives cancellation a predictable checkpoint between records, rather than during an arbitrary mutation.

Qt 4’s event loop is not magic concurrency dust. This is disappointing only until one remembers that magic concurrency dust would probably deadlock before breakfast.