The Lambda Coroutine Fiasco
This is a follow-up to Using ASIO awaitable the Easy Way.
The CppCoreGuidelines include a rule that says: “Do not use capturing lambdas that are coroutines“; and if not disabled in your .clang-tidy file, both clangd & clang-tidy will warn the use of capturing lambda coroutines.
Since std::future<> doesn’t support coroutine yet, we can adapt the guidelines’ sample code with asio::awaitable<> instead, to reproduce and then dive into the use-after-free memory access issue caused by a lambda coroutine.
1 | struct Tracker { // NOLINT(cppcoreguidelines-special-member-functions) |
Auxiliary class Tracker here is used as an indicator of destruction of lambda closure; and when we compile and run the executable, we have following output:
1 | $ ./coro_awaitable |
When the lambda goes out of scope, the Tracker instance the lambda captured gets destructed, therefore the subsequent access to its value, after the lambda coroutine is resumed, is a use-after-free access.
So why wasn’t the capture “moved-into” the coroutine frame?
The answer is simple: it was, but was reference-captured.
The coroutine lambda is compiled or transformed equivalently to
1 | auto lambda_operator_call(const lambda* this) -> asio::awaitable<void> { |
It’s pretty much like a coroutine with passed-by-reference parameters issue, which is also discouraged by CppCoreGuidelines.
Solutions
CppCoreGuidelines offers two solutions:
- Pass as arguments instead of using captures
- Simply use a normal function coroutine rather than a lambda coroutine
Either ensures arguments the coroutine demands on are alive in the coroutine by moving them into coroutine frame.
If C++23 is available to you, you can use deducing-this pattern to enforce pass-lambda-by-value:
1 | auto lambda = [value](this auto) -> asio::awaitable<void> { |
=>
1 | auto lambda_operator_call(this auto) -> asio::awaitable<void>; |
=>
1 | auto lambda_operator_call(lambda) -> asio::awaitable<void>; |
so the captures are also moved into coroutine frame.
Bonus tip: the crux of this issue is the lambda goes out of scope, so if you don’t pass the awaitable out of lambda’s scope, it’s totally fine for this capturing lambda coroutine.
What about ASAN
In fact, AddressSanitizer is able to detect this memory issue, but with a couple of caveats.
First, if the value is allocated on the stack, like an int, or a SSO short std::string, you must turn on the stack-use-after-return check by
1 | export ASAN_OPTIONS=detect_stack_use_after_return=1 |
and compile your executable in Clang, because GCC most of the time is unable to catch this kind of issue.
Second, if the value is allocated on the heap, no extra effort is required, and either GCC or Clang is able to catch the memory issue.