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_contextandasync_read/async_writeworks - How
enable_shared_from_thiskeeps 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_thiskeeps sessions alive across async operations — construct withmake_shared, callstart()afterasync_read_until('\n')plusstreambufis the idiomatic way to handle newline-delimited protocols in Asioasync_writemust use a stable buffer — store the response in a member (not a local) before the async call returns- Signal handling with
asio::signal_setintegrates 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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Boost.Asio Introduction: io_context, async_read, and](/en/blog/cpp-series-29-1-asio-intro/
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- [Custom C++ Memory Pools: Fixed Blocks, TLS, and Benchmarks](/en/blog/cpp-series-48-3-memory-pool/
이 글에서 다루는 키워드 (관련 검색어)
C++, Redis, Asio, Event Loop, Key-Value, Tutorial 등으로 검색하시면 이 글이 도움이 됩니다.