Modern production servers routinely pack 64 or 128 CPU cores and hundreds of gigabytes of RAM. Yet one of the fastest pieces of infrastructure in almost every tech stack, handling upwards of 100,000 operations per second per instance with sub-millisecond p99 latency, runs its core execution engine on exactly one CPU core.
That tool is Redis.
When junior developers first learn that Redis is fundamentally single-threaded for command execution, the immediate reaction is disbelief. Why would a database deliberately ignore 63 out of 64 available cores? Isn't multithreading the canonical path to high throughput?
The answer reveals a fundamental truth about computer architecture that high-level web frameworks hide: when your data lives entirely in RAM, CPU cycles are rarely your bottleneck. Memory access latency, lock contention, and network I/O are.
Let's open the hood and trace what actually happens inside Redis from the moment a TCP packet hits the network interface to the exact byte manipulation in physical memory.
1. The Real Bottleneck: Why Multi-Threading Destroys In-Memory Latency
In a traditional disk-backed relational database like PostgreSQL or MySQL, a query often waits for disk blocks to load from NVMe storage into the buffer pool. Because disk reads take microseconds or milliseconds, the database engine spawns worker threads or processes. While Thread A waits on disk I/O, the operating system context-switches to Thread B to perform useful work.
Redis does not wait on disk for read operations. Every key, string, hash, and set lives entirely in physical memory.
Accessing an L1/L2 CPU cache line takes roughly 1 to 4 nanoseconds. Accessing main system memory (DRAM) takes roughly 50 to 100 nanoseconds. In contrast, an operating system thread context switch costs between 1,000 and 3,000 nanoseconds, not counting the devastating hidden penalty of CPU cache invalidation (flushing L1/L2 instruction and data caches).
Now consider what happens if you make an in-memory key-value store multithreaded:
- Lock Contention: To ensure thread safety across concurrent reads and writes to shared hash tables, every thread must acquire mutexes, read-write locks, or spinlocks. Under high concurrency, threads spend more time spinning or sleeping on lock acquisitions than doing real work.
- CPU Cache Line Bouncing: When two threads running on different CPU cores read and write to adjacent memory addresses in the same 64-byte cache line, the CPU's cache coherence protocol (such as MESI) must continuously invalidate and sync cache lines across physical CPU sockets over the interconnect.
- Context Switching Overhead: The OS scheduler spends measurable CPU overhead swapping thread execution registers.
By choosing a single-threaded execution model for command processing, Salvatore Sanfilippo (antirez) eliminated all three costs at once. Redis has zero mutex overhead on data access, zero lock contention, zero thread synchronization bugs, and zero context switching within its command execution path.
2. The Heart of Redis: The Reactor Pattern and ae.c
If Redis only uses one execution thread, how does it service 10,000 concurrent client connections without blocking on slow networks?
The answer is the Reactor Pattern, implemented through Redis's custom, minimalist event library called ae (contained in src/ae.c).
Instead of giving each client connection its own thread, Redis registers non-blocking socket file descriptors with the operating system's native I/O multiplexing API:
- Linux:
epoll(src/ae_epoll.c) - macOS/BSD:
kqueue(src/ae_kqueue.c) - POSIX fallback:
select(src/ae_select.c)
The Event Loop Data Structure
At startup, initServer() in src/server.c initializes the main event loop structure:
typedef struct aeEventLoop { int maxfd; /* Highest file descriptor currently registered */ long long timeEventNextId; aeFileEvent events[AE_SETSIZE]; /* Registered file events table */ aeFiredEvent fired[AE_SETSIZE]; /* Events ready for processing */ aeTimeEvent *timeEventHead; /* Linked list of timed cron events */ int stop; void *apidata; /* Polling API specific data (e.g. epoll state) */ aeBeforeSleepProc *beforesleep; /* Hook executed right before polling */ aeBeforeSleepProc *aftersleep; /* Hook executed immediately after waking */ } aeEventLoop; Redis creates an epoll instance using epoll_create1() and sets its listening TCP socket (port 6379) to non-blocking mode (O_NONBLOCK). It then registers an EPOLLIN event on the listening socket with the callback acceptTcpHandler.
The Lifecycle of a Request
The entire engine runs inside a single while loop in aeMain():
+-------------------------------------------------------------+ | aeMain() | | +-------------------------------------------------------+ | | | 1. Execute beforesleep() callbacks | | | | (Flush client output buffers, write AOF buffer) | | | +-------------------------------------------------------+ | | | | | v | | +-------------------------------------------------------+ | | | 2. Call aeApiPoll() -> epoll_wait() | | | | (Kernel returns array of ready file descriptors) | | | +-------------------------------------------------------+ | | | | | v | | +-------------------------------------------------------+ | | | 3. Iterate over fired events: | | | | - Read socket into client query buffer | | | | - Parse RESP protocol command | | | | - Execute command in memory dict | | | | - Queue response into client reply buffer | | | +-------------------------------------------------------+ | | | | | v | | +-------------------------------------------------------+ | | | 4. Process time events (serverCron: TTL expiry, etc) | | | +-------------------------------------------------------+ | +-------------------------------------------------------------+ -
beforesleep: Before blocking for new network events, Redis flushes pending client write buffers to sockets and flushes append-only file (AOF) buffers to disk. -
aeApiPoll: Redis invokesepoll_wait(). The kernel sleeps the thread until one or more sockets have data ready to read or write. If a client sends a command,epoll_wait()returns immediately with the active file descriptors. - Execution: For each ready descriptor, Redis calls
readQueryFromClient(), reads the network bytes into a memory buffer, parses the Redis Serialization Protocol (RESP), dispatches toprocessCommand(), executes the command against the in-memory database, and stages the formatted response in the client's output buffer. -
serverCron: Once file events are handled, Redis checks its time event queue to execute maintenance tasks like active key expiration, progressive rehashing, and cluster health checks.
Because memory lookups complete in fractions of a microsecond, Redis processes thousands of commands per event loop tick without leaving the CPU cache.
3. In-Memory Data Structures: Engineered for Zero Wasted Motion
Redis does not use standard C standard library data structures. Standard C strings and generic hash tables are far too slow and memory-inefficient for high-performance memory operations. Redis engineers its own low-level structures designed around CPU cache lines.
A. Simple Dynamic Strings (SDS)
In standard C, strings are null-terminated arrays of characters (char*). To find the length of a C string, the program must scan every character until it hits \0, an $O(N)$ operation. Furthermore, standard C strings cannot contain binary data (like an image or serialized protobuf) because a zero byte is misinterpreted as the terminator.
Redis wraps all strings in SDS (Simple Dynamic Strings) (src/sds.h):
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* Used bytes (string length) */ uint8_t alloc; /* Total allocated bytes excluding header and null */ unsigned char flags; /* 3 bits for type, 5 bits unused */ char buf[]; /* Actual character payload */ }; Why this design is brilliant:
- $O(1)$ Length Checks: The string length is stored directly in the
lenfield.STRLENis instant. - Binary Safe: Strings can contain arbitrary bytes, including null bytes, because length is determined by
len, not\0. - Zero Overhead Headers: Redis defines multiple headers (
sdshdr8,sdshdr16,sdshdr32,sdshdr64). For short strings (under 256 bytes), the entire header is only 3 bytes! - CPU Cache Locality: Notice
__attribute__ ((__packed__)). The header immediately precedes the bufferbuf. A pointer tosdspoints directly tobuf[0], meaning SDS can be passed directly to standard C library functions (printf,strcmp) while allowing Redis to read the header by simply indexing backwards in memory (sds[-1]).
B. Dict and Progressive Rehashing (src/dict.c)
The primary Redis keyspace is a dictionary (hash table). In naive hash table implementations, when the load factor exceeds a threshold, the table allocates a bucket array twice as large and rehashes all existing keys in a tight loop.
If you have 20 million keys in Redis, rehashing all 20 million keys at once would lock the single thread for 300 to 500 milliseconds. Every client connected to Redis would experience a timeout spike.
To prevent this, Redis implements Progressive Rehashing:
typedef struct dict { dictType *type; dictEntry **ht_table[2]; /* Two hash tables: ht[0] (main) and ht[1] (rehashing) */ unsigned long ht_used[2]; long rehashidx; /* Rehashing progress: -1 if not rehashing, otherwise bucket index */ /* ... */ } dict; When a resize is triggered:
- Redis allocates a new table
ht_table[1]with the new capacity (e.g. double the size). - It sets
rehashidx = 0. - During normal operation: Every time a client executes a read, write, or delete (
dictAdd,dictFind,dictDelete), Redis migrates exactly one bucket fromht_table[0]toht_table[1]before servicing the command. - During background idle time: Inside
serverCron(), Redis spends up to 1 millisecond per tick migrating remaining buckets. - Once all buckets are migrated,
ht_table[1]becomesht_table[0], the old table is freed, andrehashidxresets to-1.
Through progressive rehashing, the computational cost of resizing millions of keys is amortized over thousands of individual requests. No single client ever feels the freeze.
C. Skip Lists for Sorted Sets (src/t_zset.c)
When developers need sorted data structures in database design, textbooks prescribe self-balancing binary search trees (Red-Black trees or AVL trees).
Redis rejects Red-Black trees for Sorted Sets (ZSET) and instead uses Skip Lists (zskiplist):
Level 3: [1] ------------------------------> [9] -> NULL Level 2: [1] -------------> [5] -----------> [9] -> NULL Level 1: [1] ------> [3] -> [5] ------> [7] -> [9] -> NULL Level 0: [1] -> [2]->[3] -> [5] -> [6]->[7] -> [9] -> NULL A skip list is a layered linked list where nodes are assigned random forward pointers (levels) based on a geometric distribution.
Why did Redis choose skip lists over balanced trees?
- Range Queries are Trivial: In a skip list, the bottom level is a standard doubly-linked list. Executing
ZRANGEBYSCOREor pagination simply searches for the lower bound in $O(\log N)$ time, and then traverses adjacent pointers sequentially in $O(M)$ time. - Simpler Memory Management: Rebalancing a Red-Black tree requires tree rotations, which mutate parent, left, and right pointers across multiple nodes, trashing CPU caches and complicating concurrent extensions. Inserting into a skip list only updates local pointers of predecessor nodes.
- Better Cache Density: Skip list nodes have smaller overhead per element compared to balanced tree nodes that require parent pointers, color bits, and balance factors.
4. What Redis 6.0+ Threaded I/O Actually Did
In Redis 6.0, an important architectural enhancement arrived: I/O Threads (io-threads).
Many headlines proclaimed: "Redis is finally multi-threaded!" That was deeply misleading.
Profile an active Redis instance under 150,000 requests per second with small payloads, and you will discover that the CPU spends:
- ~15% of its time reading and modifying data structures in RAM.
- ~85% of its time reading bytes from network sockets into memory buffers, parsing the RESP protocol strings, and writing formatted response packets back out to network system calls (
read(),write()).
The execution of the command itself is blisteringly fast. The network transport serialization is what burns CPU cycles.
Incoming Network Packets | v +------------------+------------------+ | I/O Thread 1 | I/O Thread 2 | <-- Parallel Socket Reads & RESP Parsing +------------------+------------------+ | | +--------+---------+ | v +----------------------+ | MAIN THREAD | <-- 100% SINGLE-THREADED COMMAND EXECUTION | (Atomic, No Mutexes) | (GET, SET, HSET, ZADD) +----------------------+ | +--------+---------+ | | v v +------------------+------------------+ | I/O Thread 1 | I/O Thread 2 | <-- Parallel Socket Writes & Network I/O +------------------+------------------+ Redis 6.0 introduced threaded I/O while strictly preserving the single-threaded execution model:
- Background I/O Threads: Multiple threads read incoming buffers from client sockets and parse raw network packets into executable commands.
- Main Thread: The single main thread executes the parsed commands against the dataset in memory sequentially. It remains 100% lock-free and atomic.
- Background I/O Threads: Once execution completes, the background I/O threads take the output buffers and write them back down to the TCP sockets in parallel.
This design delivers a 2x to 3x throughput improvement on multi-core servers without sacrificing the simplicity and consistency of lock-free data structures.
5. The Production Traps That Break Single-Threaded Redis
Understanding that Redis runs on a single event loop explains why certain practices that are harmless in PostgreSQL or MongoDB will catastrophically bring down a Redis instance.
Trap 1: The O(N) Command Freeze
In a multi-threaded database, running a poorly indexed query consumes one connection and one thread; other queries continue executing on other cores.
In Redis, running an $O(N)$ command against a large dataset locks the entire server:
-
KEYS *on a database with 5 million keys will block the event loop for 1.5 seconds. During those 1.5 seconds, every singleGET,SET, or health-check ping from every other service in your cluster stalls. -
HGETALLon a hash with 100,000 fields blocks the event loop while serializing the massive response. -
SMEMBERSon a set with hundreds of thousands of members.
The Fix: Never use KEYS in production; use cursor-based iteration with SCAN. For large collections, use HSCAN, SSCAN, and ZSCAN, or store data across sharded buckets.
Trap 2: The Fork and Copy-on-Write Memory Spike
To produce persistent snapshots on disk (BGSAVE) or rewrite the append-only file (BGREWRITEAOF), Redis does not iterate over its keyspace while serving traffic. Instead, it calls the Linux system call fork().
fork() spawns a child process that shares the exact same memory pages as the parent process via operating system Copy-on-Write (CoW).
When the parent process writes to a memory page while the child process is dumping the snapshot to disk, the Linux kernel duplicates that specific 4KB memory page so the parent can modify it without corrupting the child's snapshot.
The Failure Mode: If you have a write-heavy workload while a snapshot is running, Copy-on-Write can duplicate hundreds of thousands of memory pages. A 16GB Redis instance can suddenly require 28GB of RAM. If total memory exceeds physical RAM, the Linux Out-Of-Memory (OOM) Killer will abruptly terminate your Redis process (SIGKILL).
Trap 3: The Swap Death Spiral
Because Redis relies on microsecond-level memory access, operating system swapping is fatal.
If the Linux virtual memory manager swaps even a few hundred megabytes of Redis memory to disk or NVMe swap space, an incoming GET command that hits a swapped page causes a major page fault.
The single Redis thread halts and waits for the OS kernel to read the page from disk back into DRAM. A sub-millisecond query turns into a 20-millisecond lockup. Because all incoming connections share that single thread, connection pools queue up, timeouts trigger across all upstream microservices, and cascading failures ripple through your application.
The Fix: Always set vm.overcommit_memory = 1 in Linux sysctl, configure a conservative maxmemory limit with an eviction policy (volatile-lru or allkeys-lru), and disable swap or configure swapiness (vm.swappiness = 1 or 0).
6. The Architecture Comparison: Redis vs Memcached vs KeyDB
| Architectural Feature | Redis (Core Engine) | Memcached | KeyDB (Multithreaded Fork) |
|---|---|---|---|
| Execution Model | Single-threaded Reactor loop | Multithreaded with per-thread event loops | Multithreaded shared-nothing / fine-grained locks |
| Network I/O | Optional threaded I/O (v6.0+) | Fully multithreaded (libevent) | Fully multithreaded |
| Locking Overhead | None (zero mutexes on data path) | Bucket-level / item-level mutexes | Spinlocks & read-copy-update (RCU) |
| Data Types | Strings, Hashes, Lists, Sets, ZSets, Streams, Bitmaps, HyperLogLog | Raw Strings / Byte Blobs only | Same as Redis |
| Persistence | RDB snapshots + AOF write-ahead log | None (pure in-memory cache) | RDB + AOF |
| Sub-millisecond p99 | Consistently predictable (no lock jitter) | Susceptible to lock contention under key hotspots | High throughput, slightly higher variance |
Summary: The Senior Developer Mental Model
Redis is not fast despite being single-threaded. It is fast precisely because it is single-threaded in an in-memory, memory-bound problem space.
When building or debugging systems that rely on Redis, keep this mental model clear:
- CPU is rarely the limit: You will almost always run out of memory bandwidth, network bandwidth, or physical RAM long before you saturate the CPU core executing command logic.
- Atomicity is free: Because command execution is serialized on one thread, operations like
INCR,HSET, and Lua scripts are inherently transactional without requiring distributed transactions or 2-phase commits. - One bad query ruins the party: Because there is only one worker on the floor, an $O(N)$ command does not just slow down the caller; it suspends time for every service connected to that instance.
Architectural performance is never about throwing more threads at a problem. It is about identifying the true physical bottleneck of your hardware and stripping away every layer of abstraction that gets in the way.
