본문으로 건너뛰기
Previous
Next
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]

이 글의 핵심

Build an in-memory key-value server with Boost.Asio: single-threaded io_context, async_read_until, GET/SET/DEL, and ops patterns. Redis clone in C++ with full working code.

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

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Build an in-memory key-value server with Boost.Asio: single-threaded io_context, async_read_until, GET/SET/DEL, and ops … 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, Redis, Asio, Event Loop, Key-Value, Tutorial 등으로 검색하시면 이 글이 도움이 됩니다.