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:

  1. Explain the C++20 coroutine machinery including promise_type, coroutine_handle, and the compiler-generated coroutine frame
  2. Implement custom Awaitable types that satisfy the Awaitable concept (await_ready, await_suspend, await_resume)
  3. Design a task<T> return type that serves as a lazy future for coroutine results
  4. Bridge callback-based I/O with coroutines by wrapping Asio async operations into awaitable interfaces
  5. Understand coroutine lifetime management and who owns the coroutine frame at each point
  6. Implement the Redis RESP protocol for wire-level communication with Redis servers
  7. Debug coroutine-based code using systematic techniques for tracking suspension and resumption
  8. 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:

  1. Promise object - Controls coroutine behavior
  2. Local variables - All local state that survives suspension
  3. Suspension point - Which co_await/co_yield/co_return we’re at
  4. 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:

  1. Connects to a Redis server asynchronously
  2. Sends commands and receives responses using the RESP (Redis Serialization Protocol) format
  3. Provides a clean, sequential-looking API that is fully non-blocking
  4. Integrates with Asio for the underlying async I/O
  5. Supports the core Redis commands: PING, GET, SET, DEL, INCR

3.2 Functional Requirements

  1. Connection Management
    • co_await client.connect(host, port) - Establish async connection
    • co_await client.disconnect() - Graceful shutdown
    • Automatic reconnection on connection loss (optional extension)
  2. Redis Commands
    • co_await client.ping() - Returns “PONG”
    • co_await client.get(key) - Returns optional<string>
    • co_await client.set(key, value) - Returns bool
    • co_await client.del(key) - Returns number deleted
    • co_await client.incr(key) - Returns new value
  3. Error Handling
    • Network errors throw exceptions
    • Redis errors return in result type
    • Timeouts are configurable
  4. Coroutine Infrastructure
    • Custom task<T> type for coroutine return values
    • Reusable awaitable wrappers for Asio operations
    • Integration with Asio’s event loop

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:

  1. Zero callback pyramids - All async operations look sequential
  2. Exception-based error handling - Use try/catch with async code
  3. Composable operations - Build complex flows from simple operations
  4. 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, or co_return)
  • What is the relationship between promise_type and the return type?
  • When does initial_suspend return suspend_always vs suspend_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_read and read?
  • 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 mykey in 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

  1. Should task<T> be eager (starts immediately) or lazy (starts on await)?
  2. How will you store the result in the promise?
  3. What happens if the coroutine throws before setting a result?

Awaitable Design

  1. Should awaitables be reusable or single-use?
  2. How will you handle errors from Asio?
  3. What if the async operation completes synchronously?

Client Design

  1. Should the client own the socket or take it by reference?
  2. How will you handle partial reads in the RESP parser?
  3. 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:

  1. When get_greeting() is called, what memory is allocated?
  2. Where does execution stop the first time?
  3. What resumes the coroutine after the connect completes?
  4. 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:

  1. A coroutine_handle member to task
  2. A result type to promise_type
  3. 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

  1. “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.

  2. “What’s the difference between suspend_always and suspend_never for initial_suspend?”

    suspend_always makes the coroutine lazy (caller must resume). suspend_never makes it eager (runs until first co_await).

  3. “How do you integrate coroutines with an event loop like Asio?”

    The await_suspend captures the coroutine handle, starts an async operation, and the completion callback resumes the handle.

  4. “What happens to local variables when a coroutine suspends?”

    They’re stored in the coroutine frame (heap-allocated) and preserved across suspension.

  5. “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

  1. “How would you handle timeouts in a coroutine-based client?”

    Use asio::steady_timer with a race: co_await either(operation(), timer.async_wait()).

  2. “How would you implement connection pooling?”

    A coroutine that manages a pool: co_await pool.acquire() suspends if none available.

  3. “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_awaitable
  • async_write_awaitable
  • async_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_suspend returns suspend_always
  • Caller explicitly starts/awaits the task
  • More control, avoids race conditions

Eager:

  • initial_suspend returns suspend_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_await doesn’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:

  1. Functionality
    • connect() establishes connection asynchronously
    • ping() returns “PONG”
    • get(key) returns correct value or nullopt
    • set(key, value) stores value correctly
    • del(key) removes key
    • incr(key) increments correctly
  2. 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
  3. Testing
    • Unit tests for task
    • Unit tests for RESP serialization/parsing
    • Integration tests with real Redis
    • All tests pass consistently
  4. 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