Coroutines in C++

Coroutines at its core are functions that can be paused and resumed, enabling asynchronous and lazy computations in a natural, sequential style.

Coroutines are special functions that can suspend their execution at certain points and resume later from where they left off. Unlike regular functions that run to completion once called, coroutines allow for interleaved execution, making it possible to implement various asynchronous and stateful behaviors in a more structured and readable way.

Coroutines make it possible to write code like:

co_await some_async_op();        

Instead of callback chains or manually managing state machines.

Unlike regular functions coroutines :

  • Can yield values mid-execution (generators).
  • Can pause while waiting for async operations (I/O, timers).
  • retain state between suspensions.


Simple example of coroutine :

#include <coroutine>
#include <iostream>

// A minimal coroutine that prints messages
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() {}
    };
};

Task myCoroutine() {
    std::cout << "Coroutine started\n";
    co_await std::suspend_always{}; // Suspend here
    std::cout << "Coroutine resumed\n";
}

int main() {
    myCoroutine(); // Output: "Coroutine started"
}        

Coroutines are not a standalone feature but a framework. The compiler needs to know:

  • How to manage the coroutine’s state (e.g., variables across suspensions).
  • What to do when the coroutine suspends (co_await), yields (co_yield), or returns (co_return).
  • How to expose results to the caller.

This is done by defining:

  • A return object (e.g., Task, Generator) to interact with the coroutine.
  • A promise_type nested inside the return object to control behavior.

Key Components

  1. Promise Type
  2. Coroutine Handle
  3. Keywords

  • co_await: Suspends execution until a condition is met.
  • co_yield: Yields a value and suspends (for generators).
  • co_return: Returns a value and ends the coroutine.


Promise Type

Every coroutine has an associated promise_type defining its behavior:

Methods:

  • get_return_object(): Creates the coroutine's return object.
  • initial_suspend(): Controls suspension at start.
  • final_suspend(): Controls suspension at end.
  • yield_value(val): Handles co_yield.
  • return_void() or return_value(val): Handles co_return.
  • unhandled_exception(): Handles exceptions.


Coroutine Handle

std::coroutine_handle<Promise> is a low-level interface to interact with the coroutine:

  • Resume: .resume() continues execution until the next suspension.
  • Destroy: .destroy() frees the coroutine frame.
  • Access Promise: .promise() returns a reference to the promise_type object.


Coroutine Lifetime & States

  1. Creation: Allocates coroutine state (stack/heap).
  2. Initial Suspension: Calls initial_suspend().
  3. Execution: Runs until co_await, co_yield, or co_return.
  4. Resumption: Resumed via coroutine_handle::resume().
  5. Completion: Calls final_suspend() and destroys state.


Scheduling & Resumption

Coroutines do not have built-in scheduling—the programmer defines how/when they resume. This is controlled by awaiters (objects returned by co_await).


Memory Considerations

  • Allocation: Coroutine state is typically heap-allocated. Override operator new in promise_type for custom allocation.
  • Lifetime: Ensure coroutine handles are destroyed to avoid leaks.


Step-by-Step implementation using a Coroutine

Let's create step-by-step a simple generator coroutine that yields numbers.

Step 1: Include Coroutine Headers

#include <coroutine>
#include <iostream>        

Step 2: Define the Return Type

Coroutines don’t return like normal functions. We need to define a custom type that wraps the coroutine.

struct Generator {
     handle_type coro;
     explicit Generator(handle_type h) : coro(h) {}

    ~Generator() {
        if (coro) coro.destroy();
    }

    bool next() {
        coro.resume();
        return !coro.done();
    }

    int value() const {
        return coro.promise().current_value;
    }

// Below is defining the promise_type 
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type {
        int current_value;

        Generator get_return_object() {
            return Generator{handle_type::from_promise(*this)};
        }

        std::suspend_always initial_suspend() { return {}; }

        std::suspend_always final_suspend() noexcept { return {}; }

        std::suspend_always yield_value(int value) {
            current_value = value;
            return {};
        }

        void return_void() {}
        void unhandled_exception() { std::exit(1); }
    };
};
        


Step 3 :Write the Coroutine Function

Generator counter(int max) {
    for (int i = 0; i <= max; ++i) {
        co_yield i;
    }
}        


Step 4: Use It!

int main() {
    auto gen = counter(5);
    while (gen.next()) {
        std::cout << "Value: " << gen.value() << "\n";
    }
}        



Article content

Details of promise_type

Article content

Underhood of coroutine operation :

When you write a coroutine function like:

Generator counter(int max) {
    for (int i = 0; i <= max; ++i) {
        co_yield i;
    }
}        

The compiler rewrites this function into a state machine and generates calls to special hooks. These hooks are defined in a struct named promise_type as described earlier .

What Is promise_type?

  • It is the interface between the compiler-generated coroutine code and the user’s logic.
  • The compiler looks for a promise_type in the coroutine’s return type (Generator in our case).
  • This type must define a set of specific functions that control the coroutine lifecycle.



Breakdown of the promise_type Members



struct promise_type {
    int current_value;  // Stores the value to be yielded        

  • This is user-defined state — here we store the most recently yielded value.
  • Can include anything you need to manage the coroutine.


1. Generator get_return_object()

Generator get_return_object() {
    return Generator{handle_type::from_promise(*this)};
}        

  • Called by the compiler once, at the beginning of the coroutine, to create the return object (Generator).
  • This ties the coroutine’s internal state (promise) to the Generator wrapper via a handle.
  • handle_type is:

using handle_type = std::coroutine_handle<promise_type>;        

2. std::suspend_always initial_suspend()

std::suspend_always initial_suspend() { return {}; }        

  • Called before the coroutine runs any user code.
  • If it returns std::suspend_always, the coroutine starts suspended.
  • This gives the caller control over when to start (via .resume()).
  • Alternative: std::suspend_never makes it start immediately.


3. std::suspend_always final_suspend() noexcept

std::suspend_always final_suspend() noexcept { return {}; }        

  • Called after the coroutine finishes (e.g. hits co_return or returns normally).
  • Returning std::suspend_always means it suspends before destruction, allowing the caller to inspect the state.


4. std::suspend_always yield_value(int value)

std::suspend_always yield_value(int value) {
    current_value = value;
    return {};
}        

  1. Called every time you use co_yield.
  2. The compiler transforms co_yield value; into a call to this function.
  3. You can:

  • Save the value.
  • Decide whether to suspend (std::suspend_always) or continue (std::suspend_never).


5. void return_void()

void return_void() {}
void return_value(T value);        

  • return_void() is called when the coroutine returns normally (without a return value).
  • If your coroutine uses co_return without value, you must define this.
  • If your coroutine returns a value, you'd instead define: void return_value(T value);


6. void unhandled_exception()

void unhandled_exception() { std::exit(1); }        

  • Called if an exception is thrown in the coroutine and not caught.
  • Usually you propagate it or terminate.

void unhandled_exception() {
    std::rethrow_exception(std::current_exception());
}        

handle_type coro;

  • In outer Class -> std::coroutine_handle<promise_type> — a type-safe handle to the coroutine.
  • Created using:

handle_type::from_promise(*this)        

We use coro.resume(), coro.destroy(), and coro.done() to control it from the outer class (Generator).


Coroutines vs. Threads:

Coroutines are not a direct replacement for threads—they serve different purposes:

Threads are about parallelism, managed by the OS, and can run concurrently on multiple CPU cores. Coroutines are about concurrency, managed at the application level, and are cooperative. So coroutines aren't a replacement but a complement.


Article content

Coroutines are ideal for:

  • Managing thousands of concurrent I/O operations (e.g., servers).
  • Implementing generators/iterators.
  • Simplifying stateful async logic.

Threads are still needed for:

  • CPU-bound tasks requiring multi-core parallelism.
  • Operations blocked by system calls (e.g., file I/O).



Simple example where multiple coroutines run concurrently without sharing resources.

Each coroutine performs an independent task (printing numbers with a delay) to demonstrate parallel execution without race conditions:


#include <coroutine>
#include <iostream>
#include <thread>
#include <vector>

// Coroutine return type
struct AsyncTask {
    struct promise_type {
        AsyncTask get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

// Awaiter for non-blocking delay
struct DelayAwaiter {
    int ms;
    bool await_ready() { return false; } // Always suspend
    void await_suspend(std::coroutine_handle<> h) {
        // Resume after delay (non-blocking)
        std::thread([h, this] {
            std::this_thread::sleep_for(std::chrono::milliseconds(ms));
            h.resume();
        }).detach();
    }
    void await_resume() {}
};

// Coroutine task: Print numbers with a delay
AsyncTask printNumbers(int id, int count, int delay_ms) {
    for (int i = 1; i <= count; ++i) {
        std::cout << "Coroutine " << id << ": " << i << std::endl;
        co_await DelayAwaiter{delay_ms}; // Suspend with delay
    }
}

int main() {
    // Launch 3 independent coroutines
    printNumbers(1, 3, 100); // Prints 1, 2, 3 with 100ms delay
    printNumbers(2, 3, 200); // Prints 1, 2, 3 with 200ms delay
    printNumbers(3, 3, 150); // Prints 1, 2, 3 with 150ms delay

    // Wait for coroutines to finish
    std::this_thread::sleep_for(std::chrono::seconds(1));
}
/*
Coroutine 1: 1
Coroutine 2: 1
Coroutine 3: 1
Coroutine 1: 2
Coroutine 3: 2
Coroutine 2: 2
Coroutine 1: 3
Coroutine 3: 3
Coroutine 2: 3
*/        

  1. Coroutine Creation: printNumbers starts executing immediately (std::suspend_never).
  2. Suspension & Resumption:
  3. Independent Execution: Each coroutine runs in its own "threaded" context, printing numbers at its own pace.

Things to notice :

  1. No Shared Resources: Each coroutine has its own id, count, and delay_ms.
  2. Non-Blocking Delay: The DelayAwaiter suspends the coroutine and resumes it after a delay using a detached thread.
  3. Parallel Execution: Coroutines run concurrently (output order varies due to delays).


Example for Multiple Coroutines with Shared Resource

Let’s implement a thread-safe counter accessed by multiple coroutines running on a thread pool. We’ll use:

  • A mutex to prevent race conditions.
  • A thread pool to execute coroutines in parallel.
  • C++20 coroutines for async operations.

#include <mutex>
#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <functional>
#include <coroutine>
#include <future>

//Step 1: Shared Resource with Mutex
struct SharedCounter {
    std::mutex mtx;
    int value = 0;

    void increment() {
        std::lock_guard<std::mutex> lock(mtx);
        ++value;
    }

    int get() {
        std::lock_guard<std::mutex> lock(mtx);
        return value;
    }
};


//Step 2: Thread Pool & Async Task
class ThreadPool {
    std::vector<std::jthread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex q_mutex;
    std::condition_variable cv;
    bool stop = false;

public:
    ThreadPool(size_t threads) {
        for (size_t i = 0; i < threads; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(q_mutex);
                        cv.wait(lock, [this] { return stop || !tasks.empty(); });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    template<typename F>
    void enqueue(F&& f) {
        {
            std::lock_guard lock(q_mutex);
            tasks.emplace(std::forward<F>(f));
        }
        cv.notify_one();
    }

    ~ThreadPool() {
        {
            std::lock_guard lock(q_mutex);
            stop = true;
        }
        cv.notify_all();
    }
};

// Global thread pool
ThreadPool pool(4);

struct AsyncTask {
    struct promise_type {
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        AsyncTask get_return_object() { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

struct ResumeOnPool {
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<> h) {
        pool.enqueue([h] { h.resume(); });
    }
    void await_resume() {}
};

AsyncTask incrementCounter(SharedCounter& counter) {
    co_await ResumeOnPool{}; // Resume on thread pool
    counter.increment();
}


//Step 3: Run Coroutines in Parallel
int main() {
    SharedCounter counter;

    // Launch 1000 coroutines
    for (int i = 0; i < 1000; ++i) {
        incrementCounter(counter);
    }

    // Wait for all tasks to complete
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Final counter value: " << counter.get() << "\n";
}

        

  1. Coroutine Creation: incrementCounter starts suspended but immediately resumes on the thread pool (ResumeOnPool).
  2. Parallel Execution: The thread pool runs multiple coroutines concurrently across 4 threads.
  3. Mutual Exclusion: The mutex ensures atomic increments to SharedCounter.


Key Points

  1. Thread Safety: The SharedCounter uses a mutex to prevent race conditions.
  2. Thread Pool: Coroutines are resumed on a pool of 4 threads, enabling parallelism.
  3. Scheduling: ResumeOnPool schedules coroutine resumption on the thread pool.
  4. Output: Final counter value: 1000.


We often see "return {}" , what is it ?

"return {}" is a shorthand for returning a default-initialized value of the return type of the function.

Ex:

std::suspend_always final_suspend() noexcept {
    return {};
}        

It means "return a default-constructed std::suspend_always".


In C++, {} is value-initialization syntax (aka brace initialization), which:

  • Calls the default constructor if available.
  • Zero-initializes POD types.


Complete Generator Example :

#include <iostream>
#include <coroutine>

template <typename T>
struct generator {
    struct promise_type {
        T current_value;
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        generator get_return_object() { return generator{std::coroutine_handle<promise_type>::from_promise(*this)}; }
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    using handle_type = std::coroutine_handle<promise_type>;

    generator(handle_type h) : coro(h) {}
    ~generator() { if (coro) coro.destroy(); }

    generator(const generator&) = delete;
    generator& operator=(const generator&) = delete;

    generator(generator&& other) noexcept : coro(other.coro) {
        other.coro = nullptr;
    }
    generator& operator=(generator&& other) noexcept {
        if (this != &other) {
            if (coro) coro.destroy();
            coro = other.coro;
            other.coro = nullptr;
        }
        return *this;
    }

    bool next() {
        coro.resume();
        return !coro.done();
    }

    T value() const { return coro.promise().current_value; }

private:
    handle_type coro;
};

generator<int> fibonacci_sequence(int n) {
    if (n <= 0) {
        co_return;
    }
    int a = 0;
    int b = 1;
    if (n >= 1) co_yield a;
    if (n >= 2) co_yield b;
    for (int i = 3; i <= n; ++i) {
        int next = a + b;
        co_yield next;
        a = b;
        b = next;
    }
}

generator<int> getNext(int start = 0, int step = 1) noexcept {
    auto value = start;
    for (int i = 0;; ++i){
        co_yield value;
        value += step;
    }
}

int main() {
    std::cout << "First 10 Fibonacci numbers:" << std::endl;
    for (auto gen = fibonacci_sequence(10); gen.next(); ) {
        std::cout << gen.value() << " ";
    }
    std::cout << std::endl;
    
    std::cout << "getNext():";
    auto gen = getNext();
    for (int i = 0; i <= 10; ++i) {
        gen.next();
        std::cout << " " << gen.value();                      // (7)
    }
    
    std::cout << "\n\n";
    
    std::cout << "getNext(100, -10):";
    auto gen2 = getNext(100, -10);
    for (int i = 0; i <= 20; ++i) {
        gen2.next();
        std::cout << " " << gen2.value();
    }

    return 0;
}
/*
First 10 Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34 
getNext(): 0 1 2 3 4 5 6 7 8 9 10

getNext(100, -10): 100 90 80 70 60 50 40 30 20 10 0 -10 -20 -30 -40 -50 -60 -70 -80 -90 -100

*/        

  • fibonacci_sequence is a generator coroutine that yields Fibonacci numbers up to a specified count.
  • co_yield value; suspends the coroutine and returns value to the caller.
  • The generator class acts as an iterator. The next() method resumes the coroutine until the next co_yield or co_return is encountered.
  • The value() method retrieves the last yielded value.


Good read: Coroutines – MC++ BLOG

To view or add a comment, sign in

More articles by Amit Nadiger

  • PhantomPinned, Pin/UnPin - APIs

    is a zero-sized marker type used in a struct to prevent it from being "Unpin" automatically. In Rust, types are by…

  • Pinning in Rust

    Rust provides a powerful concept called pinning, which allows you to ensure that an object remains at a fixed memory…

  • Adapter design pattern

    The Adapter design pattern is a structural pattern that solves the problem of making incompatible interfaces work…

  • Media Streaming using Axum

    Media Streaming means sending audio or video over a network (like Wi-Fi, mobile data, etc.) in small parts so that the…

  • OnceLock

    OnceLock in std::sync - Rust OnceLock is a synchronization primitive in Rust that provides a way to initialize a value…

  • Factory design pattern

    In software development, the way objects are created can significantly impact the flexibility, maintainability, and…

  • Traits and Concepts in C++

    Traits are a foundational mechanism in C++ for introspecting type properties at compile time. They provide information…

  • jthread-joining thread

    Before C++20, multithreading was handled via std::thread, but it had some drawbacks: We have to manually join or detach…

  • List of C++ 20 additions

    Good read : Table of Content – MC++ BLOG 1. Ranges (C++20) The Ranges library in C++20 provides a new way to work with…

  • string_view in C++ 17

    in C++17 is a lightweight, non-owning reference to a string (or substring) that provides a read-only view into a…

    3 Comments

Insights from the community

Others also viewed

Explore topics