Skip to content
Home
C++ Coroutines: Async Programming with co_await co_yield co_return

C++ Coroutines: Async Programming with co_await co_yield co_return

C/C++ C/C++ 8 min read 1560 words Beginner ExcellentWiki Editorial Team

C++20 introduced coroutines, a language feature for writing asynchronous code that looks synchronous. Coroutines can suspend execution at a suspension point, transfer control back to the caller or resumer, and resume later from where they left off. They enable efficient async code without the callback nesting or state machine boilerplate of traditional approaches. Major C++ libraries including Boost.Asio, cppcoro, and folly::coro provide production-ready coroutine support.

Understanding Coroutines

A coroutine is a function that contains one of three keywords: co_await, co_yield, or co_return. When a coroutine reaches a suspension point, it can suspend and return control to its caller. Later, the coroutine can be resumed, continuing execution from the exact point it suspended. The compiler generates the coroutine frame — a heap-allocated object that stores parameters, local variables, and the promise object.

The Promise Object

Every coroutine has an associated promise object that controls the coroutine’s behavior. The promise type defines:

  • initial_suspend() — whether the coroutine suspends immediately upon entry
  • final_suspend() — whether the coroutine suspends upon completion
  • get_return_object() — the return value passed to the caller
  • return_void() or return_value() — handles co_return
  • yield_value() — handles co_yield
  • unhandled_exception() — handles exceptions thrown inside the coroutine
struct Task {
    struct promise_type {
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        Task get_return_object() { return Task{this}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    promise_type* handle;
---;

std::suspend_never indicates that suspension should not happen at the corresponding point. std::suspend_always forces suspension. The choice between them determines the coroutine’s execution model — eagerly started (suspend_never) versus lazily started (suspend_always).

The Coroutine Handle

std::coroutine_handle<promise_type> is a non-owning handle to a suspended coroutine. It is a trivially copyable type (essentially a pointer) that allows resuming, destroying, or accessing the promise object. The handle is the mechanism through which external code interacts with the coroutine.

std::coroutine_handle<Task::promise_type> handle;
handle.resume();  // Resume execution
handle.done();    // Check if completed
handle.destroy(); // Destroy the coroutine frame

Awaitable Types and the Awaitable Concept

The co_await operator suspends the coroutine until the awaited expression is ready. The awaited type must satisfy the awaiter concept with three methods:

struct Delay {
    std::chrono::milliseconds duration;
    
    bool await_ready() {
        return duration.count() == 0;
    }
    
    void await_suspend(std::coroutine_handle<> h) {
        std::thread([h, this] {
            std::this_thread::sleep_for(duration);
            h.resume();
        }).detach();
    }
    
    void await_resume() {}
---;

await_ready returns true if the operation is already complete — if so, the coroutine does not suspend. await_suspend is called when the coroutine suspends; it receives the coroutine handle and must arrange for handle.resume() to be called when the operation completes. await_resume returns the result of the await expression.

The co_await expression can suspend the current coroutine and potentially resume on a different thread. This thread migration is a powerful feature — it enables writing I/O code where operations suspend, the callback runs on the I/O completion thread, and the coroutine resumes there.

Generator Pattern with co_yield

The co_yield expression produces a value to the caller and suspends, enabling lazy sequences:

template<typename T>
struct Generator {
    struct promise_type {
        T current_value;
        
        std::suspend_always yield_value(T value) {
            current_value = value;
            return {};
        }
        
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        Generator get_return_object() { return Generator{this}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    
    struct iterator {
        std::coroutine_handle<promise_type> handle;
        bool operator!=(iterator end) const { return !handle.done(); }
        void operator++() { handle.resume(); }
        T& operator*() { return handle.promise().current_value; }
    };
    
    iterator begin() { 
        handle.resume();
        return iterator{handle};
    }
    iterator end() { return iterator{nullptr}; }
---;

Generators produce sequences lazily — values are computed on demand. This is useful for infinite sequences, expensive computations that may be short-circuited, and data processing pipelines.

Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
---

// Usage
for (int x : range(0, 10)) {
    std::cout << x << "\n";
---

Each iteration calls iterator::operator++(), which resumes the coroutine. The coroutine produces the next value with co_yield and suspends again.

Task Pattern with co_return

The co_return expression sets the coroutine’s return value and triggers final suspension. Tasks represent asynchronous operations that produce a single value:

template<typename T>
struct Task {
    struct promise_type {
        std::variant<T, std::exception_ptr> result;
        
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        void return_value(T value) {
            result.template emplace<0>(std::move(value));
        }
        
        void unhandled_exception() {
            result.template emplace<1>(std::current_exception());
        }
    };
    
    T get_result() {
        if (std::holds_alternative<std::exception_ptr>(handle.promise().result)) {
            std::rethrow_exception(std::get<std::exception_ptr>(handle.promise().result));
        }
        return std::get<T>(handle.promise().result);
    }
    
    std::coroutine_handle<promise_type> handle;
---;

Tasks compose naturally with co_await:

Task<int> compute_answer() {
    co_await Delay{100ms};
    co_return 42;
---

Task<int> compute_sum() {
    int a = co_await compute_answer();
    int b = co_await compute_answer();
    co_return a + b;
---

Memory Management

Coroutine frames are heap-allocated by default. The coroutine frame holds parameters, local variables, and the promise object. The compiler can elide heap allocation when the lifetime is known at compile time — specifically, when the coroutine is guaranteed to complete before the caller returns.

Custom Allocators

For performance-critical paths, allocate the coroutine frame from a custom arena or pool:

struct promise_type {
    void* operator new(std::size_t size) {
        return arena.allocate(size);
    }
    void operator delete(void* ptr, std::size_t size) {
        arena.deallocate(ptr, size);
    }
---;

Best Practices

Use existing coroutine libraries like cppcoro or folly::coro for production code — writing correct promise types is error-prone. Keep coroutine functions small and focused on a single async operation. Avoid holding locks across suspension points — a coroutine might resume on a different thread, causing deadlocks or undefined behavior. Test thoroughly with AddressSanitizer and ThreadSanitizer.

Real-World Coroutine Libraries

cppcoro

cppcoro by Lewis Baker provides production-ready awaitable types including task<T>, generator<T>, async_generator<T>, and shared_task<T>. It includes awaitable adapters for I/O operations, synchronization primitives (async_mutex, async_manual_reset_event), and cancellation support through cancellation_token.

cppcoro is the de facto standard reference implementation for C++ coroutine patterns. Its task<T> type supports structured concurrency — child tasks have well-defined lifetimes and cancel automatically when the parent completes.

folly::coro

Facebook’s folly library includes a comprehensive coroutine framework. It provides co_awaitTry for exception-safe await, co_withCancellation for cooperative cancellation, and co_scopeExit for cleanup. Folly’s coroutine integration with its async I/O framework (EventBase) enables high-performance network services.

Boost.Asio Coroutines

Boost.Asio supports coroutines through asio::co_spawn, asio::use_awaitable, and asio::redirect_error. Asio’s coroutine support integrates with its timer, socket, and signal handling APIs. A typical async TCP echo server using coroutines is dramatically shorter than the callback-based equivalent.

asio::awaitable<void> echo(asio::ip::tcp::socket socket) {
    std::array<char, 1024> data;
    for (;;) {
        auto n = co_await socket.async_read_some(
            asio::buffer(data), asio::use_awaitable);
        co_await async_write(socket,
            asio::buffer(data, n), asio::use_awaitable);
    }
---

Coroutine Pitfalls and Gotchas

Lifetime Management

Coroutine frames are heap-allocated and must outlive the coroutine handle. If the coroutine handle is destroyed while the coroutine is suspended, the program accesses freed memory — undefined behavior. Always ensure the handle or owning object (task, generator) lives until the coroutine completes.

Exception Safety

If a coroutine throws an exception before reaching a suspension point, the coroutine frame is destroyed normally. If it throws after suspension but before resumption, the unhandled_exception method receives the exception. The promise type must handle this case — either rethrowing, logging, or storing the exception for retrieval.

Thread Safety

Coroutines are not inherently thread-safe. Accessing the same coroutine handle from multiple threads without synchronization is undefined behavior. When a coroutine resumes on a different thread (common in I/O completion callbacks), ensure proper synchronization for any shared state.

Debugging Challenges

Coroutine frames do not appear in regular stack traces during suspension — they are heap-allocated objects, not stack frames. Debugger support has improved in recent years but still lags behind regular function debugging. Coroutine-aware profilers and debuggers are essential for diagnosing performance issues in coroutine-heavy code.

Frequently Asked Questions

Why are coroutines considered zero-cost?

C++20 coroutines are a zero-cost abstraction in the sense that you pay only for what you use. If a coroutine never suspends, the overhead is minimal. The coroutine frame is heap-allocated only when suspension is possible. Compared to callback-based approaches, coroutines eliminate the state machine boilerplate and improve code readability without runtime penalty.

How do C++20 coroutines compare to C# async/await or Python coroutines?

C++20 coroutines are more flexible but more complex. C# async/await is built on a single built-in Task type. C++20 provides the building blocks — the language handles suspension and resumption, but library authors must implement the promise types and awaitable adapters. This flexibility allows diverse use cases (generators, tasks, lazy sequences) but requires more boilerplate.

Can coroutines be used with existing async libraries?

Yes. Boost.Asio, libuv, and other async libraries provide coroutine integration. Boost.Asio supports co_await directly on asynchronous operations. The asio::use_awaitable completion token converts callbacks to awaitables. Many production codebases use Boost.Asio with coroutines for network I/O.

What debugging tools support C++20 coroutines?

GCC 13+, Clang 16+, and MSVC support coroutine debugging. GDB and LLDB have limited coroutine support — inspecting the coroutine frame and promise object is possible but not as smooth as regular stack frame debugging. AddressSanitizer and ThreadSanitizer work with coroutines and are essential for catching memory and threading bugs.

How do I avoid heap allocation for coroutine frames?

Some compilers elide heap allocation when the coroutine is known to complete synchronously. For guaranteed avoidance, use a custom allocator in the promise type. The standard pattern is to provide operator new and operator delete in the promise_type, routing allocation to a stack-based arena or pre-allocated pool.

Related Articles

Section: C/C++ 1560 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top