With C++20, coroutines are making asynchronous code in C++ much cleaner and easier to write. Coroutines allow us to write code that is as simple as synchronous code, but with the benefits of asynchronous execution. Hence, with coroutines, you can write efficient, readable, and maintainable code that's easier to understand and debug. Specifically, coroutines avoid callback hell and the compiler takes the burden of state management.
The de-facto industry-standard for high-performance, asynchronous IO is Asio or packaged with boost, boost::asio. However, Asio provides an awaitable coroutine type that is incompatible with the broader ecosystem of standard C++20 coroutines (e.g. cppcoro). Specifically, Asio's awaitable is conditionally awaitable - it works only within Asio contexts. This limitation means you cannot easily mix asio::awaitable with coroutine utilities like mutex, when_all, or other concurrency primitives. To address this, we demonstrate how to create a custom Asio completion token that integrates with standard C++20 coroutine types such as cppcoro's or folly's Task.
This approach allows you to use the rich ecosystem of coroutine utilities without sacrificing the benefits of Asio. Specifically, in this blog you will learn how to write something like this:
cppcoro::Task<std::size_t> read_from_socket(socket& socket, mutable_buffer buffer) {
std::size_t n = co_await socket.async_read_some(buffer, use_coro);
co_return n;
}Mix & match - asio::awaitable lives only inside Asio; it refuses to play with utilities like when_all, mutex, or structured-concurrency scopes.
One token, many coroutines - Drop in a single use_coro completion token and any Asio async call can now return a standard coroutine type (e.g. cppcoro's or Folly's Task<T>).
Cleaner call-sites - No more callback plumbing or wrappers;
just co_await socket.async_read_some(buf, use_coro) and keep composing.
In Asio, the interface of asynchronous operations is customizable through a concept called completion tokens. For example, you can decide if you want the operation to return std::future (asio::use_future) or behave as if it were an asio::awaitable coroutine (asio::use_awaitable). It is also possible to use raw callbacks. The async_read_some method of Asio's socket might then look like this.
socket.async_read_some(buffer,
[](std::error_code ec, std::size_t n){
// ...
});Similarly, the interface might produce a future, simply by changing the completion token.
std::future<std::size_t> fut = socket.async_read_some(buffer, asio::use_future);
auto result = fut.get();Asio's design is so flexible that it allows these different behaviors with the same implementation of the asynchronous operation.
We can define our own completion token and influence the behavior almost arbitrarily. In this blog entry, we will discuss the details of Asio's completion token system and build a completion token for standard C++ coroutines. Over the course of this text, we will build the use_coro completion token that makes asynchronous operations produce an instance of a standard C++ coroutine. For the sake of simplicity, we assume the existence of a coroutine type coro<T>. For example, you could use cppcoro's coroutine type Task<T>.
Coming back to the previous example, we want to produce this interface:
coro<std::size_t> result = socket.async_read_some(buffer, use_coro);In the background, Asio's asynchronous operations always invoke a callback (the completion handler). Completion tokens influence the side-effects of this callback. Asynchronous operations specify the signature of this callback. In Asio, this is called the completion signature.
A completion token is nothing more than a class. For brevity, Asio often defines a static instance of this class. This avoids having to write the braces after asio::use_awaitable{}, so is merely sugar. Our completion token is just this:
struct use_coro_t
{
constexpr use_coro_t() = default;
};
[[maybe_unused]] constinit static inline use_coro_t use_coro;When we invoke an asynchronous operation Asio passes the signature, completion token and implementation of the asynchronous operation to the async_result customization point. By providing a partial template specialization for async_result, we can define the completion handler (i.e., the callback) for any pair of completion token and completion signature. The template for asio::async_result looks something like this.
template<typename R, typename... A>
struct asio::async_result<use_coro_t, R(A...)>
{
// R(A...) is the signature of the completion handler
using return_type = // ...
// The initiation belongs to the asynchronous operation.
// The args are the arguments to the asynchronous operation.
// We should execute the initiation and return a coro<T> with which we can await the result.
template<typename Initiation, typename... Args>
static auto initiate(Initiation&& initiation,
const use_coro_t& completion_token,
Args&&... args) noexcept -> coro<return_type>
{
auto callback = // ...
initiation(callback, std::forward<Args>(args)...);
coro<return_type> ret = // ...
return ret;
}
};Note that the return_type of the invocation is not R. R is the return type of the completion handler, which is void in most cases. The function async_result::initiate takes an initiation (supplied by the asynchronous operation) the actual completion token (which will always be a const use_coro_t& in our case) and any arguments to the asynchronous operation. The returned coro<T> is a suspended coroutine that should be resumed as a result of the completion of the asynchronous operation. Thus, awaiting the coroutine awaits the completion of the asynchronous task.
Next, we define the actual implementation of the completion handler callback that we pass to the asynchronous operation. For this, let's define a new handler type which we call use_coro_handler. It must provide a call operator corresponding to the completion signature R(A...). Thus, it looks something like this.
template<typename R, typename... A>
class use_coro_handler
{
public:
constexpr explicit use_coro_handler() noexcept = default;
constexpr auto operator()(A... args) noexcept -> R
{
// ...
}
};We will refine this template over the next paragraphs. Asio will invoke the call operator once the asynchronous operation finished. Hence, we need to
In Asio, completion signatures are most commonly of the form
void(T),void(std::error_code, T),void(std::error_code),void(std::exception_ptr, T), orvoid(std::exception_ptr)where T is the operation's result type. Hence, to derive this from the handler's signature, we use a set of partial template specializations.
// base template
template<typename R, typename... A>
class handler_result_t;
template<typename R, typename T>
class handler_result_t<R, std::error_code, T>
{
using result_type = T;
}
template<typename R, typename T>
class handler_result_t<R, std::exception_ptr, T>
{
using result_type = T;
}
template<typename R, typename T>
class handler_result_t<R, T>
{
using result_type = T;
}
template<typename R>
class handler_result_t<R, std::error_code>
{
using result_type = void;
}
template<typename R>
class handler_result_t<R, std::exception_ptr>
{
using result_type = void;
}We can then use a pointer to a value of handler_result_t<R, A...> to store the operation's result. The awaiting coroutine will read from that same memory location after resumption and is able to return the operation's result.
Once we have moved the operation's result into that memory location, we need to signal the coroutine that it can resume. For this, we use a single_consumer_event (e.g., cppcoro's event). After calling single_consumer_event::set, the coroutine will eventually continue.
With this, we define the completion handler (only for non-void return types here) as the following.
template<typename R, typename... Args>
class use_coro_handler
{
public:
using result_type = typename handler_result_t<R, Args...>;
private:
single_consumer_event* event_ptr_;
result_type* result_ptr_;
public:
constexpr explicit use_coro_handler(single_consumer_event* event_ptr,
result_type* result_ptr) noexcept
: event_ptr_(event_ptr),
result_ptr_(result_ptr)
{}
constexpr auto operator()(result_type return_value) noexcept -> void
{
this->trigger(std::move(return_value));
}
auto operator()(std::error_code /*ec*/, result_type return_value) noexcept -> void
{
this->trigger(std::move(return_value));
}
auto operator()(const std::exception_ptr& /*ptr*/, result_type return_value) -> void
{
this->trigger(std::move(return_value));
}
private:
constexpr void trigger() noexcept {
*result_ptr_ = std::move(return_value);
event_ptr_->set();
}
};The awaiting coroutine waits for the completion of the event and then yields the result.
template<typename ResultType>
static auto waiter_task(std::unique_ptr<single_consumer_event> event,
std::unique_ptr<ResultType> result) noexcept
-> coro<ResultType>
{
co_await *event;
co_return std::move(*result);
}Finally, we can put all these parts together and define the async_result customization point for use_coro.
template<typename R, typename... A>
struct asio::async_result<corolib::use_coro_t, R(A...)>
{
// Use our completion handler type
using completion_handler_type = use_coro_handler<R, A...>;
// The return value of the asynchronous operation
using return_value_type = typename completion_handler_type::result_type;
// The return value of the initiator is a coro
using return_type = coro<return_value_type>;
template<typename Initiation, typename... Args>
static auto initiate(Initiation&& initiation,
const use_coro_t& /* token */,
Args&&... args) noexcept -> return_type
{
auto result_ptr = std::make_unique<return_value_type>();
auto event_ptr = std::make_unique<single_consumer_event>();
auto handler = completion_handler_type(event_ptr.get(),
result_ptr.get());
initiation(std::move(handler), std::move(args)...);
return waiter_task<return_value_type>(std::move(event_ptr), std::move(result_ptr));
}
};Note that this implementation is an eager initiation of the asynchronous operation. That is, the operation starts as soon as we invoke the operation. Typically, coroutines are lazy and only start the operation once we co_await the coroutine. To achieve this, we could call the initiation inside the waiter_task.
So far our use_coro adaptor eagerly starts the underlying operation as soon as you call the Asio function with it. That's perfectly fine when you always co_await the returned coroutine right away, but there are important reasons why lazy coroutines are preferable:
Scheduling Before Execution Sometimes you want to build up a pipeline of tasks and launch them all together (e.g., with when_all, a queue, or a scheduler). If the operation is already running before the task is awaited, you lose that control.
Structured Concurrency & Cancellation Libraries such as cppcoro or folly rely on the fact that starting a task is semantically tied to awaiting it. This lets a parent scope cancel, or simply destroy, children that were never awaited. Eager initiation breaks that guarantee: the socket read may still be in-flight even though the task object has gone out of scope.
To make the asynchronous operation lazy if the coro<T> is lazy, we let the returned coroutine do the initiation. Hence, if the coroutine is lazy, the operation will be initiated after the coroutine is first resumed (when you co_await it). Meanwhile, the coroutine will be suspended while it waits for the completion of the event.
template<typename ResultType, typename Initiation, typename Args...>
static auto waiter_task_lazy(std::unique_ptr<single_consumer_event> event,
std::unique_ptr<ResultType> result,
Initiation&& initiation,
Args&&... args) noexcept
-> coro<ResultType>
{
// initiates lazy if the coro is lazy
initiation(std::forward<Args>(args)...);
co_await *event;
co_return std::move(*result);
}
template<class R, class... A>
struct asio::async_result<corolib::use_coro_t, R(A...)>
{
using completion_handler_type = use_coro_handler<R, A...>;
using return_value_type = typename completion_handler_type::result_type;
using return_type = coro<return_value_type>;
template<class Initiation, class... Args>
static auto initiate(Initiation&& initiation,
const use_coro_t&,
Args&&... args) noexcept -> return_type
{
auto result_ptr = std::make_unique<return_value_type>();
auto event_ptr = std::make_unique<single_consumer_event>();
auto handler = completion_handler_type(event_ptr.get(),
result_ptr.get());
return waiter_task_lazy<return_value_type>(std::move(event_ptr),
std::move(result_ptr),
std::forward<Initiation>(initiation),
std::forward<Args>(args)...);
}
};With this implementation, Asio's asynchronous operations can be used inside environments that use standard C++ coroutines. Programmers benefit from both, Asio's asynchronous IO framework and the expressive nature of extensible standard coroutines. Hence, the use_coro completion token closes the gap between traditionally incompatible asynchronous models and broadens the applicability of Asio in modern coroutine-based applications.