Lambda Coroutine Captures
Lambda captures are a common source of undefined behavior in coroutine code. This section explains the problem and the safe patterns to use instead.
The Problem
Consider this innocent-looking code:
namespace capy = boost::capy;
void process(socket& sock)
{
auto task = [&sock]() -> capy::task<>
{
char buf[1024];
auto [ec, n] = co_await sock.read_some(make_buffer(buf));
}();
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 stored in the lambda closure object, not in the coroutine frame. Here is what happens:
-
The lambda closure is created, capturing
sockby reference -
The lambda’s
operator()()is called -
A coroutine frame is allocated (typically on the heap; HALO may elide this)
-
The coroutine suspends at
initial_suspend -
operator()()returns the task -
The lambda closure is destroyed — it was a temporary
-
Later, the coroutine resumes
-
The coroutine accesses
sockthrough the destroyed closure -
Undefined behavior
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.
namespace capy = boost::capy;
void process(socket& sock)
{
auto task = [](socket* s) -> capy::task<>
{
char buf[1024];
auto [ec, n] = co_await s->read_some(make_buffer(buf));
}(&sock);
run_async(executor)(std::move(task));
}
This is an Immediately Invoked Function 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
Broken: Using Captures
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
class connection_handler
{
socket sock_;
std::string name_;
capy::task<> handle_request();
public:
capy::task<> run()
{
// CORRECT: 'self' is a parameter, copied to coroutine frame
return [](connection_handler* self) -> capy::task<>
{
log("Connection from", self->name_);
co_await self->handle_request();
}(this);
}
};
When Are Captures Safe?
Captures are only safe when the lambda object outlives the coroutine:
// SAFE: lambda stored in 'handler', outlives coroutine
auto handler = [&sock]() -> capy::task<>
{
char buf[1024];
co_await sock.read_some(make_buffer(buf));
};
// Lambda 'handler' still exists here
capy::test::run_blocking()(handler()); // Blocks until coroutine completes
// Lambda destroyed after coroutine finishes
This pattern is rare. Most async code immediately invokes the lambda and discards it, making captures unsafe.
Rules of Thumb
-
Default to IIFE with parameters for lambda coroutines
-
Never capture by reference (
[&]) in a lambda coroutine unless the lambda outlives the coroutine -
Capturing by value (
[=],[x]) is equally broken — the copy lives in the lambda, not the coroutine frame -
Capturing
thisis particularly dangerous and common -
When in doubt, use parameters
Alternative: Named Coroutine Functions
If the IIFE syntax feels awkward, use a named function instead:
class connection_handler
{
socket sock_;
capy::task<> do_handle()
{
// 'this' is an implicit parameter, handled correctly
char buf[1024];
co_await sock_.read_some(make_buffer(buf));
}
public:
capy::task<> run()
{
return do_handle();
}
};
Member function coroutines work correctly because this is an implicit parameter, not a capture. The compiler copies it to the coroutine frame.
Quick Reference
| Pattern | Safety | Notes |
|---|---|---|
|
UNSAFE |
Capture |
|
SAFE |
Parameter |
|
UNSAFE |
Accessed through dangling |
|
SAFE* |
Reference parameter; |
Member function coroutine |
SAFE |
|
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 is 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.