Understanding Temporary Lifetimes in C++20 Coroutines

When writing custom coroutine types in C++20, managing the lifetime of your task objects and their associated coroutine frames is one of the trickiest parts. A common pattern is awaiting a temporary task directly:

co_await child();

This raises a crucial question: Does the temporary task returned by child() survive until await_resume() completes? Or does it get destroyed when the parent coroutine suspends, leading to undefined behavior when the child finishes and resumes the parent?

The Short Answer: Yes, It Survives!

According to the C++ standard, the temporary task object is guaranteed to remain alive until the entire co_await full-expression completes. This means its destructor is called after await_resume() has completed and control has returned to the parent coroutine, just before moving to the next statement.

How the C++ Standard Guarantees This

In C++, the lifetime of temporary objects is bound to the full-expression in which they are created. A co_await expression is evaluated as part of a full-expression.

Even though the coroutine suspends and control is yielded back to the caller or another thread, the evaluation of the full-expression containing co_await is suspended along with the coroutine. When the coroutine resumes, it continues evaluating the same full-expression, culminating in the call to await_resume(). Only after the entire full-expression has finished evaluating are the temporaries destroyed.

Therefore, the sequence of events is guaranteed to be:

  • 1. child() is called, returning a temporary task.
  • 2. operator co_await() is called on the temporary, returning an awaiter.
  • 3. await_ready() and await_suspend() are executed, and the coroutine suspends.
  • 4. The child coroutine executes and eventually reaches its final suspension point.
  • 5. The parent coroutine resumes, and await_resume() is executed.
  • 6. The co_await child(); full-expression finishes.
  • 7. The temporary task and the awaiter are destroyed (in reverse order of construction).

Is the Temporary Task Destructor Sequenced Correctly?

Yes. Because the temporary task is destroyed after await_resume(), it is perfectly safe for the task's destructor to destroy the coroutine frame (via handle_.destroy()). At this point, the child coroutine has already completed and is suspended at its final suspension point, making destruction completely valid.

Best Practice: Designing the Ownership Model

While keeping ownership in the task works because of the lifetime guarantees, many production-grade coroutine libraries (like cppcoro or folly::coro) prefer to transfer ownership of the coroutine handle from the task to the awaiter. Here is why:

  • Explicit Lifetime: It makes the awaiter the sole owner of the coroutine frame during the suspension period.
  • Safety: If the task is moved or if the awaiter's lifetime is extended separately, ownership is clear and double-destruction is prevented.

Implementing Ownership Transfer

To implement this model, modify your operator co_await() && to release the handle from the task, and let the awaiter's destructor clean it up:

class task {
public:
    // ...

    struct awaiter {
        handle_type handle;

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

        bool await_ready() const noexcept {
            return !handle || handle.done();
        }

        std::coroutine_handle<> await_suspend(std::coroutine_handle<> continuation) noexcept {
            handle.promise().continuation = continuation;
            return handle;
        }

        void await_resume() const noexcept {}
    };

    awaiter operator co_await() && noexcept {
        // Transfer ownership of the handle to the awaiter
        return awaiter{std::exchange(handle_, {})};
    }

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

Summary of Key Takeaways

  • Lifetime Guarantee: Temporaries in a co_await expression survive until after await_resume() has executed.
  • Destruction Order: The temporary task is destroyed after the coroutine resumes, making it safe to clean up the child frame in the task's destructor.
  • Recommended Pattern: Transferring ownership of the coroutine handle from the task to the awaiter via std::exchange is highly recommended for robust, modern C++ coroutine designs.