-
Notifications
You must be signed in to change notification settings - Fork 17
Open
Labels
Description
= Lambda Coroutine Captures: A Critical Pitfall
This guide explains a subtle but dangerous issue with C++20 lambda coroutines that every user of async libraries must understand.
== The Problem
Consider this innocent-looking code:
[source,cpp]
----
void process(socket& sock)
{
auto task = [&sock]() -> capy::task<>
{
char buf[1024];
auto [ec, n] = co_await sock.read_some(buffer(buf, sizeof(buf)));
// ... use data ...
}();
run_async(executor)(std::move(task));
}
----
**This code has undefined behavior.** It may crash, corrupt memory, or appear to work until it doesn't.
== Why It Fails
In C++20, **lambda coroutine captures are NOT stored in the coroutine frame**. They are stored in the lambda closure object. Here's what happens:
1. The lambda closure is created, capturing `sock` by reference
2. The lambda's `operator()()` is called
3. A coroutine frame is allocated on the heap
4. The coroutine suspends at `initial_suspend`
5. `operator()()` returns the task
6. **The lambda closure is destroyed** (it was a temporary!)
7. Later, the coroutine resumes
8. The coroutine tries to access `sock` through the destroyed closure
9. **Undefined behavior** — typically a crash
The coroutine frame does not contain a copy of the captured `sock`. It contains a reference to the lambda's capture storage, which no longer exists.
== The Safe Pattern: IIFE With Parameters
The solution is to pass values as **function parameters** instead of **lambda captures**. Function parameters ARE copied to the coroutine frame.
[source,cpp]
----
void process(socket& sock)
{
auto task = [](socket* s) -> capy::task<> // <1>
{
char buf[1024];
auto [ec, n] = co_await s->read_some(buffer(buf, sizeof(buf)));
// ... use data ...
}(&sock); // <2>
run_async(executor)(std::move(task));
}
----
<1> The lambda takes a parameter instead of capturing
<2> The value is passed as an argument to the immediately-invoked lambda
This is called an **Immediately Invoked Lambda Expression (IIFE)**. The parameter `s` is copied to the coroutine frame before the first suspension, so it remains valid for the coroutine's lifetime.
== Complete Example
Here's a before/after comparison:
=== BROKEN: Using Captures
[source,cpp]
----
class connection_handler
{
socket sock_;
std::string name_;
public:
capy::task<> run()
{
// BROKEN: 'this' captured in lambda, lambda destroyed after invoke
return [this]() -> capy::task<>
{
log("Connection from", name_); // UB: 'this' is dangling
co_await handle_request();
}();
}
};
----
=== CORRECT: Using Parameters
[source,cpp]
----
class connection_handler
{
socket sock_;
std::string name_;
public:
capy::task<> run()
{
// CORRECT: 'self' is a parameter, copied to coroutine frame
return [](connection_handler* self) -> capy::task<>
{
log("Connection from", self->name_); // OK
co_await self->handle_request();
}(this);
}
};
----
== When Are Captures Safe?
Captures are only safe when the lambda object **outlives the coroutine**:
[source,cpp]
----
// SAFE: lambda stored in 'handler', outlives coroutine
auto handler = [&sock]() -> capy::task<>
{
co_await sock.read_some(...);
};
// Lambda 'handler' still exists here
run_and_wait(handler()); // Blocks until coroutine completes
// Lambda destroyed after coroutine finishes
----
However, this pattern is rare. Most async code immediately invokes the lambda and discards it, making captures unsafe.
== Rules of Thumb
1. **Default to IIFE with parameters** for lambda coroutines
2. **Never capture by reference** (`[&]`) in a lambda coroutine unless you're certain the lambda outlives the coroutine
3. **Capturing by value** (`[=]`, `[x]`) is equally broken — the copy lives in the lambda, not the coroutine frame
4. **Capturing `this`** is particularly dangerous and common
5. **When in doubt, use parameters**
== Alternative: Named Coroutine Functions
If the IIFE syntax feels awkward, use a named function instead:
[source,cpp]
----
class connection_handler
{
socket sock_;
// Named coroutine member function
capy::task<> do_handle()
{
// 'this' is an implicit parameter, handled correctly
co_await sock_.read_some(...);
}
public:
capy::task<> run()
{
return do_handle();
}
};
----
NOTE: Member function coroutines work correctly because `this` is an implicit **parameter**, not a capture. The compiler handles it properly.
== Why Does C++ Work This Way?
The C++ standard specifies that coroutine parameters are copied to the coroutine state, but lambda captures are not. This is because:
- Lambda captures are part of the lambda's closure type
- The coroutine is the lambda's `operator()`
- The coroutine frame only stores what's needed for the function body
- The closure is external to the function body
There have been proposals to change this behavior, but as of C++23, the issue remains. Always use the safe patterns described above.
== Summary
|===
| Pattern | Safety | Notes
| `[x]() -> task<> { use(x); }()`
| UNSAFE
| Capture `x` destroyed with lambda
| `[](auto x) -> task<> { use(x); }(val)`
| SAFE
| Parameter `x` in coroutine frame
| `[&x]() -> task<> { use(x); }()`
| UNSAFE
| Reference to lambda storage, not `x`
| `[](auto& x) -> task<> { use(x); }(val)`
| SAFE*
| Reference parameter, `val` must outlive coroutine
| Member function coroutine
| SAFE
| `this` is implicit parameter
|===
When writing lambda coroutines, **pass values as parameters, not captures**.This is in AsciiDoc format to match your existing docs. If you want me to switch to Agent mode and create the file, let me know which path you'd like it at.
Reactions are currently unavailable