Date: Fri, 15 May 2020 17:14:18 +0300
On 5/15/20 1:27 AM, Lewis Baker via Std-Proposals wrote:
> 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);
> }));
> }
Does this not cause excess allocations for the wrapper coroutine?
> // 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.
I hope we can keep this worldview because it makes moving to a class
when a lambda is no longer appropriate easy.
>
> 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.
Suppose we require that the user declare the fact that the lambda is
meant to be consumed as an rvalue explicitly?
e.g.
[...] (...) && -> whatever<T> { ... }
This lambda is implicitly mutable.
> 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.
>
Yes. This supports explicitly requiring the && annotation. It also works
with functor classes.
> 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);
This has merits on its own, but for lambdas you usually have just a
single use and so they don't merit defining all variants of
this-capture. It also makes accessing the captured variables more verbose.
>
> 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
>
>
> 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);
> }));
> }
Does this not cause excess allocations for the wrapper coroutine?
> // 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.
I hope we can keep this worldview because it makes moving to a class
when a lambda is no longer appropriate easy.
>
> 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.
Suppose we require that the user declare the fact that the lambda is
meant to be consumed as an rvalue explicitly?
e.g.
[...] (...) && -> whatever<T> { ... }
This lambda is implicitly mutable.
> 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.
>
Yes. This supports explicitly requiring the && annotation. It also works
with functor classes.
> 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);
This has merits on its own, but for lambdas you usually have just a
single use and so they don't merit defining all variants of
this-capture. It also makes accessing the captured variables more verbose.
>
> 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-15 09:17:26