DEV Community

ISNDEV
ISNDEV

Posted on

Beyond Threads: A Guide to the Actor Model in C++ with `qb`

Stop thinking in threads and mutexes. Start thinking in actors and messages. Discover how qb's implementation of the Actor Model simplifies concurrent programming and eliminates entire classes of bugs.

Target Audience: Beginner / Intermediate C++ developers curious about concurrent programming models.

GitHub: https://github.com/isndev/qb


If you've ever written multi-threaded C++ code, you know the pain of shared state. std::mutex, std::atomic, and std::condition_variable are powerful tools, but they're also a minefield of potential deadlocks, race conditions, and complexity.

The qb framework offers a more elegant solution: the Actor Model. Instead of sharing memory, actors share nothing. They are completely isolated, stateful entities that communicate only by sending immutable messages to each other. This fundamental design choice makes concurrent programming dramatically simpler and safer.

The Three Pillars of a qb Actor

Every actor in the qb framework adheres to three simple rules:

  1. It has a private state. An actor's member variables can only be touched by the actor itself. This isolation is the cornerstone of its safety.
  2. It has an address. Every actor has a unique qb::ActorId, which other actors use to send it messages.
  3. It processes messages sequentially. Each actor has a "mailbox." It pulls one message at a time from this mailbox and processes it completely before moving to the next. This guarantees that an actor's internal state is never accessed concurrently.
             +-----------------+
             |   SenderActor   |
             +-----------------+
                    |
                    | sends MessageEvent(data)
                    |
+-------------------+-------------------+
|                   v                   |
|         +-----------------+           |
|         | Mailbox (Queue) |           |
|         +-----------------+           |
|         |   MessageEvent  |           |
|         |   AnotherEvent  |           |
|         |       ...       |           |
|         +-----------------+           |
|                   |                   |
|                   | process one-by-one|
|                   v                   |
|         +-----------------+           |
|         |  ReceiverActor  |           |
|         | (private state) |           |
|         +-----------------+           |
|                                       |
+---------- Core QB Framework ----------+
Enter fullscreen mode Exit fullscreen mode

Request-Response: The Hello World of Actor Communication

One of the most common interaction patterns is request-response. Let's see how this works in qb with a simple example where "Sender" actors ask a "Receiver" to do some work and reply.

This example demonstrates two key concepts:

  • A sender pushing a message to a known destination (receiver_id).
  • A receiver using event.getSource() to dynamically discover the sender's address and send a reply.

From examples/core/example2_basic_actors.cpp

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

// 1. Define the request and response events
struct MessageEvent : public qb::Event {
    std::string content;
    MessageEvent(std::string msg) : content(std::move(msg)) {}
};

struct ResponseEvent : public qb::Event {
    std::string content;
    ResponseEvent(std::string msg) : content(std::move(msg)) {}
};

// 2. The ReceiverActor processes requests and sends back responses
class ReceiverActor : public qb::Actor {
public:
    ReceiverActor() {
        registerEvent<MessageEvent>(*this);
    }

    // Process a message and reply to the original sender
    void on(MessageEvent& event) { // Note: event is non-const
        qb::io::cout() << "Receiver " << id() 
                       << ": Received '" << event.content << "' from " 
                       << event.getSource() << "\n";

        // To reply, we use the source ID from the incoming event
        auto original_sender = event.getSource();
        std::string response_msg = "Acknowledged: " + event.content;

        // Push a response event back to the sender
        push<ResponseEvent>(original_sender, response_msg);
    }
};

// 3. The SenderActor sends requests and waits for responses
class SenderActor : public qb::Actor {
private:
    qb::ActorId _receiver_id;

public:
    SenderActor(qb::ActorId receiver_id) : _receiver_id(receiver_id) {
        registerEvent<ResponseEvent>(*this);
    }

    bool onInit() override {
        qb::io::cout() << "Sender " << id() << ": Sending message to receiver "
                       << _receiver_id << "\n";

        // Send a request to the receiver
        push<MessageEvent>(_receiver_id, "Hello from Sender!");
        return true;
    }

    // Handle the response from the receiver
    void on(ResponseEvent& event) {
        qb::io::cout() << "Sender " << id() 
                       << ": Received response '" << event.content 
                       << "' from " << event.getSource() << "\n";

        // Task complete, shut down
        kill();
    }
};

int main() {
    qb::Main engine;

    // Create the receiver first to get its ID
    auto receiver_id = engine.addActor<ReceiverActor>(0);

    // Create the sender and give it the receiver's ID
    engine.addActor<SenderActor>(0, receiver_id);

    engine.start();
    engine.join();

    qb::io::cout() << "Application finished." << std::endl;
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Why This is a Big Deal

  • No Locks, No Race Conditions: Notice the complete absence of std::mutex. The ReceiverActor can be hammered with messages from hundreds of senders on different threads, and its internal state remains safe because the on(MessageEvent&) handler is guaranteed to execute atomically for each message.
  • Location Transparency: The sender doesn't know or care if the receiver is on the same core, a different core, or even a different machine (in a distributed qb system). The push<Event>(destination, ...) API remains the same.
  • Decoupled & Testable: Actors are naturally decoupled. You can test ReceiverActor in isolation by simply sending it MessageEvents and asserting that it produces the correct ResponseEvents.

By embracing the actor model, qb provides a powerful yet simple mental framework for building complex, concurrent systems. It lets you focus on your application's logic and flow, not on the low-level mechanics of synchronization.

Ready to dive deeper? Explore the qb framework and its examples on GitHub.

Top comments (0)