C++ Logo

STD-PROPOSALS

Advanced search

Subject: Re: [std-proposals] async coroutines vs. lambdas
From: Marcin Jaczewski (marcinjaczewski86_at_[hidden])
Date: 2020-05-15 17:19:31


pt., 15 maj 2020 o 16:15 Avi Kivity via Std-Proposals
<std-proposals_at_[hidden]> napisał(a):
>
> 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?
>

Better question is if it even allowed to merge this allocations?
Standard say that compile can elide allocations but this could be
observable by end user.
Destroying corutine context could be delegated to code that do not
know ether `co_invoke` or this lambda and there relation (not mention
possible context from `getFoo`).
Inner corutine could still running when last task that point to
corutine context of `co_invoke` get destroyed.
If they share memory and do not have any way of atomic reference
counting then we have UB, otherwise we have lot of dead memory that is
cleanable unit last small corutine finish its work.

I recently did some test on gcc.gotbolt.com and with new GCC 10 I do
not see if it merge anything.

Do standard give any restrictions of lifetime of each corutine
context? If we had restriction that inner one can't outlive outer one.

Only way I see this done is by defining task like:

```
struct task
{
    const std::coroutine_handle<promise_type> _handle;

    ~task()
    {
        _handle.destroy();
    }
}

```
with this we will root inner context to outer one, but we lose option
to move handle around.

>
> > // 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.
>

I think this is good compromise. You can explicitly opt-in to use this
and same behavior can be used for normal class member functions with
`&&`.

>
> > 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
> >
> >
>
> --
> Std-Proposals mailing list
> Std-Proposals_at_[hidden]
> https://lists.isocpp.org/mailman/listinfo.cgi/std-proposals


STD-PROPOSALS list run by std-proposals-owner@lists.isocpp.org

Standard Proposals Archives on Google Groups