Search code examples
c++boost-asioc++20coroutine

How do I use a composed Asio operation with C++20 coroutines to return a value?


I have a composed async operation which uses non-boost Asio 1.18.1 to resolve and connect to a host and service.

I want it to pass the actual endpoint it connects to, to the completion token. Right now it does not.

#include <iostream>
#include <string_view>

#include "asio.hpp"

template <typename Token, typename Executor>
auto async_connect_to(Executor&& executor, asio::ip::tcp::socket& socket,
                      asio::ip::tcp::resolver& resolver, std::string_view host,
                      std::string_view service, Token&& token) {
  return asio::async_compose<Token, void()>(
      [&](auto& self) {
        co_spawn(
            executor,
            [&]() -> asio::awaitable<void> {
              auto results = co_await resolver.async_resolve(host, service, asio::use_awaitable);
              auto endpoint = co_await asio::async_connect(socket, results, asio::use_awaitable);
              self.complete();
              co_return;
            },
            asio::detached);
      },
      token, executor);
}

int main(int argc, char* argv[]) {
  if (argc != 3) {
    std::cerr << "Usage: client <host> <port>\n";
    return 1;
  }

  asio::io_context io_context;
  asio::ip::tcp::socket socket(io_context);
  asio::ip::tcp::resolver resolver(io_context);

  async_connect_to(io_context, socket, resolver, argv[1], argv[2],
                   [](asio::ip::tcp::endpoint endpoint = {}) {
                     std::cout << "connected to: " << endpoint << "\n";
                   });

  try {
    io_context.run();
  } catch (std::exception& e) {
    std::cerr << "error: " << e.what() << "\n";
    return 1;
  }

  return 0;
}

Because I'm doing self.complete(), the completion handler in main never actually gets the endpoint we connected to so it's defaulted.

  1. How do I actually return a value from this composed async operation?
  2. How do I improve the error handling so that the completion handler can take the signature (error_code, tcp::endpoint) instead of just (tcp::endpoint), i.e. so that it works the same way as built-in async operations? (I believe this is straightforward after the first question is answered)

The docs do not provide an example with async_compose and coroutines, nor with returning a value from co_spawn.


Solution

  • I thought I had already tried this, but just changing the signature of the async_compose template parameter and self.complete works.

    For errors it seems like you need to explicitly catch the system_error, pass it as a error_code to the completion handler, and it may be either passed to the completion handler of the user or converted back into a system_error with something like a use_future token.

    As Andrew suggested in the comments, I also move-capture self into the coroutine and make the lambda mutable.

    template <typename Token, typename Executor>
    auto async_connect_to(Executor&& executor, asio::ip::tcp::socket& socket,
                          asio::ip::tcp::resolver& resolver, std::string_view host,
                          std::string_view service, Token&& token) {
      return asio::async_compose<Token,
                                 void(std::error_code, asio::ip::tcp::endpoint)>(
          [&](auto& self) {
            co_spawn(
                executor,
                [&, self = std::move(self)]() mutable -> asio::awaitable<void> {
                  try {
                    auto results = co_await resolver.async_resolve(
                        host, service, asio::use_awaitable);
                    auto endpoint = co_await asio::async_connect(
                        socket, results, asio::use_awaitable);
                    self.complete({}, endpoint);
                  } catch (std::system_error& e) {
                    self.complete(e.code(), {});
                  }
                  co_return;
                },
                asio::detached);
          },
          token, executor);
    }
    

    This seems to work fine like this:

      async_connect_to(
          io_context, socket, resolver, argv[1], argv[2],
          [](std::error_code ec, asio::ip::tcp::endpoint endpoint = {}) {
            if (ec) {
              std::cout << "error: " << ec.message() << "\n";
            } else {
              std::cout << "connected to: " << endpoint << "\n";
            }
          });
    

    And with futures:

    auto f = async_connect_to(io_context, socket, resolver, argv[1], argv[2], asio::use_future);
    try {
      auto endpoint = f.get();
      std::cout << "connected to: " << endpoint << "\n";
    } catch (std::exception& e) {
      std::cerr << "error: " << e.what() << "\n";
    }
    

    Edit: It appears that as of Asio 1.25.0 there is also asio::experimental::co_composed which is meant for precisely this purpose. It doesn't look much simpler than my above solution IMO, but it may be lighter weight.