Preforked & Prethreaded Servers High-Performance Server Design

 

Preforked & Prethreaded Servers
Chapter 60 โ€” Part 3 of 4 โ€ข High-Performance Server Design
โšก Performance
๐Ÿ“ˆ Server Pools
๐Ÿ” Interview Q&A

What You Will Learn

The traditional fork-per-client design works well for moderate loads. But what happens when a web server handles thousands of requests per minute? Creating a new process for every single request becomes a serious bottleneck. This part explains how preforked and prethreaded server designs solve this problem using a pool of pre-created workers.

Key Terms

Preforked Server Prethreaded Server Server Pool Worker Process Worker Thread Process Creation Cost Pool Management Mutual Exclusion accept() Race Condition File Descriptor Passing

๐Ÿšซ The Problem with Fork-Per-Client at High Load

Creating a new process using fork() is not free. It involves:

  • Allocating a new process table entry in the kernel.
  • Copying page table entries (even with copy-on-write, there is overhead).
  • Duplicating file descriptors.
  • Setting up signal handlers, memory mappings, etc.

For a server handling a few clients per second, this is fine. But for a web server serving thousands of requests per minute, the accumulated cost of all those fork() calls becomes a serious burden.

๐Ÿ Fork Per Client
โ— New process for every client
โ— fork() overhead every time
โ— Process cleanup (zombie reaping) every time
โ— Struggles under heavy load
โšก Preforked Server
โ— Processes created ONCE at startup
โ— Workers immediately ready, no fork() delay
โ— Processes reused for multiple clients
โ— Scales to high load efficiently

๐Ÿ”ง How a Preforked Server Works

Instead of calling fork() when a client arrives, the server creates a pool of child processes at startup, before any clients connect. These children are called worker processes. Each worker waits for a client, handles it, and then loops back to wait for the next client โ€” it never terminates between clients.

Preforked Server Architecture

SERVER PARENT โ€” Creates listening socket, then forks N children at startup

Worker 1
accept() โ†’ serve โ†’ accept() โ†’ serve โ†’ …
Worker 2
accept() โ†’ serve โ†’ accept() โ†’ serve โ†’ …
Worker 3
accept() โ†’ serve โ†’ accept() โ†’ serve โ†’ …
Worker N
accept() โ†’ serve โ†’ accept() โ†’ serve โ†’ …
All workers share the SAME listening socket (lfd) inherited from the parent via fork()

The key insight: the listening socket is created by the parent before forking. Every child inherits a copy of that file descriptor and can call accept() on it. The kernel ensures only one child gets each incoming connection.

โš  The accept() Race Condition (Thundering Herd)

When multiple worker processes all block in accept() on the same socket, and a new client connects, the kernel wakes up all the sleeping workers. Only one of them will successfully complete accept(); the rest will sleep again. This is called the thundering herd problem.

On Linux, this is handled correctly by the kernel itself: modern Linux wakes only one process per incoming connection for accept(), so the thundering herd is not a real problem here.

However, on older UNIX implementations, accept() was not atomic. Multiple processes could simultaneously be woken up and race to get the connection. The fix was to wrap accept() in a file lock (mutex):

#include <fcntl.h>

/* Each worker acquires a lock before calling accept() */
static int lockFd;   /* Shared lock file */

static void lockAcquire(void)
{
    struct flock fl;
    fl.l_type   = F_WRLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start  = 0;
    fl.l_len    = 0;
    if (fcntl(lockFd, F_SETLKW, &fl) == -1) {
        perror("fcntl F_SETLKW");
        exit(EXIT_FAILURE);
    }
}

static void lockRelease(void)
{
    struct flock fl;
    fl.l_type   = F_UNLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start  = 0;
    fl.l_len    = 0;
    if (fcntl(lockFd, F_SETLK, &fl) == -1) {
        perror("fcntl F_SETLK");
        exit(EXIT_FAILURE);
    }
}

/* Worker main loop with locking */
for (;;) {
    lockAcquire();          /* Only ONE worker in accept() at a time */
    cfd = accept(lfd, NULL, NULL);
    lockRelease();

    if (cfd == -1) {
        perror("accept");
        continue;
    }

    handleRequest(cfd);
    close(cfd);
}

On modern Linux this lock is not needed, but you may still see it in portable server code written for multiple UNIX systems.

๐Ÿ— Prethreaded Server Design

The same idea applied to threads instead of processes: create a pool of worker threads at startup. Threads are much cheaper to create than processes because they share the same address space (no page table copy needed).

Prethreaded Server Architecture
MAIN THREAD โ€” Creates listening socket, creates N worker threads
Thread 1
pthread_mutex_lock โ†’ accept() โ†’ unlock โ†’ serve
Thread 2
pthread_mutex_lock โ†’ accept() โ†’ unlock โ†’ serve
Thread 3
pthread_mutex_lock โ†’ accept() โ†’ unlock โ†’ serve
Threads share heap memory but each has its own stack. A mutex protects the accept() call to prevent races.
#include <pthread.h>

#define NUM_THREADS  10
#define PORT         9000

static int lfd;                    /* Listening socket - shared by all threads */
static pthread_mutex_t acceptMutex = PTHREAD_MUTEX_INITIALIZER;

static void *workerThread(void *arg)
{
    int cfd;
    char buf[1024];
    ssize_t n;

    for (;;) {
        /* Serialize access to accept() using a mutex */
        pthread_mutex_lock(&acceptMutex);
        cfd = accept(lfd, NULL, NULL);
        pthread_mutex_unlock(&acceptMutex);

        if (cfd == -1) {
            perror("accept");
            continue;
        }

        /* Echo data back to client */
        while ((n = read(cfd, buf, sizeof(buf))) > 0)
            write(cfd, buf, n);

        close(cfd);
        /* Loop back to accept() - no thread creation overhead! */
    }
    return NULL;
}

int main(void)
{
    pthread_t tid[NUM_THREADS];
    struct sockaddr_in addr;
    int i;

    /* 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);

    /* Pre-create the thread pool */
    for (i = 0; i < NUM_THREADS; i++)
        pthread_create(&tid[i], NULL, workerThread, NULL);

    /* Main thread waits for workers (they run forever) */
    for (i = 0; i < NUM_THREADS; i++)
        pthread_join(tid[i], NULL);

    return 0;
}

Compile with gcc -o prethreaded_server server.c -lpthread

๐Ÿ“Š Dynamic Pool Management

A fixed pool size is a simple starting point, but a production server needs dynamic pool management. The parent (or main thread) monitors the pool and adjusts it based on load:

Situation What Parent Does Why
All workers busy, more clients connecting Increase pool size (fork/create more workers) New clients should not queue too long
Too many idle workers Reduce pool size (signal workers to exit) Idle processes/threads waste memory and OS resources
Pool at minimum (e.g., 5 workers) Never go below minimum Always be ready to handle some clients instantly
Pool at maximum (e.g., 100 workers) Stop creating new workers Prevent fork bombs and resource exhaustion
/* Simplified pool management concept */
#define MIN_WORKERS   5
#define MAX_WORKERS   100
#define IDLE_THRESHOLD 2   /* if fewer than this are idle, add more */

static int activeWorkers = 0;
static int idleWorkers   = 0;

void managePool(void)
{
    /* Called periodically by the parent process or a management thread */

    if (idleWorkers < IDLE_THRESHOLD && activeWorkers < MAX_WORKERS) {
        /* Too few idle workers; add more */
        fork_new_worker();
        activeWorkers++;
    }

    if (idleWorkers > MIN_WORKERS * 2 && activeWorkers > MIN_WORKERS) {
        /* Too many idle workers; reduce pool */
        signal_worker_to_exit();
        activeWorkers--;
    }
}

Real implementations (like Apache HTTP server’s prefork MPM) implement sophisticated pool management with configurable MinSpareServers, MaxSpareServers, StartServers, and MaxRequestWorkers parameters.

โš– Preforked Processes vs Prethreaded: Which Is Better?
Factor Preforked Processes Prethreaded
Memory per worker Higher (separate address space) Lower (shared address space)
Isolation โœ” Strong (crash in one child doesn’t affect others) โš  Weak (crash can kill all threads)
Shared state Need IPC (pipes, shared memory) to share โœ” Easy (same heap); need mutex protection
Creation cost Higher (fork is expensive) Lower (pthread_create is cheaper)
Scalability Good for hundreds of workers Better for thousands of workers
Example Apache prefork MPM Apache worker MPM, Nginx

๐Ÿ“ค Alternative: Parent Does accept(), Passes fd to Worker

Instead of all workers calling accept() themselves, the parent can do all the accept() calls and then pass the connected socket to a free worker using a UNIX domain socket (a technique called file descriptor passing).

Parent: accept() โ†’ gets cfd
Parent: find a free worker from the pool
Parent: pass cfd to that worker via sendmsg() + SCM_RIGHTS
Worker: receives cfd via recvmsg(), handles client

This design gives the parent full control over load balancing โ€” it can choose the least-loaded worker, implement priority queues, etc. The trade-off is the overhead of the socket message for each new connection. This technique (using SCM_RIGHTS) is covered in detail in TLPI Section 61.13.3.

๐ŸŽ“ Interview Questions & Answers
Q1. What is the main disadvantage of fork-per-client design at high load?

Creating a new process for every client involves significant OS overhead: allocating a process table entry, copying page tables, duplicating file descriptors, etc. Under thousands of connections per minute, this overhead becomes a bottleneck. Preforked/prethreaded designs eliminate per-connection fork cost.

Q2. What is a server pool and how does it work?

A server pool is a fixed set of pre-created worker processes (or threads) that are created at server startup, before any clients connect. Each worker loops: it calls accept(), serves the client, closes the connection, and calls accept() again. Workers are never destroyed between clients.

Q3. What is the thundering herd problem with multiple workers calling accept()?

When all worker processes are sleeping in accept() and a single client connects, older UNIX systems would wake ALL workers simultaneously. Only one succeeds; the rest immediately sleep again. This wastes CPU time in a stampede of wakeups. Linux solves this in the kernel by waking only one process per connection.

Q4. Why might a preforked server still use a file lock around accept()?

On older or non-Linux UNIX implementations, accept() is not atomic. Multiple processes can simultaneously be woken and race to complete the call. A file lock (fcntl F_WRLCK) serializes access so only one worker is inside accept() at a time, preventing race conditions.

Q5. How is the listening socket shared among pre-forked children?

The parent creates the listening socket before calling fork(). Each child inherits a copy of all open file descriptors, including the listening socket. All children share the same underlying socket, and the kernel delivers each new connection to exactly one of them when they call accept().

Q6. Compare preforked processes and prethreaded designs for resource usage.

Processes: each has its own address space, so memory usage scales linearly. A crash in one child does not affect others (strong isolation). Threads: share the same address space, so memory usage is much lower per worker. A bug causing a crash or memory corruption can bring down all threads. Threads are faster to create and switch between than processes.

Q7. What is dynamic pool management and why is it important?

Dynamic pool management adjusts the number of worker processes/threads based on current load. Too few workers means clients wait too long. Too many idle workers waste memory and OS scheduler time. The server parent monitors idle vs busy workers and grows or shrinks the pool to keep idle workers within a configured range.

Q8. What real-world servers use preforked or prethreaded design?

Apache HTTP Server uses a prefork MPM (multi-processing module) where each child handles one request at a time, and a worker MPM where each child has multiple threads. Nginx uses an event-driven single-process design per CPU core (covered in Part 4). PostgreSQL database uses a preforked process per connection model.

Next: Single Process Handling Multiple Clients
Learn how a single process can handle hundreds of clients simultaneously using I/O multiplexing โ€” the Nginx approach.

Part 4 โ†’ I/O Multiplexing โ† Part 2

Leave a Reply

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