c++c++20follyc++-coroutine

Lambda lifetime explanation for C++20 coroutines


Folly has a useable library for C++20 style coroutines.

In the Readme it claims:

IMPORTANT: You need to be very careful about the lifetimes of temporary lambda objects. Invoking a lambda coroutine returns a folly::coro::Task that captures a reference to the lambda and so if the returned Task is not immediately co_awaited then the task will be left with a dangling reference when the temporary lambda goes out of scope.

I tried to make a MCVE for the example they provided, and was confused about the results. Assume the following boilerplate for all the following examples:

#include <folly/experimental/coro/Task.h>
#include <folly/experimental/coro/BlockingWait.h>
#include <folly/futures/Future.h>
using namespace folly;
using namespace folly::coro;

int main() {
    fmt::print("Result: {}\n", blockingWait(foo()));
}

I compiled the following with address sanitizer to see if there would be any dangling references.

EDIT: clarified question

Question: Why does the second example not trigger an ASAN warning?

According to cppreference:

When a coroutine reaches the co_return statement, it performs the following:

...

  • or calls promise.return_value(expr) for co_return expr where expr has non-void type
  • destroys all variables with automatic storage duration in reverse order they were created.
  • calls promise.final_suspend() and co_await's the result.

Thus perhaps the temporary lambda's state is not actually destroyed until the result is returned, because foo itself is a coroutine?


ASAN ERROR: I assume 'i' doesn't exist when the coroutine is waited on

auto foo() -> Task<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }(); // lambda is destroyed after this semicolon
    return task;
}

NO ERROR -- why?

auto foo() -> Task<int> {
  auto task = [i=1]() -> folly::coro::Task<int> {
      co_return i;
  }();
  co_return co_await std::move(task);
}

ASAN ERROR: Same problem as first example?

auto foo() -> folly::SemiFuture<int> {
    auto task = [i=1]() -> folly::coro::Task<int> {
        co_return i;
    }();
    return std::move(task).semi();
}

NO ERROR ...and for good measure, just returning a constant (no lambda state captured) works fine. Compare to first example:

auto foo() -> Task<int> {
    auto task = []() -> folly::coro::Task<int> {
        co_return 1;
    }();
    return task;
}

Solution

  • This problem is not unique or specific to lambdas; it could affect any callable object that simultaneously stores internal state and happens to be a coroutine. But this problem is easiest to encounter when making a lambda, so we'll look at it from that perspective.

    First, some terminology.

    In C++, a "lambda" is an object, not a function. A lambda object has an overload for the function call operator operator(), which invokes the code written into the lambda body. That is all a lambda is, so when I subsequently refer to "lambda", I am talking about a C++ object and not a function.

    In C++, being a "coroutine" is a property of a function, not an object. A coroutine is a function that appears identical to a normal function from the outside, but which is implemented internally in such a way that its execution can be suspended. When a coroutine is suspended, execution returns to the function that directly invoked/resumed the coroutine.

    The execution of the coroutine can later be resumed (the mechanism for doing so is not something I'm going to discuss much here). When a coroutine is suspended, all of the stack variables within that coroutine function up to the point of the coroutine's suspension are preserved. This fact is what allows resumption of the coroutine to work; it's what makes coroutine code seem like normal C++ even though execution can happen in a very disjoint fashion.

    A coroutine is not an object, and a lambda is not a function. So, when I use the seemingly contradictory term "coroutine lambda", what I really mean is an object whose operator() overload happens to be a coroutine.

    Are we clear? OK.

    Important Fact #1:

    When the compiler evaluates a lambda expression, it creates a prvalue of the lambda type. This prvalue will (eventually) initialize an object, usually as a temporary within the scope of the function that evaluated the lambda expression in question. But it could be a stack variable. Which it is doesn't really matter; what matters is that, when you evaluate a lambda expression, there is an object which is in every way like a regular C++ object of any user-defined type. That means it has a lifetime.

    Values "captured" by the lambda expression are essentially member variables of the lambda object. They could be references or values; it doesn't really matter. When you use a capture name in the lambda body, you are really accessing the named member variable of the lambda object. And the rules about member variables in a lambda object are no different from the rules about member variables in any user-defined object.

    Important Fact #2:

    A coroutine is a function which can be suspended in such a way that its "stack values" can be preserved, so that it can resume its execution later. For our purposes, "stack values" include all function parameters, any temporary objects generated up to the point of suspension, and any function local variables declared in the function up to that point.

    And that is all that gets preserved.

    A member function can be a coroutine, but the coroutine suspension mechanism does not care about member variables. Suspension only applies to the execution of that function, not to the object around that function.

    Important Fact #3:

    The main point of having coroutines at all is to be able to suspend a function's execution and have that function's execution resumed by some other code. This likely will be in some disparate part of the program, and usually in a thread distinct from the place where the coroutine was initially invoked. That is, if you create a coroutine, you expect that the caller of that coroutine will continue its execution in parallel with your coroutine function's execution. If the caller does wait for your execution to complete, the caller does so at its choosing, not yours.

    That's why you made it a coroutine to begin with.

    The point of the folly::coro::Task object is to essentially keep track of the coroutine's post-suspension execution, as well as marshall any return value(s) generated by it. It also may permit one to schedule the resumption of some other code after the execution of the coroutine it represents. So a Task could represent a long series of coroutine executions, with each feeding data to the next.

    The important fact here is that the coroutine starts in one place like a normal function, but it can end at some other point in time outside of the callstack that invoked it initially.

    So, let's put those these facts together.

    If you're a function that creates a lambda, then you (at least for some period of time) have a prvalue of that lambda, right? You will either store it yourself (as a temporary or stack variable) or you will pass it to someone else. Either yourself or that someone else will at some point invoke the operator() of that lambda. At that point, the lambda object must be a live, functional object, or you've got a much bigger problem on your hands.

    So the immediate caller of a lambda has an lambda object, and the lambda's function starts executing. If it is a coroutine lambda, then this coroutine will likely at some point suspend its execution. This transfers program control back to the immediate caller, the code which holds the lambda object.

    And that's where we encounter the consequences of IF#3. See, the lambda object's lifetime is controlled by the code which initially invoked the lambda. But the execution of the coroutine within that lambda is controlled by some arbitrary, external code. The system which governs this execution is the Task object returned to the immediate caller by the initial execution of the coroutine lambda.

    So there's the Task which represents the coroutine function's execution. But there's also the lambda object. These are both objects, but they are separate objects, with distinct lifetimes.

    IF#1 tells us that lambda captures are member variables, and the rules of C++ tell us that the lifetime of a member is governed by the lifetime of the object it is a member of. IF#2 tells us that these member variables are not preserved by the coroutine suspension mechanism. And IF#3 tells us that the coroutine execution is governed by the Task, whose execution can be (very) unrelated to the initial code.

    If you put this all together, what we find is that, if you have a coroutine lambda which captures variables, then the lambda object which was invoked must continue to exist until the Task (or whatever governs continued coroutine execution) has completed the coroutine lambda's execution. If it doesn't, then the coroutine lambda's execution may attempt to access member variables of an object whose lifetime has ended.

    How exactly you do that is up to you.


    Now, let's look at your examples.

    Example 1 fails for obvious reasons. The code invoking the coroutine creates a temporary object representing the lambda. But that temporary goes out of scoped immediately. No effort is made to ensure that the lambda remains in existence while the Task is executing. This means that it is possible for the coroutine to be resumed after the lambda object it lives within has been destroyed.

    That's bad.

    Example 2 is actually just as bad. The lambda temporary is destroyed immediately after the creation of tasks, so merely co_awaiting on it shouldn't matter. However, ASAN may simply not have caught it because it now happens inside of a coroutine. If your code had instead been:

    Task<int> foo() {
      auto func = [i=1]() -> folly::coro::Task<int> {
          co_return i;
      };
    
      auto task = func();
    
      co_return co_await std::move(task);
    }
    

    Then the code would be fine. The reason being that co_awaiting on a Task causes the current coroutine to suspend its execution until the last thing in the Task is done, and that "last thing" is func. And since stack objects are preserved by coroutine suspension, func will continue to exist so long as this coroutine does.

    Example 3 is bad for the same reasons as Example 1. It doesn't matter how you use the return value of the coroutine function; if you destroy the lambda before the coroutine finishes execution, your code is broken.

    Example 4 is technically just as bad as all the rest. However, because the lambda is captureless, it never needs to access any members of the lambda object. It never actually accesses any object whose lifetime has ended, so ASAN never notices that the object around the coroutine is dead. It's UB, but it's UB that's unlikely to hurt you. If you had explicitly extracted a function pointer from the lambda, even that UB wouldn't happen:

    Task<int> foo() {
        auto func = +[]() -> folly::coro::Task<int> { //The + extracts a function pointer from a captureless lambda for complex, convoluted reasons.
            co_return 1;
        };
        auto task = func();
        return task;
    }