Redis Event Loop
Redis is famously single-threaded for command execution. One thread accepts client
connections, reads commands off sockets, executes them, and writes replies — using a
portable reactor library called ae that wraps epoll on Linux, kqueue on BSD,
evport on Solaris, and select as a fallback. The result is a database that achieves 100k+
operations per second on a single core because the data structures don't need locks, the
cache is hot, and the dispatch loop is tight. This page traces the loop step by step,
explains how blocking commands like BLPOP work without actually blocking, and walks through
the I/O-threading additions in 6.0+ that parallelize wire-format work.
The Event Loop Iteration
Key Numbers
Why Single-Threaded
The ae Library
~500 lines of C that abstract over four kernel APIs.
src/ae.c defines a portable event-loop interface; src/ae_epoll.c,
src/ae_kqueue.c, src/ae_evport.c, and src/ae_select.c
are the four backend implementations selected at compile time. The interface offers four
operations: register a file event (read or write callback on an fd), register a time event
(one-shot or recurring callback at a deadline), run one iteration, and run forever.
aeEventLoop *aeCreateEventLoop(int setsize);
int aeCreateFileEvent(aeEventLoop *el, int fd, int mask,
aeFileProc *proc, void *clientData);
long long aeCreateTimeEvent(aeEventLoop *el, long long ms,
aeTimeProc *proc, void *clientData,
aeEventFinalizerProc *finalizerProc);
void aeMain(aeEventLoop *el); ← spins forever
Inside aeMain, each iteration calls aeProcessEvents(), which
computes the deadline of the soonest pending timer and uses that as the timeout for
epoll_wait (or kqueue equivalent). The kernel sleeps the thread until either an
fd becomes readable/writable or the timeout fires; on wake, the loop dispatches I/O
callbacks first, then any expired timers, then loops back. There's no busy-wait, no
explicit sleep — the kernel does the waiting.
Command Dispatch in processCommand
The hot path that every operation passes through.
When a client's read callback fires, Redis reads from the socket into the per-client query
buffer and tries to parse a complete RESP request. RESP is line-based: a request like
SET foo bar arrives as:
*3\r\n $3\r\nSET\r\n $3\r\nfoo\r\n $3\r\nbar\r\n
The parser detects "I have 3 bulk strings, all complete" and hands the parsed argv to
processCommand. That function:
1. lookup the command in commands.def (hash table by name) 2. validate arity, types, ACL permissions 3. if MULTI is in progress, queue the command on the MULTI list 4. if cluster: check slot ownership, possibly reply MOVED 5. if maxmemory exceeded: trigger eviction (synchronous!) 6. if AOF enabled: append command to AOF buffer 7. if writes need replication: append to replication backlog 8. cmd->proc(c) ← actually run the command 9. enqueue reply bytes on the client's output buffer 10. register the client's fd for write-readiness if reply is queued
Step 8 is where the dataset actually changes. For most commands this is a single dictionary lookup and update. For complex commands (SORT, ZRANGEBYSCORE on a huge zset, SUNIONSTORE across large sets) it can be expensive — and because we're on the single thread, no other client gets served while it runs.
Pipelining: Many Commands per Round-Trip
The reason throughput exceeds (1/RTT) commands per second.
A naive Redis client sends one command, waits for the reply, then sends the next. With a
0.5 ms RTT, max throughput is 2000 ops/sec from a single client. Pipelining
breaks this: the client sends N commands back-to-back without waiting, then reads N replies.
The server receives all N in one or a few read calls, parses them, executes
them sequentially, and the replies are batched on the wire.
// single command per round-trip: 2000 ops/s @ 0.5ms RTT SET k1 v1 ← send, wait, recv SET k2 v2 ← send, wait, recv ... // pipeline: 100k+ ops/s SET k1 v1\r\nSET k2 v2\r\nSET k3 v3\r\n... ← single send +OK\r\n+OK\r\n+OK\r\n... ← single recv
From the server's perspective, pipelining is invisible: processCommand just
runs sequentially through its queue. The win is the elimination of round-trip stalls and the
amortization of syscall overhead — one read serving 100 commands instead of 100
separate read calls. Most production Redis clients (Lettuce, hiredis, redis-py's
pipeline mode) buffer N commands client-side and flush together.
Blocking Commands (BLPOP, BRPOP, XREAD)
'Blocking' from the client's perspective; non-blocking from the server's.
BLPOP queue 5 waits up to 5 seconds for an item to appear in queue.
The server doesn't actually block any thread — that would single-thread one waiting client
for 5 seconds. Instead, the implementation:
1. check if queue is non-empty → if yes, pop and return immediately 2. otherwise, mark client.blocked = 1, attached to a "blocking_keys" map keyed on "queue" 3. set a timer for the timeout 4. return from processCommand without sending a reply 5. main loop continues serving other clients
When another client runs RPUSH queue x, that command's logic checks
db->blocking_keys for "queue" and, if any clients are waiting, picks the
longest-waiting one and serves the pop in the same RPUSH execution. The blocked client's
reply gets enqueued on its output buffer; the next event loop iteration writes it.
If the timer fires first, the client gets (nil) and is unblocked. Either way,
no thread was held — just a flag and a registration. The same pattern serves XREAD blocking
reads, BLMPOP, BLMOVE, and pub/sub pattern subscriptions.
I/O Threading (Redis 6.0+)
Splitting wire I/O across multiple threads while keeping execution serial.
For workloads dominated by many small commands from many clients (typical of caching), the
single thread can become the wire-I/O bottleneck rather than the execution bottleneck. Redis
6.0 added optional I/O threading. With io-threads 4 (and
io-threads-do-reads yes), the loop becomes:
main thread: worker threads (3): epoll_wait ← waiting on barriers collect ready clients ↓ distribute clients to N threads ↓ wait barrier --------------------- read socket, parse RESP ←-------------- collect parsed cmds ↓ execute all cmds serially ← idle ↓ distribute replies to N threads ↓ wait barrier --------------------- write socket ←-------------- collect done
Crucially, command execution is still serial — only the read-from-socket and write-to-socket steps are parallelized. The data structures stay lock-free.
Tradeoffs: I/O threading adds barrier-synchronization overhead, so it's a loss on workloads
with few clients or large pipelined batches (where wire I/O is already amortized). It helps
most when CPU profile shows significant time in read(2), write(2),
and RESP parsing — which usually means thousands of clients hitting many small operations.
Common production setting: io-threads 4 for a 4-core box, 8 for an
8-core box.
Background I/O Threads (bio.c)
Three additional threads for fsync, fclose, and lazyfree.
Even without io-threads, Redis has had background helpers since 2.x via bio.c.
Three named threads:
bio_close_file: closes() AOF and replication files (close on big files
can stall on filesystem metadata)
bio_aof_fsync: fsync()s the AOF file periodically under everysec mode,
keeping fsync latency off the main thread
bio_lazy_free: frees large objects (DEL on 1M-entry hash) asynchronously
when UNLINK is used or lazyfree-* configs are enabled
Each is a producer/consumer queue: the main thread enqueues a job and continues; the bg
thread pops and runs the syscall. UNLINK key instead of DEL key
uses lazyfree, returning instantly while a worker frees the value's memory. For a hash with
a million fields, this turns a 30 ms blocking DEL into a 1 µs UNLINK.
FAQ
Is Redis really single-threaded?
The command execution thread is single. Everything else — fsync (bio_aof_fsync), key freeing for large objects (lazyfree), close-on-quit, BGSAVE/BGREWRITEAOF (forked process), I/O reads/writes to clients (since 6.0 with io-threads N) — runs on background threads or child processes. Only the actual command execution and dataset mutation happen on the main thread, which is how Redis preserves the single-writer invariant that makes its data structures lock-free.
What does io-threads do?
Splits the read-from-socket and write-to-socket steps across N threads while keeping command execution on the main thread. The main thread accepts commands, distributes the parse-input work to N worker threads, then collects parsed commands and executes them serially, then distributes the encode-and-write-response work back out. Useful for workloads where wire I/O dominates over command execution (small commands, many clients). For pipelined or large-command workloads, it adds overhead. Default is 1 (off); 4-8 is typical for high-pps cache servers.
Why does an O(N) command like KEYS block everything?
Because everything runs on the main thread. KEYS * iterates the entire keyspace and produces a result; nothing else gets served until it finishes. On a 100M-key Redis, KEYS can block for seconds. Use SCAN instead — it's a cursor-based iterator that returns ~10 keys per call, freeing the main thread between calls. Same for HKEYS on huge hashes (use HSCAN), SMEMBERS (SSCAN), KEYS pattern matches, and DEL on a giant set (use UNLINK to free asynchronously).
What is BLPOP doing while it blocks?
It registers the client in a waiting list keyed on the list's name and unblocks itself from the read event. The main thread continues serving other clients. When another client RPUSHes, the executor checks the waiting list, picks the longest-waiting blocked client, dequeues and serves it in the same dispatch — so BLPOP wakes synchronously inside the RPUSH command. The blocked client doesn't tie up a thread; it's just a callback registered on a pub-sub-like signal.
How does Redis pick between epoll, kqueue, evport, and select?
At compile time. The ae_*.c files implement four backends: epoll for Linux (since 2.5.45), kqueue for BSD/macOS, evport for Solaris, and select as the universal fallback. The configure script picks the most efficient available. Epoll and kqueue are O(1) for delivery (the kernel maintains a ready list), while select is O(N) on the fd_set. On Linux, Redis always uses epoll.
Can a single command be too slow?
Yes. Every command runs to completion on the main thread, so a SORT BY ... LIMIT 0 1000000 with a sort-by-pattern on a million-element list will block all other clients for hundreds of milliseconds. Use the SLOWLOG to find these — SLOWLOG GET 10 shows the 10 slowest recent commands. Common offenders: KEYS, SMEMBERS on huge sets, ZRANGE with massive limits, large HGETALL, expensive Lua scripts. Lua has a hard timeout (lua-time-limit, default 5s) after which Redis switches into BUSY mode and refuses commands until SCRIPT KILL.