rmed

blog

Connecting things in C++

2024-07-14 11:18

Recently I've had to tackle the task of making two different software architectures, a modern one and a legacy one (as it happens), work together in C++. These two architectures were based on two completely different event-based frameworks, each one with their own event loop, and it was clear that they were not intended to work toegether as one, but rather assume that they are completely isolated components that handle the entire application lifecycle.

While it might seem like a rather complicated conundrum at first glance, I've come to realize that the solution is, in my humble opinion, quite simple in modern C++.

Overview of the architecture

Let's set the scene. For this post I'm going to assume that I have access to (at least) C++11 and my two components are based on the following frameworks:

  • A graphical application written in Qt 6 (this is the modern component)
  • A set of servers and remote communication handlers based on (Boost) Asio (this is the legacy component)

Now, before people start suggesting a complete rewrite of the legacy in Qt, we are going to assume that there are some constraints in place that require both elements in the software, modifying the legacy as little as possible.

These two components have their own event loop to handle their specific events (obviously). In the case of Qt, the event loop might be started like this:

#include <QGuiApplication>

int main(int argc, char* argv[])
{
    QGuiApplication app{argc, argv};

    // Event loop starts here
    app.exec();

    return 0;
}

Where the QGuiApplication::exec() method has the following information in Qt's documentation:

Enters the main event loop and waits until exit() is called, and then returns the value that was set to exit() (which is 0 if exit() is called via quit()).

For Asio, the event loop might be started like this:

#include <boost/asio.hpp>

int main(int argc, char* argv[])
{
    boost::asio::io_context context{};

    // Create a work guard to have the event loop run forever
    auto work_guard{boost::asio::make_work_guard(context)};

    // Event loop starts here
    context.run();

    return 0;
}

The work_guard is there only to prevent the event loop from stopping if there are no more pending tasks. As with Qt, the io_context::run() method states the following in its documentation:

The run() function blocks until all work has finished and there are no more handlers to be dispatched, or until the io_context has been stopped.

Now, regarding how to run both event loops at the same time, the solution is rather easy: simply run each of them in a different thread. For example, Qt might be launched in the main thread, and Asio in a secondary thread that will be stopped when the program terminates. Easy, right?

But, then again... These two are supposed to communicate and exchange information among themselves. How shall we do that?

Information exchange

This has suddenly become one of those classic synchronisation problems that universities love so much: "need to access something from a different thread? Get a mutex/atomic/semaphore/other-method in there and be done with it!"

While that is certainly a possible approach, what happens if we need to run some code in a specific event loop? I'm pretty sure Qt will complain (and that's putting it lightly) if we attempt to modify some graphical element from a different thread. Or perhaps the software needs to call a method in the Asio thread to notify a remote server about something? A lock-based solution is a possibility, but I believe it would really increase the complexity of the application (even more).

Instead, why not use some sort of message exchange-like mechanism so that their processing mechanisms do not interfere with each other?

Just of a moment, I'm going to forget about the fact that I'm talking about software components. Instead, let's imagine there are two co-workers, Alice and Bob, working side by side, each with their own tasks. Occasionally they need to interact with each other to do their job, like in the following conversation:

Bob: "Hey, Alice. I just finished writing that report our manager asked for. Would you mind giving it a look and forwarding it to her if you think it's okay as it is? I left it in our shared drive."

Alice: "Sure, just let me finish writing this email first."

Bob: "Of course, no rush. Thanks!"

A few minutes later

Alice: "Alright, I forwarded the report. By the way, I received a document from Carl, from our overseas branch, regarding the new project you are working on. I think he sent it to me by error because we tend to work together in most projects. I'll send it to you."

Bob: "Fantastic, thank you Alice."

See that? That is exactly the kind of interaction that we are looking for. Mr. Bob Qt has processed something and asks his colleague Ms. Alice Asio to review the information and send it to a remote location. Furthermore, Ms. Alice Asio receives a message and passes it on to Mr. Bob Qt. This is purely an event-based architecture, where both of them react to actions and offer a reaction independently.

But the question remains: how are we going to translate that to C++?

Everything is a function

One of my favourite things about the C++11 standard has to be std::function: it's like a good old (and unsafe) void*, but treated as an object and type safe. Pretty cool, if you ask me.

With std::function I should be able to store a pointer to a bound method (or free function, if I so desired) and call it from wherever I want, which solves half of the problem: Qt side would be able to invoke methods from the Asio side and vice versa. For example, we could define a Callback class like:

// callback.hpp
#pragma once

#include <functional>

template<class... TArgs>
class Callback
{
public:
    // The methods we are going to call must not return anything
    using Callable = std::function<void(TArgs...)>;

    // Don't want to allow building an empty callback
    Callback() = delete;

    // Copyable
    Callback(const Callback&) = default;
    Callback& operator=(const Callback&) = default;

    // Movable
    Callback(Callback&&) = default;
    Callback& operator=(Callback&&) = default;

    virtual ~Callback() = default;

    /// @brief Callback constructor from a bound method.
    ///
    /// @details
    /// This constructor initializes the internal callable with a pointer to the specified bound @p method.
    /// The developer must guarantee the lifetime of the @p object to prevent invalid memory access.
    ///
    /// @tparam TClass              Object type.
    ///
    /// @param[in] object           Pointer to the object whose method is going to be called.
    /// @param[in] method           Pointer to the method that is going to be called. The arguments of the Callback
    ///                             must match those of this method.
    template<class TClass>
    Callback(TClass* object, void (TClass::*method)(TArgs...))
        // In C++20 I would use std::bind_front()
        : m_callable
        {
            // Object and method pointers are copied into the lambda's context
            [object, method](TArgs args...)
            {
                // In C++17 I would use std::invoke()
                (object->*method)(args...);
            }
        }
    {
    }

    /// @brief Invoke the underlying callable.
    ///
    /// @param[in] args             Arguments to provide to the invokation.
    void operator()(TArgs... args) const
    {
        m_callable(args...);
    }

private:
    Callable m_callable;
};

And, here is how it could be used:

// main.cpp
#include <iostream>
#include <string>

#include "callback.hpp"

class Test
{
public:
    void do_stuff(int a, const std::string& b)
    {
        std::cout << "Doing stuff with " << a << " and " << b << std::endl;
    }
};

int main()
{
    // Create dummy object to cal its method
    Test test{};

    // Create a callback and invoke it
    Callback<int, const std::string&> cb{&test, &Test::do_stuff};
    cb(42, "a message");

    return 0;
}

The output would be:

Doing stuff with 42 and a message

Now, let's break it down. The Callback object is a simple wrapper around std::function that forces it to point to a method that returns and only method arguments are provided as part of the template. A Callback object has to be constructed by providing an object pointer and a method pointer belonging to that class (TClass) and matching the arguments provided to the Callback (TArgs...). The invocation of the stored class method itself is done via a lambda function, which comes in very handy to store the pointers for deferred execution.

Whenever a Callback object is invoked, the operator() is called, which in turn calls the underlying std::function, which in turn calls the lambda, which in turn calls Test::do_stuff(int a, const std::string& b) of object test.

Even though this is pretty cool (again, my opinion), it has an obvious limitation: invocation of the Callback happens in the same thread from where it was called, meaning that it does not solve the problem at hand entirely.

Making it dance

The interesting thing about event loops is that most, if not all, have a way to post an event to the event loop. Redundant, I know. But we can use such capabilities to our advantage. For Qt, I'm going to focus on QMetaObject::invokeMethod(), and for Asio, on boost::asio::post().

Both of these will allow us to execute something in the corresponding event loop, by enqueueing the task and notifying the event loop to handle it (eventually) in its own thread.

With this information, I'm going to rewrite the previous Callback a little, while still retaining the same API in terms of invocation (the operator() operator).

Qt version

// qt-callback.hpp
#pragma once

#include <functional>

#include <QObject>

template<class... TArgs>
class QtCallback
{
public:
    // The methods we are going to call must not return anything
    using Callable = std::function<void(TArgs...)>;

    // Don't want to allow building an empty callback
    QtCallback() = delete;

    // Copyable
    QtCallback(const QtCallback&) = default;
    QtCallback& operator=(const QtCallback&) = default;

    // Movable
    QtCallback(QtCallback&&) = default;
    QtCallback& operator=(QtCallback&&) = default;

    virtual ~QtCallback() = default;

    /// @brief Qt Callback constructor from a bound method.
    ///
    /// @details
    /// This constructor initializes the internal callable with a pointer to the specified bound @p method.
    /// The developer must guarantee the lifetime of the @p object to prevent invalid memory access.
    ///
    /// @tparam TClass              Object type. Must derive from QObject.
    ///
    /// @param[in] object           Pointer to the QObject-based object whose method is going to be called.
    /// @param[in] method           Pointer to the method that is going to be called. The arguments of the Callback
    ///                             must match those of this method.
    template<typename TClass, std::enable_if_t<std::is_base_of_v<QObject, TClass>, bool> = true>
    QtCallback(TClass* object, void (TClass::*method)(TArgs...))
        : m_callable
        {
            // Object and method pointers are copied into the lambda's context
            [object, method](TArgs... args)
            {
                QMetaObject::invokeMethod(
                    // Event loop/thread is obtained from the object itself
                    object,
                    // Yes, a lambda within a lambda to copy the arguments for the other thread
                    [object, method, args...]()
                    {
                        // In C++17 I would use std::invoke()
                        //
                        // Try to be efficient about memory usage, because copied arguments are
                        // discarded after the lambda returns.
                        (object->*method)(std::move(args)...);
                    },
                    // Make sure that the event is enqueued so that it gets executed in Qt's thread
                    Qt::QueuedConnection
                );
            }
        }
    {
    }

    /// @brief Invoke the underlying callable.
    ///
    /// @param[in] args             Arguments to provide to the invokation.
    void operator()(TArgs... args) const
    {
        m_callable(args...);
    }

private:
    Callable m_callable;
};

This QtCallback works exactly the same way as the original Callback, with one difference: the object pointer provided in the constructor must derive from QObject as this allows Qt to determine the thread in which to execute the function. This is done via the QMetaObject::invokeMethod() call inside the first lambda:

QMetaObject::invokeMethod(
    // Event loop/thread is obtained from the object itself
    object,
    // Yes, a lambda within a lambda to copy the arguments for the other thread
    [object, method, args...]()
    {
        // In C++17 I would use std::invoke()
        //
        // Try to be efficient about memory usage, because copied arguments are
        // discarded after the lambda returns.
        (object->*method)(std::move(args)...);
    },
    // Make sure that the event is enqueued so that it gets executed in Qt's thread
    Qt::QueuedConnection
);

There is some Qt magic at work here, but the summary is:

  • Arguments (args...) are copied from the parent lambda
  • The arguments are std::move()d when calling the method to try to prevent further copies
  • This lambda is executed by Qt considering a Qt::QueuedConnection, which guarantees that it will be enqueued in the event loop and executed from its thread

This is completely thread safe, as the invocation is deferred to Qt's own thread.

Asio version

// asio-callback.hpp
#pragma once

#include <functional>

#include <boost/asio.hpp>

template<class... TArgs>
class AsioCallback
{
public:
    // The methods we are going to call must not return anything
    using AsioCallback = std::function<void(TArgs...)>;

    // Don't want to allow building an empty callback
    AsioCallback() = delete;

    // Copyable
    AsioCallback(const AsioCallback&) = default;
    AsioCallback& operator=(const AsioCallback&) = default;

    // Movable
    AsioCallback(AsioCallback&&) = default;
    AsioCallback& operator=(AsioCallback&&) = default;

    virtual ~AsioCallback() = default;

    /// @brief Callback constructor from a bound method.
    ///
    /// @details
    /// This constructor initializes the internal callable with a pointer to the specified bound @p method.
    /// The developer must guarantee the lifetime of the @p object to prevent invalid memory access.
    ///
    /// @tparam TClass              Object type.
    ///
    /// @param[in] context          Pointer to the Asio context (event loop).
    /// @param[in] object           Pointer to the object whose method is going to be called.
    /// @param[in] method           Pointer to the method that is going to be called. The arguments of the Callback
    ///                             must match those of this method.
    template<class TClass>
    AsioCallback(boost::asio::io_context* context, TClass* object, void (TClass::*method)(TArgs...))
        : m_callable
        {
            // Object and method pointers are copied into the lambda's context
            [context, object, method](TArgs... args)
            {
                boost::asio::post(
                    // Event loop is accessed via the context
                    context->get_executor(),
                    // Yes, a lambda within a lambda to copy the arguments for the other thread
                    [object, method, args...]() {
                        // In C++17 I would use std::invoke()
                        //
                        // Try to be efficient about memory usage, because copied arguments are
                        // discarded after the lambda returns.
                        (object->*method)(std::move(args)...)
                    }
                )
            }
        }
    {
    }

    /// @brief Invoke the underlying callable.
    ///
    /// @param[in] args             Arguments to provide to the invokation.
    void operator()(TArgs... args) const
    {
        m_callable(args...);
    }

private:
    Callable m_callable;
};

Like QtCallback, the AsioCallback remains mostly unchanged, except for an additional context parameter in the constructor which allows us to tell Asio the event loop in which to enqueue the invocation of the function/lambda. As before, we pass Asio a second lambda to execute as part of boost::asio::post():

boost::asio::post(
    // Event loop is accessed via the context
    context->get_executor(),
    // Yes, a lambda within a lambda to copy the arguments for the other thread
    [object, method, args...]() {
        // In C++17 I would use std::invoke()
        //
        // Try to be efficient about memory usage, because copied arguments are
        // discarded after the lambda returns.
        (object->*method)(std::move(args)...)
    }
)

Again, here is a small summary of what this is doing:

  • Arguments (args...) are copied from the parent lambda
  • The arguments are std::move()'d when calling the method to try to prevent further copies
  • This lambda is enqueued (posted) by Asio to the executor of the provided context so that it is deferred and executed from its thread

Again, this is completely thread safe, as the invocation is deferred to Asio's own thread.

Conclusions

This was just a small introduction to how std::function() can be (ab)used to connet different event loops in a thread-safe way with as little overhead as possible.

Of course, it is possible to improve this solution way further, such as using std::shared_ptr in for the callback arguments to prevent any copy while passing the information to the other thread. Or even having a generic Callback object that other framework-dependent specialized callbacks can derive from to allow our code to be completely agnostic of who is listening on the other side of the callback, which is whay I did in my Asio-based framework Kouta.