C++20 Coroutines: Does a Temporary Task Survive Until await_resume?
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 temporarytask. - 2.
operator co_await()is called on the temporary, returning anawaiter. - 3.
await_ready()andawait_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
taskand theawaiterare 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
awaiterthe sole owner of the coroutine frame during the suspension period. - Safety: If the
taskis moved or if theawaiter'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_awaitexpression survive until afterawait_resume()has executed. - Destruction Order: The temporary
taskis destroyed after the coroutine resumes, making it safe to clean up the child frame in thetask's destructor. - Recommended Pattern: Transferring ownership of the coroutine handle from the
taskto theawaiterviastd::exchangeis highly recommended for robust, modern C++ coroutine designs.