As you've realized the problem is actually no different from callback-style async operations.
In both the completion handler (coro resume is that handler) may still need to run (if only to report error) after you reached the point where you want to destruct the object.
In both situations the solution is the same: you have to realize you cannot destruct the object as long as some handlers are still pending. The possible solutions are:
- coordinate cancellation before moving to destruct
- share ownership with the handlers (so they will prompt destruction when the last one releases ownership)
- a combination of the two.
In terms of implementation, c++20 - as always - leaves it to you (perhaps using a stop_token
). Asio, however, already integrates a cancellation state.
By default, continued execution of a cancelled coroutine will trigger an exception from any subsequent co_await
of an awaitable<>
object. This behaviour can be changed by using this_coro::throw_if_cancelled
.
You still need to either make sure all cancellations happen before destruction, e.g. by sharing ownership.
I recommend shared ownership regardless of details, to avoid the subtle pitfalls of remembering not to touch related state after cancellation.
Side Note: Co-locating the allocation
If you look at the problem from a distance, you'll notice that the
problem is that your operations and state don't "live together". In
fact, the cleanest solution is to make the coro encapsulate the object
instance, e.g. as a local variable. The coro stack frame automatically
has its lifetime governed by the language, so that's bullet proof.
Illustrating
Using just classic shared-ownership - this will work with classic async code patterns just as well as with coroutines:
Live On Coliru
#include <boost/asio.hpp>
#include <iostream>
namespace asio = boost::asio;
struct destroy_test : /*public*/ std::enable_shared_from_this<destroy_test>
{
int something = 0;
~destroy_test() noexcept
{
std::cout << "destroy_test destructor\n";
}
asio::awaitable<void> foo() { return do_foo(shared_from_this()); }
private:
asio::awaitable<void> do_foo(std::shared_ptr<void const> hold) {
asio::any_io_executor ioc = co_await asio::this_coro::executor;
try {
something = 3;
std::cout << "not accessing deleted data...\n";
co_return;
} catch (boost::system::system_error const& se) {
std::cerr << "Coro exception: " << se.code().message() << std::endl;
}
}
};
static inline void err_handler(std::exception_ptr e) {
try {
if (e)
std::rethrow_exception(e);
} catch (std::exception const& e) {
std::cerr << e.what() << std::endl;
}
}
int main()
{
asio::io_context io;
auto dt = std::make_shared<destroy_test>();
asio::co_spawn(io, dt->foo(), err_handler);
dt.reset(); // defers destruction to when coro done
io.run();
}
Runs fine under UBSan/ASan and prints:
not accessing deleted data...
destroy_test destructor