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 for command execution.
It is worth being precise about why this works. A single thread can serve thousands of connections because almost all of a connection’s life is spent waiting — for the client to send the next line, or for the kernel to accept outgoing bytes. Asio registers those waits with the operating system (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows) and only runs your handler when there is something to do. Each handler here does microseconds of work: parse a line, touch a hash map, format a reply. As long as that stays true, one core is enough, and you get two properties for free: no locks on the store, and every command is atomic with respect to every other command. The cost is that any slow handler stalls every client. A KEYS * over a million entries, a synchronous file write, or a DNS lookup inside a handler blocks the whole server — which is exactly why real Redis warns against KEYS in production. Since version 6, Redis can also offload socket reads and writes to I/O threads, but command execution still happens on one thread.
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(); }
};
get returns std::optional<std::string> by value, which copies the stored string. That is deliberate: returning a reference or pointer into the map would dangle as soon as another command rehashes or erases the entry, and in an asynchronous server “later” arrives sooner than you expect — the response might be written after the next client’s DEL. For large values a production store would hand out reference-counted buffers instead of copying, but copying is the correct default for a first version. Note also that std::unordered_map rehashes the whole table when it grows past its load factor; with millions of keys that single set takes noticeably longer than the others, which is one reason Redis implements its own dictionary with incremental rehashing spread over many operations.
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. (The table shows \n for readability; the code terminates replies with \r\n as RESP does.)
The simplification has a real cost: a line-based protocol cannot carry values that contain spaces or newlines, and replying to GET with +value breaks as soon as a value contains \r\n. RESP avoids both problems by length-prefixing: a client sends *3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nAlice\r\n, and the server reads exactly the announced number of bytes instead of scanning for delimiters. That is what “binary-safe” means, and it is also faster to parse because the server never has to search the payload. Interestingly, real Redis still accepts plain space-separated lines (the “inline command” format) precisely so you can poke at it with telnet as we do below.
#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(static_cast<unsigned char>(c)));
std::string arg;
while (ss >> arg) {
cmd.args.push_back(arg);
}
// For SET, the value might have spaces — grab remainder
return cmd;
}
Two small details in the parser are there for correctness rather than style. The cast to unsigned char before toupper matters because passing a negative char (any byte above 0x7F on platforms where char is signed) to toupper is undefined behavior — a client sending UTF-8 or random bytes should not be able to trigger that. And std::istringstream splits on any whitespace, which is why the comment about SET values with spaces is only a comment: supporting them means either quoting rules (as redis-cli implements) or switching to RESP. Using a string stream per command is also not free; a hand-written tokenizer over std::string_view avoids the allocation, but it is not worth the complexity until a profiler says so.
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_;
bool closing_ = false;
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") {
// Close only after the reply has been written (see writeResponse)
closing_ = true;
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) return;
if (closing_) {
boost::system::error_code ignored;
socket_.shutdown(tcp::socket::shutdown_both, ignored);
return; // no further reads; session is released
}
readCommand(); // ready for next command
});
}
};
The session is a small state machine: read a line, execute, write the reply, read again. There is only ever one outstanding operation per session, which is what keeps it simple — no two handlers of the same session can run at once, and response_ can safely be a single member. That member matters: asio::buffer(response_) does not copy the string, it only records a pointer and a length, and async_write returns immediately. If the response were a local variable, it would be destroyed before the kernel had sent a byte, and the client would receive garbage or the process would crash.
The QUIT branch is the one place where ordering bites. An earlier version of this code called socket_.shutdown() directly inside execute, before the +BYE reply had been written — so the write failed and the client never saw the reply. Deferring the shutdown to the write completion handler fixes that. The lifetime model works the same way for normal disconnects: when a read fails with asio::error::eof (client closed) or any other error, the handler returns without starting a new operation, the last shared_ptr copy captured in self goes away, and the Session destructor closes the socket.
asio::streambuf also deserves a note. async_read_until may read more than one line — a client that pipelines SET a 1\r\nGET a\r\n in one packet delivers both — and the leftover bytes stay in buffer_. The next async_read_until checks the buffer first and completes immediately if it already contains a newline, so pipelining works as long as you reuse the same streambuf rather than creating one per read. The flip side is that the buffer grows without bound while no newline arrives: a client that sends a gigabyte without \n makes the server allocate a gigabyte. Constructing it as asio::streambuf buffer_{64 * 1024}; caps it, and a line longer than the limit then fails with asio::error::not_found, which you can treat as a protocol error and disconnect.
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
});
}
};
The acceptor re-arms itself after every connection, including after errors. That is important: if accept() were only called on success, a single transient failure — typically EMFILE (“Too many open files”) when the process hits its file descriptor limit — would silently stop the server from ever accepting again while existing clients kept working. Re-arming unconditionally keeps it alive; in production you would also log the error and back off briefly, because retrying instantly on EMFILE spins the CPU. One more trap is socket.remote_endpoint(): this overload throws boost::system::system_error if the peer has already disconnected, which would propagate out of io.run() and terminate the server. The overload that takes a boost::system::error_code& is the safe choice in logging code.
Note also the ownership direction: Store lives in Server, sessions hold a Store&. That is valid because Server outlives every session in this program — io.run() returns only after io.stop(), and sessions are destroyed when their pending handlers are destroyed with the io_context. If you ever restructure so that the store can be destroyed first, the reference becomes dangling; a shared_ptr<Store> would make the dependency explicit.
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
Since Boost 1.69, Boost.System is header-only, so on recent Boost versions -lboost_system is unnecessary (and on some distributions the library no longer exists, producing cannot find -lboost_system); drop the flag if you see that error. For debugging, build once with -fsanitize=address,undefined -g: lifetime mistakes in async code show up there immediately as heap-use-after-free with both the freeing and the using stack traces.
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
Because the replies follow RESP’s shape, redis-cli -p 6379 PING will connect but fail: redis-cli sends requests as RESP arrays (*1\r\n$4\r\nPING\r\n), which this parser reads as a command named *1. That is a quick way to see where the protocol simplification ends. nc localhost 6379 works as well as telnet and is easier to script: printf 'SET a 1\r\nGET a\r\n' | nc localhost 6379 also exercises the pipelining path described above.
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.
Capturing only this:
The mistake I made most often when first writing Asio servers was capturing [this] without self in one handler, usually a newly added one. It works in every quick test because the session happens to still be alive, then crashes under load when a client disconnects between an operation starting and its handler running. Every completion handler that touches members needs the self copy; grepping for [this] in session code is a cheap review check.
Performance Tips
TCP_NODELAY — disable Nagle’s algorithm for request-response protocols:
socket.set_option(tcp::no_delay(true));
Nagle’s algorithm holds back small writes while earlier data is unacknowledged; combined with delayed ACKs on the client, a request-response protocol can see replies delayed by tens of milliseconds. Set the option on each accepted socket, inside the accept handler.
Buffer reuse — instead of allocating a new string for each response, reuse a member buffer.
Reserve the hash map — if you know approximate load (add a reserve method to Store that forwards to the map):
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.
Be honest about what this buys. For a store whose operations take microseconds, the bottleneck is usually system calls and network round trips, not CPU, and a global mutex around the map turns N threads back into roughly one while adding contention. Designs that actually scale split the key space: several independent single-threaded shards, each owning part of the keys, with connections or keys routed by hash. That is the model used by Redis Cluster across processes and by alternatives such as KeyDB and Dragonfly within one process. Measure with a load generator (for example redis-benchmark after you implement RESP) before adding threads.
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";
}
Setting an expiry time is the easy half; something must also remove expired keys. Redis combines two strategies, and a clone should too. Lazy expiry: every GET (and any command touching a key) checks expires first and treats an expired entry as missing, erasing it on the spot — this guarantees correctness. Active expiry: a periodic task samples a handful of keys that have a TTL and deletes the expired ones, so keys nobody reads again don’t sit in memory forever. In Asio the periodic task is a steady_timer that re-arms itself every 100 ms or so; because it runs on the same io_context thread, it needs no locking either. Use steady_clock, as the struct does, not system_clock — a wall-clock adjustment by NTP must not expire or resurrect keys.
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";
}
As written, saveSnapshot runs on the event loop thread, so every client waits while the file is written — the reply says “background” but it is not. Redis solves this with fork(): the child process gets a copy-on-write snapshot of memory and writes it to disk while the parent keeps serving requests, with only modified pages actually copied. Copying the map and handing it to a worker thread is the portable alternative, at the cost of briefly doubling memory. The other classic approach is an append-only log of write commands, which loses less data on crash but needs periodic compaction.
Asio design choices behind the server
- 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. Why does my server crash with bad_weak_ptr or drop connections right after accept?
A. Both come from session lifetime. Calling shared_from_this() inside the Session constructor throws bad_weak_ptr because the owning shared_ptr does not exist yet, so create sessions with make_shared and call start() afterwards. If connections close immediately, the session is probably owned by a local variable that dies when the accept handler returns; capturing shared_from_this() in every async handler keeps it alive until the last operation finishes.
Related Articles
- Boost.Asio Introduction: io_context, async_read, and
- Build a Minimal C++ HTTP Framework from Scratch with Asio
- Custom C++ Memory Pools: Fixed Blocks, TLS, and Benchmarks