Single Process, Multiple Clients I/O Multiplexing Server Design

 

Single Process, Multiple Clients
Chapter 60 โ€” Part 4 of 4 โ€ข I/O Multiplexing Server Design
โšก High Scalability
๐Ÿ“ˆ Event-Driven
๐Ÿ” Interview Q&A

What You Will Learn

All server designs we have seen so far use multiple processes or threads to handle multiple clients. In this part you will learn about a completely different approach: one single process handles all clients simultaneously by using I/O multiplexing. This is the design behind Nginx and Node.js, and it can serve thousands of concurrent connections with minimal resources.

Key Terms

I/O Multiplexing select() poll() epoll Event-Driven Non-blocking I/O File Descriptor Set Readiness Notification C10K Problem Signal-Driven I/O

๐Ÿ’ก The Core Insight: Most Clients Are Idle Most of the Time

In a typical network server scenario, think about what each connected client is actually doing at any moment:

  • A web browser fetches a page โ€” then the user reads it for 30 seconds. The connection is idle.
  • A chat client sends a message every few seconds. Between messages: idle.
  • A database client executes a query โ€” then waits for the application to process results. Idle.

If 99% of clients are idle at any given moment, dedicating an entire process or thread to each one is extremely wasteful. We have 500 sleeping processes burning memory, just waiting for data to arrive.

Key idea: Instead of one process per client, use one process that monitors ALL clients simultaneously and acts only when a client has data ready. This is I/O multiplexing.

Multi-Process/Thread (500 clients)
โ— 500 processes/threads
โ— ~500 MB memory (1MB per process)
โ— 495 sleeping, 5 active
โ— Heavy scheduler overhead
I/O Multiplexing (500 clients)
โ— 1 process monitors 500 fds
โ— ~5 MB memory
โ— No sleeping processes
โ— Minimal scheduler overhead

๐Ÿ”ง Three Ways to Do I/O Multiplexing on Linux

Linux provides three mechanisms, each an improvement over the previous:

select()
POSIX standard
Max 1024 fds
Rebuilds fd set each call
O(max_fd) scan
poll()
POSIX standard
No fd limit
Array-based API
O(n) scan per call
epoll
Linux-specific
No fd limit
Event-based
O(1) per event
POSIX (1989)
POSIX (1989)
Linux 2.5.44 (2002)

๐Ÿ“ƒ select() โ€” The Classic Approach

select() takes a set of file descriptors and tells you which ones are ready for reading, writing, or have an error. You call it in a loop and process each ready fd.

#include <sys/select.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT      9000
#define MAX_FDS   64
#define BUF_SIZE  1024

int main(void)
{
    int lfd, cfd;
    int clients[MAX_FDS];          /* Track connected client fds */
    int numClients = 0;
    struct sockaddr_in addr;
    fd_set readfds;
    int maxFd;

    /* Initialize client list */
    memset(clients, -1, sizeof(clients));

    /* Create listening socket */
    lfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&addr, 0, sizeof(addr));
    addr.sin_family      = AF_INET;
    addr.sin_port        = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(lfd, 10);

    printf("Single-process echo server on port %d\n", PORT);

    for (;;) {
        /*
         * Build the fd_set EVERY iteration.
         * select() modifies the set in place, so we must rebuild it.
         */
        FD_ZERO(&readfds);
        FD_SET(lfd, &readfds);   /* Always watch the listening socket */
        maxFd = lfd;

        /* Add all connected client sockets to the set */
        for (int i = 0; i < numClients; i++) {
            if (clients[i] != -1) {
                FD_SET(clients[i], &readfds);
                if (clients[i] > maxFd)
                    maxFd = clients[i];
            }
        }

        /*
         * select() blocks until at least one fd is ready.
         * Returns: number of ready fds, or -1 on error, 0 on timeout.
         * First arg: maxFd + 1 (must be one more than the highest fd).
         */
        int ready = select(maxFd + 1, &readfds, NULL, NULL, NULL);
        if (ready == -1) {
            perror("select");
            break;
        }

        /* Check if the listening socket has a new connection */
        if (FD_ISSET(lfd, &readfds)) {
            cfd = accept(lfd, NULL, NULL);
            if (cfd != -1 && numClients < MAX_FDS) {
                clients[numClients++] = cfd;
                printf("New client connected: fd=%d\n", cfd);
            }
        }

        /* Check each client socket for data */
        for (int i = 0; i < numClients; i++) {
            if (clients[i] == -1) continue;
            if (FD_ISSET(clients[i], &readfds)) {
                char buf[BUF_SIZE];
                ssize_t n = read(clients[i], buf, BUF_SIZE);
                if (n <= 0) {
                    /* n == 0: client disconnected; n == -1: error */
                    printf("Client fd=%d disconnected\n", clients[i]);
                    close(clients[i]);
                    clients[i] = -1;  /* Mark slot as free */
                } else {
                    /* Echo the data back */
                    write(clients[i], buf, n);
                }
            }
        }
    }

    return 0;
}

Limitation of select(): The maximum number of file descriptors in an fd_set is FD_SETSIZE, typically 1024 on Linux. Servers needing more than 1024 simultaneous connections must use poll() or epoll.

๐Ÿ“ƒ poll() โ€” No fd Limit

poll() works similarly to select() but uses an array of struct pollfd instead of bit sets. There is no hard limit on the number of file descriptors. However, it still scans the entire array on every call (O(n) per wakeup).

#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT     9000
#define BUF_SIZE 1024
#define MAX_FDS  1000

int main(void)
{
    struct pollfd fds[MAX_FDS];
    int nfds = 0;
    int lfd;
    struct sockaddr_in addr;

    lfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&addr, 0, sizeof(addr));
    addr.sin_family      = AF_INET;
    addr.sin_port        = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(lfd, 10);

    /* Add listening socket as first entry */
    fds[0].fd     = lfd;
    fds[0].events = POLLIN;
    nfds = 1;

    for (;;) {
        /*
         * poll() waits until at least one fd is ready.
         * Timeout -1 means wait forever.
         * After return, check fds[i].revents for what happened.
         */
        int ready = poll(fds, nfds, -1);
        if (ready == -1) {
            perror("poll");
            break;
        }

        /* Check listening socket */
        if (fds[0].revents & POLLIN) {
            int cfd = accept(lfd, NULL, NULL);
            if (cfd != -1 && nfds < MAX_FDS) {
                fds[nfds].fd     = cfd;
                fds[nfds].events = POLLIN;
                nfds++;
                printf("New client: fd=%d\n", cfd);
            }
        }

        /* Check each client */
        for (int i = 1; i < nfds; i++) {
            if (fds[i].revents & POLLIN) {
                char buf[BUF_SIZE];
                ssize_t n = read(fds[i].fd, buf, BUF_SIZE);
                if (n <= 0) {
                    close(fds[i].fd);
                    /* Remove by replacing with last entry */
                    fds[i] = fds[nfds - 1];
                    nfds--;
                    i--;
                } else {
                    write(fds[i].fd, buf, n);
                }
            }
        }
    }

    return 0;
}

โšก epoll โ€” The Linux High-Performance Solution

epoll is Linux’s scalable I/O event notification mechanism. Unlike select() and poll() which scan ALL monitored fds on every wakeup, epoll returns ONLY the fds that are actually ready. This gives O(1) scaling per event โ€” the performance does not degrade as the number of monitored fds grows.

Feature select()/poll() epoll
Work per wakeup Scan ALL monitored fds O(n) Only READY fds returned O(1)
fd registration Rebuild set/array every call Register once, kernel remembers
Kernel-to-user copy Copy entire set on every call Only copy ready events
Max fds 1024 (select) / unlimited (poll) Unlimited (/proc/sys/fs/epoll/max_user_watches)
#include <sys/epoll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT       9000
#define MAX_EVENTS 64
#define BUF_SIZE   1024

/* Make a socket non-blocking */
static void setNonBlocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void)
{
    int lfd, epfd, cfd;
    struct sockaddr_in addr;
    struct epoll_event ev, events[MAX_EVENTS];

    /* 1. Create listening socket */
    lfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&addr, 0, sizeof(addr));
    addr.sin_family      = AF_INET;
    addr.sin_port        = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(lfd, SOMAXCONN);
    setNonBlocking(lfd);

    /* 2. Create the epoll instance */
    epfd = epoll_create1(0);
    if (epfd == -1) {
        perror("epoll_create1");
        exit(EXIT_FAILURE);
    }

    /* 3. Register the listening socket with epoll */
    ev.events  = EPOLLIN;      /* Notify when readable */
    ev.data.fd = lfd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev) == -1) {
        perror("epoll_ctl: lfd");
        exit(EXIT_FAILURE);
    }

    printf("epoll echo server listening on port %d\n", PORT);

    /* 4. Event loop */
    for (;;) {
        /*
         * epoll_wait() blocks until events are ready.
         * Returns: number of ready events in the events[] array.
         * Timeout -1: wait forever.
         */
        int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nready == -1) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nready; i++) {
            if (events[i].data.fd == lfd) {
                /* New client connection */
                cfd = accept(lfd, NULL, NULL);
                if (cfd == -1) {
                    perror("accept");
                    continue;
                }
                setNonBlocking(cfd);

                /* Register the new client with epoll */
                ev.events  = EPOLLIN | EPOLLET;  /* Edge-triggered mode */
                ev.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                printf("New client: fd=%d\n", cfd);

            } else {
                /* Data available from a client */
                char buf[BUF_SIZE];
                ssize_t n = read(events[i].data.fd, buf, BUF_SIZE);
                if (n <= 0) {
                    /* Client disconnected - remove from epoll and close */
                    epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
                    close(events[i].data.fd);
                    printf("Client fd=%d disconnected\n", events[i].data.fd);
                } else {
                    write(events[i].data.fd, buf, n);
                }
            }
        }
    }

    close(lfd);
    close(epfd);
    return 0;
}

๐ŸŒ epoll: Level-Triggered vs Edge-Triggered

epoll supports two notification modes:

Level-Triggered (default, EPOLLIN)

epoll_wait() keeps notifying you as long as the fd is readable (there is still data in the buffer). Like a smoke alarm that keeps beeping until the smoke clears. Easier to use correctly, similar to select/poll behaviour.

Edge-Triggered (EPOLLIN | EPOLLET)

epoll_wait() notifies you ONCE when new data arrives. Even if data is still in the buffer, it will NOT notify again until more new data arrives. You MUST read all available data in a loop when notified. Requires non-blocking sockets. Higher performance but more complex.

/* Edge-triggered: you MUST read all data in a loop when notified */
if (events[i].events & EPOLLIN) {
    char buf[1024];
    ssize_t n;
    /* Loop until EAGAIN = no more data right now */
    while ((n = read(fd, buf, sizeof(buf))) > 0) {
        write(fd, buf, n);  /* Echo back */
    }
    if (n == -1 && errno != EAGAIN) {
        /* Real error */
        perror("read");
        close(fd);
    }
    /* n == 0: client disconnected */
    /* n == -1 with EAGAIN: all data read, wait for next event */
}

โš– The Single-Server Scheduling Tradeoff

When you have one process per client (fork model), the kernel’s scheduler ensures every client gets fair access to the CPU. Each process runs for a time slice, then the next one runs.

With a single-process event-driven design, you become the scheduler. If handling one client’s request takes a long time (for example, a slow disk read), ALL other clients wait because there is only one process. This is a critical difference:

Scenario Multi-Process Single-Process (Event-Driven)
Client sends small HTTP request โœ” Handled in a few ms โœ” Handled in a few ms
Client triggers slow disk read (1 second) โœ” Other clients unaffected (different process) โŒ ALL clients blocked for 1 second
10,000 idle connections โŒ 10,000 sleeping processes = huge memory โœ” 1 process, monitoring 10,000 fds = tiny memory

This is why event-driven servers like Nginx use async I/O or worker threads for slow operations (like file reads), while using a single event loop for network I/O. Node.js solves this by making all I/O asynchronous and using libuv.

๐Ÿ“ˆ Choosing the Right Server Design
Design Best For Avoid When Real Example
Iterative Services with instant responses (DNS, time service) Long service times Simple UDP servers
Fork Per Client Moderate load, simple implementation, strong isolation Thousands of connections/sec inetd-launched services
Preforked High load, need process isolation Extremely high connection counts Apache prefork MPM
Prethreaded High load, need shared state between workers When thread safety is hard to ensure Apache worker MPM
Event-Driven (epoll) Very high connection counts, mostly I/O-bound clients CPU-intensive per-client work Nginx, Node.js, Redis

๐Ÿ”” Signal-Driven I/O: A Third Alternative

Besides I/O multiplexing, Linux also supports signal-driven I/O. Instead of calling select()/poll()/epoll_wait() to wait, the process sets up a signal handler for SIGIO and then the kernel delivers SIGIO whenever a file descriptor becomes ready. The process continues doing other work and is interrupted by the signal when I/O is ready.

/* Enable signal-driven I/O on a socket */
#include <fcntl.h>
#include <signal.h>

/* 1. Set the process to receive SIGIO for this fd */
fcntl(sockfd, F_SETOWN, getpid());

/* 2. Enable O_ASYNC flag */
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);

/* 3. Install signal handler */
struct sigaction sa;
sa.sa_handler = io_handler;  /* Called when fd is ready */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGIO, &sa, NULL);

Signal-driven I/O is rarely used in practice for servers because of the complexity of signal handlers and because epoll is more efficient and easier to use correctly. It is mentioned here for completeness.

๐ŸŽ“ Interview Questions & Answers
Q1. What is I/O multiplexing and why is it useful for servers?

I/O multiplexing lets a single process monitor multiple file descriptors simultaneously and be notified when any of them become ready for I/O. It is useful because most clients are idle most of the time; instead of wasting a process per idle client, one process monitors all of them and acts only when there is real work to do.

Q2. What is the main limitation of select() for high-performance servers?

Two limitations: (1) The maximum number of fds in an fd_set is FD_SETSIZE, typically 1024, so select() cannot monitor more than 1024 connections. (2) Even if only one fd is ready, select() requires scanning all monitored fds to find which ones are ready (O(n) per wakeup), which becomes slow when monitoring thousands of fds.

Q3. How does epoll achieve O(1) performance per event?

With select/poll, the kernel must scan all monitored fds to check which are ready, then return the full list. With epoll, the kernel maintains an internal data structure (a red-black tree) of monitored fds and an event queue. When a fd becomes ready, the kernel adds it directly to the event queue. epoll_wait() returns only the ready fds from the event queue, not all monitored fds. The work is O(1) per ready event.

Q4. What is the difference between level-triggered and edge-triggered epoll?

Level-triggered (default): epoll_wait() continues to return the fd as ready as long as there is unread data in its buffer. Edge-triggered (EPOLLET): epoll_wait() notifies only once per transition from not-ready to ready. You must read all data in a non-blocking loop when notified; otherwise you will never be notified again for the remaining data. Edge-triggered requires non-blocking sockets and careful programming.

Q5. Why do event-driven servers like Nginx need non-blocking sockets?

In a single-process event loop, a blocking call (like read() on an empty socket) would pause the entire process. All other clients would be unresponsive until the blocking call returns. Non-blocking I/O ensures read() returns immediately with EAGAIN if no data is available, so the event loop can continue processing other ready fds.

Q6. What is the C10K problem?

The C10K problem (10,000 concurrent connections) was a famous challenge described by Dan Kegel in 1999: how do you serve 10,000 simultaneous clients on a single server? At the time, the dominant fork-per-client model would require 10,000 processes. The solution was event-driven I/O multiplexing (select/poll/epoll), which allows one process to handle tens of thousands of connections efficiently. Modern servers handle millions of connections this way.

Q7. In an event-driven server, who handles scheduling fairness between clients?

The application itself. In a multi-process model the kernel scheduler ensures each process gets CPU time. In a single-process event loop, if handling one client’s event takes too long (CPU-intensive work), all other clients wait. This is why event-driven servers work well for I/O-bound workloads but poorly for CPU-intensive ones. Long-running tasks must be offloaded to worker threads or processes.

Q8. List the three system calls in epoll and their purpose.

epoll_create1(flags) creates an epoll instance and returns its file descriptor. epoll_ctl(epfd, op, fd, event) adds (EPOLL_CTL_ADD), modifies (EPOLL_CTL_MOD), or removes (EPOLL_CTL_DEL) a file descriptor from the epoll instance. epoll_wait(epfd, events, maxevents, timeout) blocks until one or more fds are ready and returns them in the events array.

Chapter 60 Complete!
You now know all four server design patterns: iterative, concurrent fork, preforked/prethreaded, and event-driven I/O multiplexing. Go back to the index to review.

โ† Back to Chapter Index โ† Part 3

Leave a Reply

Your email address will not be published. Required fields are marked *