Logo
I/O Multiplexing and Event Loops: How Redis Handles Thousands of Clients
Overview

I/O Multiplexing and Event Loops: How Redis Handles Thousands of Clients

January 1, 2026
8 min read

The Problem: How Do You Handle 10,000 Concurrent Clients?

Imagine you’re running a web server. 10,000 clients connect simultaneously, each wanting to store or retrieve data.

Naive Approach 1: One Thread Per Client

Client 1 → Thread 1
Client 2 → Thread 2
Client 3 → Thread 3
...
Client 10,000 → Thread 10,000

Problems:

  • Creating 10,000 threads consumes massive memory (each thread needs a stack)
  • Context switching between 10,000 threads is expensive
  • If a thread is blocked waiting for I/O, that CPU core is idle
  • Making code thread-safe is complex (locks, mutexes, race conditions)

Result: Your server becomes a resource hog and slows to a crawl.

Naive Approach 2: Process Per Client

Even worse than threads. Processes have even more overhead.

What We Actually Need:

One thread (or one process) that efficiently manages all 10,000 clients without blocking. This is where I/O multiplexing comes in.


Part 1: The Fundamental Problem With Blocking I/O

Blocking System Calls

Most I/O operations are blocking:

// This blocks until data arrives
int bytes_read = read(file_descriptor, buffer, 1024);
// This blocks until the socket is ready to accept data
write(socket_fd, data, length);

When a thread calls read(), it waits there until data arrives. Meanwhile, other clients are also waiting. If you have one thread per client, 9,999 threads are blocked, and only 1 is doing work.

The Flow of Data

Let’s trace what happens when data arrives from the network:

┌──────────────┐
│ Network Card │ ← Data arrives from client
└───────┬──────┘
┌──────────────────────────┐
│ Kernel Buffer │ ← Kernel stores incoming data here
│ (part of OS memory) │
└───────┬──────────────────┘
│ (Interrupt: "Hey, data is here!")
┌──────────────────────────┐
│ User Space (Your app) │ ← You read data here when ready
└──────────────────────────┘

The key insight: The kernel knows when data has arrived (it stored it in the kernel buffer). Your application can ask: “Kernel, which of my file descriptors have data ready?”

This is what I/O multiplexing does.


Part 2: What Is I/O Multiplexing?

The Core Idea

Instead of blocking on one file descriptor, you ask the OS: “Tell me which of these 10,000 file descriptors are ready for I/O.”

The OS efficiently monitors all of them and tells you which ones have data.

Process:

1. Register 10,000 file descriptors with the OS
2. Ask OS: "Which ones are ready?"
(This call blocks until at least one is ready)
3. OS tells you: "Descriptors 5, 42, and 1337 are ready"
4. You read from those 3 descriptors
5. Go back to step 2

This single-threaded loop can handle 10,000 clients because it never blocks on any individual client. It asks the OS to efficiently check all of them.

File Descriptors: Everything Is a File

In Unix-like systems, everything is a file:

  • Disk files → File descriptor
  • Network sockets → File descriptor
  • Pipes → File descriptor
  • Device files → File descriptor

A file descriptor is a small integer (32-bit or 64-bit) that uniquely identifies an open file/socket.

Example:

int fd = open("myfile.txt", O_RDONLY); // Returns file descriptor (e.g., 3)
int socket_fd = socket(AF_INET, SOCK_STREAM, 0); // Returns file descriptor (e.g., 4)

File descriptor 0 = stdin, 1 = stdout, 2 = stderr. Other descriptors are assigned sequentially.

The OS keeps a table mapping file descriptors to actual files/sockets. This is how it tracks everything.


Part 3: How I/O Multiplexing Works (The Three Approaches)

Different operating systems provide different system calls for I/O multiplexing. All work on the same principle, but with slightly different APIs.

Approach 1: epoll (Linux)

epoll stands for “event poll.” It’s the most efficient on Linux.

Step 1: Create an epoll instance

int epoll_fd = epoll_create1(0);

This creates an epoll object that will monitor file descriptors.

Step 2: Register file descriptors

struct epoll_event event;
event.events = EPOLLIN; // Monitor for incoming data
event.data.fd = client_socket;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_socket, &event);

You tell epoll: “Monitor this socket for incoming data (EPOLLIN).”

You do this for every client that connects.

Step 3: Wait for events

struct epoll_event events[10]; // Space for 10 events
int num_ready = epoll_wait(epoll_fd, events, 10, -1);

This is a blocking call. The OS puts the process to sleep. When any of the monitored file descriptors becomes ready, the OS wakes up the process.

num_ready tells you how many file descriptors are ready.

Step 4: Process ready events

for (int i = 0; i < num_ready; i++) {
int ready_fd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
// This file descriptor has incoming data, read it
read_data(ready_fd);
}
}

The Loop (Event Loop Pseudocode):

while (1) {
// Block until some file descriptors are ready
int num_ready = epoll_wait(epoll_fd, events, 10, -1);
// Process all ready file descriptors
for (int i = 0; i < num_ready; i++) {
int ready_fd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
// Read data and process command
handle_client(ready_fd);
}
}
// Loop back and wait for next batch of ready events
}

This single loop handles all clients!

Approach 2: kqueue (macOS, BSD)

kqueue is BSD’s version. Works similarly but with different API.

int kq = kqueue();
struct kevent changes[1];
struct kevent events[10];
EV_SET(&changes[0], client_socket, EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, changes, 1, events, 10, NULL);

Conceptually identical to epoll, just different function names and struct names. Both monitor file descriptors and report ready ones.

Approach 3: IOCP (Windows)

Windows uses IOCP (I/O Completion Ports). The API is different but the principle is the same: monitor multiple file descriptors efficiently.

Why Three Different Implementations?

Each OS has different underlying I/O mechanisms:

  • Linux: Best with epoll (highly scalable, supports millions of file descriptors)
  • macOS/BSD: Best with kqueue (elegant, flexible)
  • Windows: Best with IOCP (integrates with Windows’ asynchronous I/O model)

A portable library (like libuv, used by Node.js and Redis) abstracts over all three.

Note

Why this matters: Understanding that epoll/kqueue/IOCP are just thin wrappers around OS mechanisms helps you understand that I/O multiplexing isn’t magic. The OS already knows which sockets have data ready. These system calls just expose that information to your application.


Part 4: The Data Flow In Detail

Let’s trace the complete flow when a client sends data.

Step 1: Network Card Receives Data

Network → Network Card → Kernel Buffer (OS memory)

The network card receives packets and DMA (direct memory access) them into a kernel buffer. The OS allocates memory, and the hardware writes directly to it—no CPU involved yet.

Step 2: Interrupt Handler

Network Card → Interrupt
CPU (Interrupt Handler)
"Data arrived for socket 5"

The network card raises an interrupt. The CPU stops what it’s doing and runs an interrupt handler (part of the OS kernel).

The interrupt handler updates internal OS structures: “Socket descriptor 5 now has data ready.”

Step 3: Unblock epoll_wait

epoll_wait(epoll_fd, events, 10, -1);
[Process was sleeping]
[Interrupt fired, OS updated socket state]
[OS wakes up process and returns]

If your process was blocked in epoll_wait(), the OS wakes it up and returns the list of ready file descriptors.

Step 4: Application Reads Data

num_ready = epoll_wait(epoll_fd, events, 10, -1); // Returns 1
int ready_fd = events[0].data.fd; // Socket 5
char buffer[1024];
int bytes = read(ready_fd, buffer, 1024); // Read from kernel buffer into user space

The read() call copies data from the kernel buffer to your application’s user space memory.

Step 5: Process Command

// Parse the command
Command cmd = parse_redis_command(buffer);
// Execute (e.g., SET key value)
redis_set(cmd.key, cmd.value);
// Write response back to socket
write(ready_fd, response, response_len);

Part 5: Why This Is Efficient

Why Not Block on Individual File Descriptors?

If you blocked on individual file descriptors:

// Bad: What if client 1 is slow?
for (int i = 0; i < 10000; i++) {
read(client_sockets[i], buffer, 1024); // Blocks if client 1 has no data
// Can't check client 2-9999 until client 1 sends something
}

You’d check clients serially. If client 1 has no data, you’d wait forever.

Why I/O Multiplexing Is Better

// Good: Check all at once, process ready ones
epoll_wait(...); // Returns only ready file descriptors
// Now process only the ones with data
for (int i = 0; i < num_ready; i++) {
read(ready_fds[i], buffer, 1024); // Never blocks, data is ready
}

You check all 10,000 simultaneously. The OS handles the work of monitoring. You only read from sockets that have data, so read() never blocks.

CPU Efficiency

With threads:

  • 10,000 threads created = massive memory
  • Most threads blocked = CPU cores idle
  • Context switching between threads = CPU cycles wasted

With I/O multiplexing:

  • 1 thread = minimal memory
  • Thread never blocks = CPU always busy (or sleeping efficiently)
  • No context switching = all CPU for actual work
  • CPU sleeps when no I/O ready = power efficient

Part 6: Event Loops in Practice

An event loop is simply the infinite loop that uses I/O multiplexing.

Generic Event Loop Pseudocode

while (true) {
// 1. Wait for I/O (blocking call)
ready_fds = wait_for_io(epoll_fd);
// 2. Process all ready file descriptors
for (fd in ready_fds) {
if (fd == server_socket) {
// New client connecting
new_client = accept(fd);
register_with_epoll(new_client);
} else {
// Existing client has data
read_and_process(fd);
}
}
// 3. Go back to step 1
}

This is the heart of Redis, Node.js, Python’s asyncio, and every high-performance server.

How Redis Uses epoll

Redis uses libuv (a C library that abstracts over epoll/kqueue/IOCP) to implement its event loop:

Redis Server Start
Create epoll (via libuv)
Register server socket with epoll
Enter event loop:
├─ Wait for client connections or client data (epoll_wait)
├─ When ready, accept new connections or read client commands
├─ Execute Redis commands (SET, GET, ZADD, etc.)
├─ Write responses back to clients
└─ Loop back to wait

Because Redis processes all events in one loop, it’s single-threaded and efficient.


Part 7: Event Loop vs Threading Model

Threading Model

┌─────────────────────────────────────────────────────┐
│ Thread 1: Client A (blocked waiting for I/O) │
│ Thread 2: Client B (blocked waiting for I/O) │
│ Thread 3: Client C (blocked waiting for I/O) │
│ ... │
│ Thread 10000: Client J (blocked waiting for I/O) │
└─────────────────────────────────────────────────────┘

Problem: Most threads are blocked, CPU cores are idle.

Event Loop Model

┌──────────────────────────────────────────┐
│ Single Thread: Event Loop │
│ │
│ while (true) { │
│ ready = epoll_wait(...) │
│ for (fd in ready) { │
│ process(fd) │
│ } │
│ } │
└──────────────────────────────────────────┘
All 10,000 clients handled by one thread that's never blocked.

Part 8: Common Misconceptions

Misconception 1: “Event loops are faster because they’re single-threaded”

Reality: Event loops are faster because they don’t block. They’re single-threaded, but that’s not the speed advantage—it’s the non-blocking nature.

A multi-threaded server can be just as fast if implemented correctly. But it’s more complex (locks, synchronization).

Misconception 2: “If one command takes 10 seconds, all clients are stuck”

True: If you execute a command that takes 10 seconds, the event loop is blocked, and all other clients wait.

But for a well-designed server (like Redis), commands are fast (microseconds to milliseconds). The 10-second problem is rare.

Solution: Use worker threads or background processing for long operations.

Misconception 3: “epoll/kqueue can monitor an unlimited number of file descriptors”

Reality: There’s a limit. Linux 5.5+ can handle ~1 million file descriptors per process. That’s plenty for most servers, but not unlimited.

Also, your system has limits:

Terminal window
ulimit -n # Show max file descriptors (often 1024 or 65536)

Misconception 4: “I/O multiplexing only works for network sockets”

Reality: It works for any file descriptor: disks, pipes, devices, etc.

However, epoll on Linux has nuances: it works best with sockets and pipes (truly asynchronous). Disk I/O might not use epoll effectively (better to use thread pools for disk).

Note

Critical: I/O multiplexing is not a magic solution. It’s a specific tool for a specific problem: handling many concurrent I/O operations with one thread. If your workload is CPU-bound (lots of computation), I/O multiplexing won’t help. You need parallelism (threads or processes) for CPU-bound work.


Part 9: Practical Example: A Tiny Redis-Like Server

Let’s write pseudocode for a minimal server using epoll:

#include <sys/epoll.h>
#include <unistd.h>
int main() {
// 1. Create server socket
int server_socket = socket(AF_INET, SOCK_STREAM, 0);
bind(server_socket, ...);
listen(server_socket, 128);
// 2. Create epoll
int epoll_fd = epoll_create1(0);
// 3. Register server socket
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = server_socket;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_socket, &event);
// 4. Event loop
struct epoll_event events[10];
while (1) {
// Wait for events (blocks until something happens)
int num_ready = epoll_wait(epoll_fd, events, 10, -1);
for (int i = 0; i < num_ready; i++) {
int ready_fd = events[i].data.fd;
if (ready_fd == server_socket) {
// New client connection
int client_socket = accept(server_socket, NULL, NULL);
// Register client socket
struct epoll_event client_event;
client_event.events = EPOLLIN;
client_event.data.fd = client_socket;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_socket, &client_event);
} else {
// Existing client has data
char buffer[1024];
int bytes = read(ready_fd, buffer, 1024);
if (bytes > 0) {
// Parse command (e.g., "SET key value")
char *response = process_command(buffer);
write(ready_fd, response, strlen(response));
} else {
// Client disconnected
close(ready_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, ready_fd, NULL);
}
}
}
}
return 0;
}

This tiny server:

  • Handles multiple clients
  • Never blocks on individual clients
  • Uses one thread
  • Efficiently multiplexes I/O via epoll

This is the essence of how Redis works (with more features, of course).


Part 10: Scaling Limits

Why Does epoll Scale So Well?

epoll is O(1) for checking if a file descriptor is ready. It doesn’t check all 10,000 descriptors sequentially. Instead, it uses an event-based mechanism (interrupt handlers update data structures). You get only the ready ones.

Compare to select() (older I/O multiplexing):

// select() is O(n) - checks all descriptors every time
fd_set rfds;
for (int i = 0; i < 10000; i++) {
FD_SET(i, &rfds);
}
select(10000, &rfds, NULL, NULL, NULL); // Slow: checks all 10,000

vs epoll:

// epoll is O(1) - only returns ready ones
epoll_wait(epoll_fd, events, 10, -1); // Fast: only returns ready ones

This is why epoll can handle 100,000+ concurrent connections, while select() struggles with 1,000.

When Does I/O Multiplexing Stop Working?

When you have 10 million concurrent connections and need to process command in microseconds, even epoll might hit limits:

  1. OS memory for epoll structures
  2. Bandwidth (network I/O capacity)
  3. DNS queries, database latency (external I/O)

At this scale, you need:

  • Multiple epoll instances (multiple processes or threads, each with its own epoll)
  • Load balancing
  • Horizontal scaling

Conclusion: The Elegance of I/O Multiplexing

I/O multiplexing is simple yet powerful:

  1. Ask the OS: “Which file descriptors are ready?”
  2. Wait: (OS blocks you)
  3. OS Responds: “Here are the ready ones”
  4. Process: Read from ready descriptors
  5. Loop: Go back to step 1

This simple loop:

  • Handles 100,000+ concurrent clients
  • Uses minimal resources
  • Requires no locks or synchronization
  • Sleeps efficiently when no I/O is ready

It’s why:

  • Redis is single-threaded and fast
  • Node.js can handle thousands of concurrent requests
  • Python’s asyncio works
  • High-performance servers use event loops

Understanding I/O multiplexing unlocks a whole new way of thinking about concurrency. It’s not threads or processes—it’s event-driven programming at its finest.


Further Reading

Now you understand the magic behind high-performance servers. There’s no magic—just clever use of OS primitives.