C++ Logo

std-proposals

Advanced search

Re: async coroutines vs. lambdas

From: Lewis Baker <lbaker_at_[hidden]>
Date: Thu, 14 May 2020 22:27:58 +0000
The lifetime issues of temporary coroutine lambdas is indeed a big footgun.
It is something that our developers run into regularly.

We've ended up developing a handful of helpers to allow us to safely invoke
coroutine lambdas in contexts where we're there are lifetime concerns. These
helpers end up wrapping the call to the lambda in another coroutine that takes
a copy of the lambda and all arguments and ensures they are kept alive until
the

e.g. The folly::coro::co_invoke() helper:

  R resource;
  std::vector<folly::coro::Task<int>> tasks;
  for (int i = 0; i < 10; ++i) {
    tasks.push_back(
      folly::coro::co_invoke([&, i]() -> folly::coro::Task<int> {
        auto foo = co_await resource.getFoo(i);
        co_return processFoo(foo);
      }));
  }

  // Later wait for all tasks
  co_await folly::coro::collectAll(std::move(tasks));


This issue of coroutines and lambdas was raised before [1] and it's something that
Gor and I have discussed on several occasions.

I have had use-cases where I've made use of stateful, mutable coroutine lambdas before
and so capturing the lambda object by reference in the coroutine seemed to make sense.
It also seemed consistent with the (perhaps misguided?) world-view that a lambda is a
syntactic sugar for a class with an operator() where captures are a data-members.

However, if you have the world-view that the captures of a lambda are like hidden
parameters to the lambda function then this behavior might be surprising.

Making calling an rvalue lambda capture-by-value?
=================================================

The idea had been floated that we could distinguish between lambda::operator() & and
lambda::operator() && and have the latter move the captures into the coroutine frame
rather than capturing a reference to the lambda.


However, this approach also has a few potential issues:

It would mean that for coroutine lambdas that we have to instantiate the coroutine body
twice, once for lvalue overload and once for rvalue overload. We would need a different
instantiation because the coroutine frame used for each would have a different layout;
the lvalue version would capture a single reference to the lambda in the coroutine-frame
while the rvalue version would effectively capture a copy of the lambda in the
coroutine-frame.

This extra instantiation would be additional compile-time cost which may not be
used in many cases.

We would also need to decide what the behavior should be for the case where we have
captured move-only types in a non-mutable lambda.

e.g.
  [p=std::make_unique<T>()]() -> task<void> {
    co_await p->async_foo();
  }

Would the rvalue overload of this lambda be 'const &&'-qualified (it's not declared mutable)?

If so, and we still wanted rvalue-coroutine-lambda-call-operators to move/copy their contents
then this rvalue overload would fail to instantiate as std::unique_ptr<T> is not constructible
from a 'const std::unique_ptr<T>&&'.

Now, we wouldn't want this to be a hard-error (we might only invoke an lvalue of this lambda)
and so we'd probably need to declare the rvalue-overload as deleted in this case so that
attempting to call the rvalue lambda overload is ill-formed while leaving the lambd expression
itself as well-formed.


Another issue is the subtle differences in behavior that would be introduced between
calling an lvalue lambda vs calling an rvalue lambda, which would have significantly
different capture semantics.


Other Member Functions
======================

The issue of capturing the lambda's 'this' by-reference is not unique to lambdas.
It can also occur when calling named member functions on an rvalue object.
In both cases the implicit object parameter is captured by reference rather
than by value.

  struct X {
    int data;

    task<void> foo(int arg) {
      co_await f(data, arg);
    }
  };

  // Boom! t just captured a dangling reference to temporary X.
  task<void> t = X{123}.foo(456);


Deducing This to the rescue?
============================

One potentially promising direction for addressing this is the "deducing this" proposal,
P0874 [2], which would allow us to define the member function or lambda to take the
implicit object parameter by-value instead of by-reference.

e.g. for lambdas:

  // No dangling!
  auto t = [capture](this auto, int arg) -> task<void> {
    co_await f(capture, arg);
  }(42);

or for member functions:

  struct X {
    int data;

    task<void> foo(this X self, int arg) {
      co_await f(self.data, arg);
    }
  };

  // No dangling!
  auto t = X{123}.foo(456);


One limitation with P0874, however, is that it doesn't allow defining overload-sets
where calling a method on an rvalue has capture-by-value semantics, whereas calling
on an lvalue has capture-by-reference semantics.
Perhaps this is an extension that could be made to that proposal?

Another limitation is that it doesn't prevent you from shooting yourself in the
foot. But it does at least let you express the desired capture semantics.


Cheers,
Lewis.


[1] - https://github.com/GorNishanov/coroutines-ts/issues/32
[2] - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p0847r4.html


Received on 2020-05-14 17:31:03