DEV Community

Cover image for One Runtime, Two Programs That Have Nothing in Common
ISNDEV
ISNDEV

Posted on

One Runtime, Two Programs That Have Nothing in Common

A C++20 actor framework where a REST API and a market-data pipeline run on the same execution rules.


Here are two functions from the same codebase.

The first is an HTTP handler. Reads a cache, falls back to Postgres, refreshes the cache, writes JSON:

qb::io::async::task<void>
TaskManager::handle_list_tasks(ctx_t ctx) {
    auto cached = co_await _redis.get("tasks:list");

    if (cached.ok() && cached.result().has_value() && !cached.result()->empty()) {
        ctx->response().add_header("X-Cache", "HIT");
        ctx->json(qb::json::parse(*cached.result()));
        co_return;
    }

    auto result = co_await _db->execute("select_all_tasks", qb::pg::params{});
    if (!result.ok()) {
        ctx->internal_server_error(result.error().what());
        co_return;
    }

    auto json = models::TaskList(result.result(), false).to_json();
    (void) co_await _redis.setex("tasks:list", 60LL, json.dump());

    ctx->response().add_header("X-Cache", "MISS");
    ctx->json(json);
}
Enter fullscreen mode Exit fullscreen mode

The second is the hot path of a market-data hub. A foreign thread pushes quotes across a lock-free ring, and an ingest actor drains it once per loop turn:

void IngestActor::on(qb::LoopEvent const &) {
    Quote q;
    while (_ring.dequeue(q))                       // drain the SPSC bridge
        send<QuoteEvent>(_pool.for_key(q.symbol),  // sticky routing by symbol
                         q);
}
Enter fullscreen mode Exit fullscreen mode

No HTTP, no SQL, no shared vocabulary at the application level.

There is also no configuration difference between these two programs. Same engine, same scheduler, same ownership rules. The second one isn't the framework running in some other mode; it's the framework doing the only thing it does.

One rule

One worker thread per lane. One event loop on that thread. Actors, sockets, timers and coroutine resumptions all live on it. Mutable state belongs to exactly one lane. To reach state on another lane, you send a message.

In qb a lane is a VirtualCore: one worker thread owning a set of actors, running one event loop over them, draining one private lock-free mailbox. An actor is thread-affine to the core that created it and never migrates.

The rest falls out of that. An actor's fields need no mutex because no second thread can reach them. An ActorId is a 32-bit {ServiceId, CoreId} pair, so the address carries its own route and a send resolves to a mailbox slot through a precomputed dense index — no registry, no hash lookup.

And the only memory that multiple threads concurrently mutate anywhere in the system is the inter-core mailbox: an MPSC ring where many cores enqueue and only the owner dequeues.

I want to be precise about that last claim, because it's the kind of thing everyone says and few people mean. The actor maps, the service-id pool, the thread_local event loop, the coroutine scheduler: one thread each, no synchronization on any of them. No std::mutex on the message path at all. Two locks exist in the engine — one guards a service-id map during static init, the other is the condition variable a core parks on when it goes genuinely idle. That's the list. If someone finds a third place where application-visible state is concurrently mutated, that's a bug report, not a design discussion, and I'd want it filed.

Three layers

qb-io is the async runtime: libev-based event loop, non-blocking TCP/UDP/SSL, a protocol layer, C++20 coroutines, timers, filesystem watching, crypto, compression, a logger, QUIC. It knows nothing about actors. You can write a whole program against it — qb::io::async::init(), drive the loop yourself, done. A whole tier of the example corpus does exactly that on purpose, twelve programs, not an actor in sight.

That separation isn't a retrofit. qb-io existed first and the actor layer grew on top of it, which is why the dependency runs one way and why it's still worth using alone.

qb-core adds the actor semantics: identity, lifecycle, ownership, message topology, placement, lock-free inter-core delivery. It doesn't demand that every async function in your program become an actor.

The qbm modules put application protocols on the same foundation — HTTP (1.1 always; HTTP/2, HTTP/3, WebSocket and JWT on SSL and QUIC builds), PostgreSQL, Redis. Separate repositories, added as submodules, discovered by CMake.

Now, the Postgres client is the one I'd defend hardest, and it's also the one people ask about most, so let me get into it. qbm-pgsql speaks the v3 frontend/backend protocol directly over a qb-io socket. Handshake, SCRAM-SHA-256 (and MD5, and cleartext, because you still meet both), the simple query protocol, the extended one, parameter type encoding across the OID table, LISTEN/NOTIFY, the SSLRequest upgrade. Seventeen translation units. No libpq anywhere.

People assume that's a purity thing. It isn't. Wrap a blocking client in a thread pool and your co_await hands its continuation to a foreign executor — at which point the actor's state is reachable from a thread that doesn't own it, and every guarantee in the section above is gone. Not weakened. Gone. There was no version of this where I got to reuse someone else's client. I spent longer on the Postgres wire format than on the actor scheduler, and I'd do it again, but I won't pretend it was fun.

What sharing one execution model actually gets you

Go back to handle_list_tasks. Three protocols cross it. The interesting part is the list of things that don't happen: the actor doesn't migrate, the database operation doesn't borrow a worker from a pool, the coroutine doesn't resume on an unrelated executor, and the Redis socket doesn't belong to a foreign runtime that has to marshal back into this one. HTTP session, both protocol clients, coroutine frame, actor: one VirtualCore. While Redis or Postgres is pending the frame sits suspended and the lane keeps serving everything else.

Now the ingest loop. Completely different application, same vocabulary:

  • qb::ICallback gives the actor an on(qb::LoopEvent const&) that fires once per loop turn, near the end, after the worker has flushed its outbound pipes and drained its mailbox. That's your hook for pumping a foreign source.
  • qb::lockfree::spsc::ringbuffer is the sanctioned bridge from a thread the engine doesn't own. A real std::thread producing quotes crosses in through it, explicitly, at one place in the program.
  • qb::WorkerPool::for_key does sticky routing — the same symbol always lands on the same shard, which is what makes per-symbol aggregation state safe to touch without a lock. Placement doing the work that discipline usually has to.
  • qb::batcher coalesces the aggregator output before it goes out.

PublisherActor then runs a qb::io::use<T>::tcp::server<Session> inside an actor, with a binary wire format built from the shipped framing toolbox, and SubscriberActor sits on the other end of that wire in the same process. Which means the format gets exercised rather than just specified.

Two applications, nothing in common at the domain level, one vocabulary underneath.

The small end

#include <qb/main.h>
#include <qb/actor.h>
#include <qb/io.h>

struct GreetingEvent : qb::Event {
    qb::string<64> message;
    explicit GreetingEvent(const char *msg) : message(msg) {}
};

class GreeterActor : public qb::Actor {
public:
    qb::io::async::task<bool> onInit() final {
        registerEvent<GreetingEvent>(*this);
        push<GreetingEvent>(id(), "Hello");
        co_return true;
    }

    void on(const GreetingEvent &event) {
        qb::io::cout() << "Received: " << event.message << '\n';
        kill();
    }
};

int main() {
    qb::Main engine;
    engine.addActor<GreeterActor>(0);
    engine.start();
    engine.join();
}
Enter fullscreen mode Exit fullscreen mode

That's a complete program. No mutex, no condition variable, no shared queue — there's no second thread that could need one.

onInit() returns task<bool>, which matters more than it looks. It means startup stays sequential:

qb::io::async::task<bool> onInit() final {
    if (!co_await _db->connect(database_uri))
        co_return false;
    if (!(co_await prepare_statements()).ok())
        co_return false;
    if (!co_await _redis.connect(redis_uri))
        co_return false;
    configure_routes();
    co_return true;
}
Enter fullscreen mode Exit fullscreen mode

And the runtime has an answer for the window while that's suspended, which took me three tries to get right. The actor sits in an Activating phase: inbound business events are held in a bounded FIFO and replayed in order once init succeeds, kill events and broadcasts have defined bypass behaviour, initialization has a deadline, and co_return false destroys the actor before it ever sees a message. The bypass rules are the fiddly part — hold everything indiscriminately and an init request waiting on its own correlated reply deadlocks itself.

An HTTP server is the same shape. Actor, mix in qb::http::Server<>, define routes, compile the router, listen:

class ApiServer : public qb::Actor, public qb::http::Server<> {
public:
    qb::io::async::task<bool> onInit() override {
        router().get("/users/:id", [](auto ctx) {
            ctx->response().body() = "user " + ctx->path_param("id");
            ctx->complete();
        });
        router().compile();

        if (listen({"tcp://0.0.0.0:8080"})) { start(); co_return true; }
        co_return false;
    }
};
Enter fullscreen mode Exit fullscreen mode

What ships with it

Eleven headers under qb/core/patterns/: pub/sub, supervisor, worker pool, scatter/gather, resilience, streaming, saga, batching, idempotency, discovery, and qb::ask for request/response.

ask is the one I'm happiest with. Inside a scoped coroutine, co_await qb::ask(ctx, target, req, timeout) sends a request and resolves to the reply — correlation, timeout and cancel-on-kill all handled by a single awaiter, with no detached helper task hanging around. The correlation registry is a per-worker-thread thread_local map, so no locks. The slots live inside the awaiter, in the asking coroutine's own frame, and unregister themselves on resume, timeout, cancel or destruction. Getting that to work without a heap allocation per request took a while.

The coroutine layer also carries a structured-concurrency surface: scopes with exit policies, bounded fan-out, channels with select, generators, async streams, six sync primitives, retry, shared_task. Mono-thread per core, cooperative, no scheduler to configure.

Keeping the examples honest

The example corpus is a separate repository in seven tiers, meant to be read in order: actors, qb-io standalone, coroutines, patterns, services, modules, then three full applications.

The mechanism is the part I'd want to know about if this were someone else's project. Every program carries a header block — @teaches, @demonstrates, @prerequisites, @expect — and a script asserts that every @demonstrates name actually occurs in that file's code. A name that isn't true of the file under it is the recurring defect in an example corpus, and that check is the guard against it. The capability index is generated from those blocks and gated byte-exact.

@expect goes further. Where a program can measure the thing it teaches, it prints a whole sentence chosen by that measurement instead of splicing a number into one. So when the runner can't find the line, the behaviour changed, not the wording.

CMake targets and binary names derive from the path. 02-io/03-tcp.cpp becomes qb-example-io-tcp, always, never hand-written.

I bring this up because having examples is worth approximately nothing, and having examples that structurally cannot lie about what they demonstrate is worth something.


The bill

One blocking call poisons an entire lane. Architectural, not advisory. A blocking syscall or a long CPU loop delays every actor, socket, timer and coroutine on that worker. qb caps the ready-coroutine drain at 65,536 resumptions per loop turn so the scheduler queue can't monopolize a turn, but it does not preempt a running handler and enforces no per-task CPU budget. Your handler starves the lane until it returns.

No work stealing. Actors never migrate, so bad placement stays bad and a hot actor pins its lane while a neighbour idles. Placement is also an interference domain: a latency-sensitive actor colocated with a throughput-heavy one inherits that neighbour's scheduling delay even when nobody misbehaves. This model wants a natural partitioning key. Without one, a work-stealing pool beats it and I wouldn't argue.

An uncaught exception in a handler kills the process. The dispatch trampoline is noexcept, so a throw escaping on(Event&) is std::terminate. Not "restart that actor" — the process. A lane is a failure domain and actor semantics don't hand you Erlang isolation for free. I go back and forth on whether this was the right call. It's the honest default for a runtime that has no supervision tree, but it's also the answer that makes people wince, and they're not wrong to.

Events are relocated by memcpy. The engine moves event bytes and never runs the source destructor, so payloads have to be trivially relocatable, not merely copyable. A by-value std::string is rejected by contract: libstdc++'s short form stores _M_p addressing the object's own inline buffer, so relocating it leaves a pointer into dead storage. libc++ and MSVC don't have that shape, which means this particular bug corrupts on Linux and is invisible on macOS. Use qb::string<N> or an indirection.

There's no compile-time check for the general case, and C++ doesn't currently give me one. Trivial relocation went into C++26 at Hagenberg in February 2025, then came back out at Kona in November — partly over whether "trivially relocatable" should guarantee that a type is memcpy-able at all, which is precisely the guarantee I need. The work moved to C++29. In the meantime I'm making assumptions that aren't portable in principle and hold everywhere I've tested, which is uncomfortably close to what Boost.JSON says about its own situation. I don't have a better answer and I'd take one.

Cancellation is cooperative. An awaiter that registers no cancellation hook won't wake because a token changed. The Postgres reply awaiter is one of those: kill() neither wakes nor unwinds a coroutine parked on co_await db.execute(...). It stays parked until the operation completes on its own — safely, since the awaiter's alive flag makes a late completion a no-op, but not promptly. Wrapping it in ctx.cancellable(...) works by destroying the frame, which is a different thing from cancelling the operation, and the difference is worth understanding before you rely on it.

Memory and idle CPU. The routing topology allocates outbound pipes and inbound rings eagerly across the configured core set and never shrinks them: roughly 0.6 MiB at one core, 22.5 at eight, 85 MiB at sixteen. And the default idle latency is zero, meaning busy-spin at 100% of a core. Both are deliberate, both will surprise you if nobody says so out loud.

One more, from the examples rather than the runtime. co_await qb::io::async::tcp::connect<Transport>(uri, timeout) written directly in an actor's onInit() suspends and never resumes, even though the TCP connection genuinely is established. The same expression inside spawn() resumes normally. That's recorded in the market-data-hub notes because it cost me an afternoon, and I still don't have a satisfying explanation for it — only a reproduction and a workaround.

On numbers

The market-data-hub prints its end-to-end latency as a distribution — min, p50, p90, p99, max — rather than a single figure. One number would be a claim about hardware the program has never run on. And the 4 ms batch window plus the ring backlog dominate the tail by construction, so a p99 that looks terrible is usually a batch that was waiting on purpose.

Compare the shape between two builds on one machine. Don't compare medians between machines. That's the only guidance I trust.

The architecture has plausible performance properties: no synchronization on lane-local state, state that stays hot in one core's cache because it never migrates, O(1) routing, batched message movement, no executor hop for async I/O. Properties, not results. Name a configuration you'd find convincing and I'll run it and publish the method next to the number.

Where it fits

Good fit when mutable state has a natural owner or shard key, when the workload is mostly non-blocking I/O, when placement and locality matter, and when the team will actually hold the no-blocking line. Protocol gateways, stateful network services, market data, game backends, telemetry, anything partitioned by account or session or instrument or tenant.

Bad fit when most of the work is independent CPU-bound computation, when your critical dependencies only expose blocking APIs, when state genuinely has to be mutated from arbitrary threads, or when skew shifts faster than you can reshard.

C++20 by default, C++23 supported, Linux/macOS/Windows on x86_64 and ARM64, CMake 3.24, Apache-2.0.

The question I started with wasn't whether a C++ library can expose actors, async I/O and coroutines at once — plenty can, and the names are cheap. It was whether one execution model could carry an application from a REST endpoint to a binary market-data pipeline without quietly becoming a different framework somewhere in the middle. I think the answer is yes. I'd like to know where it stops being yes, and that's a more useful thing to send me than a star.


github.com/isndev/qb · examples · threading model · core invariants

Top comments (0)