c++boostc++20coroutineasio

What is happend if a completion handler is neither invoked nor stored with C++20 coroutine?


Overview

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.

code

#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;
}

output

before proc1
trace::trace()
before call=true
after  call=true
before call=false
trace::~trace()
finish

godbolt link: https://godbolt.org/z/3dzoYban6

NOTE

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
    );
}

Solution

  • 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 |
    //                                                 |                 |
    //                                                 +-----------------+
    

    Docs?

    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.

    From What does boost::asio::spawn do?