2

Here is a code that crash when, in main, line (2) version is used (and line (1) is commented). Weird enough, this code compiles fines with a simple replacement implementation (line (1)) that mimic the behavior of line (2). Of course, if it's an undefined behavior, it can't have a good explanation, but I don't understand why it crashes. Basically, it's a generator implementation coroutine in C++, tested with captures by references. It works always, except when used with unique_ptr (raw pointer works). Just, why ?

#include <iostream>
#include <coroutine>
#include <cassert>
#include <optional>
#include <memory>

using std::cout;
using std::endl;

template<typename T>
class generator
{
public:
    struct promise_type
    {
        std::optional<T> t_;

        promise_type() = default;
        ~promise_type() = default;

        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() {}
        generator get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }

        std::suspend_always yield_value(T t) { t_ = t; return {}; }
        void return_void() {}
    };
private:
    std::coroutine_handle<promise_type> h_;

    generator(std::coroutine_handle<promise_type> h) : h_(h) {}

public:
    generator() = default;

    // ------ Prevent copies
    generator(const generator&) = delete;
    generator& operator=(const generator&) = delete;

    // ------ Allow moves
    generator(generator&& other) noexcept
        : h_(move(other.h_)) // move may be unnecessary, coroutine_handle acts like a lightweight pointer
    {
        other.h_ = {}; // Unlink handle in moved generator
                       // move() does not guarantee to destroy original value
    }

    generator& operator=(generator&& other) noexcept
    {
        h_ = move(other.h_);
        other.h_ = {};
        return *this;
    }

    ~generator()
    {
        if(h_)
        {
            h_.destroy();
            h_ = {};
        }
    }

    bool is_resumable() const
    {
        return h_ && !h_.done();
    }

    bool operator()()
    {
        return resume();
    }

    bool resume()
    {
        assert(is_resumable());

        h_();

        return !h_.done();
    }

    [[nodiscard]] const T& get() const
    {
        return h_.promise().t_.value();
    }

    [[nodiscard]] T& get() // Allow movable
    {
        return h_.promise().t_.value();
    }
};

struct F
{
    /*F(const std::function<generator<int>()>& del)
    {
        handle = del();
    }*/

    template<typename T>
    F(T del)
    {
        handle = del();
    }

    ~F() { cout << "dtor" << endl; }

    generator<int> handle;
};

template<typename T>
struct UniquePtr
{
    UniquePtr(T* t) : t_(t) {}

    UniquePtr(UniquePtr&&) = delete;
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(UniquePtr&&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    ~UniquePtr() { delete t_; }

    T* operator->() const { return t_;}

private:
    T* t_;
};

int main()
{
    int x = 10;
    auto a = [&]() -> generator<int> {
        x = 20;
        co_yield x;
    };

    //UniquePtr<F> ptr(new F(a)); // (1)
    std::unique_ptr<F> ptr(new F(a)); // (2)

    generator<int>& gen = ptr->handle;
    gen();
    cout << gen.get() << "/" << x << endl;

    return 0;
}

EDIT. :

It crashes also a Godbolt (error 139), here is the link : https://godbolt.org/z/cWYY8PKx4. Maybe is it a gcc implementation problem, around std::unique_ptr optimizations? I can't test on other compilers on Godbolt, there is no support for coroutines on clang.

Ext3h
  • 5,713
  • 17
  • 43
rafoo
  • 1,506
  • 10
  • 17
  • How / where does it exactly crash? Did you compile it without optimizations? – Ext3h Jun 21 '21 at 19:25
  • I compiled it in debug mode, with gcc. It crash in `x = 20`. `co_yield x` also yield an invalid value (garbage like `23982739`) but it makes not crashing. – rafoo Jun 21 '21 at 19:42
  • 2
    `using namespace std;` stop it. It makes code harder to understand, and can cause bugs. Every time you use a std named thing, someone reading your code has to audit your code base to figure out if it is actually the std thing. – Yakk - Adam Nevraumont Jun 21 '21 at 20:19
  • @Yakk-AdamNevraumont the using namespace std is not used (only cout and endl). I see it appears to be in header but it's only to make a minimal reproductible example. Plus, it's opinion-biaised, and irrelevant here. – rafoo Jun 21 '21 at 21:10
  • 2
    you can do `using std::cout; using std::endl;` Also see this https://stackoverflow.com/questions/1452721/why-is-using-namespace-std-considered-bad-practice – bolov Jun 21 '21 at 21:22
  • I edited it, I highly doubt it makes more readable, but why not. It makes a discussion around `using namespace` o/. – rafoo Jun 21 '21 at 21:25
  • @bolov you linked a topic unrelated to the problem. – rafoo Jun 22 '21 at 19:11
  • No, it's not related to the problem you are asking about. But it's a good article to read related to the code you presented in the question. And that's ok because it's a comment, not an answer. – bolov Jun 22 '21 at 19:49
  • @bolov I don't agree, because every time a std class is used, the `std::` prefix is used, except `cout` and `endl`. It only shows the code was read until the first 8 lines. – rafoo Jun 22 '21 at 20:56

4 Answers4

1

While still unclear why it happens, it appears that std::suspend_always initial_suspend() { return {}; } when used in conjunction with a lambda and combined with optimizations for scoped unique-ptrs also incorrectly performs the parameter capture in the wrong scope.

Slighly modified variant:

struct F
{
    template<typename T>
    F(T del)
    {
        auto y = 0;
        cout << "&y   = " << &y << endl;
        handle = del();
    }

    ~F() { cout << "dtor" << endl; }

    generator<int> handle;
};

int main()
{
    int x = 10;
    cout << "&x   = " << &x << endl;
    auto a = [&]() -> generator<int> {
        cout << "[&x] = " << &x << endl;
        co_yield x;
    };

    auto ptr = std::make_unique<F>(a);

    generator<int>& gen = ptr->handle;
    gen();

    a()();

    return 0;
}

Output:

&x   = 0x7fff46a718cc
&y   = 0x7fff46a71834
[&x] = 0x7fff46a71840
[&x] = 0x7fff46a718cc
dtor

The 2nd and 3rd output points within the stackframe of the constructor of F(). Which is only expected for &y though, the last one performs correctly.

It also does behave correctly when going with std::suspend_never initial_suspend() { return {}; }, or when casting to an explicit std::function, indicating a bug in the capture mechanic.

Depending on the optimization level, the captured scope is either this of F or the stack frame of the constructor of F().

Ext3h
  • 5,713
  • 17
  • 43
  • 1
    This seems like it has to be related to https://gcc.gnu.org/bugzilla/show_bug.cgi?id=99576 or https://gcc.gnu.org/bugzilla/show_bug.cgi?id=100611 ("In my case it seemed like co_awaiting expressions that involved compiler-generator constructors binding references to pr-values was problematic, or something along these lines.") – Jeff Garrett Jun 22 '21 at 17:13
1

I think I figured out why it crashes. Without the details, I will try to explain it what happens step by step.

Let's focus on this part:

    int x = 10;
    auto a = [&]() -> generator<int> {
        x = 20;
        co_yield x;
    };

We may be used to these lambdas, but sometimes we forget the basics and what happens really, and we may get fooled. Firstly, replace the lambda syntaxic sugar by the explicit final equivalent.

    int x = 10;
    struct A {
        int &x;
        A(int& x) : x(x) {}
        generator<int> operator()() {
            x = 20;
            co_yield x;
        }
    }
    A a(x);

Then, things become more clearer. Now, focus on this part.

    template<typename T>
    F(T del)
    {
        handle = del();
    }

After template parsing, this will effectively do this code:

    F(A del)
    {
        handle = del();
    }

Invoking del(), we create a new coroutine but as explained later, the coroutine refers to variables of the structure A. When we leave the scope of F(), del is destroyed. Now, when we do it:

    A a(x);
    F f(a);
    f.handle();

This is essentially the same as replacing f.handle(); by:

    del.x = 20;

This snippet of code is short, but need explanation since the variable del does not exist here, it is for semantic illustration. Let's explain it: We assign to the member reference x of del the value 20. For the moment, it is just obvious translation. But what is del ? It refers to the variable created in the constructor of F. In C++, this is implicitly used for looking up variables, which we usually abuse when using lambdas like with x = 20; co_yield x; (otherwise, lambdas would be way less useful) and which here is just del. But since we are outside the constructor of f, del does not exists anymore.

Yes, but since the variable is captured by reference we should be fine here, isn't it ?

Actually no, because yes, x is captured by reference, and this reference exists. In C++, references can translate to pointer, or even nothing at all. In this situation, since we store a reference in a class member, the reference is probably compiled into a raw pointer. And it points to a value that actually exists when we need it. So, what's the problem ? The pointed value exists, but the pointer actually does not exist anymore. This is the opposite of usual runtime errors and a confusing one, because the pointee is not in cause. It wasn't actually explicited but the type of del here is, semantically, a reference to the del argument in the constructor of F, which does not exist anymore. Hence, the error appears obvious.

I am not an expert so I don't if it is right, but with the tests and alternatives I use I am pretty sure this is what happen under the hood. I tried related code with MSVC and it also crashs. It is not a compiler bug. The fact it works sometime and not with some std::unique_ptr falls maybe under the undefined behavior case, it is just high-levelly speaking bad luck. In some situations, a good alternative would be heap allocated lambdas, which unfortunately, and probably for good reasons, does not exist.

rafoo
  • 1,506
  • 10
  • 17
0

I'm not following all your code but the first thing that jumps out at me is that you are creating a lambda (a) that uses a local variable by reference. I believe that may mean x isn't used until it is not in scope in certain control paths. It is much cleaner to pass x as an argument rather than pass it by reference.

I may be way off though if I am misunderstanding some syntax around the lambda.

Sean Walker
  • 108
  • 1
  • 9
  • It has definitively something to do with captures, because it works fine with a reference argument instead of a reference capture. – rafoo Jun 21 '21 at 18:41
  • `x` captured as reference is definitely not the problem here. `x` is the scope of `main` so it is "alive" for the entire duration of the program. – bolov Jun 21 '21 at 21:26
  • Yes, but try the program... If the variable is given as an const reference argument there is no runtime error. That's why I find this error weird. Maybe the coroutine support is not stable yet? – rafoo Jun 21 '21 at 21:28
0

The parameter del needs to be const reference in this case. I don't expertise the details of unique_ptr implementation but it may have some special things

template<typename T>
F(const T& del)
{
    handle = del();
}
nhatnq
  • 1,173
  • 7
  • 16
  • It does not tell why, but at least it doesn't crash anymore! I thought it was a restriction of captures with coroutine. Thank you – rafoo Jun 22 '21 at 09:32
  • That may work around the crash, but all this did was avoiding a single copy of the lambda. If doing a single copy less (and doing that outside the coroutine context!) did somehow prevent the crash from happening, that's even more worry-some. At worst, this has uncovered a bug in the handling of temporary copies of lambdas with capture by reference. – Ext3h Jun 22 '21 at 12:24
  • I detailed the reasons why it's right in my answer. In fact, this is absolutly correct, regardless of the upvote count. – rafoo Jul 08 '21 at 18:31