Asynchronous TCP server

404

Now it's time to look at our first Boost.Asio asynchronous TCP server. This is the last time when I don't use namespace and type aliases. Next time I will because names are becoming too long and because you are already know were do things come from.

This time our server does the following:

Listen on the port 15001 for incoming TCP connections.

Accept incoming connection.

Read the data from the connection until it “sees” end-of-the-line character, which is "\n".

Write received data (or a string) into stdout.

Close the connection.

Take a look at the complete example. Below we will slice it into pieces and see what's going on in each piece. Error handling is omitted to make things clear. We will talk about error handling a bit later.

#include <iostream>
#include <optional>
#include <boost/asio.hpp>

class session : public std::enable_shared_from_this<session>
{
public:

    session(boost::asio::ip::tcp::socket&& socket)
    : socket(std::move(socket))
    {
    }

    void start()
    {
        boost::asio::async_read_until(socket, streambuf, '\n', [self = shared_from_this()] (boost::system::error_code error, std::size_t bytes_transferred)
        {
            std::cout << std::istream(&self->streambuf).rdbuf();
        });
    }

private:

    boost::asio::ip::tcp::socket socket;
    boost::asio::streambuf streambuf;
};

class server
{
public:

    server(boost::asio::io_context& io_context, std::uint16_t port)
    : io_context(io_context)
    , acceptor  (io_context, boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), port))
    {
    }

    void async_accept()
    {
        socket.emplace(io_context);

        acceptor.async_accept(*socket, [&] (boost::system::error_code error)
        {
            std::make_shared<session>(std::move(*socket))->start();
            async_accept();
        });
    }

private:

    boost::asio::io_context& io_context;
    boost::asio::ip::tcp::acceptor acceptor;
    std::optional<boost::asio::ip::tcp::socket> socket;
};

int main()
{
    boost::asio::io_context io_context;
    server srv(io_context, 15001);
    srv.async_accept();
    io_context.run();
    return 0;
}

Oh, that's much more code in comparison with our previous server! But don't panic, it's just 60 lines of code, and it is complete and almost production-quality asynchronous TCP server!

So, last time I told you that async_ functions run in the background. Where is this “background”? Well, you should consider that this “background” is somewhere inside the operating system. In fact, you shouldn't really care where is that somewhere — the only thing you should care is where do completion handlers being invoked from. And this happens inside io_context.run(). Let's take a look at our main function:

int main()
{
    boost::asio::io_context io_context;
    server srv(io_context, 15001);
    srv.async_accept(); // Doesn't block
    io_context.run(); // Blocks
    return 0;
}

So, boost::asio::io_context::run function is a sort of event loop function that manages all I/O operations. Call of run blocks the caller thread until all asynchronous operations associated with its io_context are completed. Every async_ operation is associated with some io_context. In some programming languages (e.g. JavaScript) event loop function is hidden from the developer. But in C++ we like to keep everything under control, so we decide where exactly event loop function will run.

OK, let's look at our server class. There are some new things inside its private section:

boost::asio::ip::tcp::socket — this is the same socket as before, just serving within TCP protocol (instead of UDP from our previous server).

boost::asio::ip::tcp::acceptor — this is something new. acceptor is an object that accepts incoming connections.

If you look at the acceptor initializer, you'll see something like we saw before when we used receive_from member function of UDP socket:

acceptor(io_context, boost::asio::ip::tcp::endpoint(boost::asio::ip::tcp::v4(), port))

This initialization means that we need an acceptor that will listen for incoming TCP connections on any network interface on the specified port.

Take a look at the acceptor's async_accept function call:

acceptor.async_accept(*socket, [&] (boost::system::error_code error)
{
    std::make_shared<session>(std::move(*socket))->start();
    async_accept();
});

This means in English “Wait for the incoming connection, and when you get one, associate established connection with this socket and call this completion handler”. As you remember, async_ functions don't block the caller thread.

So, the server waits for a new incoming connection, and when it gets one, it creates a session object and moves a socket associated with accepted connection inside the session. After that the server starts to wait for another incoming connection.

Note that the server doesn't care what's going on with the accepted connection. It starts to wait for the next connection right away, without any concerns of what is going on with the previous connection. Accepted connections perform in background. There can be any (well, almost any, limited by the OS open file descriptors limit) amount of simultaneous connections performing in background — that's how asynchronous I/O does work.

OK, now we know how does the server work. Now let's take a look at the session class. Simply put, session is a class that maintains the connection. It holds some data associated with the connection and provides some connection-related functionality. Let's take a look at the sesson's start function:

void start()
{
    boost::asio::async_read_until(socket, streambuf, "\n", [self = shared_from_this()] (boost::system::error_code error, std::size_t bytes_transferred)
    {
        std::cout << std::istream(&self->streambuf).rdbuf();
    });
}

Here is what do we ask for: “Read the data from the socket into streambuf, and when you find "\n" character, consider you're done, and invoke this completion handler”.

boost::asio::streambuf is also something new. It is a child class inherited from std::streambuf so at this time you can just consider it as asio-related streambuf.

So, the session reads the data from the socket until it gets "\n" character, and after that it writes the data received into stdout.

Note that the session class is inherited from the std::enable_shared_from_this facility. Also note that it captures self's shared pointer into completion handler's lambda. We do this to keep the session object alive until the completion handler is invoked. After we done, we just do nothing, so the session shared pointer goes out of scope and destroy right after the control flow returns from the completion handler. This is a common practice when dealing with sessions in many cases (but not all of them).

So, now you know how to write a basic TCP asynchronous server and how does it work. The last thing we need to do is to test it in real life. Run the server in the terminal:

./server

Run telnet client in another terminal, connect to the 15001 port, print Hello asio! and hit enter (which is expected "\n" character!):

telnet localhost 15001
Hello asio!

You should see this message in the server's terminal:

./server
Hello asio!

Yay! We've just started and you're already know how to write an almost professional asynchronous TCP server in modern C++ with Boost.Asio, congratulations!

Lesson 6
Share this page:

Learning plan

A brief description of the difference between network transport protocols
What is server anyway? The most simple example
It's time to say “goodbye” to a synchronous I/O
5. Asynchronous TCP server
The first simple asynchronous TCP server
How to handle Boost.Asio errors
There are several new things we should learn before jumping into a bigger example of a server
A bigger example of a server where you'll need to apply everything you've learned so far