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

aeMain → aeProcessEvents repeats forever 1. compute next timer deadline → epoll_wait 2. epoll_wait(fd, ms) block until I/O or timeout 3. fire I/O callbacks read, parse, execute, write 4. fire timers expirations, cron loop, no sleep — back to step 1 immediately Read callback (per-client): read from socket → append to query buf → if a complete RESP request, processCommand() processCommand: lookup → call cmd->proc(c) → mutate dataset → enqueue reply on output buf Write callback (per-client): drain output buf → if drained, deregister write event Timers: serverCron at 100ms cadence — TTL eviction, slow log, replication tasks, stats

Key Numbers

Per-core throughput
100k+ ops/s
P50 latency
~50 µs
serverCron interval
100 ms
Default I/O threads
1 (off)
Bio threads
3 (close, fsync, lazyfree)
Lua time limit
5 s
Slowlog default
10 ms

Why Single-Threaded

No locks, no contention
Every data structure can be touched freely because only one thread ever touches it. No mutex acquisition, no atomic compare-and-swap, no cache-line ping-pong between cores. The hot path is straight-line code.
Cache-resident
All the recently-accessed data structures stay in one core's L2/L3. A multithreaded design would have those structures bouncing between cores, each migration costing hundreds of cycles.
Memory bandwidth-bound
For typical workloads, a single core is bottlenecked by memory bandwidth before CPU. Adding more threads to the same memory subsystem doesn't help. The fix is more processes (Redis Cluster), not more threads.

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.