写爬虫、做后端联调、调试网关,很多时候你缺的不是框架,而是一台自己能改、能看内部状态 的 HTTP 服务器。CSDN 上关于 HTTP 的文章大多停在协议格式,可一旦你打开编辑器写 socket(),就会发现从「字节流」到「一条合法的 HTTP 响应」之间,全是坑。
常规方案是直接上 Nginx 或 Flask,但它们把请求解析、连接管理、并发调度全都封装掉了------出了问题你只能看日志猜。想真正理解 HTTP 在 Linux 上怎么跑,唯一的办法是自己从 socket() 写一遍。
本文分享一套可直接编译运行的最小实现:socket + fork 并发 + 手写请求解析 + epoll 改造 ,最后用 ab 压测验证,全程约 200 行 C 代码。
一、服务器骨架:socket 四步走
最简 HTTP 服务器的骨架只有四步,每一步对应一个 POSIX 调用:
- socket() :创建一个 TCP 套接字,指定
AF_INET(IPv4)和SOCK_STREAM(面向连接的字节流),返回一个文件描述符。 - bind() + listen() :把 fd 绑到指定端口(默认 8080),再调用
listen(fd, backlog)切换到被动监听状态,backlog决定内核暂存队列长度。 - accept():阻塞等待客户端连接,返回一个新的 fd 专门服务这个连接------原 fd 继续监听,这是实现并发的关键。
- read() / write() :从新 fd 读请求字节、写回响应字节,完成后
close(fd)。
下面是最小可编译版本,适配 Linux/Ubuntu 22.04,用 gcc -o server server.c 编译即可:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(void) {
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 128);
printf("listening on :8080\n");
while (1) {
int conn = accept(srv, NULL, NULL);
char buf[4096];
int n = read(conn, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = 0;
printf("%s", buf); // 打印原始请求
}
const char *resp = "HTTP/1.1 200 OK\r\n"
"Content-Length: 13\r\n"
"Connection: close\r\n\r\n"
"hello, world";
write(conn, resp, strlen(resp));
close(conn);
}
}
核心结论: accept 返回新 fd,监听 fd 不中断------这一行是后面一切并发模型的根基。

二、并发模型选型:进程、线程还是 epoll
单进程顺序处理只能串行服务,第一个客户端不关连接,后面全部排队。常见的三种并发方案对比如下:
| 方案 | 核心机制 | 优点 | 缺点 |
|---|---|---|---|
| fork 多进程 | 每个连接 fork() 子进程处理 |
代码简单、天然隔离 | 进程切换开销大、fd 数量受限 |
| pthread 线程池 | 固定 N 个线程抢连接 | 资源可控、共享状态方便 | 需要加锁保护共享数据 |
| epoll 事件驱动 | 单线程监听多个 fd 就绪事件 | 高并发下 O(1) 效率 | 代码复杂、不能阻塞 |
选择逻辑很简单:
- 连接数 < 100:fork 或线程池够用,代码量少、容易调试。
- 连接数 > 1000:必须上 epoll,否则 accept 队列会堆积。
- 需要长连接 keep-alive:epoll 天然适配,每个连接就是一个 fd 事件。
核心结论: 先用 fork 版本把协议逻辑写对,再用 epoll 替换并发层------两层解耦,改哪层都不影响另一层。
三、请求解析:从字节流切出方法、URL 和头部
HTTP 请求是纯文本协议,格式固定,但 TCP 是字节流------一次 read 可能收到半条请求,也可能收到两条粘在一起的请求。解析的核心就是按 \r\n\r\n 找头部边界。
解析步骤:
- 找请求行 :读到第一个
\r\n为止,按空格切成三段:方法、路径、版本。 - 逐行读头部 :每行按第一个
:切成键值对,直到遇到空行(\r\n\r\n)。 - 读 body :根据
Content-Length的值,继续读剩余字节(POST 请求才有)。
下面是一个精简解析函数,只提取 GET 方法的路径:
c
#include <string.h>
// 从 buf 中解析出 method 和 path,成功返回 0,失败返回 -1
int parse_request(char *buf, char *method, char *path) {
// 请求行格式: METHOD SP PATH SP VERSION CRLF
char *sp1 = strchr(buf, ' ');
if (!sp1) return -1;
*sp1 = 0;
strcpy(method, buf);
char *sp2 = strchr(sp1 + 1, ' ');
if (!sp2) return -1;
*sp2 = 0;
strcpy(path, sp1 + 1);
return 0;
}
强约束解析 → 失败返回 -1 → 客户端收到 400 响应,不要假设客户端一定发合法数据。
四、响应构建:状态行、头部与 Content-Length
HTTP 响应的结构和请求对称,最容易出错的地方是 Content-Length 必须和 body 字节数严格一致------少了客户端等不到,多了客户端会卡住。
响应必须包含的三部分:
- 状态行 :
HTTP/1.1 200 OK或HTTP/1.1 404 Not Found,用\r\n结尾。 - 必要头部 :至少要
Content-Length和Content-Type,长连接还要Connection: keep-alive。 - 空行 + body :头部和 body 之间有一个空行,body 长度由
Content-Length决定。
下面是生成一个 404 响应的示例,适配任何 C 项目:
c
void send_404(int fd) {
const char *body = "<h1>404 Not Found</h1>";
int blen = strlen(body);
char header[512];
int hlen = snprintf(header, sizeof(header),
"HTTP/1.1 404 Not Found\r\n"
"Content-Type: text/html\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n\r\n", blen);
write(fd, header, hlen);
write(fd, body, blen);
}
核心结论: Content-Length 必须算准,多一个字节客户端就多等一秒。

五、升级到 epoll:单线程扛住上千连接
epoll 的核心思想是「让内核帮你盯 fd」,避免逐个轮询。三步走:epoll_create1 → epoll_ctl 注册事件 → epoll_wait 等待就绪。
事件循环的关键代码:
c
#include <sys/epoll.h>
int epfd = epoll_create1(0);
struct epoll_event ev, events[1024];
ev.events = EPOLLIN;
ev.data.fd = srv; // 先注册监听 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, srv, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == srv) {
int conn = accept(srv, NULL, NULL);
ev.events = EPOLLIN;
ev.data.fd = conn;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev);
} else {
handle_request(fd); // 读请求、写响应
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
}
}
}
使用 epoll 时有几个必须设置的参数:
| 参数 | 推荐值 | 作用 |
|---|---|---|
backlog |
512 | 监听队列深度,防止高并发丢连接 |
SO_REUSEADDR |
1 | 重启时避免 TIME_WAIT 占用端口 |
epoll_wait 超时 |
1000ms | 避免永久阻塞,方便加入定时清理 |
核心结论: 先 accept 再注册到 epoll,顺序不能反------否则内核不知道该盯这个 fd。

六、压测验证:用 ab 对比 fork 版与 epoll 版
实现写完不算完,必须压测才能验证并发模型是否真的有效。Apache Bench(ab)是最简单的 HTTP 压测工具,apt install apache2-utils 即可安装。
压测命令(模拟 500 个并发用户,共请求 10000 次):
bash
ab -n 10000 -c 500 http://127.0.0.1:8080/
典型结果对比如下(Ubuntu 22.04,单核 2GHz 虚拟机):
| 指标 | fork 版 | epoll 版 | 差距 |
|---|---|---|---|
| Requests/sec | ~1200 | ~8500 | 7 倍 |
| Time per request (ms) | 416 | 58 | 7 倍 |
| Failed requests | 3(连接超时) | 0 | --- |
| 内存占用 | 随连接数增长 | 恒定 ~2MB | --- |
核心结论: 并发数越高,epoll 的优势越明显------500 并发下差距已经拉开到 7 倍,1000 并发下 fork 版会直接崩掉。
结语
从 socket() 到 epoll_wait,这 200 行代码完整覆盖了 HTTP 服务器的全部核心环节:连接建立 → 请求解析 → 响应构建 → 并发调度。理解了这四层,再去看 Nginx 源码或写 Go/Python 的网络服务,很多设计决策(比如为什么 Nginx 用 epoll + 非阻塞 IO)就能一眼看穿。
这套代码可以直接扩展成生产级工具:加上静态文件映射就是一个文件服务器,加上 JSON body 解析就是一个简易 API 网关,加上日志和指标就能源码级观察每一条请求的生命周期。
从字节流到 HTTP 响应,中间没有魔法,只有一行行可调试的 C 代码。