Connecting things in C++
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.