C++ Logo

std-proposals

Advanced search

Re: async coroutines vs. lambdas

From: Avi Kivity <avi_at_[hidden]>
Date: Thu, 14 May 2020 16:42:58 +0300
On 14/05/2020 16.30, Marcin Jaczewski via Std-Proposals wrote:
> czw., 14 maj 2020 o 14:58 Avi Kivity via Std-Proposals
> <std-proposals_at_[hidden]> napisaƂ(a):
>>
>> 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 mailing list
>> Std-Proposals_at_[hidden]
>> https://lists.isocpp.org/mailman/listinfo.cgi/std-proposals
> And code like:
>
> ```
> lazy<int> get_custom_lambda() {
> struct X
> {
> int i = 3;
> lazy<int> operator()(){ co_return i; }
> } x();
> return x();
> }
>
> ```
>
> This still crash, and this is same code as `get_real_lambda`.
> How do you like fix it?


If the coroutine is a member function, then

If the member function was called on an rvalue object, the object is
captured in the coroutine frame by value (and is not a reference in
coroutine_trait's type parameters)

If the member function was called on an lvalue object, the object is
captured by reference (as the current standard requires).


> and what if lambda is not movable?


If the lambda cannot be moved or copied to the coroutine frame, then the
program is ill formed (same as passing a non-copyable, non-movable
argument).

Received on 2020-05-14 08:46:05