March 22nd, 2019

Turning anything into a fire-and-forget coroutine

Last time, we wrote a helper function for converting an awaitable into a winrt::fire_and_forget, as well as another helper function that takes a lambda that returns an awaitable, and which invokes the lambmda as a winrt::fire_and_forget.

After I wrote the two functions, I wondered if I could unify them. Mostly because I wanted to use the same name no_await for both functions.

This took me down the horrible rabbit hole known as C++ template metaprogramming. I wanted two versions of the function, one that is used if the parameter is awaitable, and another that is used if the parameter is a functor. This led me to try using things like std::enable_if to detect which case I’m in, and that led to lots of frustration, especially because there’s no easy way to detect if a type is awaitable. My closest approach was

template<typename T, typename Promise = std::void_t<>>
struct is_awaitable : std::false_type {};

template<typename T>
struct is_awaitable<T, std::void_t<typename std::experimental::coroutine_traits<T>::promise_type>> : std::true_type {};

template<typename T>
inline constexpr bool is_awaitable_v = is_awaitable<T>::value;

which infers that a type is awaitable by sniffing whether it has an associated promise_type. This isn’t foolproof, because some types like winrt::fire_and_forget have a promise_type that cannot be awaited.

My first realization was that I could flip the test. Instead of checking whether the argument is awaitable, I check whether it is invokable.

My second realization was that I didn’t have to do fancy template metaprogramming at all. I could take advantage of the new if constexpr feature.

template<typename T>
fire_and_forget no_await(T t)
{
    if constexpr (std::is_invocable_v<T>)
    {
        co_await t();
    }
    else
    {
        co_await t;
    }
}

Now you can use no_await with awaitables or functors that return awaitables.

void Stuff()
{
  // Start this operation but don't wait for it to finish
  no_await(DoSomethingAsync());

  // Start this sequence of things and don't wait for
  // them to finish.
  no_await([=]() -> IAsyncAction
  {
    co_await Step1Async();
    // Step 2 doesn't start until Step 1 completes.
    co_await Step2Async();
  });
}

On the other hand, for the case of the lambda passed to no_await, you could just declare your lambda as returning a winrt::fire_and_forget, and then you wouldn't need no_await.

void Stuff()
{
  // Start this operation but don't wait for it to finish
  no_await(DoSomethingAsync());

  // Start this sequence of things and don't wait for
  // them to finish.
  invoke_async_lambda([=]() -> winrt::fire_and_forget
  {
    co_await Step1Async();
    // Step 2 doesn't start until Step 1 completes.
    co_await Step2Async();
  });
}

But I like the fact that the first example uniformly uses the name no_await to describe the concept of "I'm not going to wait for this thing to finish." And also I'm perhaps unduly attached to the cute name.

Topics
Code

Author

Raymond has been involved in the evolution of Windows for more than 30 years. In 2003, he began a Web site known as The Old New Thing which has grown in popularity far beyond his wildest imagination, a development which still gives him the heebie-jeebies. The Web site spawned a book, coincidentally also titled The Old New Thing (Addison Wesley 2007). He occasionally appears on the Windows Dev Docs Twitter account to tell stories which convey no useful information.

3 comments

Discussion is closed. Login to edit/delete existing comments.

  • Ji Luo

    As a by-product, you could also `no_await(DoSomethingAsync)`, which is somehow bad style.

  • Kalle Niemitalo

    Recently, I was implementing overloads of template<typename Writer> void f(Writer&& writer), and the overload resolution needed to depend on whether std::forward<Writer>(writer)(os) would be valid given std::ostream& os, or whether Writer would prefer some other type of parameter. I considered using std::is_invocable_v<Writer&&, std::ostream&> but decided not to, because that answers a different question: whether std::invoke(std::forward<Writer>(writer), os) is valid. The latter would also allow e.g. &std::ostream::flush as a writer and I didn't want to support that.

    Read more
  • Damien Knapman

    I’m conflicted on the naming. On the one hand, it’s cute. On the other hand, we’ve now got two vastly different behaviours (co_await vs no_await) that are only distinguished by one letter and the presence/absence of a pair of parenthesis. Something that could be easily overlooked in haste.