TCP echo server

124

Allright, here's what we're going to do today. We will implement a simple TCP echo server. We'll start with the most simple naive implementation, and then try to improve its performance as much as possible.

In general TCP echo server works like that:

  1. Accept incoming connection;
  2. Receive some data;
  3. Send this data back to the client;
  4. Repeat until the connection is closed by the client.

Implementation

So, let's start from the straightforward implementation. main function is quite simple: we need an instance of server with listening port specified, and io_context object which we will run in the main thread:

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        std::cout << "Usage: server [Port]\n";
        return 0;
    }

    io::io_context io_context;
    server srv(io_context, boost::lexical_cast<std::uint16_t>(argv[1]));
    io_context.run();

    return 0;
}

We've done this before, nothing new here. server class for the current implementation also has nothing new:

class server
{
public:

    server(io::io_context& io_context, std::uint16_t port)
    : io_context(io_context)
    , acceptor  (io_context, tcp::endpoint(tcp::v4(), port))
    {
        accept();
    }

private:

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

        acceptor.async_accept(*socket, [&] (error_code error)
        {
            // Make session object and start it right away
            std::make_shared<session>(std::move(*socket))->start();
            accept();
        });
    }

    io::io_context& io_context;
    tcp::acceptor acceptor;
    std::optional<tcp::socket> socket;
};

It accepts incoming connections, and create and start session instances for those connections. Now, let's take a closer look at the session class. We will do most of the improvements here:

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

    session(tcp::socket&& socket)
    : socket(std::move(socket))
    {
    }

    void start()
    {
        // To start an echo session we should start to receive incoming data
        read();
    }

private:

    void read()
    {
        // Schedule asynchronous receiving of a data
        io::async_read(socket, streambuf, io::transfer_at_least(1), std::bind(&session::on_read, shared_from_this(), _1, _2));
    }

    void on_read(error_code error, std::size_t bytes_transferred)
    {
        if(!error)
        {
            // When the data is received and no error occurred, send it back to the client
            write();
        }
    }

    void write()
    {
        // Schedule asynchronous sending of the data
        io::async_write(socket, streambuf, std::bind(&session::on_write, shared_from_this(), _1, _2));
    }

    void on_write(error_code error, std::size_t bytes_transferred)
    {
        if(!error)
        {
            // When the data is sent and no error occurred, return to the first step
            read();
        }
    }

    tcp::socket socket;
    io::streambuf streambuf;
};

Pretty simple so far. Each function is just one line of code plus error checking. Now we can build and run our server, and test it with telnet:

./server 1234
./telnet 127.0.0.1 1234
Connecting To 127.0.0.1...
jjeeoo55nnxxoowwppff

Everything seems to work properly: server sends back everything it receives and we can see repeated character for each key we've pressed. To find what can we improve we should first understand what's wrong now.

Analysis

The major wrong thing about the implementation above is serial execution of read and write operations. At each moment the server can either read or write data through the socket, but not both. What's wrong about that?

Look at the async_read completion condition. It's transfer_at_least(1) which means that the operation will complete after any amount of data is received. Assume that client's read and write operations are serial too. Let's say that the client sends 512 bytes to the server and receives the response after. However server's async_read may complete, for example, after first 256 bytes of data are received. The server stops reading data and switches to writing it. However the client is still trying to write the rest of the data and not trying to read it back yet. Both client and server are trying to send data which other side doesn't try to receive. In this case both of them will stuck. Client's code may look like this:

char buffer[512];
io::write(socket, io::buffer(buffer));
io::read(socket, io::buffer(buffer));

They also will stuck if the client is acting not as it is expected by the server. For example, the client has successfully sent 128 bytes of data and the server has started to send it back. Now assume that the client received only 64 bytes of data and now tries to send something else:

char request[128];
char response[64];
io::write(socket, io::buffer(request));
io::read(socket, io::buffer(response));
io::write(socket, io::buffer(request));
io::read(socket, io::buffer(response));
io::read(socket, io::buffer(response));
io::read(socket, io::buffer(response));

256 bytes in, 256 bytes out. This should work, however this will stuck because the client operates serially just like the server, and they go out of sync.

We've could document this behavior, make it as a part of the server's protocol, declare that client should act upon some agreement on how it should transfer data, etc. But it's not the way you write good servers. A client could be badly implemented. A client could have bugs. A client could be implemented by some third-party developer. A good server should be ready for all of these.

But wait a minute — you may say — real life HTTP server won't send you a response until you send a complete request to it! Isn't it the same? No, it's not. HTTP server have to read complete request before it send response back because the response is dependent on the request content. HTTP server doesn't know what to answer until it sees the complete request. And after you sent a request to it, you can send the next one right away, no need to read response first.

In our case we know what to send back right away because, well, it's just an echo server, it just sends back everything it received.

Note that everything seems to work fine when you test the server with telnet and your keyboard. The same implementation works perfectly with small pieces of data, however it doesn't mean it's gonna work with larger data packets.

There is another thing, which is not really an issue for the current implementation, but still should be mentioned. We didn't limit streambuf capacity, so theoretically it can grow until we run out of memory. We should limit its capacity by some reasonable value and correctly handle a situation when it grown till that value. For the current implementation passing the value of the streambuf capacity to its constructor would be enough. However more complex implementation may have more complex solution.

Therefore, we could state these general rules out of this lesson:

  • A server should be ready to read and write data at any moment unless it doesn't know what to do yet;
  • A server should impose as less restrictions on the client's I/O sequence as possible;
  • Still, some reasonable restrictions should be applied, so the server couldn't run out of memory or stuck forever.

Next step

So, the first big improvement we should do is to implement read and write operations in parallel. If a client operates serially then it shouldn't be a problem. After this improvement a limitation of streambuf capacity become an issue, so we should handle this properly as well. And this is the subject for the next lesson.

Full source code for this lesson: tcp-echo-server.cpp

Share this page:

Learning plan

How to deal with secure connections with Boost.Asio and OpenSSL
A short break before we go into Boost.Asio application design principles
A short notes on Boost.Asio server application quality issues
31. TCP echo server
Simple straightforward implementation and discussion of TCP echo server
First approach on improvement of TCP echo server implementation: making read and write work in parallel
Second approach on improvement of TCP echo server implementation: eliminating gaps and memory copying
Third approach on improvement of TCP echo server implementation: multithreading