CProjects

Project: Building a Multi-Threaded HTTP Server in C from Scratch

TT
TopicTrick Team
Project: Building a Multi-Threaded HTTP Server in C from Scratch

Project: Building a Multi-Threaded HTTP Server in C from Scratch

Phase 4 Capstone. You've mastered TCP sockets, POSIX threads, and file I/O. Now you'll combine them to build a real HTTP/1.1 server: a program that accepts browser connections, parses HTTP requests, reads HTML/CSS files from disk, and sends proper HTTP responses. This is exactly how the early web worked — and still the foundation of Apache, Nginx, and every C-based web server today.


Table of Contents


HTTP/1.1 Protocol Primer

HTTP is a text-based request-response protocol over TCP. Every browser interaction follows the same pattern:

Request (browser → server):

text
GET /index.html HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: Mozilla/5.0...\r\n
\r\n

Response (server → browser):

text
HTTP/1.1 200 OK\r\n
Content-Type: text/html; charset=utf-8\r\n
Content-Length: 1234\r\n
Connection: close\r\n
\r\n
<html>...file contents...</html>

Key rules: headers end with \r\n, the header section ends with \r\n\r\n (blank line), then the body follows.


Server Architecture: Thread Pool Model


Step 1: Create and Configure the Listening Socket

c
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

static int create_server_socket(uint16_t port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); return -1; }
    
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr = {
        .sin_family      = AF_INET,
        .sin_port        = htons(port),
        .sin_addr.s_addr = INADDR_ANY,
    };
    
    if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(fd); return -1;
    }
    if (listen(fd, 128) < 0) {
        perror("listen"); close(fd); return -1;
    }
    
    printf("Server listening on http://localhost:%d/\n", port);
    return fd;
}

Step 2: Thread Pool Implementation

c
#include <pthread.h>
#include <stdbool.h>

#define POOL_SIZE 8
#define QUEUE_SIZE 256

typedef struct {
    int        client_fds[QUEUE_SIZE];
    int        head, tail, count;
    pthread_mutex_t lock;
    pthread_cond_t  not_empty;
    pthread_cond_t  not_full;
    volatile bool   shutdown;
    pthread_t        workers[POOL_SIZE];
} ThreadPool;

static void pool_enqueue(ThreadPool *pool, int fd) {
    pthread_mutex_lock(&pool->lock);
    while (pool->count == QUEUE_SIZE && !pool->shutdown)
        pthread_cond_wait(&pool->not_full, &pool->lock);
    if (!pool->shutdown) {
        pool->client_fds[pool->tail] = fd;
        pool->tail = (pool->tail + 1) % QUEUE_SIZE;
        pool->count++;
        pthread_cond_signal(&pool->not_empty);
    }
    pthread_mutex_unlock(&pool->lock);
}

static int pool_dequeue(ThreadPool *pool) {
    pthread_mutex_lock(&pool->lock);
    while (pool->count == 0 && !pool->shutdown)
        pthread_cond_wait(&pool->not_empty, &pool->lock);
    if (pool->shutdown && pool->count == 0) {
        pthread_mutex_unlock(&pool->lock);
        return -1;
    }
    int fd = pool->client_fds[pool->head];
    pool->head = (pool->head + 1) % QUEUE_SIZE;
    pool->count--;
    pthread_cond_signal(&pool->not_full);
    pthread_mutex_unlock(&pool->lock);
    return fd;
}

Step 3: HTTP Request Parsing

c
#define MAX_PATH 256

typedef struct {
    char method[8];       // "GET", "POST", etc.
    char path[MAX_PATH];  // "/index.html", "/css/style.css"
    char version[16];     // "HTTP/1.1"
} HTTPRequest;

// Parse only the first line: "GET /path HTTP/1.1"
int parse_request(const char *raw, HTTPRequest *req) {
    int n = sscanf(raw, "%7s %255s %15s", req->method, req->path, req->version);
    if (n != 3) return -1;
    
    // Security: prevent path traversal attacks
    if (strstr(req->path, "..")) {
        fprintf(stderr, "Path traversal attempt blocked: %s\n", req->path);
        return -1;
    }
    
    // Default to index.html for root
    if (strcmp(req->path, "/") == 0) {
        strncpy(req->path, "/index.html", MAX_PATH - 1);
    }
    
    return 0;
}

Step 4: MIME Type Detection

c
typedef struct {
    const char *extension;
    const char *mime_type;
} MIMEEntry;

static const MIMEEntry mime_table[] = {
    { ".html", "text/html; charset=utf-8"  },
    { ".css",  "text/css"                  },
    { ".js",   "application/javascript"    },
    { ".json", "application/json"          },
    { ".png",  "image/png"                 },
    { ".jpg",  "image/jpeg"                },
    { ".jpeg", "image/jpeg"                },
    { ".gif",  "image/gif"                 },
    { ".svg",  "image/svg+xml"             },
    { ".ico",  "image/x-icon"              },
    { ".txt",  "text/plain"                },
    { NULL,    "application/octet-stream"  }, // Default
};

const char* get_mime_type(const char *path) {
    const char *ext = strrchr(path, '.'); // Find last '.'
    if (ext) {
        for (const MIMEEntry *e = mime_table; e->extension; e++) {
            if (strcasecmp(ext, e->extension) == 0) return e->mime_type;
        }
    }
    return "application/octet-stream";
}

Step 5: File Serving with sendfile

c
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>

#define WWW_ROOT "./www"

int serve_file(int client_fd, const char *path) {
    // Construct file path: www + requested path
    char file_path[512];
    snprintf(file_path, sizeof(file_path), "%s%s", WWW_ROOT, path);
    
    // Open the file
    int file_fd = open(file_path, O_RDONLY);
    if (file_fd < 0) return -1; // File not found → 404
    
    // Get file size
    struct stat st;
    fstat(file_fd, &st);
    off_t file_size = st.st_size;
    
    // Build and send HTTP response header
    const char *mime = get_mime_type(path);
    char header[512];
    int header_len = snprintf(header, sizeof(header),
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: %s\r\n"
        "Content-Length: %lld\r\n"
        "Connection: close\r\n"
        "\r\n",
        mime, (long long)file_size);
    
    send(client_fd, header, header_len, 0);
    
    // Efficient file transfer using sendfile (Linux kernel copies directly)
    // No user-space buffer needed — zero copy!
    off_t offset = 0;
    sendfile(client_fd, file_fd, &offset, file_size);
    
    close(file_fd);
    return 0;
}

Step 6: HTTP Response Building

c
void send_error(int client_fd, int status_code, const char *status_text) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>",
        status_code, status_text);
    
    char response[512];
    int resp_len = snprintf(response, sizeof(response),
        "HTTP/1.1 %d %s\r\n"
        "Content-Type: text/html\r\n"
        "Content-Length: %d\r\n"
        "Connection: close\r\n"
        "\r\n"
        "%s",
        status_code, status_text, body_len, body);
    
    send(client_fd, response, resp_len, 0);
}

void handle_client(int client_fd) {
    char buffer[4096] = {0};
    
    ssize_t n = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
    if (n <= 0) { close(client_fd); return; }
    
    HTTPRequest req = {0};
    if (parse_request(buffer, &req) < 0) {
        send_error(client_fd, 400, "Bad Request");
        close(client_fd);
        return;
    }
    
    // Only handle GET requests
    if (strcmp(req.method, "GET") != 0) {
        send_error(client_fd, 405, "Method Not Allowed");
        close(client_fd);
        return;
    }
    
    if (serve_file(client_fd, req.path) < 0) {
        send_error(client_fd, 404, "Not Found");
    }
    
    close(client_fd);
}

Step 7: Graceful Shutdown with SIGTERM

c
#include <signal.h>

static volatile sig_atomic_t g_running = 1;

static void shutdown_handler(int sig) {
    g_running = 0;
}

// In main():
struct sigaction sa = { .sa_handler = shutdown_handler };
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT,  &sa, NULL);

// Accept loop:
while (g_running) {
    int client = accept(server_fd, NULL, NULL);
    if (client < 0) {
        if (!g_running) break; // Interrupted by signal
        continue;
    }
    pool_enqueue(&pool, client);
}

Testing Your Server

bash
# Create a test www/ directory
mkdir www
echo '<h1>Hello from C HTTP Server!</h1>' > www/index.html

# Compile and run
gcc -pthread -O2 server.c -o server
./server 8080

# Test with curl
curl -v http://localhost:8080/
curl http://localhost:8080/index.html
curl -I http://localhost:8080/nosuchfile  # Should return 404

# Load test with Apache Bench
ab -n 10000 -c 100 http://localhost:8080/

# Expected: 1000-10000 req/sec for static files on modern hardware

Extension Challenges

  1. Keep-Alive: Support Connection: keep-alive — reuse the TCP connection for multiple requests, eliminating per-request TCP handshake overhead.
  2. Range requests: Support Range: bytes=0-4095 headers for video streaming (seek without downloading the entire file).
  3. epoll-based event loop: Replace the thread-per-request model with a single-thread epoll loop — handle 10,000+ concurrent connections with no context switching overhead.
  4. Gzip compression: Compress text files before sending using zlib — reduce bandwidth by 60-80% for HTML/CSS/JS.
  5. Virtual hosting: Parse the Host: header; serve from different www/ root directories based on domain name.

Phase 4 Reflection

You've just built a significant piece of infrastructure — a real web server that can serve content to a browser. By combining sockets, threads, file I/O, and signal handling, you've touched every layer of the systems stack.

The fundamentals here — listen/accept loop, thread pool, HTTP parsing, file serving — are exactly how Apache httpd was architected in the late 1990s. Nginx replaced the thread model with an event loop (epoll), achieving 10× better concurrency — the extension challenge above is your path to that same transformation.

Read next: C23 Modern Evolution & C Security Hardening →

Frequently Asked Questions

Q: What is the key architectural difference between a blocking and a non-blocking C web server? A blocking server calls accept() and then read()/write() — if the client is slow, the thread stalls waiting for data. To handle N clients concurrently it needs N threads, which is expensive. A non-blocking server uses epoll (Linux) to monitor many file descriptors simultaneously: one or a few threads call epoll_wait() which returns only the fds ready for I/O, process them, and loop — handling thousands of clients with minimal threads. This is the event-loop model used by nginx and Node.js.

Q: How do you use epoll to build a non-blocking event loop in C? Create an epoll instance with epoll_create1(0). Add the listening socket with epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &event) with EPOLLIN. In a loop, call epoll_wait(epfd, events, MAX_EVENTS, -1) — it blocks until events arrive. For each event, if it is the listening socket, accept() the new connection and add it to epoll. If it is a client socket, read() the request, parse it, write() the response. Set all client sockets to O_NONBLOCK with fcntl() so reads and writes never block.

Q: How do you parse HTTP/1.1 requests efficiently in C? An HTTP request consists of a request line (GET /path HTTP/1.1 ), headers (Key: Value pairs), a blank line ( ), and optional body. Parse by scanning for delimiters using strstr() or manual pointer arithmetic. Extract method, path, and version from the request line with sscanf() or strtok_r(). For robustness, cap the maximum header size (8 KB is conventional) and reject requests that exceed it with a 400 response. Avoid strtok() (not thread-safe) — use strtok_r() in multi-threaded servers.


Part of the C Mastery Course — 30 modules from C basics to production systems engineering.