Boost.Asio - using std::move on handler before calling it
Asked Answered
L

1

6

I'm confused by a Boost.Asio idiom I frequently see - calling a handler (function object) like this:

std::move(handler)(param1, param2);

What is the reason for writing it this way? My understanding is that this is exactly the same as

handler(param1, param2);

unless handler's operator() method is ref-qualified with && (for info about ref-qualification see "member functions with ref-qualifier" here). Is this something to be expected? I've never actually seen this idiom paired with a ref-qualified operator(), so this seems like an unlikely explanation.

Examples:

Lo answered 16/3, 2023 at 11:14 Comment(0)
S
6

In general, the explicit support for move-only handlers ties in with allocation order guarantees that Asio makes:

As noted previously, all resources must be released prior to calling the completion handler.

This enables memory to be recycled for subsequent asynchronous operations within an agent. This allows applications with long-lived asynchronous agents to have no hot-path memory allocations, even though the user code is unaware of associated allocators.

Specifically, the second example

auto op = async_write_messages(socket, "Testing deferred\r\n", 5, asio::deferred);

defines a packaged operation op with type

asio::deferred_async_operation<
    void(boost::system::error_code),
    asio::detail::initiate_composed_op<void(boost::system::error_code),
                                              void(asio::any_io_executor)>,
    async_write_messages_implementation>

Looking at the implementation thereof, indeed we see exactly what you surmised:

template <BOOST_ASIO_COMPLETION_TOKEN_FOR(Signature) CompletionToken>
auto operator()(
    BOOST_ASIO_MOVE_ARG(CompletionToken) token) BOOST_ASIO_RVALUE_REF_QUAL;

template <BOOST_ASIO_COMPLETION_TOKEN_FOR(Signature) CompletionToken>
auto operator()(
    BOOST_ASIO_MOVE_ARG(CompletionToken) token) const &;

Which, removing macro noise, in c++20 becomes:

template <asio::completion_token_for<Signature> CompletionToken>
auto operator()(CompletionToken&& token) &&;

template <asio::completion_token_for<Signature> CompletionToken>
auto operator()(CompletionToken&& token) const&;

The &&-qualified overload optimizes execution. This makes intuitive sense when you realize that a deferred handler may merely represent deferred_values, to be passed to the user-handler. These - effectively callback arguments - may also be expensive to copy, or move-only.

In this case, deferred_async_operation implements a function object that delays initiation of another asynchronous operation. The initiation function takes arguments that might, again, be expensive to copy, or be move-only.

Indeed the rvalue-ref-qualified version supports those move-semantics, where the const-qualified version does not (again Asio code heavily redacted for legibility, and assuming C++14 or up):

template <typename CompletionToken, std::size_t... I>
auto invoke_helper(CompletionToken&& token, std::index_sequence<I...>)
{
    return asio::async_initiate<CompletionToken, Signature>(
        std::move(initiation_), token, std::get<I>(std::move(init_args_))...);
}

template <typename CompletionToken, std::size_t... I>
auto const_invoke_helper(CompletionToken&& token, std::index_sequence<I...>) const&
{
    return asio::async_initiate<CompletionToken, Signature>( //
        initiation_t(initiation_), token, std::get<I>(init_args_)...);
}

Does It Matter?

Arguably in code that doesn't support ref-qualification, the unqualified version of operator() would successfully pass lvalue-references to the initiation function. This would even work for move-only types IFF the arguments were taken by lvalue-reference. If mutable, those could even be moved from.

The more precise forwarding allows initiation where move-only ("sink") parameters are taken by value, too.

In cases where copies are avoided, this has the important benefit of optimizing the (de)allocation patterns of the application. Consider what would happen if one of the arguments involved contains a reference-counted resource (e.g. shared_ptr). Even if the wrapping type (e.g. deferred_async_operation) goes away "immediately" after invocation, there's can be a difference in the order of allocation/de-allocations when the ref-counted resource temporarily has a non-unique refcount.

TL;DR

I'd like to boil it down to expressive code: callables that are meant to be invoked only once should express that "unique invocation" much like any other move-only type signal "unique ownership" by the presence of std::move().

There are places where it matters, and a general-purpose library like Asio should not impose unnecessary overhead.

Illustrating - Live Demos

In the example, the async_write_messages_implementation is move-only because it contains unique_ptr members. Therefore, going through the const_invoke_helper would not be able to compile: https://godbolt.org/z/KTc5ooPhT

You can fix it by changing the unique_ptr to shared_ptr, but now you run into the issue described where ownership of the resources will not be unique during the call to async_write_messages_implementation::operator() which leads to the reset()s not releasing their resource until later: https://godbolt.org/z/n9fbE3zxh

Stickleback answered 17/3, 2023 at 2:17 Comment(4)
Because no answer should go without live code illustration, added one.Stickleback
wow what a Lovely answer !!Chervonets
Is boost::asio::deferred the only example of this behaviour?Lo
@Lo Hardly. In terms of public interface, it might be - as far as I can tell asio::deferred_t is also exceptional in that it seems to assume c++11 support. However, in library implementations this pattern comes up quite a bit - when implementing stable async operations that do not rely on shared ownership. Your third link is also an example of that in the wild.Stickleback

© 2022 - 2024 — McMap. All rights reserved.