The Problem
We have some shared resource: a memory pool, thread-unsafe API, take your pick. We would like to control access to said resource via an ASIO strand. All routines accessing the resource should run on that strand.
We’re also using C++20 coroutines and enjoy the illusion of sequential execution they provide.
When accessing the shared resource we would like to suspend the coroutine with co_await
, switch to the blessed strand, do whatever with the resource, and then return back to the coroutine on its native executor.
Caveats:
- We don’t want to use the “
dispatch
trick”, because the ergonomics are bad and it’s a race condition waiting to happen ie.
auto s1 = bind_executor(strand, asio::deferred);
co_await asio::dispatch(s1);
// Access shared resource
co_await asio::dispatch(asio::deferred);
- We don’t want to wrap said trick inside another
asio::awaitable
and possibly allocate a coroutine frame for operations that don’t ask for one (viause_awaitable
)
The Current Solution
This is what I hacked up this morning, obviously it’s not great (not using concepts, doesn’t forward arguments, no return values allowed, etc), but it illustrates what I’m going for:
(Godbolt)
static std::atomic_int tid_gen;
thread_local int const tid = tid_gen++;
inline void out(auto const& msg) { std::print("T{:x} {}n", tid, msg); }
template <typename F, typename Ex, typename CT>
auto async_run_on(F f, Ex ex, CT&& token) {
return asio::async_initiate<CT, void()>(
[](auto handler, F f, Ex ex) {
ex.dispatch(
[=, handler = std::move(handler)]() mutable {
std::invoke(f);
handler.get_executor().execute(std::move(handler));
},
asio::get_associated_allocator(ex));
},
token, std::move(f), ex);
}
asio::awaitable<void> f(auto strand) {
out("Main");
co_await async_run_on([](){ out("Strand"); }, strand, asio::deferred);
out("Main again");
}
int main() {
asio::io_context io;
asio::thread_pool tp(1);
co_spawn(io, f(make_strand(tp)), asio::detached);
io.run();
}
It’s hopefully self-evident that we could expand this to build callable awaitables that always run on a given executor and return to wherever.
The Question
How is this use case supposed to work?
I am very bad at ASIO, the worst. There’s also zero chance I’m the first to run into this problem. This makes me immensely suspicious of any solution I come up with. Coroutines and strands are ASIO building blocks, and I’m no sehe or ChrisK, I’m not the person figuring out how these puzzle pieces go together.
But Google and SO and the ASIO docs are all weirdly silent about this. There is very little cross-pollination between the examples given for strands and the examples given for coroutines.
Are we supposed to be co_await
‘ing strand-bound deferred operations each time or is there a free function I completely missed somewhere?