Project 4: Coroutine-based Redis Client
Write async code that looks synchronous using C++20 coroutines
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Master |
| Time Estimate | 2-4 weeks |
| Language | C++20 |
| Prerequisites | Project 2 (Thread-Safe Queue), callbacks/futures, strong C++ skills |
| Key Topics | Coroutines, co_await, Awaitable, promise_type, Asio integration |
| Main Book | “C++20 - The Complete Guide” by Nicolai M. Josuttis |
| Alternative Books | “C++ Concurrency in Action, 2nd Ed” by Anthony Williams |
1. Learning Objectives
By completing this project, you will be able to:
- Explain the C++20 coroutine machinery including
promise_type,coroutine_handle, and the compiler-generated coroutine frame - Implement custom Awaitable types that satisfy the Awaitable concept (
await_ready,await_suspend,await_resume) - Design a
task<T>return type that serves as a lazy future for coroutine results - Bridge callback-based I/O with coroutines by wrapping Asio async operations into awaitable interfaces
- Understand coroutine lifetime management and who owns the coroutine frame at each point
- Implement the Redis RESP protocol for wire-level communication with Redis servers
- Debug coroutine-based code using systematic techniques for tracking suspension and resumption
- Answer advanced interview questions about async programming, coroutines, and event-driven architectures
2. Theoretical Foundation
2.1 Core Concepts
What Are Coroutines?
Coroutines are resumable functions. Unlike regular functions that run from start to finish, coroutines can suspend execution at specific points and be resumed later. This enables writing asynchronous code that looks sequential.
Regular Function Coroutine
┌─────────────────┐ ┌─────────────────┐
│ call() │ │ call() │
│ ↓ │ │ ↓ │
│ execute all │ │ execute... │
│ ↓ │ │ ↓ │
│ return │ │ co_await │ ← suspension point
└─────────────────┘ │ ↓ │
│ (suspended) │
│ ... │ ← other code runs
│ resume() │
│ ↓ │
│ continue exec │
│ ↓ │
│ co_return │
└─────────────────┘
The Coroutine Frame
When you call a coroutine, the compiler allocates a coroutine frame on the heap. This frame stores:
- Promise object - Controls coroutine behavior
- Local variables - All local state that survives suspension
- Suspension point - Which
co_await/co_yield/co_returnwe’re at - Parameters - Copies of function parameters
┌─────────────────────────────────────────────────────────────┐
│ Coroutine Frame │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Promise Object ││
│ │ - get_return_object() ││
│ │ - initial_suspend() ││
│ │ - final_suspend() ││
│ │ - return_value() / return_void() ││
│ │ - unhandled_exception() ││
│ └─────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Local Variables ││
│ │ - All variables declared in the coroutine body ││
│ │ - Survive across suspension points ││
│ └─────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Suspension State ││
│ │ - Current suspension point index ││
│ │ - Resume address for each suspension point ││
│ └─────────────────────────────────────────────────────────┘│
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Parameter Copies ││
│ │ - Copies of all parameters passed to coroutine ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
The Promise Type
Every coroutine has an associated promise type that controls:
- Lifecycle hooks: What happens at start, end, and on exceptions
- Return value handling: How the coroutine result is stored
- Suspension behavior: Whether to suspend at start or end
struct task_promise {
// What to return when coroutine is called
task get_return_object() { return task{handle_from_this()}; }
// Suspend at start? (lazy start)
std::suspend_always initial_suspend() { return {}; }
// Suspend at end? (allow cleanup)
std::suspend_always final_suspend() noexcept { return {}; }
// Handle return value
void return_value(T value) { result = std::move(value); }
// Handle exceptions
void unhandled_exception() { exception = std::current_exception(); }
T result;
std::exception_ptr exception;
};
The Coroutine Handle
A std::coroutine_handle<Promise> is a non-owning pointer to a coroutine frame. It provides:
handle.resume(); // Resume suspended coroutine
handle.destroy(); // Destroy the coroutine frame
handle.done(); // Check if coroutine is finished
handle.promise(); // Access the promise object
The Awaitable Concept
Any type can be co_awaited if it satisfies the Awaitable concept:
struct MyAwaitable {
// Can we skip suspension? (optimization)
bool await_ready() { return false; }
// What to do when suspending
// 'h' is the handle of the suspending coroutine
void await_suspend(std::coroutine_handle<> h) {
// Store h, start async operation, etc.
}
// What value to return after resumption
T await_resume() { return result; }
};
The flow of co_await expr:
┌─────────────────────────────────────────────────────────────────┐
│ co_await Expression Flow │
│ │
│ co_await awaitable │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ await_ready()? │─── true ───┐ │
│ └────────┬───────────┘ │ │
│ │ false │ │
│ ▼ │ │
│ ┌────────────────────┐ │ │
│ │ await_suspend(h) │ │ │
│ └────────┬───────────┘ │ │
│ │ │ │
│ ▼ │ │
│ ┌────────────────────┐ │ │
│ │ SUSPENDED │ │ │
│ │ (coroutine yields │ │ │
│ │ control) │ │ │
│ └────────┬───────────┘ │ │
│ │ h.resume() │ │
│ ▼ │ │
│ ┌────────────────────────────────────────┐ │
│ │ await_resume() │◄──────────────────┘│
│ │ (returns the result of co_await) │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2.2 Why This Matters
The Callback Hell Problem
Before coroutines, async code with callbacks quickly became unmaintainable:
// Callback hell - hard to read, harder to debug
void get_and_update_user(int user_id) {
connect("localhost", 6379, [user_id](socket_t sock) {
send_command(sock, "GET user:" + to_string(user_id), [sock](string name) {
if (!name.empty()) {
send_command(sock, "SET user:" + to_string(user_id),
name + "_processed", [](bool ok) {
if (ok) {
cout << "Updated successfully\n";
} else {
cerr << "Update failed\n";
}
});
}
});
});
}
With coroutines:
// Clean, sequential-looking async code
task<void> get_and_update_user(int user_id) {
auto sock = co_await connect("localhost", 6379);
auto name = co_await send_command(sock, "GET user:" + to_string(user_id));
if (!name.empty()) {
bool ok = co_await send_command(sock, "SET user:" + to_string(user_id),
name + "_processed");
if (ok) {
cout << "Updated successfully\n";
} else {
cerr << "Update failed\n";
}
}
}
Error Handling
Callbacks make error handling complex and error-prone. Coroutines let you use familiar try-catch:
task<void> safe_operation() {
try {
auto result = co_await risky_async_op();
process(result);
} catch (const network_error& e) {
// Clean error handling!
log_error(e.what());
}
}
2.3 Historical Context
Stackful vs Stackless Coroutines
| Aspect | Stackful (Boost.Coroutine) | Stackless (C++20) |
|---|---|---|
| Memory | Full stack (~1MB) per coroutine | Just the frame (~bytes to KB) |
| Scalability | Thousands | Millions |
| Performance | Context switch overhead | Compiler-optimized |
| Implementation | Platform-specific assembly | Language feature |
| Nested suspension | Anywhere in call stack | Only in coroutine function |
C++20 chose stackless coroutines for efficiency. The tradeoff is that suspension points must be explicit with co_await/co_yield.
Evolution of Async in C++
1998 2003 2011 2014 2017 2020
│ │ │ │ │ │
│ │ │ │ │ │
v v v v v v
C++98 C++03 C++11 C++14 C++17 C++20
────────►std::thread std::async Executors Coroutines
std::mutex improved (TS) co_await
std::future futures co_yield
co_return
2.4 Common Misconceptions
Misconception 1: “Coroutines are threads”
Reality: Coroutines are about suspending and resuming execution. They can run on a single thread. The event loop or scheduler decides when to resume which coroutine.
Single Thread with Coroutines:
────────────────────────────────────────────────────────►
│ │ │ │ │
│ │ │ │ │
coro1 coro2 coro1 coro3 coro1
runs runs resumes runs finishes
Misconception 2: “co_await blocks the thread”
Reality: co_await suspends the coroutine, not the thread. The thread is freed to do other work until the awaited operation completes.
Misconception 3: “The heap allocation is slow”
Reality: Compilers can elide the heap allocation through HALO (Heap Allocation eLision Optimization) when the coroutine’s lifetime is contained within the caller.
Misconception 4: “Coroutines are just syntactic sugar”
Reality: Coroutines introduce new semantics. The compiler generates a state machine, manages the coroutine frame, and handles suspension/resumption. This is more than syntax transformation.
3. Project Specification
3.1 What You Will Build
A simple asynchronous Redis client using C++20 coroutines that:
- Connects to a Redis server asynchronously
- Sends commands and receives responses using the RESP (Redis Serialization Protocol) format
- Provides a clean, sequential-looking API that is fully non-blocking
- Integrates with Asio for the underlying async I/O
- Supports the core Redis commands:
PING,GET,SET,DEL,INCR
3.2 Functional Requirements
- Connection Management
co_await client.connect(host, port)- Establish async connectionco_await client.disconnect()- Graceful shutdown- Automatic reconnection on connection loss (optional extension)
- Redis Commands
co_await client.ping()- Returns “PONG”co_await client.get(key)- Returnsoptional<string>co_await client.set(key, value)- Returnsboolco_await client.del(key)- Returns number deletedco_await client.incr(key)- Returns new value
- Error Handling
- Network errors throw exceptions
- Redis errors return in result type
- Timeouts are configurable
- Coroutine Infrastructure
- Custom
task<T>type for coroutine return values - Reusable awaitable wrappers for Asio operations
- Integration with Asio’s event loop
- Custom
3.3 Non-Functional Requirements
- Performance: Minimal overhead vs raw Asio callbacks
- Memory: Coroutine frame size < 1KB per operation
- Scalability: Support 1000+ concurrent operations
- Portability: GCC 10+, Clang 14+, MSVC 2019+
3.4 Example Usage / Output
#include "redis_client.hpp"
#include <iostream>
task<void> demo() {
RedisClient client;
// Connect asynchronously
co_await client.connect("localhost", 6379);
std::cout << "Connected!\n";
// Simple operations
auto pong = co_await client.ping();
std::cout << "PING: " << pong << "\n"; // "PONG"
// SET and GET
co_await client.set("greeting", "Hello, Coroutines!");
auto value = co_await client.get("greeting");
if (value) {
std::cout << "GET greeting: " << *value << "\n";
}
// Counter operations
co_await client.set("counter", "0");
for (int i = 0; i < 5; ++i) {
auto n = co_await client.incr("counter");
std::cout << "INCR counter: " << n << "\n";
}
// Cleanup
co_await client.del("greeting");
co_await client.del("counter");
std::cout << "Demo complete!\n";
}
int main() {
asio::io_context io;
// Start the demo coroutine
auto task = demo();
task.start_on(io);
// Run the event loop
io.run();
return 0;
}
Expected Output:
$ ./redis_demo
Connected!
PING: PONG
GET greeting: Hello, Coroutines!
INCR counter: 1
INCR counter: 2
INCR counter: 3
INCR counter: 4
INCR counter: 5
Demo complete!
3.5 Real World Outcome
You’ll have a working async Redis client that demonstrates:
- Zero callback pyramids - All async operations look sequential
- Exception-based error handling - Use try/catch with async code
- Composable operations - Build complex flows from simple operations
- Production patterns - The same techniques used in high-performance servers
# Running the test suite
$ ./run_tests
[PASS] test_connect_disconnect
[PASS] test_ping_pong
[PASS] test_set_get_del
[PASS] test_incr_decr
[PASS] test_concurrent_operations
[PASS] test_error_handling
[PASS] test_timeout
All tests passed!
# Performance comparison
$ ./benchmark
Callback-based: 10000 ops in 1.23s (8130 ops/sec)
Coroutine-based: 10000 ops in 1.25s (8000 ops/sec)
Overhead: ~1.6% (acceptable!)
4. Solution Architecture
4.1 High-Level Design
┌─────────────────────────────────────────────────────────────────────┐
│ User Code (Coroutine) │
│ task<void> user_coroutine() { │
│ co_await client.connect(...); ─────────┐ │
│ co_await client.set(...); ─────────┼──► Suspension Points│
│ co_await client.get(...); ─────────┘ │
│ } │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ RedisClient │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Awaitable Wrappers │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ │
│ │ │connect_await│ │ read_await │ │ write_await │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └────────────┬────────────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────┼─────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ await_suspend(handle) │ │ │
│ │ │ → Store handle │ │ │
│ │ │ → Start async op with callback │ │ │
│ │ │ → Callback calls handle.resume()│ │ │
│ │ └───────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ RESP Protocol Layer │ │
│ │ - Serialize commands to RESP format │ │
│ │ - Parse RESP responses │ │
│ └───────────────────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Asio I/O Layer │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ asio::ip::tcp::socket │ │
│ │ async_connect, async_read, async_write │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ asio::io_context (Event Loop) │ │
│ │ - Polls for I/O events │ │
│ │ - Dispatches completion handlers │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
4.2 Key Components
Component 1: task<T> - The Coroutine Return Type
template<typename T>
class task {
public:
struct promise_type { /* ... */ };
using handle_type = std::coroutine_handle<promise_type>;
// Move-only
task(task&& other) noexcept;
task& operator=(task&&) noexcept;
~task();
// Resumption
void resume();
bool done() const;
// Result access
T& get();
private:
handle_type handle_;
};
Component 2: Awaitable for Asio Operations
template<typename CompletionHandler>
class asio_awaitable {
public:
asio_awaitable(/* async operation parameters */);
bool await_ready() { return false; } // Always suspend
void await_suspend(std::coroutine_handle<> h) {
// Start async operation, resume 'h' in callback
}
auto await_resume() {
// Return result or throw exception
}
private:
std::coroutine_handle<> handle_;
std::error_code ec_;
// ... result storage
};
Component 3: Redis Client
class RedisClient {
public:
explicit RedisClient(asio::io_context& io);
// Connection
auto connect(std::string_view host, uint16_t port) -> task<void>;
auto disconnect() -> task<void>;
// Commands
auto ping() -> task<std::string>;
auto get(std::string_view key) -> task<std::optional<std::string>>;
auto set(std::string_view key, std::string_view value) -> task<bool>;
auto del(std::string_view key) -> task<int64_t>;
auto incr(std::string_view key) -> task<int64_t>;
private:
asio::ip::tcp::socket socket_;
asio::streambuf read_buffer_;
auto send_command(std::vector<std::string> args) -> task<RespValue>;
};
4.3 Data Structures
Redis RESP (REdis Serialization Protocol) Format
RESP Type Prefix Example
────────────────────────────────────────────────────────
Simple String + +OK\r\n
Error - -ERR unknown command\r\n
Integer : :42\r\n
Bulk String $ $5\r\nhello\r\n
Array * *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
Null $-1 $-1\r\n
// RESP value representation
struct RespValue {
enum class Type { SimpleString, Error, Integer, BulkString, Array, Null };
Type type;
std::variant<
std::string, // SimpleString, Error, BulkString
int64_t, // Integer
std::vector<RespValue>, // Array
std::monostate // Null
> data;
};
Coroutine Frame Layout (Conceptual)
┌─────────────────────────────────────────────────────────┐
│ task<optional<string>> get(string_view key) │
│ │
│ Coroutine Frame: │
│ ┌─────────────────────────────────────────────────────┐│
│ │ Promise: ││
│ │ result: optional<string> ││
│ │ exception: exception_ptr ││
│ │ continuation: coroutine_handle<> ││
│ ├─────────────────────────────────────────────────────┤│
│ │ State: ││
│ │ suspension_point: int = 0, 1, 2... ││
│ ├─────────────────────────────────────────────────────┤│
│ │ Locals: ││
│ │ key: string_view (copy or reference?) ││
│ │ cmd: vector<string> ││
│ │ resp: RespValue ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
4.4 Algorithm Overview: Suspend/Resume Flow
User calls: co_await client.get("mykey")
Step 1: Build Command
────────────────────────────────────────────────────────────
get() formats: *2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n
Step 2: Write Command (await_suspend)
────────────────────────────────────────────────────────────
┌─────────────────┐ ┌─────────────────┐
│ Coroutine │───►│ write_awaitable │
│ (suspends) │ │ │
└─────────────────┘ │ await_suspend: │
│ store handle │
│ async_write │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ Asio callback │◄───│ I/O completes │
│ calls resume() │ │ │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Coroutine │
│ (resumes) │
└─────────────────┘
Step 3: Read Response (await_suspend again)
────────────────────────────────────────────────────────────
Same pattern: suspend → async_read → callback → resume
Step 4: Parse RESP, Return Result
────────────────────────────────────────────────────────────
Parse $5\r\nhello\r\n → optional<string>("hello")
Coroutine returns to caller
5. Implementation Guide
5.1 Development Environment Setup
Compiler Requirements
- GCC 10+ with
-std=c++20 -fcoroutines - Clang 14+ with
-std=c++20 - MSVC 2019 16.8+ with
/std:c++20
Dependencies
# Ubuntu/Debian
sudo apt install libboost-all-dev # For Asio (or use standalone Asio)
# macOS
brew install asio
# Or use standalone Asio header-only
git clone https://github.com/chriskohlhoff/asio.git
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(redis_coroutine_client CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find Asio
find_package(asio CONFIG)
if(NOT asio_FOUND)
# Use header-only version
add_library(asio INTERFACE)
target_include_directories(asio INTERFACE ${ASIO_INCLUDE_DIR})
target_compile_definitions(asio INTERFACE ASIO_STANDALONE)
endif()
add_executable(redis_client
src/main.cpp
src/task.hpp
src/redis_client.hpp
src/redis_client.cpp
src/resp.hpp
src/resp.cpp
)
target_link_libraries(redis_client PRIVATE asio pthread)
# Enable coroutines for GCC
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_compile_options(redis_client PRIVATE -fcoroutines)
endif()
5.2 Project Structure
redis_coroutine_client/
├── CMakeLists.txt
├── src/
│ ├── main.cpp # Demo application
│ ├── task.hpp # task<T> coroutine return type
│ ├── awaitable.hpp # Asio awaitable wrappers
│ ├── redis_client.hpp # Redis client interface
│ ├── redis_client.cpp # Redis client implementation
│ ├── resp.hpp # RESP protocol types
│ └── resp.cpp # RESP serialization/parsing
├── tests/
│ ├── test_task.cpp # Unit tests for task<T>
│ ├── test_resp.cpp # Unit tests for RESP parsing
│ └── test_client.cpp # Integration tests
└── examples/
├── simple_demo.cpp # Basic usage
└── concurrent_demo.cpp # Multiple concurrent operations
5.3 The Core Question You’re Answering
“How do you take callback-based async I/O and wrap it in a clean, sequential interface without sacrificing the non-blocking benefits?”
This project forces you to understand:
- What exactly happens when you call a coroutine function?
- Who allocates the coroutine frame and when?
- How does a callback that fires later know to resume the right coroutine?
- How do you propagate errors through suspended coroutines?
- Who is responsible for destroying the coroutine frame?
5.4 Concepts You Must Understand First
Before writing code, research and answer these questions:
1. Coroutine Mechanics
- What triggers a function to be compiled as a coroutine? (Answer: presence of
co_await,co_yield, orco_return) - What is the relationship between
promise_typeand the return type? - When does
initial_suspendreturnsuspend_alwaysvssuspend_never?
Book Reference: “C++20 - The Complete Guide” by Josuttis, Chapter 14
2. Ownership and Lifetime
- Who owns the coroutine frame?
- When is the frame destroyed?
- What happens if a coroutine is destroyed while suspended?
Book Reference: “C++ Concurrency in Action” 2nd Ed, Chapter 11
3. Asio Async Model
- What is the difference between
async_readandread? - What is a completion handler in Asio?
- How does
io_context::run()work?
Book Reference: Asio documentation, “Boost.Asio C++ Network Programming”
4. Redis Protocol
- What does RESP stand for?
- How do you serialize
GET mykeyin RESP? - How do you distinguish a null response from an empty string?
Reference: Redis protocol specification (redis.io)
5.5 Questions to Guide Your Design
Task Design
- Should
task<T>be eager (starts immediately) or lazy (starts on await)? - How will you store the result in the promise?
- What happens if the coroutine throws before setting a result?
Awaitable Design
- Should awaitables be reusable or single-use?
- How will you handle errors from Asio?
- What if the async operation completes synchronously?
Client Design
- Should the client own the socket or take it by reference?
- How will you handle partial reads in the RESP parser?
- Should commands be pipelined for efficiency?
5.6 Thinking Exercise
Before coding, trace through this scenario by hand:
task<string> get_greeting() {
RedisClient client(io);
co_await client.connect("localhost", 6379); // suspension point 1
auto msg = co_await client.get("greeting"); // suspension point 2
co_return msg.value_or("default");
}
int main() {
asio::io_context io;
auto t = get_greeting(); // What happens here?
// ...
io.run(); // And here?
}
Questions to answer:
- When
get_greeting()is called, what memory is allocated? - Where does execution stop the first time?
- What resumes the coroutine after the connect completes?
- What happens to the coroutine frame when the function returns?
Draw a timeline showing:
- Which lines execute in
main() - When the coroutine frame is created
- Each suspension and resumption point
- When the coroutine frame is destroyed
5.7 Hints in Layers
Hint 1: Starting Point (Conceptual Direction)
Start with the smallest possible coroutine that compiles:
struct task {
struct promise_type {
task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
task my_coroutine() {
co_return;
}
Then incrementally add:
- A coroutine_handle member to task
- A result type to promise_type
- suspend_always for lazy start
Hint 2: Next Level (More Specific Guidance)
For Asio integration, the key insight is in await_suspend:
void await_suspend(std::coroutine_handle<> h) {
// 'h' is the handle to the suspended coroutine
// Store it, then start the async operation
// The completion handler calls h.resume()
asio::async_read(socket, buffer,
[h](error_code ec, size_t n) mutable {
// Store ec and n somewhere the awaitable can access
h.resume(); // Resume the coroutine!
});
}
The challenge: where do you store ec and n? The awaitable object itself!
Hint 3: Technical Details (Approach/Pseudocode)
A complete awaitable structure:
template<typename T>
class async_read_awaitable {
asio::ip::tcp::socket& socket_;
asio::mutable_buffer buffer_;
std::error_code ec_;
size_t bytes_read_ = 0;
public:
async_read_awaitable(socket& s, mutable_buffer buf)
: socket_(s), buffer_(buf) {}
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> h) {
asio::async_read(socket_, buffer_,
[this, h](std::error_code ec, size_t n) mutable {
ec_ = ec;
bytes_read_ = n;
h.resume();
});
}
size_t await_resume() {
if (ec_) throw std::system_error(ec_);
return bytes_read_;
}
};
Hint 4: Tools/Debugging (Verification Methods)
Debug coroutine flow with strategic prints:
struct promise_type {
promise_type() { std::cout << "promise ctor\n"; }
~promise_type() { std::cout << "promise dtor\n"; }
auto initial_suspend() {
std::cout << "initial_suspend\n";
return std::suspend_always{};
}
// ...
};
Also use AddressSanitizer and UBSanitizer:
g++ -std=c++20 -fcoroutines -fsanitize=address,undefined ...
5.8 The Interview Questions They’ll Ask
Conceptual Questions
-
“Explain the lifecycle of a C++20 coroutine from call to completion.”
Key points: Frame allocation, promise construction, initial_suspend, body execution with suspension points, final_suspend, frame destruction.
-
“What’s the difference between
suspend_alwaysandsuspend_neverforinitial_suspend?”suspend_alwaysmakes the coroutine lazy (caller must resume).suspend_nevermakes it eager (runs until firstco_await). -
“How do you integrate coroutines with an event loop like Asio?”
The
await_suspendcaptures the coroutine handle, starts an async operation, and the completion callback resumes the handle. -
“What happens to local variables when a coroutine suspends?”
They’re stored in the coroutine frame (heap-allocated) and preserved across suspension.
-
“How do C++20 coroutines differ from goroutines or Python async?”
C++20: Stackless, library-customizable. Go: Stackful, runtime-managed. Python: Stackless, event loop in stdlib.
Design Questions
-
“How would you handle timeouts in a coroutine-based client?”
Use
asio::steady_timerwith a race:co_await either(operation(), timer.async_wait()). -
“How would you implement connection pooling?”
A coroutine that manages a pool:
co_await pool.acquire()suspends if none available. -
“What’s the overhead of coroutines vs raw callbacks?”
~50-100 bytes frame allocation, one indirect call per suspension. Usually negligible, can be optimized away (HALO).
5.9 Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| C++20 Coroutines fundamentals | “C++20 - The Complete Guide” by Josuttis | Ch. 14-15 |
| Coroutine machinery details | “C++ Concurrency in Action, 2nd Ed” by Williams | Ch. 11 |
| Async I/O patterns | “Boost.Asio C++ Network Programming” by Torjo | Ch. 3-4 |
| Task-based async design | “Concurrency in C++ Modern Approaches” | Various |
| Redis protocol | redis.io documentation | RESP spec |
5.10 Implementation Phases
Phase 1: Minimal Task (Days 1-2)
Goal: Create a compilable, runnable coroutine.
// task.hpp
#include <coroutine>
struct task {
struct promise_type {
task get_return_object() {
return task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
using handle_type = std::coroutine_handle<promise_type>;
explicit task(handle_type h) : handle_(h) {}
~task() { if (handle_) handle_.destroy(); }
task(task&& other) noexcept : handle_(other.handle_) {
other.handle_ = nullptr;
}
void resume() { handle_.resume(); }
bool done() const { return handle_.done(); }
private:
handle_type handle_;
};
Validation:
task my_coro() {
std::cout << "Step 1\n";
co_return;
}
int main() {
auto t = my_coro(); // "Step 1" NOT printed (lazy)
t.resume(); // "Step 1" printed now
assert(t.done());
}
Phase 2: Task with Return Value (Days 3-4)
Goal: Support returning values from coroutines.
Add to promise_type:
std::optional<T> result;void return_value(T value) { result = std::move(value); }
Add to task
T& get() { return *handle_.promise().result; }
Validation:
task<int> compute() {
co_return 42;
}
int main() {
auto t = compute();
t.resume();
assert(t.get() == 42);
}
Phase 3: Basic Awaitable Wrapper (Days 5-7)
Goal: Wrap a simple Asio timer operation.
struct timer_awaitable {
asio::steady_timer& timer;
std::chrono::milliseconds duration;
std::error_code ec_;
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> h) {
timer.expires_after(duration);
timer.async_wait([this, h](std::error_code ec) {
ec_ = ec;
h.resume();
});
}
void await_resume() {
if (ec_) throw std::system_error(ec_);
}
};
auto async_sleep(asio::steady_timer& t, std::chrono::milliseconds ms) {
return timer_awaitable{t, ms};
}
Validation:
task<void> test_timer(asio::io_context& io) {
asio::steady_timer timer(io);
auto start = std::chrono::steady_clock::now();
co_await async_sleep(timer, std::chrono::milliseconds(100));
auto elapsed = std::chrono::steady_clock::now() - start;
assert(elapsed >= std::chrono::milliseconds(100));
}
Phase 4: Socket Awaitables (Days 8-10)
Goal: Wrap Asio socket operations.
Create awaitables for:
async_connect_awaitableasync_write_awaitableasync_read_until_awaitable
Key pattern:
struct connect_awaitable {
asio::ip::tcp::socket& socket;
asio::ip::tcp::endpoint endpoint;
std::error_code ec_;
bool await_ready() { return false; }
void await_suspend(std::coroutine_handle<> h) {
socket.async_connect(endpoint, [this, h](std::error_code ec) {
ec_ = ec;
h.resume();
});
}
void await_resume() {
if (ec_) throw std::system_error(ec_);
}
};
Phase 5: RESP Protocol (Days 11-13)
Goal: Implement Redis protocol encoding/decoding.
Serialization:
std::string serialize_command(const std::vector<std::string>& args) {
std::string result;
result += "*" + std::to_string(args.size()) + "\r\n";
for (const auto& arg : args) {
result += "$" + std::to_string(arg.size()) + "\r\n";
result += arg + "\r\n";
}
return result;
}
// serialize_command({"GET", "mykey"}) → "*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n"
Parsing: Build a state machine or recursive parser.
Phase 6: Redis Client Integration (Days 14-18)
Goal: Combine all pieces into the Redis client.
class RedisClient {
public:
explicit RedisClient(asio::io_context& io);
task<void> connect(std::string_view host, uint16_t port) {
asio::ip::tcp::resolver resolver(io_);
auto endpoints = co_await async_resolve(resolver, host, port);
co_await async_connect(socket_, endpoints);
}
task<std::optional<std::string>> get(std::string_view key) {
co_await send_command({"GET", std::string(key)});
auto resp = co_await read_response();
if (resp.type == RespValue::Type::Null) {
co_return std::nullopt;
}
co_return std::get<std::string>(resp.data);
}
// ... other commands
};
Phase 7: Testing and Polish (Days 19-21)
Goal: Comprehensive testing and error handling.
- Unit tests for each component
- Integration tests with real Redis
- Error handling for all edge cases
- Documentation and examples
5.11 Key Implementation Decisions
Decision 1: Lazy vs Eager Start
Lazy (recommended):
initial_suspendreturnssuspend_always- Caller explicitly starts/awaits the task
- More control, avoids race conditions
Eager:
initial_suspendreturnssuspend_never- Runs immediately until first
co_await - Can be surprising, harder to reason about
Decision 2: Exception Handling Strategy
Store exceptions in the promise:
void unhandled_exception() {
exception_ = std::current_exception();
}
T await_resume() {
if (exception_) {
std::rethrow_exception(exception_);
}
return std::move(result_);
}
Decision 3: Frame Ownership
The task<T> object owns the coroutine handle and destroys the frame in its destructor. This prevents leaks but requires care with move semantics.
6. Testing Strategy
Unit Tests
// test_task.cpp
TEST(Task, ReturnsValue) {
task<int> coro = []() -> task<int> { co_return 42; }();
coro.resume();
EXPECT_EQ(coro.get(), 42);
}
TEST(Task, PropagatesException) {
task<int> coro = []() -> task<int> {
throw std::runtime_error("oops");
co_return 0;
}();
coro.resume();
EXPECT_THROW(coro.get(), std::runtime_error);
}
// test_resp.cpp
TEST(Resp, SerializesCommand) {
auto result = serialize_command({"SET", "key", "value"});
EXPECT_EQ(result, "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n");
}
TEST(Resp, ParsesSimpleString) {
auto result = parse_resp("+OK\r\n");
EXPECT_EQ(result.type, RespValue::Type::SimpleString);
EXPECT_EQ(std::get<std::string>(result.data), "OK");
}
Integration Tests
Require a running Redis instance:
TEST(RedisClient, PingPong) {
asio::io_context io;
auto test = [&]() -> task<void> {
RedisClient client(io);
co_await client.connect("localhost", 6379);
auto response = co_await client.ping();
EXPECT_EQ(response, "PONG");
};
auto t = test();
t.resume();
io.run();
}
TEST(RedisClient, SetGetRoundtrip) {
asio::io_context io;
auto test = [&]() -> task<void> {
RedisClient client(io);
co_await client.connect("localhost", 6379);
co_await client.set("test_key", "test_value");
auto value = co_await client.get("test_key");
EXPECT_TRUE(value.has_value());
EXPECT_EQ(*value, "test_value");
co_await client.del("test_key");
};
auto t = test();
t.resume();
io.run();
}
Stress Tests
TEST(RedisClient, ConcurrentOperations) {
asio::io_context io;
std::atomic<int> completed{0};
auto worker = [&](int id) -> task<void> {
RedisClient client(io);
co_await client.connect("localhost", 6379);
for (int i = 0; i < 100; ++i) {
co_await client.incr("counter");
}
++completed;
};
std::vector<task<void>> tasks;
for (int i = 0; i < 10; ++i) {
tasks.push_back(worker(i));
}
for (auto& t : tasks) {
t.resume();
}
io.run();
EXPECT_EQ(completed, 10);
}
7. Common Pitfalls & Debugging
Pitfall 1: Dangling References in Awaitables
Problem: Capturing references that go out of scope.
// WRONG!
task<void> bad() {
std::string data = "hello";
co_await async_write(socket, data); // 'data' might be destroyed!
}
Fix: Copy or move data into the awaitable:
task<void> good() {
std::string data = "hello";
co_await async_write(socket, std::move(data));
}
Pitfall 2: Not Resuming After Final Suspend
Problem: Coroutine leaks if you don’t handle final_suspend.
// If final_suspend returns suspend_always, you MUST destroy the handle
~task() {
if (handle_) {
handle_.destroy();
}
}
Pitfall 3: Double Resume
Problem: Resuming an already-running or finished coroutine.
auto t = my_coro();
t.resume();
t.resume(); // Undefined behavior if already done!
Fix: Always check done() before resuming.
Pitfall 4: Exception in await_suspend
Problem: If await_suspend throws, the coroutine is in a weird state.
Fix: Never throw from await_suspend. Catch exceptions and store them for await_resume.
Pitfall 5: Forgetting to Run the Event Loop
Problem: Coroutine suspends but nothing processes I/O.
int main() {
auto t = my_async_operation();
t.resume();
// Nothing happens - no one is processing I/O!
// Fix: Run the event loop
io.run();
}
Debugging Techniques
1. Trace Coroutine State
void trace(const char* msg) {
std::cout << "[" << std::this_thread::get_id() << "] " << msg << "\n";
}
struct promise_type {
auto initial_suspend() {
trace("initial_suspend");
return std::suspend_always{};
}
// ...
};
2. Use Sanitizers
# Catch memory errors
g++ -fsanitize=address,undefined -g ...
# Catch race conditions (if multithreaded)
g++ -fsanitize=thread -g ...
3. GDB Debugging
# Set breakpoint on coroutine entry
break my_coroutine
# Print coroutine handle
print handle_.address()
# Step through await
step
8. Extensions & Challenges
Challenge 1: Connection Pooling
Implement a pool that reuses connections:
class ConnectionPool {
public:
task<std::shared_ptr<RedisClient>> acquire();
void release(std::shared_ptr<RedisClient>);
};
Challenge 2: Pipelining
Send multiple commands without waiting for responses:
auto [r1, r2, r3] = co_await client.pipeline(
client.get("key1"),
client.get("key2"),
client.get("key3")
);
Challenge 3: Pub/Sub
Implement Redis pub/sub with a message stream:
auto subscription = co_await client.subscribe("channel");
while (auto msg = co_await subscription.next()) {
process(*msg);
}
Challenge 4: Timeout Support
Add timeout wrappers for all operations:
auto result = co_await with_timeout(
client.get("key"),
std::chrono::seconds(5)
);
Challenge 5: Cluster Support
Implement Redis Cluster client with slot routing:
ClusterClient cluster({"node1:7000", "node2:7000", "node3:7000"});
auto value = co_await cluster.get("key"); // Routes to correct node
9. Real-World Connections
Production C++ Coroutine Libraries
| Library | Description |
|---|---|
| cppcoro | Lewis Baker’s pioneering coroutine library |
| folly::coro | Facebook’s production coroutine utilities |
| libcoro | Lightweight, Asio-integrated coroutines |
| Boost.Cobalt | Upcoming Boost coroutine library |
Industry Usage
- Gaming: Low-latency game servers use coroutines for handling player connections
- Trading: High-frequency trading systems use coroutines for order processing
- Databases: Redis 7.0+ uses similar techniques internally
- Networking: Modern HTTP/2 and gRPC implementations
Career Applications
Mastering coroutines prepares you for:
- Systems programming roles at major tech companies
- Game engine development
- High-performance backend services
- Database engine development
10. Resources
Primary Reading
| Topic | Resource | Notes |
|---|---|---|
| C++20 Coroutines | “C++20 - The Complete Guide” Ch. 14-15 | Essential reading |
| Coroutine Mechanics | Lewis Baker’s blog posts | Deep technical details |
| Asio Async | Asio documentation | async_* functions |
| Redis Protocol | redis.io/topics/protocol | RESP specification |
Video Resources
- CppCon 2019: “Introduction to C++ Coroutines” by James McNellis
- CppCon 2020: “Structured Concurrency” by Lewis Baker
- CppCon 2021: “C++20 Coroutines: A Deep Dive” by Andreas Fertig
Code References
- cppcoro - Reference implementation
- libcoro - Production-ready library
- asio-coro - Asio’s experimental coroutine support
11. Self-Assessment Checklist
Before moving on, verify you can:
- Explain the role of
promise_type,coroutine_handle, and the coroutine frame - Describe what happens step-by-step when a coroutine is called
- Implement a basic
task<T>return type from scratch - Create custom Awaitable types that wrap callback-based APIs
- Explain why
co_awaitdoesn’t block the thread - Debug a coroutine that’s not resuming correctly
- Implement the RESP protocol for Redis communication
- Answer all interview questions in section 5.8 confidently
- Explain the tradeoffs between stackful and stackless coroutines
- Integrate coroutines with an event loop (Asio)
12. Submission / Completion Criteria
Your project is complete when:
- Functionality
connect()establishes connection asynchronouslyping()returns “PONG”get(key)returns correct value ornulloptset(key, value)stores value correctlydel(key)removes keyincr(key)increments correctly
- Code Quality
- All async operations use coroutines (no raw callbacks in user code)
- Errors are handled via exceptions
- No memory leaks (verified with ASan)
- Clean separation: task, awaitable, client, protocol
- Testing
- Unit tests for task
- Unit tests for RESP serialization/parsing
- Integration tests with real Redis
- All tests pass consistently
- Unit tests for task
- Documentation
- README explaining how to build and run
- API documentation for public interfaces
- Examples demonstrating usage
Previous Project: P03 - Compile-Time Unit Conversion Library
Back to: README - Advanced C++ Learning Path