Why std::jthread Throws in Its Destructor After coroutine_handle::resume()
When combining modern C++20 coroutines with std::jthread, you might encounter subtle lifetime and concurrency traps that manifest as mysterious runtime crashes. A common head-scratcher occurs when a std::jthread throws an exception during destruction right after calling coroutine_handle::resume()—specifically failing deep inside the runtime with errors like _Thrd_join(_Thr, nullptr) != _Thrd_result::_Success.
The Short Answer: The Thread Is Trying to Join Itself
The root cause of this failure is a thread self-join. In C++, a thread cannot call join() on itself; doing so triggers a deadlock detection failure and throws std::system_error (with an error code like resource_deadlock_would_occur).
Because h.resume() is called from within the background thread, the remainder of the coroutine—including the evaluation and destruction of temporary objects in the co_await expression—executes on that same background thread. When the std::jthread destructor runs, it attempts to join its own thread, causing MSVC's runtime check inside _Thrd_join to fail.
Tracing the Execution Step-by-Step
Let's break down what actually happens under the hood when this code runs:
- Coroutine suspends:
test()hitsco_await Awaitable{std::jthread{}};. The temporarystd::jthread{}is constructed and bound to the reference member inAwaitable. - New thread created:
await_suspend()launches a newstd::jthreadand assigns it tothread. - Resumption on background thread: The newly spawned thread begins executing its lambda and invokes
h.resume(). - Execution switches contexts:
h.resume()resumestest()right where it left off. The coroutine callsawait_resume()and proceeds. - End of full-expression cleanup: Because the coroutine finishes (or advances past the statement), the temporaries created for the
co_awaitexpression must now be destroyed. Since execution is currently running on the new thread, the destructor runs on that very same thread. - Self-join crash:
std::jthread::~jthread()automatically callsjoin()ifjoinable()is true. But because the thread executing the destructor is the thread represented by thestd::jthreadobject,join()detects a self-join and aborts with an error.
Another Hidden Flaw: Rvalue Reference Member
Beyond the self-join issue, having an rvalue reference member in a struct is an anti-pattern:
struct Awaitable {
// ...
std::jthread&& thread; // Lifetime hazard!
};In C++, temporary objects bound to reference members in aggregates do not have their lifetimes extended beyond the immediate expression. The temporary std::jthread{} passed to Awaitable{std::jthread{}} will expire at the end of the full-expression, leaving you with a dangling reference if accessed outside that window.
How to Fix the Issue
1. Detach the Thread if It Runs Independently
If you intentionally want the worker thread to manage its own lifecycle and not be joined, use std::thread with .detach() instead of std::jthread (which is designed specifically to enforce joining on destruction):
struct Awaitable {
auto await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
std::thread([h] {
h.resume();
}).detach();
}
void await_resume() noexcept {}
};2. Keep the Thread Ownership Outside the Coroutine
If the background work must be joined before program exit, transfer ownership of the std::jthread somewhere that outlives the coroutine invocation, ensuring the caller or an executor thread performs the join:
#include <coroutine>
#include <thread>
#include <iostream>
struct Resumer {
std::jthread worker;
auto operator co_await() {
struct Awaiter {
Resumer& parent;
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) noexcept {
parent.worker = std::jthread([h] {
h.resume();
});
}
void await_resume() noexcept {}
};
return Awaiter{*this};
}
};In this pattern, parent.worker is destroyed from whichever thread owns the Resumer instance (for example, main()), not from inside the resumed coroutine itself.
Key Takeaway
In C++20 coroutines, calling coroutine_handle::resume() immediately transfers the coroutine's control flow to the calling thread. Never allow a thread to destroy synchronization primitives or std::jthread instances representing itself during the cleanup of resumed statements.