Hello! In our last lesson, we analyzed the Leader/Followers pattern, a high-performance architecture for concurrent event handling. We saw that its core dynamic relies on a form of callback: a promoted leader thread detects an event and directly calls a handler method to process it.
Today, we'll expand on that idea to compare different high-level approaches to asynchronous task execution. While the Leader/Followers pattern is about who does the work (the leader), this lesson is about how we represent and manage that work. We will explore three fundamental models:
- Callbacks: The foundational, "C-style" approach.
- Futures and Promises: A modern C++ abstraction for handling results that aren't ready yet.
- Message Passing: A model where independent entities communicate by sending data to each other, often seen in the Actor Model.
Understanding these models is essential for designing clean, scalable, and maintainable concurrent APIs, which will be a central part of the thread pool you'll build for your portfolio project.
The Foundation: Synchronous vs. Asynchronous Execution
Before diving into the models, let's establish the fundamental difference between synchronous and asynchronous operations.

All three models we're about to discuss are built on the asynchronous, non-blocking principle. They are different strategies for managing the "handle the response later" part of the diagram.
1. The Callback Model
A callback is the most direct and lowest-level way to handle an asynchronous result. You simply pass a function to the asynchronous operation, and that function is "called back" upon completion.
You've already seen this in action with the handle_event() method in the Leader/Followers pattern. It's a classic approach, very common in C libraries and low-level I/O frameworks like Node.js or Boost.Asio.
// A conceptual example of a callback-based API
void on_read_complete(ErrorCode error, size_t bytes_read) {
if (!error) {
std::cout << "Read " << bytes_read << " bytes successfully.\n";
} else {
std::cout << "Read failed: " << error.message() << "\n";
}
}
// Initiate the async operation and provide the callback
socket.async_read(my_buffer, on_read_complete);
// Your program can continue doing other work here...
The key idea is that the responsibility for handling the result is passed along with the request itself. To understand the trade-offs, particularly the performance aspect that interests you, let's look at a paper that compares this model directly with futures.
A UNIVERSAL MODEL FOR ASYNCHRONOUS ...
This paper, 'A Universal Model for Asynchronous Operations', provides an excellent, low-level comparison of callbacks and futures. We'll start by focusing on how it defines the callback model and its performance characteristics.
Please read the sections 'CALLBACKS VERSUS FUTURES', 'ANATOMY OF AN ASYNCHRONOUS OPERATION', and 'COMPOSITION OF OPERATIONS'. Pay close attention to the sequence diagrams that illustrate the internal events. Notice how the callback model avoids the need for shared state and extra synchronization compared to the futures model.
As the paper highlights, the callback model has distinct advantages and disadvantages:
- Pros:
- High Performance & Low Overhead: Callbacks are extremely efficient. As the diagrams in the paper show, there is no need for dynamic memory allocation to manage a shared state or complex synchronization between the operation and the continuation. The compiler can often inline the callback, resulting in nearly zero abstraction penalty.
- Cons:
- "Callback Hell": When you need to chain several asynchronous operations (e.g., read a config file, then make a network request based on it, then write the result to a database), you end up with deeply nested callbacks. This is often called the "Pyramid of Doom" and can make code very difficult to read, write, and debug.
2. The Future and Promise Model
The future/promise model provides a more structured way to handle asynchronous results that directly addresses the "Callback Hell" problem.
- A
std::futureis an object that acts as a placeholder for a result that is not yet available. It's a read-only handle to the eventual outcome. - A
std::promiseis an object that can set the value of an associatedstd::future. You can think of it as the writable end of the communication channel.
The asynchronous function returns a std::future immediately. The caller can then decide how and when to get the result.
To get a feel for how these are used in practice and how they enable better composition, let's watch a segment from a CppCon talk.
CppCon 2014: Hartmut Kaiser "Asynchronous Computation in C++"
This talk by Hartmut Kaiser, 'Asynchronous Computation in C++', explains the standard C++ tools for asynchrony and how they can be composed.
Watch from 14:22 to 32:15. The first part (14:22 - 26:52) gives a recap of the three ways to create a future: std::async, std::packaged_task, and std::promise. The second part (26:52 - 32:15) is the most important for our comparison: it introduces the .then() continuation, which is the key to solving 'Callback Hell' by composing futures sequentially.
As the video explains, a key feature (proposed for C++ and available in many libraries) is the ability to attach a continuation to a future with .then():
// Conceptual example with .then()
std::future<Config> f1 = read_config_async("config.json");
std::future<APIResponse> f2 = f1.then([](std::future<Config> config_future) {
Config cfg = config_future.get(); // Get result from the first operation
return call_api_async(cfg.get_endpoint()); // Start the second operation
});
f2.then([](std::future<APIResponse> api_future) {
APIResponse res = api_future.get();
std::cout << "Final result: " << res.get_data() << "\n";
});
This linear chain is much more readable than nested callbacks.
Let's summarize the trade-offs, referencing the paper LINK you read earlier, which masterfully breaks down the internal costs:
- Pros:
- Superior Composition: Easily chain asynchronous operations, avoiding "Callback Hell" and leading to cleaner, more linear code.
- Decoupling: Separates the initiation of an operation from the handling of its result. Different parts of the code can trigger an operation and consume its result.
- Cons:
- Higher Overhead: As shown in the paper LINK, futures require a shared state, which is often heap-allocated. This state needs synchronization (e.g., atomics or a mutex) to coordinate between the promise (writer) and the future (reader). This overhead, while small, can be significant in ultra-high-performance applications compared to the raw efficiency of callbacks.
3. Message Passing and the Actor Model
The third model takes a completely different philosophical approach. Instead of sharing memory (e.g., via a future's shared state) and using locks to protect it, this model advises: "Do not communicate by sharing memory; instead, share memory by communicating."
This is the foundation of the Actor Model.

An actor is a lightweight, concurrent entity that encapsulates:
- State: Its own private data, which no other actor can touch.
- Behavior: The logic it executes when it receives a message.
- A Mailbox: A queue where incoming messages are stored.
An actor processes one message from its mailbox at a time. This serialization of access to its internal state elegantly eliminates the need for locks within the actor itself.
To see how this is implemented "from scratch" in C++ and how it relates to our other models, let's explore an excellent article.
Implementing the Actor Model from scratch in C++ | by Arthur M
This article, 'Implementing the Actor Model from scratch in C++', will be very relevant to your interests. It breaks down how to build the core components and shows how message passing can be combined with futures to support different interaction patterns.
Please read the sections from 'The Actor Model' through to 'Testing it out'. Pay close attention to the distinction between Tell (fire-and-forget message passing) and Ask (request-response using futures). This demonstrates how the different asynchronous models can work together.
The article highlights two primary communication patterns:
Tell(Fire-and-Forget): You send a message to an actor and don't expect a direct reply. This is pure asynchronous message passing.Ask(Request-Response): You send a message and immediately get back afuture. The receiving actor will eventually perform the work and use apromiseto fulfill thatfuturewith a result. This beautifully integrates the future model into the actor paradigm.
- Pros:
- Excellent State Management: A powerful way to manage state in a highly concurrent system without explicit locks. The actor becomes the "guardian" of its data.
- Scalability & Decoupling: Actors are highly decoupled and can be distributed across processes or machines with little change to the programming model.
- Cons:
- Indirection: Communication is always indirect, via a message queue. This can feel less direct than a simple function call and may add latency.
- Implementation Overhead: A full actor system requires a scheduler, thread pool, and thread-safe mailboxes, representing a non-trivial piece of infrastructure.
Test your understanding!
Imagine you're designing a client for a chat application. A user types a message and hits "send". This triggers a sequence of actions:
- The message is sent to a server.
- The server acknowledges receipt of the message.
- Upon receiving acknowledgment, the UI updates the message status from "sending..." to "sent".
How would you sketch an implementation of this logic using (a) callbacks, (b) futures with .then(), and (c) two actors (e.g., a NetworkActor and a UIActor)?
Show answer
(a) Callback Approach:
This would likely lead to nested callbacks.
void on_ack_received(MessageID id) {
update_ui_status(id, "sent");
}
void on_message_sent(MessageID id) {
// Wait for the server's acknowledgment for this message
wait_for_ack_async(id, on_ack_received);
}
// User action
send_message_async(user_message, on_message_sent);
The nesting here is a simple example of "Callback Hell."
(b) Future Approach:
This approach linearizes the flow.
std::future<MessageID> f1 = send_message_async(user_message);
std::future<Ack> f2 = f1.then([](std::future<MessageID> f) {
return wait_for_ack_async(f.get());
});
f2.then([](std::future<Ack> f) {
Ack ack = f.get();
update_ui_status(ack.get_message_id(), "sent");
});
The logic flows from top to bottom, making it much easier to follow.
(c) Actor/Message Passing Approach:
Here we decouple the network and UI logic completely.
// User action: Tell the NetworkActor to send a message
network_actor.tell(SendMessage{user_message});
// --- Inside NetworkActor's message handler ---
// When it receives SendMessage:
// 1. Sends the message over the network.
// 2. Starts listening for an acknowledgment.
// When it receives the acknowledgment from the server:
// 3. It tells the UIActor to update the status.
ui_actor.tell(UpdateStatus{message_id, "sent"});
// --- Inside UIActor's message handler ---
// When it receives UpdateStatus:
// 1. It updates the corresponding UI element.
Notice the clear separation of concerns. The NetworkActor doesn't know anything about the UI, and the UIActor doesn't know about the network. They communicate via messages.
Conclusion
You've now compared the three dominant models for structuring asynchronous code. There is no single "best" approach; the choice depends on the specific problem you're solving.
Key Takeaways:
- Callbacks are the foundational, high-performance mechanism, ideal for low-level tasks but can become unwieldy when composed.
- Futures provide a powerful abstraction for composing asynchronous operations into clean, readable chains, at the cost of some manageable overhead.
- Message Passing (Actor Model) excels at managing concurrent state by encapsulating it and forcing communication through messages, promoting massive decoupling and scalability.
- These models are not mutually exclusive. As we saw, the
Askpattern in actors relies on futures to deliver responses.
Next Lesson Preview:
All of these asynchronous models require an engine to drive them. This engine is often an event loop. In the next lesson, we will implement a simple event loop to process tasks or I/O events from a queue. This will be a critical piece of infrastructure, tying together concepts of I/O, task scheduling, and the execution of the asynchronous operations we discussed today.