Why do we pass parameters to coroutines by value in C++?

C++ coroutines and const reference parameters

A coroutine func accepts a parameter by const reference in the code below:

#include <boost/asio.hpp>

#include <iostream>

namespace asio = boost::asio;
using asio::awaitable;
using asio::use_awaitable;

class Param
{
public:

    Param(int val) : m_val(val)
    {
        std::cout << "Param constructor " << m_val << std::endl;
    }
    void func() const
    {
        std::cout << "Param func " << m_val << std::endl;
    }

    ~Param()
    {
        std::cout << "Param destructor" << m_val << std::endl;
    }

    int m_val;
};

awaitable<void> func(const Param& param)
{
    param.func();

    co_return;
}

awaitable<void> caller_func()
{
    co_await func(15);
}

int main()
{
    asio::thread_pool pool;

    asio::co_spawn(pool, callerFunc(), asio::detached);

    pool.join();

    return 0;
}

The code is well-formed and outputs the following:

Param constructor 15
Param func 15
Param destructor15

until we change caller_func() as follows:

awaitable<void> caller_func()
{
    auto task = func(15);

    co_await std::move(task);
}

The lifetime of a temporary used as a function argument is extended to that of the full expression containing the function call, so Param(15) is destroyed “at semicolon” and the coroutine accesses the reference to a destroyed object after it is resumed from its initial suspension point. The program contains UB and outputs the following with both GCC and MSVC:

Param constructor 15
Param destructor 15
Param func 15

Passing the parameters by value

To prevent careless coroutine caller to do wrong things with temporary objects we pass the coroutine parameters by value as demonstrated in the code below:

#include <boost/asio.hpp>

#include <iostream>

namespace asio = boost::asio;
using asio::awaitable;
using asio::use_awaitable;

namespace
{
    class Param
    {
    public:

        Param(int val) : m_val(val)
        {
            std::cout << "Param constructor " << m_val << std::endl;
        }

        Param(const Param& other) : m_val(other.m_val)
        {
            std::cout << "Param copy constructor " << m_val << std::endl;
        }

        Param(Param&& other) noexcept : m_val(other.m_val)
        {
            std::cout << "Param move constructor " << m_val << std::endl;
        }

        Param& operator= (const Param& other)
        {
            m_val = other.m_val;

            std::cout << "Param copy assignment operator " << m_val << std::endl;

            return *this;
        }

        Param& operator= (Param&& other) noexcept
        {
            m_val = other.m_val;

            std::cout << "Param move assignment operator " << m_val << std::endl;

            return *this;
        }

        void func() const
        {
            std::cout << "Param func " << m_val << std::endl;
        }

        ~Param()
        {
            std::cout << "Param destructor " << m_val << std::endl;
        }

        int value() const
        {
            return m_val;
        }

        void setValue(int val)
        {
            m_val = val;
        }

    private:

        int m_val;
    };

    void simpleFunc(Param param)
    {
        std::cout << "Simple func ";

        param.func();
    }

    awaitable<void> coroFunc(Param param)
    {
        auto exec = co_await asio::this_coro::executor;

        asio::steady_timer timer{ exec };

        timer.expires_after(std::chrono::milliseconds(100));

        co_await timer.async_wait(use_awaitable);

        param.func();

        co_return;
    }

    awaitable<void> callerFunc()
    {
        // Copy elision, No move no assignment.
        simpleFunc(10);
        
        // Move constructor is called.
        co_await coroFunc(1);

        std::cout << std::endl;

        {
            Param param(2);

            // Copy constructor is called.
            simpleFunc(param);

            // Copy and Move constructors are called.
            co_await coroFunc(param);
        }

        std::cout << std::endl;

        {
            Param param(3);

            std::cout << "Initialized param " << param.value() << std::endl;

            co_await coroFunc(param);

            param.setValue(4);

            std::cout << "Updated param " << param.value() << std::endl;
        }
    }
}

int main()
{
    asio::thread_pool pool;

    asio::co_spawn(pool, callerFunc(), asio::detached);

    pool.join();

    return 0;
}

The output is the following:

Param constructor 10
Simple func Param func 10
Param destructor 10
Param constructor 1
Param move constructor 1
Param destructor 1
Param func 1
Param destructor 1

Param constructor 2
Param copy constructor 2
Simple func Param func 2
Param destructor 2
Param copy constructor 2
Param move constructor 2
Param destructor 2
Param func 2
Param destructor 2
Param destructor 2

Param constructor 3
Initialized param 3
Param copy constructor 3
Param move constructor 3
Param destructor 3
Param func 3
Param destructor 3
Updated param 4
Param destructor 4

As we can see from the output, copy elision does not prevent the parameter from being moved all the time when we call the coroutine.

2 Responses to Why do we pass parameters to coroutines by value in C++?

Leave a Reply

Your email address will not be published. Required fields are marked *