Multithreaded execution

357

Examples we've reviewed so far were running within a single thread. However in real life you want to utilize all of your CPU cores in your Boost.Asio application. So we should learn how to do things in Boost.Asio with multiple threads. As always, let's keep things short:

namespace io = boost::asio;
using tcp = io::ip::tcp;
using error_code = boost::system::error_code;

There are two common ways to do that. In this lesson we will learn the simplest one. Let's remember the single-threaded approach:

io::io_context io_context;
// Prepare things
io_context.run();

The simplest way to jump into a multithreaded approach is to run io_context::run member function on multiple threads with the same io_context object.

#define BOOST_ASIO_NO_DEPRECATED
#include <boost/asio.hpp>
// ...

io::io_context io_context;
// Prepare things
std::vector<std::thread> threads;
auto count = std::thread::hardware_concurrency() * 2;

for(int n = 0; n < count; ++n)
{
    threads.emplace_back([&]
    {
        io_context.run();
    });
}

for(auto& thread : threads)
{
    if(thread.joinable())
    {
        thread.join();
    }
}

In that case io_context operates like a classic thread pool. Asynchronous tasks are performed somewhere on the OS side, however completion handlers are invoked on those threads where io_context::run function is running. To be more precise: every completion handler is invoked on a first free thread which io_context::run function is running on.

It means that completion handlers could run in parallel. And this is, in turn, mean that we've reached a point where we need some synchronization.

The less you have to care about synchronization in a multithreaded environment — the better. The good news is that we don't need such edgy low-level synchronization tools like mutexes or semaphores to get things synchronized in the Boost.Asio multithreaded environment.

The only thing you need to get your completion handlers synchronized properly is io_context::strand class instance. It works pretty simple: completion handlers attached to the same io_context::strand will be invoked serially. They could be invoked from different threads, however those invocations will be serialized. This means that things won't go in parallel and you don't have to deal with synchronization.

So, everything you need to do is to decide which completion handlers operate on a shared data and should be attached to the same io_context::strand, and which of them are independent and can go in parallel. You should use boost::asio::bind_executor function to wrap a completion handler into a strand. Let's look at the example. Assume that our io_context::run is running on multiple threads:

class session
{
    session(io::io_context& io_context)
    : socket(io_context)
    , read  (io_context)
    , write (io_context)
    {
    }

    void async_read()
    {
        io::async_read(socket, read_buffer, io::bind_executor(read, [&] (error_code error, std::size_t bytes_transferred)
        {
            if(!error)
            {
                // ...
                async_read();
            }
        }));
    }

    void async_write()
    {
        io::async_read(socket, write_buffer, io::bind_executor(write, [&] (error_code error, std::size_t bytes_transferred)
        {
            if(!error)
            {
                // ...
                async_write();
            }
        }));
    }

private:

    tcp::socket socket;
    io::io_context::strand read;
    io::io_context::strand write;
}

In the example above we used two strands, one for reading and one for writing operations. This means that read completion handlers will be serialized with one strand and write handlers will be serialized with another strand. Which means that completion handlers of the same type will go serially, however read and write handlers will go in parallel to each other. And that's all you need to keep your control flow synchronized, so simple! Note that you can't get deadlocked here or run into other common multithreading issues. As long as you designate your strands properly.

You can also use boost::asio::post function with io_context::strand to execute your functors within a given strand:

io::post(read, []
{
    std::cout << "We're inside a read sequence, it's safe to access a read-related data here!\n";
});

Summary

To scale your Boost.Asio application on multiple threads you should do the following:

Create one io_context object.

Run io_context::run member function of that object on multiple threads.

Realize which control flow branches operate on a shared data and therefore need to be synchronized, and which can go in parallel.

Create io_context::strand object for every control flow branch which requires execution serialization.

Wrap your completion handlers into corresponding strand objects with boost::asio::bind_executor function.

If you need to execute some regular code within a some specific strand, post that code into the strand with boost::asio::post function.

Share this page:

Learning plan

Principles you should take into consideration during the development of your applications
How to keep io_context::run running even when there is no work to do
How execute a regular code within io_context::run polling loop
12. Multithreaded execution
We've dealt with a single-threaded environment so far; now it's time to go multithreading
A special execution model with a custom load balancer
14. Timers
Working with asynchronous timers within io_context polling loop
What's the difference between a client and a server, and what do they have in common