When I use BoostAsio's CompletionToken facility, I design that completion_handler
generated from CompletionToken
finally invoked.
But I am interested in the behavior if completion_handler
is neither invoked nor stored. In this case, completion_handler
is simply destroyed.
I've tested the behavior with C++20 coroutine using use_awaitable
CompletionToken. Then I observed interesting behavior. After co_await
with completion_handler
destroying path execution, the stack is unwinded and ioc.run()
is finished in int main()
. During this process, no exceptions are caught.
Is this expected behavior or undefined behavior? If it is expected behavior, where is the document? I couldn't find, so far.
#include <iostream>
#include <boost/asio.hpp>
namespace as = boost::asio;
template <typename CompletionToken>
auto func(bool call, CompletionToken&& token) {
auto init =
[]
(
auto completion_handler,
bool call
) {
if (call) {
std::move(completion_handler)();
}
else {
// What is happend if completion_handler is neither invoked nor stored ?
}
};
return as::async_initiate<
CompletionToken,
void()
>(
init,
token,
call
);
}
struct trace {
trace() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
~trace() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
};
as::awaitable<void> proc1() {
trace t; // destructed correctly
try {
{
std::cout << "before call=true" << std::endl;
co_await func(true, as::use_awaitable);
std::cout << "after call=true" << std::endl;
}
{
std::cout << "before call=false" << std::endl;
co_await func(false, as::use_awaitable);
// the following part is never executed
std::cout << "after call=false" << std::endl;
}
}
catch (...) {
std::cout << "caught exception" << std::endl;
}
std::cout << "co_return" << std::endl;
co_return;
}
as::awaitable<void> proc2() {
for (int i = 0; i != 2; ++i) {
std::cout << "before proc1" << std::endl;
co_await proc1();
std::cout << "after proc1" << std::endl;
}
}
int main() {
as::io_context ioc;
as::co_spawn(ioc.get_executor(), proc2, as::detached);
ioc.run();
std::cout << "finish" << std::endl;
}
before proc1
trace::trace()
before call=true
after call=true
before call=false
trace::~trace()
finish
godbolt link: https://godbolt.org/z/3dzoYban6
In practice, I don't use "might uninvokable" token. I use an error code based approach.
template <typename CompletionToken>
auto func(bool call, CompletionToken&& token) {
auto init =
[]
(
auto completion_handler,
bool call
) {
if (call) {
std::move(completion_handler)(boost::system::error_code{});
}
else {
// invoke with error code
std::move(completion_handler)(
boost::system::errc::make_error_code(
boost::system::errc::operation_canceled
)
);
}
};
return as::async_initiate<
CompletionToken,
void(boost::syste::error_code const&)
>(
init,
token,
call
);
}
The completion_handler type is
auto init = [](auto completion_handler, bool should_complete) {
boost::asio::detail::awaitable_handler<boost::asio::any_io_executor> ch = std::move(completion_handler);
if (should_complete) {
std::move(ch)();
} else {
// What is happend if completion_handler is neither invoked nor stored ?
}
};
awaitable_handler
indirectly inherits from awaitable_thread
, which on destruction indeed takes care of stack unwinding:
// Clean up with a last ditch effort to ensure the thread is unwound within
// the context of the executor.
~awaitable_thread()
{
if (bottom_of_stack_.valid())
{
// Coroutine "stack unwinding" must be performed through the executor.
auto* bottom_frame = bottom_of_stack_.frame_;
(post)(bottom_frame->u_.executor_,
[a = std::move(bottom_of_stack_)]() mutable
{
(void)awaitable<awaitable_thread_entry_point, Executor>(
std::move(a));
});
}
}
Normally, the ownership gets transferred from handler to handler, but here, nothing references it anymore, so it ceases to exist.
Although this is implementation detail, it makes that if a coro can never be resumed, the reference count goes to zero. Also, along the way there is some decent code commenting that you may find insightful, e.g. impl/awaitable.hpp
starts out with
// An awaitable_thread represents a thread-of-execution that is composed of one
// or more "stack frames", with each frame represented by an awaitable_frame.
// All execution occurs in the context of the awaitable_thread's executor. An
// awaitable_thread continues to "pump" the stack frames by repeatedly resuming
// the top stack frame until the stack is empty, or until ownership of the
// stack is transferred to another awaitable_thread object.
//
// +------------------------------------+
// | top_of_stack_ |
// | V
// +--------------+---+ +-----------------+
// | | | |
// | awaitable_thread |<---------------------------+ awaitable_frame |
// | | attached_thread_ | |
// +--------------+---+ (Set only when +---+-------------+
// | frames are being |
// | actively pumped | caller_
// | by a thread, and |
// | then only for V
// | the top frame.) +-----------------+
// | | |
// | | awaitable_frame |
// | | |
// | +---+-------------+
// | |
// | | caller_
// | :
// | :
// | |
// | V
// | +-----------------+
// | bottom_of_stack_ | |
// +------------------------------->| awaitable_frame |
// | |
// +-----------------+
I found the older answer where Tanner Sansbury explains the equivalent semantics for stackful coroutines [my emphasis]:
The coroutine is suspended until either the operation completes and the completion handler is invoked, the io_service is destroyed, or Boost.Asio detects that the coroutine has been suspended with no way to resume it, at which point Boost.Asio will destroy the coroutine.