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
- Server Architecture: Thread Pool Model
- Step 1: Create and Configure the Listening Socket
- Step 2: Thread Pool Implementation
- Step 3: HTTP Request Parsing
- Step 4: MIME Type Detection
- Step 5: File Serving with sendfile
- Step 6: HTTP Response Building
- Step 7: Graceful Shutdown with SIGTERM
- Complete Server: Putting It Together
- Testing Your Server
- Extension Challenges
- Phase 4 Reflection
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):
GET /index.html HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: Mozilla/5.0...\r\n
\r\nResponse (server → browser):
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
#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
#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
#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
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
#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
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
#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
# 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 hardwareExtension Challenges
- Keep-Alive: Support
Connection: keep-alive— reuse the TCP connection for multiple requests, eliminating per-request TCP handshake overhead. - Range requests: Support
Range: bytes=0-4095headers for video streaming (seek without downloading the entire file). - 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.
- Gzip compression: Compress text files before sending using zlib — reduce bandwidth by 60-80% for HTML/CSS/JS.
- Virtual hosting: Parse the
Host:header; serve from differentwww/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.
