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_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
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.
Related Articles (Internal Links)
Other articles related to this topic.
- Boost.Asio Introduction: io_context, async_read, and
- C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
- Custom C++ Memory Pools: Fixed Blocks, TLS, and Benchmarks
Keywords Covered in This Article (Related Search Terms)
This article covers C++, Redis, Asio, Event Loop, Key-Value, Tutorial.