1

I have a class that contains a coroutine method. If the coroutine is executed and waits in a halted state the object may be deleted. At some point the coroutine will resume while it's class got deleted.

In traditional async code i made methods with some hook (like callback_hook on_something(std::function<void(int)> callback) ) and the callback_hook is a RAII object that's lifetime is coupled to the callback. So I get notified if the callback-receiver got deleted and can remove the callback.

But I don't see any possible solution for coroutines. I don't know if I can ever safe delete an object that has some awaitable methods.

See example:

#include <iostream>
#include <string>
#include <boost/asio.hpp>


struct destroy_test {
  int something {0};

  ~destroy_test() {
    std::cout << "destroy_test destructor\n";
  }

  boost::asio::awaitable<void> foo(boost::asio::any_io_executor ioc) {
    std::cout << "now accessing deleted data...\n";
    something = 3; // Error
    co_return;
  }
};

int main() {
  boost::asio::io_context io;
  destroy_test *dt = new destroy_test();
  boost::asio::co_spawn(io, [dt]() -> boost::asio::awaitable<void> { delete dt; co_return; }, [](std::exception_ptr e) {});
  boost::asio::co_spawn(io, dt->foo(io.get_executor()), [](std::exception_ptr e) {});
  io.run();
}

Run on Coliru

Spide
  • 89
  • 5
  • what about using smart pointers? They would ensure the instance NOT to be simply deleted and you could "mark" the instance (e.g. in a member) to be deleted and check this mark in your coroutine. In case the instance says "I should be deleted", the coroutine does nothing but return, which would erase its local (hopefully last) smart pointer to the instance and thus delete the object. – Synopsis Apr 27 '23 at 12:06
  • No that's not possible. The coroutine may already been executed while the object was in a good state. It's not a solution that after every co_await I have to check somehow if the class is sill existing. I mean: ```foo``` may contain a ```co_await timer(10s)``` and while these 10 sec the objects gets deleted. I looks like a very basic problem to me, there must be some automatism that the coroutine gets deleted (and removed from the executors) when the owning class does no longer exist. – Spide Apr 27 '23 at 12:23

1 Answers1

3

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
sehe
  • 374,641
  • 47
  • 450
  • 633
  • Thank you for this answer. I have to elaborate... Especially your side-note was very helpful. – Spide Apr 27 '23 at 13:41