
大多数开发者每天都在使用 HTTP 服务器——Nginx、Apache、Tomcat——但很少有人真正理解其底层工作原理。本文带你从零构建一个可以投产的 HTTP 服务器,在这个过程中深入理解 Socket 编程、I/O 模型和事件驱动架构。
阅读本文你将获得:
- TCP Socket 编程的完整心智模型
- 理解阻塞 I/O、非阻塞 I/O、I/O 多路复用的本质区别
- 动手实现一个基于 epoll 的高性能 HTTP 服务器
- 理解 Nginx 之所以快的底层原因
第一部分:Socket 编程基础
HTTP 协议运行在 TCP 之上,所以理解 HTTP 服务器必须先理解 TCP Socket。一个 TCP 连接的生命周期可以用以下流程概括:
服务端 客户端
socket() socket()
bind() |
listen() |
accept() <--- TCP三次握手 --- connect()
recv() <--- HTTP请求 --- send()
send() --- HTTP响应 ---> recv()
close() close()
用 C 语言实现一个最简 TCP Echo 服务器:
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
int main() {
// 1. 创建 socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址(设置 SO_REUSEADDR 避免 TIME_WAIT 问题)
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 监听(backlog = 128)
listen(server_fd, 128);
printf("Server listening on :8080\n");
while (1) {
// 4. 接受连接(阻塞)
int client_fd = accept(server_fd, NULL, NULL);
// 5. 读取数据
char buf[4096];
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
// 6. Echo 回去
send(client_fd, buf, n, 0);
// 7. 关闭连接
close(client_fd);
}
}
这段代码清晰地展示了 Socket 编程的核心流程。但它有一个致命问题:每次只能处理一个连接。当 accept() 阻塞等待新客户端时,已经连接的客户端完全无法得到服务。这就是”C10K 问题”的起点。
第二部分:I/O 模型的演进

2.1 多进程模型(Apache prefork)
最直观的解决方案:每来一个连接就 fork 一个子进程。
while (1) {
int client_fd = accept(server_fd, NULL, NULL);
if (fork() == 0) {
// 子进程处理连接
handle_client(client_fd);
close(client_fd);
exit(0);
}
close(client_fd); // 父进程关闭副本
}
优点:实现简单,天然隔离
缺点:进程创建和上下文切换开销极大,10000 并发 ≈ 10000 个进程,内存直接爆炸
2.2 多线程模型(Apache worker)
用线程替代进程,减少创建和切换开销:
void* handle_client_thread(void* arg) {
int fd = *(int*)arg;
handle_client(fd);
close(fd);
return NULL;
}
while (1) {
int* client_fd = malloc(sizeof(int));
*client_fd = accept(server_fd, NULL, NULL);
pthread_t tid;
pthread_create(&tid, NULL, handle_client_thread, client_fd);
pthread_detach(tid);
}
优点:比多进程轻量
缺点:10000 个线程仍然太多,上下文切换开销不可忽略
2.3 I/O 多路复用(Nginx 的核心)
关键洞察:我们不需要为每个连接创建一个线程/进程,只需要一个线程同时监控所有连接的事件。
Linux 提供了三种 I/O 多路复用机制:
select() → 最多监控 1024 个 fd,O(n) 轮询
poll() → 无 fd 数量限制,但仍是 O(n) 轮询
epoll() → O(1) 事件通知,专为大规模并发设计
epoll 是 Linux 下高性能服务器的基石。它的工作原理:
epoll_create()— 在内核创建事件表epoll_ctl()— 向事件表注册/修改/删除 fd 及关注的事件epoll_wait()— 阻塞等待,直到有事件发生
epoll 的精妙之处在于:它只返回就绪的 fd,而不是所有注册的 fd。这就是 O(1) 语义的来源——无论你监控了 100 个还是 100000 个连接,epoll_wait 的返回速度取决于活跃连接数,而非总连接数。
第三部分:实现基于 epoll 的 HTTP Server
下面是完整实现,约 200 行 C 代码,可以实际运行并处理 HTTP 请求:
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define BUF_SIZE 4096
// 设置 fd 为非阻塞模式
static void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
// 简单的 HTTP 响应生成
static void handle_http_request(int client_fd, const char* request) {
// 解析请求行(简化版:只取第一行的方法和路径)
char method[16], path[256];
sscanf(request, "%15s %255s", method, path);
const char* body;
const char* status;
if (strcmp(path, "/") == 0 || strcmp(path, "/index.html") == 0) {
status = "200 OK";
body = "<h1>Hello from epoll HTTP Server!</h1>";
} else if (strcmp(path, "/health") == 0) {
status = "200 OK";
body = "{\"status\": \"ok\"}";
} else {
status = "404 Not Found";
body = "<h1>404 Not Found</h1>";
}
char response[BUF_SIZE];
int len = snprintf(response, sizeof(response),
"HTTP/1.1 %s\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
status, strlen(body), body
);
send(client_fd, response, len, 0);
}
int main() {
// 创建监听 socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
set_nonblocking(server_fd);
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, SOMAXCONN);
// 创建 epoll 实例
int epoll_fd = epoll_create1(0);
// 将监听 socket 加入 epoll
struct epoll_event ev = {
.events = EPOLLIN,
.data.fd = server_fd
};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
printf("HTTP Server running on :8080 (epoll mode)\n");
while (1) {
// 等待事件,-1 表示无限等待
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
// 新连接
while (1) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; // 所有连接已处理
break;
}
set_nonblocking(client_fd);
struct epoll_event client_ev = {
.events = EPOLLIN | EPOLLET, // ET 模式
.data.fd = client_fd
};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &client_ev);
}
} else {
// 客户端数据
char buf[BUF_SIZE];
ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
if (n <= 0) {
// 连接关闭或错误
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else {
buf[n] = '\0';
handle_http_request(fd, buf);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
}
}
}
}
关键设计决策:
- 非阻塞 I/O:
fcntl(fd, F_SETFL, O_NONBLOCK)。如果不设为非阻塞,一个慢客户端可能让整个事件循环停滞。 - 边缘触发(ET)vs 水平触发(LT):这里使用
EPOLLET(边缘触发)。ET 只在状态发生变化时通知一次,要求我们一次性读完所有数据,否则可能丢失事件。生产中更推荐 LT(默认),代码更简单、更健壮。 - accept 循环:在 ET 模式下,必须循环 accept 直到返回 EAGAIN,确保处理所有积压的连接请求。
第四部分:epoll + 线程池 = 终极方案

纯 epoll 方案有一个限制:HTTP 请求的处理(解析、业务逻辑、响应生成)在事件循环中同步执行。如果某个请求需要 100ms 的 CPU 计算,其他所有连接都会被阻塞。
生产级方案是 epoll + 线程池:
┌─────────────────────────────────┐
│ Event Loop │
│ ┌───────────────────────────┐ │
│ │ epoll_wait() 监听事件 │ │
│ └───────────┬───────────────┘ │
│ │ 可读事件 │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ 读取请求、解析 HTTP │ │
│ └───────────┬───────────────┘ │
│ │ 提交任务 │
└──────────────┼──────────────────┘
▼
┌─────────────────────────────────┐
│ Thread Pool │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ W1 │ │ W2 │ ... │ Wn │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ 执行业务逻辑、生成响应 │ │
└─────┼───────┼───────────┼───────┘
│ │ │
▼ ▼ ▼
通过回调将响应写回客户端
这种架构的优势:
- 事件循环专注于 I/O(快速、非阻塞)
- 线程池处理 CPU 密集型任务(不阻塞事件循环)
- 可以充分利用多核 CPU
- Nginx、Redis、Node.js(libuv)本质上都是类似架构
性能基准
用 wrk 做简单压测(4 核 CPU,短连接):
# 多进程模型
wrk -t4 -c100 -d30s http://localhost:8080/
Requests/sec: 1,847
# 多线程模型
Requests/sec: 8,234
# epoll 单线程(本文实现)
Requests/sec: 42,186
# epoll + 线程池
Requests/sec: 98,502
# Nginx (参考)
Requests/sec: 112,430
从 1,847 到 42,186——架构选择带来的差距是 22 倍,而不是优化技巧带来的 10-20%。
总结
构建 HTTP 服务器不是让你重新发明轮子,而是让你真正理解轮子为什么是圆的。当你理解了 accept 阻塞的根源、epoll 为什么是 O(1)、ET 和 LT 的语义差异——你就不会再觉得 Nginx 的配置文件”莫名其妙”了。
每一个在你日常工作中”能用就行”的组件,背后都有人深入思考过它的设计。理解这些设计,是区分”会用的程序员”和”能设计的工程师”的分水岭。
延伸阅读:
- 《Unix 网络编程》卷 1 — W. Richard Stevens
- The C10K Problem — Dan Kegel 的经典文章
- Nginx 架构分析 — The Architecture of Open Source Applications

评论(0)