2

Background: This question arose when I was reading the source of cppcoro, specifically this line.

Question: Consider the following code:

#include <coroutine>
#include <stdexcept>
#include <cassert>
#include <iostream>

struct result_type {
    result_type() {
        std::cout << "result_type()\n";
    }
    result_type(result_type&&) noexcept {
        std::cout << "result_type(result_type&&)\n";
    }
    ~result_type() {
        std::cout << "~result_type\n";
    }
};

struct task;

struct my_promise {
    using reference = result_type&&;
    result_type* result;

    std::suspend_always initial_suspend() { return {}; }

    std::suspend_always final_suspend() noexcept {
        return {};
    }

    task get_return_object();

    void return_value(reference result_ref) {
        result = &result_ref;
    }

    auto yield_value(reference result_ref) {
        result = &result_ref;
        return final_suspend();
    }

    void unhandled_exception() {}
};

struct task {
    std::coroutine_handle<my_promise> handle{};

    ~task() {
        if (handle) {
            handle.destroy();
        }
    }

    void run() {
        handle.resume();
    }

    my_promise::reference result() {
        return std::move(*handle.promise().result);
    }
};

task my_promise::get_return_object() {
    return { std::coroutine_handle<my_promise>::from_promise(*this) };
}

namespace std {
    template <>
    struct coroutine_traits<task> {
        using promise_type = my_promise;
    };
}

task f1() {
    co_return result_type{};
}

task f2() {
    co_yield result_type{};
    
    // silence "no return_void" warning. This should never hit.
    assert(false);
    co_return result_type{};
}

int main() {
    {
        std::cout << "with co_return:\n";
        auto t1 = f1();
        t1.run();
        auto result = t1.result();
    }

    std::cout << "\n==================\n\n";

    {
        std::cout << "with co_yield:\n";
        auto t2 = f2();
        t2.run();
        auto result = t2.result();
    }
}

In the code above:

  1. Calling f1() and f2() both start a coroutine. The coroutine is immediately suspended and a task object containing the handle of the coroutine is returned to the caller. The promise of the coroutine contains result - a pointer to result_type. The intention is to make it point to the result of the coroutine when it finishes.

  2. task.run() is called on the returned task, which resumes the stored coroutine handle.

  3. Here is where f1 and f2 diverges:

    • f1 uses co_return result_type{} to invoke return_value on the promise. Note that return_value accepts an r-value reference of result_type, therefore bounding it to a temporary.
    • f2 uses co_yield result_type{} to invoke yield_value on the promise. Same as return_value, it accepts an r-value
      • In addition, yield_value returns final_suspend(), which in turn returns std::suspend_always, instructing the coroutine to suspend after yielding the value.
    • In both return_value and yield_value, result is set to point to the argument they received.

However, since final_suspend is also invoked (and its result awaited on) after a co_return, I expect no difference between using a co_return and a co_yield. However, the compiler proved me wrong:

with co_return:
result_type()
~result_type
result_type(result_type&&)
~result_type

==================

with co_yield:
result_type()
result_type(result_type&&)
~result_type
~result_type

Note that in the above output, the co_return version constructs a result, destroy it, and then move construct from it, invoking undefined behavior. However, the co_yield version seems to work fine, destroying the result only after move constructing from it.

Why is the behavior different here?

ph3rin
  • 4,426
  • 1
  • 18
  • 42
  • "*In addition, `f2` returns `final_suspend()`*" ... where? There is only one thing that calls `final_suspend`: when the coroutine actually exits and executes the "final-suspend-point". – Nicol Bolas Apr 20 '21 at 04:31
  • @NicolBolas My bad, I meant to say `yield_value` calls `final_suspend`. This is what actually happened in the library I referred to in the background, thought I'd preserve that. – ph3rin Apr 20 '21 at 04:51

1 Answers1

4

You are breaking a cardinal rule of C++: you wrote a function that takes a potential prvalue and store a pointer that outlives the function call that gave them a prvalue. Indeed, any time you see a function that takes an rvalue-reference (or a const-lvalue-reference) and stores a pointer/reference to that object which will outlive that function, you should consider that code to be highly dubious at best.

If a function takes a parameter as an rvalue reference, that means you're expected to either use it or move from it within that function call. Prvalues are not normally expected to outlive the function call they are passed to, so you either use them or lose them.

In any case, the behavior you're seeing is exactly what you're supposed to see. When a coroutine issues a co_return, it... returns. That means the main body of the coroutine block has exited. return_value is called while the block still exists, but once that is done, the coroutine block and all of its automatic variables (including parameters) go away.

This is why returning references to automatic variables from a normal function is a bad idea. This is just as bad an idea for co_return, even if you're indirectly shepherding that reference to the caller.

The co_yield version works (you still shouldn't do it, because that's not how you're supposed to treat prvalues, but it is required to work) because the co_yield statement itself is told to suspend by the return value of yield_value. This preserves the coroutine's stack, including any prvalues that were within the co_yield statement itself until the coroutine is resumed.

But again, you should do the move in the yield_value function, just as you would for rvalue reference parameters normally.

Nicol Bolas
  • 449,505
  • 63
  • 781
  • 982
  • I see, so `co_yield expr` is equivalent to `co_await promise.yield_value(expr)` and the temporary bound to the reference parameter in `yield_value` will live until the full-expression (including `co_await`) is done, but the execution of the expression is suspended in the middle because `yield_value` instructed it to do so? – ph3rin Apr 20 '21 at 04:57
  • "*But again, you should do the move in the `yield_value` function.*" - Now after reading your answer I think that the reason why cppcoro is doing this is that they want to leverage the object already preserved in the coroutine's state instead of making a new copy/move in the promise. – ph3rin Apr 20 '21 at 05:00