Search code examples
c++boostcoroutineasio

Can asio co_composed lambda function capture this pointer?


I've read this Q&A. Lambda lifetime explanation for C++20 coroutines

If I understand correctly, lambda expression with capture something is not safe if the lambda expression is used as coroutine function.

Now I come up with the following question.

#include <iostream>
#include <boost/asio.hpp>
#include <boost/asio/experimental/co_composed.hpp>

namespace asio = boost::asio;

struct foo {
    template <typename CompletionToken>
    auto mf1(
        CompletionToken&& token
    ) {
        return asio::async_initiate<
            CompletionToken,
            void()
        >(
            asio::experimental::co_composed<
                void()
            >(
                [](                 // capture nothing
                    auto /*state*/,
                    foo& self
                ) -> void {
                    // co_await some_async_function(asio::deferred);
                    self.val_++;    // accesses member variables via self
                    co_return {};
                }
            ),
            token,
            std::ref(*this)
        );
    }

    template <typename CompletionToken>
    auto mf2(
        CompletionToken&& token
    ) {
        return asio::async_initiate<
            CompletionToken,
            void()
        >(
            asio::experimental::co_composed<
                void()
            >(
                [this](              // capture this.
                    auto /*state*/
                ) -> void {
                    // co_await some_async_function(asio::deferred);
                    val_++;          // accesses member variables
                    co_return {};
                }
            ),
            token
        );
    }

    int val_ = 0;
};

asio::awaitable<void> proc() {
    foo f;
    co_await f.mf1(asio::deferred);
    co_await f.mf2(asio::deferred);
    std::cout << f.val_ << std::endl;
    co_return;
}

int main() {
    asio::io_context ioc;
    asio::co_spawn(
        ioc.get_executor(),
        proc(),
        asio::detached
    );
    ioc.run();
}

godbolt link: https://godbolt.org/z/d7snvYYqb

class foo has two member functions. Both member functions do the same thing. They are async function implemented by co_composed. mf1()'s lambda expression captures nothing, and passed *this as self. val_ is accessed through self. I think that it is safe.

mf2()'s lambda expression captures this pointer. val_ is accessed through this pointer. Is it safe? Before accessing val_, co_await could be placed, so the context could be switched and switched back. In this case, captured this pointer is treated well? I suspect that it could be unsafe.


Solution

  • What makes it safe to use a reference? The reference must point to a valid object of the expected type.

    When would that cease to be the case? When the lifetime of the referred-to object ends, or when the object is otherwise invalidated (e.g. by being moved from).

    In your code none of the two are applicable. foo lives in Asio's coro frame, which is stable.

    Your capture is safe.

    Side-tangent:

    It's critical to observe here that this refers to the foo instance, not the lambda. The lambda instance is likely moved along the async call chain, so can not be considered stable. This might be relevant when you have value-captures and accidentally pass references to them around. Luckily you would probably not have a need for much state in the lambda because the foo instance basically

    UPDATE

    That Side-tangent proves to be prophetic. In the comments we analyzed down to this case:

    Live

    #include <boost/asio.hpp>
    namespace asio = boost::asio;
    
    int main() {
        asio::io_context ioc;
        auto bar = [v = 42]() -> asio::awaitable<void> { assert(v); co_return; };
    
        co_spawn(ioc, bar, asio::detached);   // fine
        co_spawn(ioc, bar(), asio::detached); // fine
        co_spawn(ioc,
            [v = 42]() -> asio::awaitable<void> { assert(v); co_return; },
            asio::detached); // fine
    
        // OOPS: this one is broken
        co_spawn(ioc,
            [p = 42] -> asio::awaitable<void> { assert(p); co_return; }(),
            asio::detached);
    
        ioc.run();
    }
    

    You'll note that when the lambda is a temporary, it leads to an awaitable<> with a dangling reference to the lambda instance. This makes it a tiny bit clearer, maybe:

    // OOPS: this one is broken
    // aw stores a reference to a temporary lambda instance
    asio::awaitable<void> aw = [p = 42] -> asio::awaitable<void> { assert(p); co_return; }();
    
    // the lambda instance is gone, but the coroutine is going to invoke its `operator()() const` still:    
    co_spawn(ioc, std::move(aw), asio::detached);
    

    It's extremely subtle, and you can avoid it by not invoking the lambda, but instead passing it by value.