Hello everyone! I've been experimenting with C++ coroutines for a few months now. Initially, I started building an async runtime based on io_uring to better understand the C++ stackless coroutine model, and I was amazed at how writing asynchronous code can be as clean and readable as in other languages. Soon, I wanted to go beyond that and see how far the coroutine model can simplify asynchronous programming, so I decided to build an asynchronous Redis client library on top of my runtime. During the development of the Redis client library, I quickly realized that my runtime was missing some utilities for complex workflows, such as the when_all and when_any combinators. However, this was quickly resolved under the strong expressive capability of the C++ coroutine model, and I finally achieved the simple interface I expected like this:
auto [_1, _2, _3, exec_res] = co_await redis.multi()
.set("user:{1001}", "val1")
.set("item:{1001}", "val2")
.exec();
Throughout this process, I experimented with different task types via promise_type and gained a much deeper understanding of the mechanics behind Awaiter and std::coroutine_handle<>. Thus, I am now convinced that the current C++ coroutine model has greatly reduced the complexity of asynchronous programming, except for……
Cancellation Mechanism
Cancelling a single IO, such as recv/send, seems straightforward since the runtime already provides that function. However, extending this capability to a task level gets tricky. For instance, if Task A is awaiting B or C, and C will await D (A->B/C->D), manually registering every single IO in the async call tree for cancellation will be a nightmare. We might just want to call A.cancel() or derive a cancel token from A instead of checking what exactly single IO is in D. Additionally, cancelling C might not affect B but could cancel D.
std::execution describes stoppable_token and set_stopped() to achieve this goal, but it requires very careful implementation of each receiver to check the stop token. Coroutine-based IO suggests that the token might be hidden within the promise_type, as long as the root suspended nodes remember to check if it is stopped and register its callback in the call chain with some tricks in await_suspend and await_transform() like:
template<class Promise>
bool await_suspend(std::coroutine_handle<Promise> h) {
if constexpr (requires { h.promise().hook(this); }) {
bool stopped = h.promise().hook(this);
if (stopped) {
return false;
}
}
return true; // Add return value to ensure function returns
}
It is hard to determine which method is "better" because the cancellation itself is scenario-dependent and outside the language core. Currently, I accept the second method as it fits my coroutine-based runtime simply. For example, if you are awaiting commands to the Redis server, it is hard to give a good definition of the cancellation of that operation since the TCP packets might have already reached the server side. In summary, you can achieve a lot with the C++20 coroutine model today, but we still have many open questions to resolve in asynchronous programming. My repo if you are interested.
Blogger's Review: The C++20 coroutine model has revolutionized asynchronous programming by simplifying code structure and readability, but further exploration is needed in complex cancellation logic. Thoughtful design and implementation will be key to future developments.