본문으로 건너뛰기 A Minimal Redis-like Server in Modern C++ [#48-1]

A Minimal Redis-like Server in Modern C++ [#48-1]

A Minimal Redis-like Server in Modern C++ [#48-1]

Introduction

Redis uses a single-threaded event loop and an in-memory hash table. This tutorial builds a minimal version with Boost.Asio: an async TCP server that accepts connections, parses a simple text protocol, and executes GET/SET/DEL commands against a shared unordered_map.

Building this server teaches you:

  • How async I/O with io_context and async_read/async_write works
  • How enable_shared_from_this keeps sessions alive across async operations
  • How a protocol parser fits into an event-driven flow
  • The design tradeoffs between single-threaded simplicity and multi-threaded throughput

Prerequisites: basic Boost.Asio (io_context, acceptor, sockets) and C++17.


Architecture

The server has three layers:

Client TCP connection
       |
    Acceptor (port 6379)
       |
    Session (one per connection)
    - async_read_until('\n')  → parse command
    - execute against Store
    - async_write response
       |
    Store (unordered_map<string,string>)
    - shared across all sessions (single thread = no lock needed)

One thread runs io_context::run(). All sessions share the same thread, so access to the hash map requires no synchronization. This is the same design Redis uses.


The Storage Layer

A simple wrapper around unordered_map:

#include <unordered_map>
#include <string>
#include <optional>

class Store {
    std::unordered_map<std::string, std::string> data_;
public:
    void set(const std::string& key, const std::string& value) {
        data_[key] = value;
    }

    std::optional<std::string> get(const std::string& key) const {
        auto it = data_.find(key);
        if (it == data_.end()) return std::nullopt;
        return it->second;
    }

    bool del(const std::string& key) {
        return data_.erase(key) > 0;
    }

    size_t size() const { return data_.size(); }
};

Protocol

Commands are newline-terminated text lines:

SET key value\n     → +OK\n
GET key\n           → +value\n  or  $-1\n (nil)
DEL key\n           → :1\n (deleted) or :0\n (not found)
QUIT\n              → +BYE\n  then close

This is a simplified version of Redis’s RESP protocol. Real RESP uses type prefixes (+ for simple strings, $ for bulk strings with length, : for integers, * for arrays) — we’re borrowing the response format but simplifying the request format.

#include <string>
#include <sstream>
#include <vector>

struct Command {
    std::string name;
    std::vector<std::string> args;
};

Command parseCommand(const std::string& line) {
    Command cmd;
    std::istringstream ss(line);
    ss >> cmd.name;
    // Convert to uppercase for case-insensitive matching
    for (char& c : cmd.name) c = static_cast<char>(toupper(c));

    std::string arg;
    while (ss >> arg) {
        cmd.args.push_back(arg);
    }
    // For SET, the value might have spaces — grab remainder
    return cmd;
}

The Session

Each client connection gets a Session object. enable_shared_from_this ensures the session stays alive while async operations are pending:

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

using boost::asio::ip::tcp;
namespace asio = boost::asio;

class Session : public std::enable_shared_from_this<Session> {
    tcp::socket socket_;
    asio::streambuf buffer_;
    Store& store_;
    std::string response_;

public:
    Session(tcp::socket socket, Store& store)
        : socket_(std::move(socket)), store_(store) {}

    void start() {
        readCommand();
    }

private:
    void readCommand() {
        auto self = shared_from_this();
        asio::async_read_until(socket_, buffer_, '\n',
            [this, self](boost::system::error_code ec, size_t /*bytes*/) {
                if (ec) {
                    // Connection closed or error — session ends
                    return;
                }
                std::istream stream(&buffer_);
                std::string line;
                std::getline(stream, line);

                // Remove \r if present (telnet sends \r\n)
                if (!line.empty() && line.back() == '\r') {
                    line.pop_back();
                }

                response_ = execute(line);
                writeResponse();
            });
    }

    std::string execute(const std::string& line) {
        auto cmd = parseCommand(line);

        if (cmd.name == "SET" && cmd.args.size() >= 2) {
            // Value might contain spaces — reconstruct from position after key
            // Simple approach: args[1] is the value (single word)
            store_.set(cmd.args[0], cmd.args[1]);
            return "+OK\r\n";
        }
        else if (cmd.name == "GET" && cmd.args.size() >= 1) {
            auto val = store_.get(cmd.args[0]);
            if (val) return "+" + *val + "\r\n";
            return "$-1\r\n";  // nil
        }
        else if (cmd.name == "DEL" && cmd.args.size() >= 1) {
            bool deleted = store_.del(cmd.args[0]);
            return deleted ? ":1\r\n" : ":0\r\n";
        }
        else if (cmd.name == "DBSIZE") {
            return ":" + std::to_string(store_.size()) + "\r\n";
        }
        else if (cmd.name == "QUIT") {
            // Send response then close
            socket_.shutdown(tcp::socket::shutdown_both);
            return "+BYE\r\n";
        }
        else {
            return "-ERR unknown command\r\n";
        }
    }

    void writeResponse() {
        auto self = shared_from_this();
        asio::async_write(socket_, asio::buffer(response_),
            [this, self](boost::system::error_code ec, size_t /*bytes*/) {
                if (!ec) {
                    readCommand();  // ready for next command
                }
            });
    }
};

The Acceptor

class Server {
    tcp::acceptor acceptor_;
    Store store_;

public:
    Server(asio::io_context& io, uint16_t port)
        : acceptor_(io, tcp::endpoint(tcp::v4(), port))
    {
        acceptor_.set_option(asio::socket_base::reuse_address(true));
        std::cout << "Server listening on port " << port << '\n';
        accept();
    }

private:
    void accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::cout << "New connection from "
                              << socket.remote_endpoint() << '\n';
                    // make_shared — session manages its own lifetime
                    std::make_shared<Session>(std::move(socket), store_)->start();
                }
                accept();  // accept next connection
            });
    }
};

main() and Signal Handling

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

int main() {
    try {
        asio::io_context io;

        // Graceful shutdown on Ctrl+C
        asio::signal_set signals(io, SIGINT, SIGTERM);
        signals.async_wait([&io](auto, auto) {
            std::cout << "\nShutting down...\n";
            io.stop();
        });

        Server server(io, 6379);

        std::cout << "Running. Press Ctrl+C to stop.\n";
        io.run();  // blocks until io.stop() is called
    }
    catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
        return 1;
    }
    return 0;
}

Build:

g++ -std=c++17 -O2 redis_clone.cpp -lboost_system -pthread -o redis_clone

Test with telnet:

telnet localhost 6379

# Type commands:
SET name Alice
+OK
GET name
+Alice
DEL name
:1
GET name
$-1
DBSIZE
:0
QUIT
+BYE

Common Errors

Port already in use:

bind: Address already in use

Fix: set SO_REUSEADDR (we do this with reuse_address(true)) or change the port. Also check if a previous instance is still running.

bad_weak_ptr crash: If you call shared_from_this() in the Session constructor, the shared_ptr doesn’t exist yet and weak_from_this() returns an expired weak pointer. Always construct sessions with make_shared and call start() after construction, never from the constructor.

Connection drops immediately: The session’s shared_ptr must stay alive across async operations. If you store a Session as a stack variable or raw pointer, it will be destroyed when the accept lambda returns. The shared_from_this() pattern captures a shared_ptr in the lambda, keeping the session alive.


Performance Tips

TCP_NODELAY — disable Nagle’s algorithm for request-response protocols:

socket.set_option(tcp::no_delay(true));

Buffer reuse — instead of allocating a new string for each response, reuse a member buffer.

Reserve the hash map — if you know approximate load:

store_.reserve(10000);

Multiple io_context threads — if you want parallelism:

// Run io_context on N threads (need strand or mutex for shared store)
std::vector<std::thread> threads;
for (int i = 0; i < std::thread::hardware_concurrency(); ++i) {
    threads.emplace_back([&io] { io.run(); });
}

But then the Store needs protection — use a strand to serialize store access, or a mutex on each operation.


Extending the Server

Add TTL support:

struct Entry {
    std::string value;
    std::chrono::steady_clock::time_point expires;  // max() for no expiry
};
std::unordered_map<std::string, Entry> data_;

Add EXPIRE command:

else if (cmd.name == "EXPIRE" && cmd.args.size() >= 2) {
    int seconds = std::stoi(cmd.args[1]);
    auto it = data_.find(cmd.args[0]);
    if (it != data_.end()) {
        it->second.expires = std::chrono::steady_clock::now()
                           + std::chrono::seconds(seconds);
        return ":1\r\n";
    }
    return ":0\r\n";
}

Add persistence — write a snapshot on BGSAVE:

else if (cmd.name == "BGSAVE") {
    // Serialize the hash map to a file
    // In a real system this runs in a fork/background thread
    saveSnapshot("dump.rdb");
    return "+Background saving started\r\n";
}

Key Takeaways

  • Single-threaded io_context matches Redis’s architecture — event-driven, no lock contention on the store
  • enable_shared_from_this keeps sessions alive across async operations — construct with make_shared, call start() after
  • async_read_until('\n') plus streambuf is the idiomatic way to handle newline-delimited protocols in Asio
  • async_write must use a stable buffer — store the response in a member (not a local) before the async call returns
  • Signal handling with asio::signal_set integrates cleanly with the event loop for graceful shutdown

Frequently Asked Questions (FAQ)

Q. When would I use this in practice?

A. Build an in-memory key-value server with Boost.

Q. What should I read before this?

A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.

Q. Where can I study this more deeply?

A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.


Other articles related to this topic.


Keywords Covered in This Article (Related Search Terms)

This article covers C++, Redis, Asio, Event Loop, Key-Value, Tutorial.