C++ Logo

STD-PROPOSALS

Advanced search

Subject: Re: [std-proposals] async coroutines vs. lambdas
From: Avi Kivity (avi_at_[hidden])
Date: 2020-05-14 07:56:45


On 14/05/2020 15.09, Ville Voutilainen wrote:
> On Thu, 14 May 2020 at 14:59, Avi Kivity <avi_at_[hidden]> wrote:
>> On 5/14/20 2:46 PM, Ville Voutilainen wrote:
>>> On Thu, 14 May 2020 at 14:34, Avi Kivity <avi_at_[hidden]> wrote:
>>>>>> With a lambda coroutine, we have a problem. This is because lambdas are
>>>>>> captured by reference:
>>>>> Says what?
>>>> Says the quote below, from cppreference.
>>> Your wording was confusing; I thought you meant a lambda as a
>>> coroutine parameter.
>>> For cases where we are in a non-static member function, the *this is
>>> indeed not copied.
>>
>> Ah, the lambda _is_ a coroutine parameter in a sense, if you follow the
>> regular translation of a lambda to a struct with operator().
>>
>>
>> And note this is not specific to lambdas, it happens for any member
>> coroutine called on a temporary. A lambda is just the most common way of
>> generating such calls.
> Right. Calling a member function coroutine of a temporary seems like
> madness to me.
> It's quite like calling a member function that queues async work that
> is later completed in
> some other member of the object.

It is super common in async frameworks.

For example the predecessor of coroutines is future::then, which accepts
a callable that is called when the future becomes ready. So if I write

 Â Â  future<int> get_int();

 Â Â  int foo;

 Â Â  extern int bar;

 Â Â  get_int().then([foo] (int val) -> future<> {

 Â Â Â Â Â Â Â  int another = co_await get_int();

 Â Â Â Â Â Â Â  bar = foo + val + another;

 Â Â  });

This breaks with the current standard, because the lambda object can
still be on the stack when operator()(int) is invoked.

>
> I'm not sure I understand where the temporary really is in the
> not-so-lame testcase in the bug report. To me,
> it seems like everything is stored and lifetimes don't end
> prematurely, but I don't understand how initial_suspend
> really works. :)

Copying it here for reference:

> #include <coroutine>
> #include <functional>
> #include <optional>
> #include <cassert>
>
> template <typename T>
> class lazy {
> std::function<T ()> _compute;
> lazy(std::function<T ()> compute) : _compute(std::move(compute)) {}
> public:
> T get() { return _compute(); }
> static lazy make(std::function<T ()> compute) {
> return lazy(std::move(compute));
> }
> };
>
> namespace std {
>
> template <typename T, typename... Args>
> struct coroutine_traits<lazy<T>, Args...> {
> struct promise_type {
> std::optional<T> value;
> suspend_always initial_suspend() const { return {}; }
> suspend_always final_suspend() const { return {}; }
> void return_value(T val) {
> value = std::move(val);
> }
> lazy<T> get_return_object() {
> return lazy<T>::make([this] () -> T {
> auto handle = coroutine_handle<promise_type>::from_promise(*this);
> handle.resume();
> auto ret = std::move(*value);
> handle.destroy();
> return ret;
> });
> }
> void unhandled_exception() {
> std::terminate();
> }
> };
> };
>
> }
>
> struct fake_lambda_state {
> int i = 5;
> };
>
> lazy<int> fake_lambda(fake_lambda_state s) {
> co_return s.i;
> }
>
> lazy<int> get_fake_lambda() {
> // fake_lambda_state() is captured in the promise
> return fake_lambda(fake_lambda_state());
> }
>
> lazy<int> get_real_lambda() {
> // the state (i) is not captured
> return [i = 6] () -> lazy<int> {
> co_return i;
> }();
> }
>
> int main(int ac, char** av) {
> auto l1 = get_fake_lambda();
> assert(l1.get() == 5);
> auto l2 = get_real_lambda();
> assert(l2.get() == 6);
> return 0;
> }

The following happens in the execution of "auto l2 = get_real_lambda()".
Please forgive any excessive detail, I figure it is better to provide
too much than too little:

1. A lambda object is constructed on the stack, with i initialized to 6.

2. The lambda's operator()() is called.

3. A coroutine frame is allocated with space for a pointer to the lambda.

4. The pointer to the lambda is copied to the coroutine frame.

5. initial_suspend() is called, and because it returns suspend_always,
get_return_object() is called.

6. get_return_object returns a lazy<int> initialized with some
std::function, that captures the coroutine handle (really the frame)

7. the coroutine returns the its caller, get_real_lambda()

8. get_real_lambda destroys the lambda (creating a dangling reference)

9. the lazy<int> is assigned to l2.

The next line, l2.get():

10. lazy<int>::get() is called, calling the function we created in step 6

11. The coroutine handle is used to resume the coroutine just after the
point where we called initial_suspend (at the "{")

12. We evaluate "i" for "co_return i". This is really lambda_ptr->i,
where lambda_ptr was captured in step 4.

13. the access to i is to a destroyed stack frame, which is undefined
behavior, so the compiler is at liberty to destroy all life on earth.

Note that fake_lambda conceptually does exactly the same things, but
because it is captured by value, everything works.



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

Standard Proposals Archives on Google Groups