Ch. 27 · Redis

Why Is Redis Single-Threaded? Event Loop and I/O Threads

Why one Redis thread handles huge request rates, what io-threads and background threads change, and which commands block every client.

~8 min readintermediateupdated Oct 6, 2026

“Redis is single-threaded, so how can it handle hundreds of thousands of requests per second?” is one of the most common Redis interview openers. The follow-ups are where candidates separate: “is it really single-threaded?”, “what does io-threads do?”, “why did our whole platform slow down when someone ran one command?” Interviewers ask because the threading model explains both Redis’s biggest strength (atomic commands without locks) and its most common production incident (one slow command blocking everyone).

This guide targets Redis 7.4 and 8.x. Valkey, the Linux Foundation fork of Redis 7.2.4, shares the same execution model, although its I/O threading implementation has evolved separately since the fork.

Before you start

You should know what a TCP connection and a network round trip are, and roughly what a system call is. It helps to have met an event loop before, for example in Node.js: one thread waits for “socket is readable” notifications from the operating system and handles whichever connections are ready. Familiarity with redis-cli and basic commands such as GET, SET and INCR is enough for the examples.

The short answer

Redis executes all commands on one main thread that runs an event loop over epoll (Linux) or kqueue (macOS, BSD). It is fast because the data is in memory, the data structures are compact and chosen for each type, and a single executor needs no locks, so the real limit is usually network round trips and syscalls rather than CPU. Redis is not literally one thread: background threads free memory and fsync the AOF, fork() creates child processes for snapshots, and since Redis 6 optional I/O threads read sockets, parse requests and write replies, while command execution stays serial.

How it works

The event loop (Redis’s small ae library) does the same thing over and over:

  1. Ask the kernel which client sockets are readable or writable, waiting at most until the next timer is due.
  2. For each readable socket, read the bytes into that client’s query buffer and parse complete commands in the RESP protocol.
  3. Execute each complete command against the in-memory dataset, then append the reply to the client’s output buffer.
  4. Before sleeping again, flush pending replies to sockets and run housekeeping, such as fsyncing the AOF in always mode.
  5. Run time events: serverCron, hz times per second (10 by default), handles active expiry, statistics, client timeouts and rehashing.

Because step 3 runs one command at a time, no other client can observe a command half-done. INCR is a read-modify-write, yet two clients incrementing the same key never lose an update. The flip side is that a command taking 2 seconds makes every other client wait 2 seconds.

The parts that do run elsewhere:

Work Where it runs Why
Command execution main thread atomicity without locks
Socket read, RESP parsing, reply write main thread, or I/O threads with io-threads > 1 network syscalls dominate at high load
Freeing large values (UNLINK, FLUSHALL ASYNC) background thread freeing millions of allocations is slow
AOF fsync with everysec background thread disk latency must not stall clients
RDB snapshot, AOF rewrite, full replica sync forked child process consistent copy via copy-on-write

Enabling I/O threads is a config change, and it only helps when the main thread is saturated by network work on a machine with spare cores:

# redis.conf
io-threads 4          # default 1 (disabled); the main thread counts as one of them
Terminal

Redis 8.0 reworked the I/O threading implementation so that I/O threads own a set of client connections and hand parsed commands to the main thread, which improved throughput over the Redis 6/7 design. In both designs the dataset is touched only by the main thread.

Step-by-step walkthrough

Step 1: See atomicity from serial execution

Two clients incrementing the same counter is the classic race in a multi-threaded store. In Redis it is safe because INCR runs as one indivisible step on the main thread.

# Start with no page:views key, then run this in two terminals at the same time
for i in $(seq 1 5000); do redis-cli INCR page:views > /dev/null; done
Terminal
redis-cli GET page:views
# "10000"
Terminal

If the clients had done GET, added one in application code and then SET, updates would be lost, because the two commands from one client can interleave with the other client’s commands. Serial execution makes each command atomic, not each sequence of commands.

Step 2: Measure round trips, the real bottleneck

A single command is processed in microseconds, but the client waits a full network round trip for each reply. Sending 1,000 commands one by one over a 0.5 ms link costs at least half a second regardless of how fast Redis is. Pipelining sends them all before reading the replies:

import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# One round trip per command
prices = [r.get(f"price:{pid}") for pid in product_ids]

# One round trip for the whole batch
pipe = r.pipeline(transaction=False)
for pid in product_ids:
    pipe.get(f"price:{pid}")
prices = pipe.execute()

# Or, for plain reads, a single multi-key command
prices = r.mget([f"price:{pid}" for pid in product_ids])
python

redis-benchmark shows the same effect: run redis-benchmark -t get -n 200000 -q, then add -P 16 to pipeline 16 requests per round trip, and the requests per second rise sharply on the same single thread.

Step 3: Know which commands block

Every O(N) command over a large N runs on the main thread from start to finish. The usual offenders:

Command Problem Safer alternative
KEYS pattern walks the whole keyspace SCAN with a cursor
HGETALL, SMEMBERS, LRANGE 0 -1 on huge keys returns millions of elements HSCAN, SSCAN, bounded ranges
DEL of a big collection frees every element synchronously UNLINK
FLUSHALL / FLUSHDB synchronous by default FLUSHALL ASYNC
Long Lua scripts or functions nothing else runs meanwhile keep scripts short, batch outside

SCAN returns a cursor and a small batch per call, so other clients run between calls. Its guarantee is that every key present for the whole iteration is returned at least once; keys may appear twice, so deduplicate if it matters.

count = 0
for key in r.scan_iter(match="session:*", count=1000):
    count += 1
python

Step 4: Decide whether I/O threads will help

I/O threads help when INFO cpu shows the main thread near 100% of one core with simple commands and many connections, typically above a few hundred thousand operations per second. They do nothing for a slow command, a big key or an expensive script, because execution is still serial. The redis.conf guidance is to leave cores spare: on a 4-core machine try 2 or 3 I/O threads, on 8 cores try about 6. For more throughput than one primary can execute, scale out with Redis Cluster instead.

Worked scenario

An operations dashboard shows “active sessions”. Its backend runs this every 30 seconds:

# Broken: blocks Redis for the full keyspace walk
active = len(r.keys("session:*"))
python

With 2 million keys this took about 2 seconds per call. Every 30 seconds, checkout, login and search requests across the platform timed out, and nobody connected the spikes to a dashboard. SLOWLOG made it obvious:

127.0.0.1:6379> SLOWLOG GET 1
1) 1) (integer) 14
   2) (integer) 1759834023
   3) (integer) 2380512
   4) 1) "KEYS"
      2) "session:*"
   5) "10.0.4.17:51234"
   6) ""
Terminal

The third field is the execution time in microseconds: 2.38 seconds during which nothing else ran. Switching to SCAN removes the stall, but it still walks every key. The better fix maintains the number directly:

# Fixed: O(1) to read, maintained at write time
pipe = r.pipeline()
pipe.set(f"session:{sid}", payload, ex=1800)
pipe.zadd("sessions:active", {sid: now})
pipe.execute()

# Count sessions seen in the last 30 minutes
r.zremrangebyscore("sessions:active", "-inf", now - 1800)
active = r.zcard("sessions:active")
python

The dashboard now issues two cheap commands, and the sorted set doubles as a list of recent sessions.

Common mistake

  • “Redis is single-threaded, so it cannot use multiple cores.” Background threads, forked children and I/O threads all use other cores. Only command execution is serial.
  • “Turn on io-threads to fix latency.” If the latency comes from a slow command, a big key or a script, I/O threads change nothing.
  • “A transaction or pipeline of commands is atomic because Redis is single-threaded.” Each command is atomic; a pipeline can interleave with other clients. Use MULTI/EXEC or Lua for groups.
  • Running KEYS “just once” in production. Once is enough to cause an outage on a large instance; rename-command KEYS "" or ACLs (-@dangerous) can block it.
  • Ignoring the fork. Snapshot and AOF rewrite forks pause the main thread briefly, proportional to memory size; check latest_fork_usec in INFO stats.

Verify the behavior

These commands make the threading model visible on a test instance:

redis-cli CONFIG GET io-threads          # 1 means command I/O on the main thread
redis-cli CONFIG SET slowlog-log-slower-than 10000   # log commands over 10 ms
redis-cli DEBUG SLEEP 2 &                # needs enable-debug-command; blocks the server for 2 s
redis-cli --latency -i 1                 # in another terminal: watch max latency jump to ~2000 ms
redis-cli SLOWLOG GET 5
redis-cli INFO commandstats | head       # calls, usec and usec_per_call per command
Terminal

DEBUG SLEEP is a deliberate stand-in for any slow command: while it runs, every PING from --latency waits. On Linux, ps -T -p $(pidof redis-server) lists the threads: the main thread, a few background (bio) threads and, with io-threads above 1, the I/O threads.

Follow-up questions

Why did Redis choose a single execution thread? It avoids locks on every data structure, keeps commands atomic, and makes performance predictable. For an in-memory store the CPU work per command is tiny, so parallel execution would add contention for little gain.

How do you use all cores of a big machine? Run several Redis processes (one per core or two) as Cluster shards, or enable I/O threads if network work is the bottleneck.

Is MULTI/EXEC needed if every command is atomic? Yes, when several commands must apply together without other clients’ commands in between.

What happens to clients while a Lua script runs? They wait. After busy-reply-threshold (5 seconds) they receive BUSY errors until the script ends or is killed.

Interview exercise

A product page handler loads 400 product prices with individual GET calls in a loop. Redis is in the same region, with a 0.6 ms round trip. The p50 page latency is about 250 ms, Redis CPU is at 15%, and a colleague proposes enabling io-threads 4. What is happening, and what would you do?

Answer and reasoning

The time is spent waiting on the network, not inside Redis: 400 sequential round trips at 0.6 ms is 240 ms, which matches the observed latency, and 15% CPU confirms the main thread is mostly idle. I/O threads would not help, because they parallelize socket work inside the server, and the server is not the bottleneck. Replace the loop with one MGET (or a pipeline, if the commands differ), which makes it one round trip of well under a few milliseconds. If the handler is in a Cluster, the client splits MGET by slot, which costs a few round trips instead of 400. Then revisit whether the page needs all 400 prices at once.

Continue learning

Practise with the Redis interview questions and the Redis MCQs. Related notes: Node.js blocking work and request latency shows the same single-thread trade-off in application code, and Node.js caching layers and invalidation covers where Redis sits in a cache hierarchy. Primary sources: the Redis documentation, Redis pipelining, Diagnosing latency issues and the SCAN command reference.

More in Redis

esc