· 6 min read ·

C++ Coroutines, Dangling References, and the Event-Driven Model That Reframes Both

Source: isocpp

The two most persistent criticisms of C++20 coroutines both stem from the same underlying tension: coroutines look like functions but behave fundamentally differently. Andrzej Krzemieński’s December 2025 post “Event-driven flows” takes these criticisms head-on and argues that modeling coroutines as event-driven flows, rather than as async functions, resolves or at least reframes both of them. Looking back at it now, the argument holds up and deserves more examination than the original post length allowed.

The Two Criticisms

The first is about dangling references. When you write:

Task<void> process(const std::string& name) {
    co_await some_io();
    fmt::print("{}\n", name); // Is name still valid?
}

you have a problem. The caller might pass a temporary, or a local variable that goes out of scope before the coroutine resumes. In a regular function, this cannot happen: the function completes before the caller’s stack frame changes. In a coroutine, control returns to the caller at every suspension point, and the coroutine can outlive anything the caller owns.

The second criticism is about syntactic opacity. You cannot tell from a function declaration whether it is a coroutine:

Task<int> compute(int x);  // Coroutine? Regular function returning Task<int>?

You have to look at the body. If it contains co_await, co_yield, or co_return, it is a coroutine. Otherwise it is a regular function returning a coroutine-type object. Compare this with Rust and Python, where the async keyword in the declaration makes the intent explicit:

async fn compute(x: i32) -> i32 { ... }
async def compute(x: int) -> int: ...

In C++, coroutine-ness is an implementation detail rather than a signature detail. This was a deliberate committee choice, discussed at length during standardization, and it remains a source of friction for teams adopting coroutines.

The Event-Driven Reframe

Krzemieński’s response to both criticisms involves changing the mental model. Instead of comparing coroutines to regular functions, compare them to event-driven callbacks. The post uses signal handling as its illustration:

void on_interrupt(int signal) {
    cleanup();
    exit(0);
}

signal(SIGINT, on_interrupt);

on_interrupt is not a function you call directly. It is a handler registered to fire when an event occurs. Its lifetime is decoupled from whoever registered it. Nobody expects signal handler arguments to be valid across multiple invocations; the handler’s context comes from global state or from closures, not from parameters passed at call time.

A coroutine waiting on an event channel fits this model better than it fits the regular-function model. It is a suspended computation that will resume when something happens. The “caller” does not own it; the executor or event loop does. When you think of a coroutine as a registered handler rather than a called function, the lifetime issues change in character.

Dangling References Reconsidered

The dangling reference concern is real. The C++ Core Guidelines (CP.52) warn against holding references or pointers across suspension points. The Clang-Tidy checker cppcoreguidelines-avoid-reference-coroutine-parameters exists because this is a genuine footgun.

But the criticism assumes you would use reference parameters in a coroutine the same way you use them in a regular function. In an event-driven model, you would not: event handlers own their data or access it through stable handles. The idiomatic fix for coroutines follows the same principle, either copy the argument or use a handle to stable storage.

// Problematic: reference may dangle after suspension
Task<void> process(const std::string& name) {
    co_await some_io();
    fmt::print("{}\n", name);
}

// Safe: coroutine frame captures name by value
Task<void> process(std::string name) {
    co_await some_io();
    fmt::print("{}\n", name);
}

The coroutine frame captures by-value parameters for the coroutine’s lifetime, just as a lambda captures by value. The gap is that C++ does not warn you when you pass by reference to a coroutine. Clang and GCC have been adding warnings for this case, though they are not on by default everywhere. The issue is a missing diagnostic, and fixing it at the language level would require either mandatory warnings or the async keyword the committee chose not to add.

This parallels a known issue in C++ lambda captures. You can write [&] in a lambda and capture a dangling reference; the language permits it and tools warn about it inconsistently. Coroutines are worse because the suspension point is invisible at the call site, but the category of problem is the same: reference semantics in a context where object lifetimes are not scope-bounded.

The Distinguishability Problem Reconsidered

The argument that coroutines should be distinguishable from the declaration is, at bottom, asking for C++ to add async to the language. There has been WG21 discussion of this; it is not a fringe request. Krzemieński’s position is that the return type already carries the information that matters to callers.

If you see Task<int>, Generator<int>, or std::future<T>, you know you are dealing with deferred or lazy computation. Whether the callee achieves that via co_return, co_yield, or just constructing the return type directly is an implementation detail the caller does not need to know.

This mirrors how Rust actually works beneath the syntax sugar. An async fn is syntactic sugar for a function returning impl Future<Output = T>. You can write the desugared form explicitly:

fn compute(x: i32) -> impl Future<Output = i32> {
    async move { x * 2 }
}

The async keyword on the declaration is a convenience, not essential information. C++ skips the keyword and works directly with the type, consistent with how C++ generally encodes semantic information in types rather than in declaration qualifiers. Whether that trade-off was correct is a reasonable debate, but it is a trade-off with a rationale, not an oversight.

What Coroutine-Based Event Flows Look Like in Practice

The practical space for coroutine-based event handling in C++ is underused but well-developed. Libraries like Lewis Baker’s cppcoro, Asio’s coroutine support, and libunifex show what event-driven C++ looks like when coroutines are used as flows rather than as async wrappers around blocking calls.

The SIGINT pattern from the article becomes:

Task<void> run(IoContext& ctx) {
    co_await ctx.wait_for_signal(SIGINT);
    co_await shutdown(ctx);
}

This is not a function that computes and returns. It is a flow that suspends until an event occurs, expressed as sequential code with explicit suspension points. Reasoning about lifetimes here means reasoning about when events fire and who owns the executor, not about stack frames and return addresses. The ownership conventions that make this safe are the same ones that make any event-driven system safe: the executor owns the coroutine, and the coroutine owns its captured state.

The deeper implication is that coroutines change what you should pass as parameters. Data with unbounded lifetime (shared ownership, handles to stable storage, borrowed slices from a longer-lived owner) is appropriate. Local temporaries are not, for exactly the same reason you would not store a pointer to a local variable in a callback and expect it to be valid later.

Where This Leaves the Criticisms

Both criticisms of C++20 coroutines point at real rough edges. What the event-driven framing adds is a way to understand why those rough edges exist: they map to problems that event-driven programming has always had, with solutions that already exist in ownership conventions and type design. Coroutines did not create the dangling-reference risk or the syntactic ambiguity; they surfaced them in a new context where C++ programmers were not expecting them.

The missing piece is tooling. Compilers should warn by default on reference parameters to coroutines, and the ecosystem documentation should make the event-driven mental model more prominent. The language design can be questioned on the async-keyword question, but the lifetime problem is solvable today with existing tools and conventions, if developers know to look for it.

Was this interesting?