1

Question

Boost.Asio's asynchronous functions have various CompletionToken signature. For example, boost::asio::async_write has WriteToken.

void write_handler(
    const boost::system::error_code& ec,
    std::size_t bytes_transferred)

boost::asio::ip::tcp::resolver::async_resolve has ResolveToken.

void resolve_handler(
    const boost::system::error_code& ec,
    boost::asio::ip::tcp::resolver::results_type results);

The first argument types are both const boost::system::error_code& but the second argument types are different.

Stackless Coroutine uses operator() that's sigunature matchs the series of async operations CompletionToken signature.

However, async_write() and async_resolve have different CompletionToken signature.

What is the best way to write the stackless coroutine code with different CompletionToken signature ?

Environment

C++17 Boost 1.82.0

What I tried

I wrote resolve-connect-write code using several way. So far, std::any seems good but perhaps there would be better way.

Use std::any

It requires std::any_cast. If the type is mismatched, then exception is thrown. not bad.

#include <iostream>
#include <any>

#include <boost/asio.hpp>

#include <boost/asio/yield.hpp>

struct myapp {
    myapp(
        boost::asio::ip::tcp::resolver& res,
        boost::asio::ip::tcp::socket& sock
    ): res{res}, sock{sock}
    {}

    void operator()(
        boost::system::error_code const& ec = boost::system::error_code{},
        std::any second = std::any{}
    ) {
        reenter (coro) {
            // resolve
            yield res.async_resolve("localhost", "1883", *this);
            std::cout << "async_resolve:" << ec.message() << std::endl;
            if (ec) return;

            // connect
            yield {
                auto results = std::any_cast<boost::asio::ip::tcp::resolver::results_type>(second);
                boost::asio::async_connect(
                    sock,
                    results.begin(),
                    results.end(),
                    *this
                );
            }
            std::cout << "async_connect:" << ec.message() << std::endl;
            if (ec) return;

            // write
            yield {
                auto buf = std::make_shared<std::string>("hello");
                boost::asio::async_write(
                    sock,
                    boost::asio::buffer(*buf),
                    boost::asio::consign(
                        *this,
                        buf
                    )
                );
            }
            std::cout << "async_write:"
                      << ec.message()
                      << " bytes transferred:"
                      << std::any_cast<std::size_t>(second)
                      << std::endl;
        }
    }

    boost::asio::ip::tcp::resolver& res;
    boost::asio::ip::tcp::socket& sock;
    boost::asio::coroutine coro;
};

#include <boost/asio/unyield.hpp>

int main() {
    boost::asio::io_context ioc;
    boost::asio::ip::tcp::resolver res{ioc.get_executor()};
    boost::asio::ip::tcp::socket sock{ioc.get_executor()};
    myapp ma{res, sock};
    ma(); // start coroutine
    ioc.run();
}

Use template with if constexpr

No cast required. Template instantiated multiple times, but coro based switch case seems to work well.

#include <iostream>
#include <type_traits>

#include <boost/asio.hpp>

#include <boost/asio/yield.hpp>

struct myapp {
    myapp(
        boost::asio::ip::tcp::resolver& res,
        boost::asio::ip::tcp::socket& sock
    ): res{res}, sock{sock}
    {}

    template <typename Second = std::nullptr_t>
    void operator()(
        boost::system::error_code const& ec = boost::system::error_code{},
        Second&& second = nullptr
    ) {
        reenter (coro) {
            // resolve
            yield res.async_resolve("localhost", "1883", *this);
            std::cout << "async_resolve:" << ec.message() << std::endl;
            if (ec) return;

            // connect
            yield {
                if constexpr(
                    std::is_same_v<std::decay_t<Second>, boost::asio::ip::tcp::resolver::results_type>
                ) {
                    boost::asio::async_connect(
                        sock,
                        second.begin(),
                        second.end(),
                        *this
                    );
                }
            }
            std::cout << "async_connect:" << ec.message() << std::endl;
            if (ec) return;

            // write
            yield {
                auto buf = std::make_shared<std::string>("hello");
                boost::asio::async_write(
                    sock,
                    boost::asio::buffer(*buf),
                    boost::asio::consign(
                        *this,
                        buf
                    )
                );
            }
            if constexpr(
                std::is_same_v<std::decay_t<Second>, std::size_t>
            ) {
            std::cout << "async_write:"
                      << ec.message()
                      << " bytes transferred:"
                      << std::any_cast<std::size_t>(second)
                      << std::endl;
            }
        }
    }

    boost::asio::ip::tcp::resolver& res;
    boost::asio::ip::tcp::socket& sock;
    boost::asio::coroutine coro;
};

#include <boost/asio/unyield.hpp>

int main() {
    boost::asio::io_context ioc;
    boost::asio::ip::tcp::resolver res{ioc.get_executor()};
    boost::asio::ip::tcp::socket sock{ioc.get_executor()};
    myapp ma{res, sock};
    ma(); // start coroutine
    ioc.run();
}

Prepare multiple operator()()

One of the big advantage of stackless coroutine is continuous code. This approach lose the advantage so I don't choose it.

Takatoshi Kondo
  • 3,111
  • 17
  • 36
  • Since you already know all possible types at compile time you could also use `std::variant` instead of `std::any`, which avoids additional heap allocations for the `std::size_t` case. (If you pass the second argument by value or it requires a conversion into a wrapper type, you'll always have allocations for complex types, such as resolver results, as they'll be copied. You could get around that with `reference_wrapper` for those types, though I haven't tried that.) Other than that I can't think of any other straight-forward way that you haven't already thought of yourself. – chris_se Apr 17 '23 at 07:01

1 Answers1

2

IMHO the correct answer is to have multiple overloads of operator(), which you dismiss out of hand. (rationale below¹)

You might of course have the best of both worlds:

    void operator()()                                   { return call();                      } 
    void operator()(error_code ec, size_t n)            { return call(ec, n);                 } 
    void operator()(error_code ec, endpoints eps)       { return call(ec, 0, std::move(eps)); } 
    void operator()(error_code ec, endpoint /*unused*/) { return call(ec, 0);                 } 

private:
    void call(
        error_code ec = {},
        size_t bytes_transferred = {},
        std::optional<endpoints> eps = {})
    {
        reenter(coro)
        {

See it Live On Coliru²

#include <iostream>
#include <optional>

#include <boost/asio.hpp>
#include <boost/asio/yield.hpp>
namespace asio = boost::asio;
using asio::ip::tcp;

struct myapp {
    using error_code = boost::system::error_code;
    using endpoint = tcp::endpoint;
    using endpoints = tcp::resolver::results_type;

    myapp(
        tcp::resolver& res,
        tcp::socket& sock
    ): res{res}, sock{sock}
    {
    }

    void operator()()                                   { return call();                      } 
    void operator()(error_code ec, size_t n)            { return call(ec, n);                 } 
    void operator()(error_code ec, endpoints eps)       { return call(ec, 0, std::move(eps)); } 
    void operator()(error_code ec, endpoint /*unused*/) { return call(ec, 0);                 } 

private:
    void call(
        error_code ec = {},
        size_t bytes_transferred = {},
        std::optional<endpoints> eps = {})
    {
        reenter(coro)
        {
            // resolve
            yield res.async_resolve("localhost", "1883", *this);
            std::cout << "async_resolve:" << ec.message() << std::endl;
            if(ec) return;

            // connect
            yield asio::async_connect(sock, *eps, *this);

            std::cout << "async_connect:" << ec.message() << std::endl;
            if (ec) return;

            // write
            yield {
                auto buf = std::make_shared<std::string>("hello\n");
                asio::async_write(
                    sock,
                    asio::buffer(*buf),
                    asio::consign(
                        *this,
                        buf
                    )
                );
            }
            std::cout << "async_write:" << ec.message()
                      << " bytes transferred:" << bytes_transferred
                      << std::endl;
        }
    }

    tcp::resolver& res;
    tcp::socket& sock;
    asio::coroutine coro;
};

#include <boost/asio/unyield.hpp>

int main()
{
    asio::io_context ioc;

    tcp::socket sock{ioc};
    tcp::resolver res{sock.get_executor()};

    myapp ma{res, sock};
    ma(); // start coroutine

    ioc.run();
}

Output

g++ -std=c++20 -O2 -Wall -pedantic -pthread main.cpp
nc -lp 1883& sleep 1; ./a.out; kill %1
async_resolve:Success
async_connect:Success
async_write:Success bytes transferred:6
hello

¹ The main reason is that it plays better with "stable" coroutines, where the coro itself is passed as the "self" argument which avoids copying all of the coro state, instead just moving ownership of a single (dynamic) allocation.

Another reason is that it allows me to code the state machine with overload resolution, avoiding dangerously subtle surprises around the reenter and yield macros.

² COLIRU doesn't have asio::consign or DNS service

sehe
  • 374,641
  • 47
  • 450
  • 633
  • Ah operator() overloads forward to call(). I didn't think about that. I thought that each overload has the different boost::asio::coroutine object as `coro1`, `coro2` ... so I didn't choose it. Your approach seems good. It is very straight forward code and efficient. Thank you! – Takatoshi Kondo Apr 17 '23 at 13:25
  • There's actually no difference if you _call_ (hah) `call()` a different name, like `operator()`: http://coliru.stacked-crooked.com/a/0e691d19194cdba8 I just happen to think `call`, `invoke`, or even `entrypoint` is a clearer name. Also it avoids confusion with overloads. – sehe Apr 17 '23 at 14:49
  • Just for comparison, here's my overload-based approach: http://coliru.stacked-crooked.com/a/c7a7c07b746f88c0. You can make it work with incomplete types, so you can [declare states `using states = std::tuple;`](http://coliru.stacked-crooked.com/a/0540617a323fe641), or you can embrace that the states can contain members: http://coliru.stacked-crooked.com/a/72d6794f4cd41596 – sehe Apr 17 '23 at 17:04
  • Found some extra time to remember how to do the `stable_op` (so you don't need to `make_shared` the member data, e.g.). http://coliru.stacked-crooked.com/a/081afb2bf62af788 – sehe Apr 18 '23 at 00:55