Skip to content

Repository files navigation

QtCoroutine

CI License: MIT C++23 Qt6

A C++23 coroutine library for Qt 6. Header-only, designed to make asynchronous Qt code read like sequential code.

#include <QtCoroutine>

QtCoroutine::QTask<QByteArray> fetchData(QNetworkAccessManager & nam, QUrl url, std::stop_token st) {
    auto * reply = nam.get(QNetworkRequest{url});

    co_await QtCoroutine::connect(reply, &QNetworkReply::finished)
        .cancelledBy(st)
        .withTimeout(std::chrono::seconds(30));

    auto data = reply->readAll();
    reply->deleteLater();
    co_return data;
}

Features

Awaitable Qt Signals

co_await any Qt signal with a chainable builder API:

// Void signal
co_await QtCoroutine::connect(&button, &QPushButton::clicked);

// Signal with arguments — automatically unwrapped
auto text = co_await QtCoroutine::connect(&edit, &QLineEdit::textChanged);

// Structured bindings for multi-arg signals
auto [id, name] = co_await QtCoroutine::connect(&api, &Api::userCreated);

// Full builder chain
auto result = co_await QtCoroutine::connect(&obj, &Obj::finished)
    .resumeOn(&context)                    // resume on a specific thread
    .cancelledBy(stopToken)                // cancellation via std::stop_token
    .withTimeout(std::chrono::seconds(5))  // timeout support
    .readyIf([](Obj * o) -> std::optional<int> {  // skip if already ready
        if (o->hasResult()) return o->result();
        return std::nullopt;
    });

Signal Streams

co_await connect(...) captures one emission. For signals that fire repeatedly, a stream connects once — at construction, so nothing is missed — queues every emission, and hands them out one per co_await:

auto bytes = QtCoroutine::stream(&socket, &QIODevice::readyRead);
while (co_await bytes.next()) {              // void signal → bool
    consume(socket.readAll());
}

auto progress = QtCoroutine::stream(&worker, &Worker::progress)
    .latestOnly();                           // conflate: only the newest value is kept
while (auto p = co_await progress.next()) {  // int signal → std::optional<int>
    updateBar(*p);
}

The sender being destroyed ends the stream like reaching EOF: queued values still drain, then next() returns false/nullopt and the loop exits — no exception, no try/catch for ordinary object teardown. Caller-side aborts do throw, consistent with one-shot awaits: .cancelledBy(st)AwaitCancelled{Stopped} (immediate, terminal), .withTimeout(ms)AwaitCancelled{Timeout} for any wait that goes that long without an emission (non-fatal — the stream stays armed and usable). next().asExpected() opts into std::expected instead. Buffering is unbounded by default; multi-arg signals yield std::optional<std::tuple<...>>, and the stream type is spelled by the signal's argument types — QSignalStream<int> — so it stores cleanly as a member.

QTask — Coroutine Return Type

QTask<T> is a coroutine type with continuations, cancellation state, and QFuture bridging:

QtCoroutine::QTask<int> compute() {
    co_await QtCoroutine::sleep(std::chrono::seconds(1));
    co_return 42;
}

// From non-coroutine code
auto task = compute();
task.then([](int val) { qDebug() << val; });
task.onCancelled([](const auto & err) { qWarning() << err.message(); });
task.onError([](std::exception_ptr ep) { /* handle */ });

// Bridge to QFuture for QFutureWatcher, QtFuture::whenAll, etc.
QFuture<int> future = task.toFuture();

// On a temporary, toFuture() bridges and detaches in one step: the
// coroutine keeps running and the future settles with its result.
QFuture<int> f = compute().toFuture();

Lifetime. A QTask owns its coroutine frame; destroying the task destroys the frame, cancelling any pending awaits. Either co_await it from another coroutine, keep it alive until it settles, or call .detach() for fire-and-forget — the frame then self-destructs on completion. QTask is [[nodiscard]], so accidentally discarding one (compute();) is a compiler warning rather than silently lost work — and because a warning can't cover member calls on a temporary, compute().then(cb); (where cb could never fire) is a compile error: the callback trio is deleted on rvalues. An awaited task may safely outlive its awaiter: if the awaiting frame is destroyed mid-suspend, the parked continuation is un-registered and the task settles parked for its owner (the reverse — destroying a pending task someone is awaiting — remains on the caller). Callbacks fire while the coroutine is suspended, which makes destroying or reassigning a task from inside its own .then() callback safe — frame destruction is deferred, like QObject::deleteLater() called from a slot.

QDetachedTask — Self-Owning Fire-and-Forget

For work that should simply run — no result, no observation — QDetachedTask removes the lifetime question entirely: the coroutine frame owns itself, starts eagerly, and self-destructs when the body finishes. The returned token is inert; discarding it is always safe:

QtCoroutine::QDetachedTask pollOnce(Db & db, std::stop_token st) {
    auto rows = co_await db.claimBatch(st);
    process(rows);
}

pollOnce(db, st);  // fine — runs to completion; nothing to keep alive

Handle outcomes inside the body: pass a std::stop_token parameter for cancellation (AwaitCancelled ends the body quietly), and catch errors where they occur — an exception that escapes the body goes to a process-wide hook, QtCoroutine::setDetachedExceptionHandler(...) (default: qWarning and drop). When callers need the value or completion, return QTask instead; .detach() remains the right tool when callbacks must be registered first.

spawn — Safe Coroutine Launch for Closures

A capturing lambda that is itself a coroutine stores only a reference to the closure object in its frame — once the closure dies (end of statement for a temporary, end of scope for a local), every capture dangles and the resume is a use-after-free. spawn() takes the callable and its arguments by value, so they live in spawn's own coroutine frame, which stays alive until the inner coroutine completes:

auto task = QtCoroutine::spawn([config, &service]() -> QtCoroutine::QTask<int> {
    co_await QtCoroutine::signal(&service, &Service::ready);
    co_return config.retries;   // safe: the closure lives in spawn's frame
});

for (auto & job : jobs)
    QtCoroutine::spawnDetached([job]() -> QtCoroutine::QTask<void> {  // loop bodies: fire-and-forget
        co_await process(job);
    });

spawn(f, args...) returns the same QTask<T> that f produces (ownable, awaitable); spawnDetached() returns a QDetachedTask with its semantics (self-owning, exceptions to the handler hook, cancellation silent).

QFuture as Coroutine Return Type

Functions returning QFuture<T> are automatically coroutines:

#include <QtCoroutine/QFutureCoroutineTraits>

QFuture<int> pipeline() {
    auto raw = co_await QtConcurrent::run([] { return fetchRawData(); });
    auto processed = co_await QtConcurrent::run([&] { return process(raw); });
    co_return processed;
}

Exceptions thrown by the awaited future rethrow at the co_await (unwrapped from QUnhandledException), and cancelling the awaited future resumes the coroutine with AwaitCancelled once the future settles.

Value-Based Error Handling

Opt into std::expected instead of exceptions with .asExpected():

auto result = co_await QtCoroutine::connect(&obj, &Obj::finished)
    .cancelledBy(st)
    .asExpected();

if (!result) {
    qWarning() << result.error().message();  // "Stop request received"
    co_return;
}
auto value = *result;

QTask also works directly with std::expected as a return type:

QTask<std::expected<void, QString>> tryConnect(Client & c, std::stop_token st) {
    auto reply = co_await QtCoroutine::connect(&c, &Client::connected)
        .cancelledBy(st)
        .asExpected();

    if (!reply)
        co_return std::unexpected{reply.error().message()};

    co_return {};  // success — use {} instead of bare co_return
}

Combinators and Utilities

// Wait for all tasks (waits for every task to settle before propagating errors)
auto [r1, r2, r3] = co_await QtCoroutine::whenAll(task1, task2, task3);

// Wait for all tasks (short-circuits on first error)
auto [r1, r2] = co_await QtCoroutine::tryAll(task1, task2);

// Race tasks — the first to settle wins
auto [index, value] = co_await QtCoroutine::whenAny(taskA, taskB);  // same T: winner index + value
std::size_t winner = co_await QtCoroutine::whenAny(voidA, voidB);   // all void: winner index
auto either = co_await QtCoroutine::whenAny(intTask, stringTask);   // mixed: std::variant<int, QString>

// Timeout on any QTask
auto result = co_await QtCoroutine::withTimeout(task, std::chrono::seconds(5));

// Cancellation on any QTask
auto result = co_await QtCoroutine::cancelledBy(task, stopToken);

// Sleep
co_await QtCoroutine::sleep(std::chrono::milliseconds(500));

Blocking Bridge — waitFor

For main(), tests, and other non-coroutine code, waitFor blocks on a nested event loop until the task settles, then returns its value or rethrows its cancellation/error:

int value = QtCoroutine::waitFor(compute());

Headers

Header Purpose
QtCoroutine/qtcoroutine.hpp Signal awaiting, builder pattern, sleep()
QtCoroutine/qdetachedtask.hpp QDetachedTask — self-owning fire-and-forget coroutines
QtCoroutine/qsignalstream.hpp QSignalStream — lossless co_await loops over repeating signals
QtCoroutine/qtask.hpp QTask<T>, whenAll, tryAll, whenAny, withTimeout, cancelledBy
QtCoroutine/qfuture_coroutine_traits.hpp QFuture<T> as coroutine return type + co_await
QtCoroutine/utils.hpp Type utilities, AwaitCancelled

Cancellation

The library uses std::stop_token for cancellation, which propagates automatically through coroutine chains via AwaitCancelled exceptions. QTask catches these and stores them as state:

QtCoroutine::QTask<void> work(std::stop_token st) {
    // AwaitCancelled propagates automatically if st is cancelled
    co_await QtCoroutine::connect(&obj, &Obj::step1Done).cancelledBy(st);
    co_await QtCoroutine::connect(&obj, &Obj::step2Done).cancelledBy(st);
}

auto task = work(stopSource.get_token());
stopSource.request_stop();  // cancels wherever the coroutine is suspended

// From non-coroutine code
if (task.isCancelled())
    qDebug() << *task.cancelReason();  // Stopped, SenderDestroyed, Timeout, ...

cancelReason() returns std::optional<AwaitCancelled::Reason>, engaged only when the task was actually cancelled (it still compares directly against reasons: task.cancelReason() == AwaitCancelled::Stopped). QtCoroutine::AwaitCancelled is the public spelling of the cancellation type.

Threading

The defaults follow QObject::connect: the suspended coroutine is the receiver, and resuming it is the slot.

  • A co_await resumes on the thread it suspended on, regardless of which thread the sender lives on or emits from — code after the co_await runs on the same thread as the code before it. A same-thread emission resumes synchronously inside the emit, exactly like a direct-connected slot.
  • .resumeOn(ctx) explicitly migrates the coroutine: delivery and everything after the co_await run on ctx's thread.
  • A signal stream pins its consumer instead of migrating it: next() is awaited on the stream's construction thread — or, with .resumeOn(ctx), on ctx's thread from the first next() on (debug-asserted). Emissions remain unrestricted inputs from any thread; values are queued from the emitting thread and the wake is marshalled to the consumer.
  • Inputs are unrestricted: signals may be emitted, futures completed, and request_stop() called from any thread; the library marshals the resume back to the right thread.
  • The task handle is thread-affine: while a task is live, owner-side operations — co_await, then/onCancelled/onError, toFuture, detach, destruction — belong on the thread that created it (debug-asserted). Once settled, querying and destroying are safe from any thread that has synchronized with the completion. After migrating with resumeOn, bridge results back with toFuture().

Shutdown and Teardown

A co_await resumes only when the awaiting thread's event loop turns again. After QCoreApplication::exec() returns, nothing turns the main loop anymore — a plain co_await reached from that point (teardown paths, destructors, code after exec()) suspends forever. The debug assert at suspension points checks that a dispatcher exists, which is precisely insufficient here: post-exec() the dispatcher still exists, it just never runs again.

Two sanctioned patterns for that window:

  • waitFor keeps working after exec() returns (and before it ever starts — main() setup, tests, tools with no running loop): it spins a nested QEventLoop, which delivers the queued resumes itself. Caveat: a nested loop delivers all pending queued events, so watch re-entrancy during teardown.
  • Plain synchronous code — for teardown work, not using coroutines at all is often the simplest correct answer.

Diagnostics: QtCoroutine::debug::liveSuspendedAwaits() returns the number of library awaits currently suspended on an external event (a signal emission, future completion, stream item, or timer); coroutines awaiting other coroutines aren't double-counted. If any remain when QCoreApplication is destroyed, the library emits a qWarning with the count — turning the "shutdown work silently never ran" failure mode into a one-line diagnosis.

Integration

Git submodule (recommended)

Add the library under external/ so its version is pinned in your repository:

git submodule add https://github.com/Goeries/qtcoroutine.git external/qtcoroutine
git -C external/qtcoroutine checkout v0.2.0-alpha   # optional: pin to a release

Then add it from your CMakeLists.txt:

add_subdirectory(external/qtcoroutine)
target_link_libraries(myapp PRIVATE qtcoroutine::qtcoroutine)

Anyone cloning your project pulls the library along with it:

git clone --recurse-submodules <your-repo>
# or, in an existing clone:
git submodule update --init --recursive

FetchContent

include(FetchContent)
FetchContent_Declare(qtcoroutine
    GIT_REPOSITORY https://github.com/Goeries/qtcoroutine.git
    GIT_TAG v0.2.0-alpha
)
FetchContent_MakeAvailable(qtcoroutine)

target_link_libraries(myapp PRIVATE qtcoroutine::qtcoroutine)

This automatically sets up include paths, C++23, and Qt6 dependencies.

Subdirectory

add_subdirectory(qtcoroutine)
target_link_libraries(myapp PRIVATE qtcoroutine::qtcoroutine)

Copy headers

Copy include/ into your project and add both directories to your include path — the first for #include <QtCoroutine/qtask.hpp> style includes, the second so the umbrella #include <QtCoroutine> resolves:

target_include_directories(myapp PRIVATE path/to/include path/to/include/QtCoroutine)
target_link_libraries(myapp PRIVATE Qt6::Core Qt6::Concurrent)

Requirements

  • C++23 (GCC 13+, MSVC 2022 17.5+; Clang with libstdc++ is currently unsupported — libstdc++'s std::expected requires GCC)
  • Qt 6
  • CMake 3.22+
  • A running Qt event loop on every awaiting thread (asserted in debug builds; in a release build an await without one simply never resumes)

Naming: connect() vs signal()

QtCoroutine::connect() and QtCoroutine::signal() are the exact same function. The examples use connect() because it mirrors QObject::connect and QtFuture::connect. If that name ever clashes in your code — for example with QObject::connect or QtFuture::connect brought in unqualified via using namespace — use signal() instead; it's identical, just collision-free.

Tested On

Compiler Platform
GCC 13.3 Ubuntu 24.04 — including AddressSanitizer and ThreadSanitizer CI jobs
MSVC 2022 Windows
MinGW GCC 13 Windows 11

License

MIT © 2026 Jeandré Gouws

About

Modern C++ Coroutines for Qt

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

Contributors

Languages