C++20 introduced several groundbreaking features that significantly streamline asynchronous programming. Among these, coroutines stand out as a transformative addition, allowing developers to write asynchronous code that looks and behaves like synchronous code. In this article, we’ll explore the core concepts behind coroutines, illustrate their usage with practical examples, and discuss common pitfalls and best practices for integrating them into modern C++ projects.
1. What is a Coroutine?
A coroutine is a generalized routine that can suspend its execution and later resume from the same point. Unlike traditional threads, coroutines do not require context switches; they rely on cooperative suspension, meaning the coroutine itself decides when to pause. The C++ language provides the co_await, co_yield, and co_return keywords to manage these suspension points.
2. The Coroutine Types
- Generator: A coroutine that yields a sequence of values lazily. Use
co_yieldto produce each value. - Task: Represents an asynchronous operation that eventually produces a result. Typically uses
co_returnto deliver the final value. - Async Function: Combines both generator and task behavior, suitable for async/await patterns.
3. Building a Simple Generator
#include <coroutine>
#include <iostream>
#include <optional>
template<typename T>
struct generator {
struct promise_type;
using handle_type = std::coroutine_handle <promise_type>;
struct promise_type {
std::optional <T> current_;
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(T value) {
current_ = std::move(value);
return {};
}
void return_void() {}
void unhandled_exception() {
std::exit(1);
}
};
handle_type coro_;
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; }
class iterator {
handle_type coro_;
public:
iterator(handle_type h) : coro_(h) {}
iterator& operator++() {
coro_.resume();
return *this;
}
T const& operator*() const { return *coro_.promise().current_; }
bool operator==(std::default_sentinel_t) const {
return !coro_ || coro_.done();
}
};
iterator begin() {
if (!coro_.done()) coro_.resume();
return iterator{coro_};
}
std::default_sentinel_t end() { return {}; }
};
generator <int> count_to(int n) {
for (int i = 0; i <= n; ++i)
co_yield i;
}
Usage:
int main() {
for (auto v : count_to(5))
std::cout << v << ' ';
// Output: 0 1 2 3 4 5
}
4. Writing an Asynchronous Task
#include <coroutine>
#include <future>
#include <iostream>
struct async_task {
struct promise_type;
using handle_type = std::coroutine_handle <promise_type>;
struct promise_type {
std::promise <int> promise_;
async_task get_return_object() {
return async_task{handle_type::from_promise(*this)};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_value(int value) { promise_.set_value(value); }
void unhandled_exception() {
promise_.set_exception(std::current_exception());
}
};
handle_type coro_;
async_task(handle_type h) : coro_(h) {}
~async_task() { if (coro_) coro_.destroy(); }
std::future <int> get_future() { return coro_.promise().promise_.get_future(); }
};
async_task async_sum(int a, int b) {
co_return a + b; // Simulates an async operation
}
Usage:
int main() {
auto task = async_sum(10, 32);
std::future <int> fut = task.get_future();
std::cout << "Result: " << fut.get() << '\n';
}
5. Integrating with std::future and Event Loops
Real-world async programming often requires integration with event loops or thread pools. One common pattern is to convert a coroutine into a std::future and dispatch it to an executor:
#include <thread>
#include <queue>
#include <condition_variable>
class ThreadPool {
public:
ThreadPool(size_t n) : stop_(false) {
for (size_t i = 0; i < n; ++i)
workers_.emplace_back([this] { this->worker(); });
}
~ThreadPool() { shutdown(); }
template<typename F>
auto submit(F&& f) -> std::future<decltype(f())> {
using Res = decltype(f());
auto task = std::make_shared<std::packaged_task<Res()>>(std::forward<F>(f));
std::future <Res> fut = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex_);
tasks_.push([task]() { (*task)(); });
}
cv_.notify_one();
return fut;
}
void shutdown() {
{
std::unique_lock<std::mutex> lock(queue_mutex_);
stop_ = true;
}
cv_.notify_all();
for (auto& w : workers_)
w.join();
}
private:
void worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex_);
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex queue_mutex_;
std::condition_variable cv_;
bool stop_;
};
Then, you can wrap coroutines:
async_task async_computation() {
co_await std::suspend_always{}; // Simulate async suspension
co_return 42;
}
int main() {
ThreadPool pool(4);
auto fut = pool.submit([] { return async_computation(); }).get();
std::cout << "Async result: " << fut.get() << '\n';
}
6. Common Pitfalls
| Issue | Cause | Remedy |
|---|---|---|
| Unawaited suspension | Forgetting to co_await a promise |
Always handle the coroutine’s promise or convert to std::future. |
| Resource leaks | Holding onto coroutine handles after destruction | Ensure handles are destroyed or use RAII wrappers. |
| Deadlocks | Blocking on a coroutine that requires the same thread | Avoid synchronous waits inside async contexts; use co_await or future::get. |
| Stack overflows | Deep recursion without tail-call optimization | Use iterative coroutines or limit recursion depth. |
7. Best Practices
- Use dedicated coroutine types:
generator,task, andasync_taskshould have clear semantics and limited responsibilities. - Prefer
co_awaitoverco_yieldwhen integrating withstd::future: It keeps the coroutine cooperative and avoids hidden blocking. - Leverage
std::expected(C++23) for error handling in coroutines, reducing reliance on exceptions. - Profile and monitor coroutine lifetimes: Large numbers of coroutines can consume significant stack space; use lightweight coroutine libraries if necessary.
8. Conclusion
C++20 coroutines have opened a new paradigm for writing asynchronous code that is both readable and efficient. By understanding the underlying mechanics—promises, suspension points, and coroutine handles—developers can craft elegant solutions that blend seamlessly with existing C++ tooling. As the language evolves, expect even richer coroutine abstractions in future standards, further simplifying asynchronous programming in C++.
Happy coroutine coding!