Implementing Asynchronous I/O with Epoll for a Concurrent Redis Server

Arpit Bhayani

Arpit Bhayani

Apr 25, 2026 • 9 min read

Play

Note: This article is an AI-generated write-up based on the captions and transcript of the video above. Watch the embedded video for the full visual walk-through and nuances.

The Challenge of Concurrent Servers: From Blocking to Non-Blocking I/O

Building high-performance network services like Redis requires efficient handling of numerous concurrent client connections. A naive synchronous TCP server, while simple to implement, quickly becomes a bottleneck due to its blocking nature. This document explores the limitations of synchronous I/O and demonstrates how to build a highly concurrent, single-threaded asynchronous TCP server using event loops and Linux’s epoll system calls, mimicking Redis’s core concurrency model.

The Problem with Synchronous TCP Servers

Consider a basic synchronous TCP server implementation. It typically operates with an infinite loop that first accepts a client connection, and then enters another infinite loop to read commands and respond to that single client. Both accept, read, and write are blocking I/O calls.

Demonstration of Limitation:

When connecting two clients to a synchronous server running on port 7379 (our custom implementation):

  1. The first client connects successfully and can send PING commands, receiving PONG responses.
  2. The second client attempting to connect to 7379 gets stuck; the connection is not accepted.
  3. Only when the first client disconnects does the second client’s connection get accepted.

This behavior highlights a critical limitation: the server can only support one concurrent client. The single thread of execution is blocked while continuously reading commands from the first connected client, preventing it from accepting new connections.

Program Flow of a Synchronous Server:

graph TD
    A[Start Server on Port 7379] --> B{Listen & Accept Connection (Blocking)};
    B -- Client 1 Connects --> C[Enter Client 1 Read/Respond Loop (Blocking)];
    C -- Read Command --> D[Process Command];
    D -- Send Response --> C;
    C -- Client 1 Disconnects or Quits --> B;
    B -- Client 2 Connects (now unblocked) --> E[Enter Client 2 Read/Respond Loop (Blocking)];

The server gets stuck in the first accept loop until a client connects, then it gets stuck in the read/write loop for that client. This sequential processing is unsuitable for high-concurrency scenarios.

Introducing Asynchronous I/O and Event Loops

To overcome the limitations of synchronous I/O, we need an asynchronous approach that allows a single thread to manage multiple I/O operations without blocking. This is achieved through I/O Multiplexing and Event Loops.

The Goal: Handle a large number of concurrent requests efficiently with a single thread.

Core Mechanism: I/O Multiplexing

I/O multiplexing allows a single process to monitor multiple file descriptors (sockets) for I/O readiness. Instead of blocking on a single read or accept call, the server asks the operating system to notify it when any of its monitored file descriptors are ready for an operation (e.g., data to read, connection to accept, buffer ready to write).

System Calls for I/O Multiplexing (Linux):

  • epoll_create1: Creates a new epoll instance and returns a file descriptor for it (epollFD).
  • epoll_ctl: Used to add, modify, or delete file descriptors from the epoll instance’s interest list.
  • epoll_wait: Blocks until one or more registered file descriptors are ready for I/O, or a timeout occurs. It returns a list of ready events.

Platform-Specific Alternatives:

  • macOS/BSD: kqueue
  • Windows: IOCP (I/O Completion Ports)

For this implementation, we will focus on epoll due to its simplicity and efficiency on Linux.

Implementing the Asynchronous TCP Server with Epoll

The source code for this implementation can be found at github.com/dyesdb/dice, specifically at the fourth commit.

1. Server Initialization

The runAsyncTCPServer function begins by setting up the necessary components.

  • maxClient: A variable to define the maximum number of clients the server can handle, used for backlog in listen and buffer size for epoll_wait events (e.g., 20,000).
  • events buffer: An array of epoll_event structs to hold the events returned by epoll_wait.

2. Raw Socket Creation and Configuration

To interact with epoll, we need raw file descriptors, bypassing Go’s standard net package abstractions.

  • syscall.Socket: Creates a new socket.
    • AF_INET: IPv4 address family.
    • SOCK_STREAM: TCP stream socket.
    • SOCK_NONBLOCK: Crucially, sets the socket to non-blocking mode. This means read and write operations will return immediately if no data is available or the buffer is full, rather than blocking the thread.
  • syscall.SetsockoptInt: Further ensures the socket is non-blocking.
  • syscall.Bind: Associates the socket with a specific IP address and port (e.g., 127.0.0.1 on 7379). The IP address is passed as a 4-byte integer array (e.g., {127, 0, 0, 1}).
  • syscall.Listen: Puts the server socket into listening mode, ready to accept incoming connections. The maxClient value is used as the backlog.
  • serverFD: This is the file descriptor for our main server socket, which will be monitored by epoll for new connection requests.

3. Epoll Instance Creation and Server Socket Registration

  • syscall.EpollCreate1(0): Creates an epoll instance and returns its file descriptor, epollFD.
  • epoll_event for serverFD: An epoll_event struct is prepared for the serverFD.
    • Events: Set to syscall.EPOLLIN, indicating interest in incoming data or connection requests.
    • Fd: Set to serverFD.
  • syscall.EpollCtl(epollFD, syscall.EPOLL_CTL_ADD, serverFD, &event): Registers the serverFD with the epoll instance. Now, epoll will notify us whenever a new client attempts to connect to our server socket.

4. The Event Loop: Orchestrating Concurrency

This is the heart of the asynchronous server, an infinite for loop that continuously monitors for I/O events.

graph TD
    A[Start Event Loop] --> B{Epoll Wait (epollFD, eventsBuffer, maxClient, -1)};
    B -- I/O Events Ready (nEvents) --> C[For i = 0 to nEvents-1];
    C --> D{Get Event (events[i])}; 
    D -- Is event.Fd == serverFD? --> E{New Client Connection};
    E --> F[Accept Connection (clientSocketFD)];
    F --> G[Register clientSocketFD with Epoll (EPOLLIN)];
    G --> B;
    D -- Is event.Fd == clientSocketFD? --> H{Client Data Ready};
    H --> I[Read Command from clientSocketFD];
    I --> J[Respond to clientSocketFD];
    J --> B;
  • syscall.EpollWait(epollFD, events, maxClient, -1): This is the main blocking call within the event loop. It waits indefinitely (-1) until one or more registered file descriptors are ready for I/O. Upon readiness, it populates the events buffer with nEvents (the number of ready events).
  • Processing Ready Events: The loop iterates from 0 to nEvents - 1 to process each ready event.
    • event.Fd == serverFD (New Connection):
      • If the serverFD is ready for I/O, it means a new client is attempting to connect.
      • syscall.Accept(serverFD): Accepts the incoming connection, returning a new clientSocketFD (the file descriptor for the connection between our server and the new client).
      • Register clientSocketFD: This new clientSocketFD must also be registered with epoll to monitor for incoming data from this client.
        • An epoll_event is created for clientSocketFD with EPOLLIN.
        • syscall.EpollCtl(epollFD, syscall.EPOLL_CTL_ADD, clientSocketFD, &event) adds it to the epoll instance.
    • event.Fd == clientSocketFD (Client Data Ready):
      • If a clientSocketFD is ready for I/O, it means the client has sent data (a command) that needs to be read and processed.
      • Read Command & Respond: The server reads the command from clientSocketFD and sends a response back.

5. Abstraction for Read/Write Operations

To reuse existing command processing logic (like ReadCommand and Respond functions that previously accepted net.Conn objects), a small abstraction layer is introduced.

  • io.ReadWriter Interface: The ReadCommand and Respond functions are modified to accept an io.ReadWriter interface instead of net.Conn. This makes them more generic.
  • fdCommunicator Struct (com.go): A new struct, fdCommunicator, is created to wrap a raw file descriptor (int32). It implements the io.ReadWriter interface by internally calling syscall.Read and syscall.Write on the encapsulated file descriptor.
// Simplified fdCommunicator structure
type fdCommunicator struct {
    fd int32
}

func (f *fdCommunicator) Read(p []byte) (n int, err error) {
    return syscall.Read(f.fd, p)
}

func (f *fdCommunicator) Write(p []byte) (n int, err error) {
    return syscall.Write(f.fd, p)
}

// Usage in event loop:
// communicator := &fdCommunicator{fd: clientSocketFD}
// command := ReadCommand(communicator)
// Respond(communicator, command_output)

This allows the ReadCommand and Respond functions to work seamlessly with both net.Conn (for synchronous server or other abstractions) and raw file descriptors (for the epoll-based asynchronous server).

6. Summary of Code Changes

  • main.go: Changed the server startup from syncTCPServer to asyncTCPServer.
  • async_tcp.go: New file containing the entire epoll-based event loop implementation.
  • com.go: New file defining the fdCommunicator struct and its Read and Write methods using syscall.Read and syscall.Write.
  • eval.go: Modified functions (e.g., ReadCommand, Respond) to accept io.ReadWriter instead of net.Conn.

Demonstration and Benchmarking

After implementing the asynchronous server, its concurrency and performance are tested.

Concurrent Client Connections:

  • With the asynchronous server running on port 7379, both client 1 and client 2 can connect simultaneously.
  • Both clients can send PING commands and receive PONG responses concurrently, demonstrating true multi-client handling by a single-threaded server.

Redis Benchmark Comparison:

Using redis-benchmark to fire 10,000 PING requests with 200 concurrent clients:

  • Actual Redis Server (port 6379): Achieves approximately 37,735 requests per second.
  • Our Asynchronous Go Server (port 7379): Achieves approximately 36,231 requests per second.

This benchmark clearly shows that our single-threaded, epoll-based asynchronous Go server can handle a high volume of concurrent requests with performance very close to that of the actual Redis server for simple operations. This validates the effectiveness of the event loop and I/O multiplexing approach.

Conclusion and Next Steps

By leveraging epoll and an event loop, we successfully transformed a blocking TCP server into a highly concurrent, single-threaded system. This approach is fundamental to how many high-performance network applications, including Redis, achieve their scalability.

It is highly recommended to re-implement this system to gain a deeper understanding of low-level network I/O, system calls, and the design principles behind concurrent servers. The source code is available for reference, but hands-on implementation provides invaluable insights.

Next Steps:

In the subsequent video, the focus will shift from just handling PING commands to implementing GET and SET commands, moving towards building a simple in-memory key-value store akin to Redis.

Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses