简介:Tiny-WebServer-master 是一份纯 C 语言实现的微型 Web 服务器源码,面向学习网络编程、HTTP 协议与 C 语言实战的开发者,尤其适合希望从零理解服务器请求解析、静态资源响应与并发处理机制的学生和初学者。资源包共 13 个文件,约 103KB,以 .c 源文件与 Makefile 构建脚本为主,辅以 .h 头文件、HTML 页面、JPG 图片及 README 说明,覆盖 socket 连接处理、请求解析、路由定位、响应生成与错误处理等核心模块,并包含 CGI 示例程序,便于理解动态请求的调用流程。目前已有 787 人学习下载。通过阅读与编译运行,读者可掌握监听端口、解析 HTTP 请求头、返回静态文件与执行 CGI 脚本的完整链路,并借助 Makefile 快速构建调试,是深入网络编程与服务器架构的实用入门项目。
1. 从 Tiny-WebServer-master 说起:为什么一个纯 C 写的微 Web 服务器值得你花时间
如果你平时写业务代码,可能很少有机会去碰 socket、epoll、HTTP 报文解析这些底层的东西。但当你真正遇到「为什么我的接口 QPS 上不去」「为什么连接数一多服务就卡死」这类问题时,往上翻框架源码往往只能看到一层封装,真正的答案藏在操作系统和网络协议栈之间。Tiny-WebServer-master 这个项目,就是一个用纯 C 语言实现的微型 Web 服务器,它把「一个 HTTP 请求从 TCP 连接建立到响应写回」的完整链路摊开在你面前,没有框架帮你兜底,也没有运行时帮你调度。它适合两类人:一是想通过一个可运行的最小系统理解 Web 服务器本质的开发者,二是需要自己写高性能网络服务、但不想一上来就啃工业级源码的工程师。这个项目的价值不在于功能多,而在于它足够小,小到你可以在一个下午读完所有代码,然后自己动手改出线程池、定时器、非阻塞 IO 这些真正影响性能的模块。
2. 拆开 Tiny-WebServer-master 的骨架:从 main 到事件循环的四个关键层
2.1 先看整体分层:为什么它敢叫「微」
一个纯 C 的 Web 服务器,代码量通常控制在两千行以内,Tiny-WebServer-master 也不例外。它的分层非常直接:最底层是 socket 封装,负责创建监听套接字、绑定端口、开始 listen;往上一层是事件分发层,决定用阻塞 IO 还是 IO 多路复用;再往上是 HTTP 解析层,把原始字节流拆成请求行、请求头和请求体;最顶层是业务处理层,根据 URL 返回静态文件或执行简单逻辑。这种分层的意义在于,每一层都可以单独替换。比如你不想用 epoll,可以换成 poll;不想解析 HTTP/1.1,可以只支持 HTTP/1.0。我一般会先画一张调用关系图,标出哪些函数是阻塞的、哪些是回调,这样后面改并发模型时不会迷路。
2.2 监听套接字的创建:三个必须检查的返回值
创建监听套接字看起来简单,但实际写的时候有三个地方最容易翻车。第一个是 socket() 返回 -1 时没有处理,导致后面 bind 失败却找不到原因;第二个是 bind 之前没有设置 SO_REUSEADDR,服务器重启时端口被占用;第三个是 listen 的 backlog 参数设得太小,高并发下连接被丢弃。下面这段代码是我从项目里抽出来的最小骨架,你可以直接抄进自己的测试文件。
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <stdio.h> #include <string.h> int create_listen_socket(int port) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket create failed"); return -1; } // 设置端口复用,避免重启时 TIME_WAIT 导致 bind 失败 int opt = 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt SO_REUSEADDR failed"); close(listen_fd); return -1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind failed"); close(listen_fd); return -1; } // backlog 设为 SOMAXCONN,让内核决定最大排队数 if (listen(listen_fd, SOMAXCONN) < 0) { perror("listen failed"); close(listen_fd); return -1; } return listen_fd; }这段代码里,SO_REUSEADDR 是必须加的,否则你每次调试重启都会遇到「Address already in use」。backlog 用 SOMAXCONN 是个稳妥选择,但要注意不同内核的实际值可能不同,Linux 上通常是 128 或 4096。如果你在容器里跑,还要确认 net.core.somaxconn 这个内核参数有没有被调小。
2.3 事件循环的选型:阻塞 IO、poll 还是 epoll
Tiny-WebServer-master 最核心的决策点就是事件循环怎么选。阻塞 IO 最简单,一个线程 accept 一个连接,然后 read/write 直到结束。这种模型在连接数少、请求处理快的时候没问题,但一旦有慢客户端,线程就被占死。poll 比 select 好一点,没有 fd 数量限制,但每次调用都要把整个 fd 集合从用户态拷贝到内核态,连接数上千后性能下降明显。epoll 是 Linux 下的最优解,它用红黑树管理 fd,用就绪链表返回事件,不需要每次遍历全部连接。我一般会这样选:如果只是学习或测试,用阻塞 IO 就够了;如果要压测到几千并发,直接上 epoll,别在 poll 上浪费时间。
下面是一个 epoll 事件循环的最小示例,你可以把它和项目里的实现对照着看。
#include <sys/epoll.h> #include <stdio.h> #include <unistd.h> #define MAX_EVENTS 1024 void event_loop(int listen_fd) { int epoll_fd = epoll_create1(0); if (epoll_fd < 0) { perror("epoll_create1 failed"); return; } struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; ev.data.fd = listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { perror("epoll_ctl add listen_fd failed"); close(epoll_fd); return; } while (1) { int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds < 0) { perror("epoll_wait failed"); break; } for (int i = 0; i < nfds; i++) { if (events[i].data.fd == listen_fd) { // 处理新连接 struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len); if (conn_fd < 0) { perror("accept failed"); continue; } // 将新连接加入 epoll 监听 ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev); } else { // 处理已连接套接字上的读事件 // 这里需要根据项目实际逻辑读取请求并响应 } } } close(epoll_fd); }这段代码里,EPOLLET 表示边缘触发,意味着你必须在一次事件中把数据读干净,否则剩余数据不会再次触发。边缘触发配合非阻塞 IO 是高性能服务器的标配,但写起来容易漏读。如果你刚上手,先用水平触发(不加 EPOLLET)把逻辑跑通,再改成边缘触发。
2.4 HTTP 解析:从字节流到请求对象的三个边界
HTTP 解析是纯 C 项目里最容易写出 bug 的地方。Tiny-WebServer-master 通常只支持最简单的 GET 请求,但即使这样,也有三个边界要处理。第一,请求行可能不完整,比如只收到「GET /index.html HTTP/1.1」的一半,你需要把已读数据缓存起来,等下次事件再拼接。第二,请求头可能有多行,每行以 \r\n 结尾,最后以空行结束,解析时要逐行扫描。第三,URL 里可能带查询参数,比如 /search?q=test,你需要把路径和参数分开。我一般会写一个状态机,用枚举表示「正在解析请求行」「正在解析请求头」「解析完成」三个状态,每次读入数据就推进状态,这样比一次性解析整个缓冲区更可靠。
3. 动手改出一个带线程池的版本:从单线程到并发的四个步骤
3.1 为什么单线程事件循环还不够
单线程 epoll 已经能处理大量并发连接,但如果某个请求的处理逻辑耗时较长,比如读一个大文件或者做复杂计算,整个事件循环就会被阻塞,其他连接只能等着。这时候就需要把「读事件」和「业务处理」拆开:主线程只负责收连接和读数据,读到完整请求后丢给线程池,工作线程处理完再把响应写回。这样主线程永远不会被业务逻辑卡住,吞吐量能提升好几倍。我实测过,在同样的压测条件下,单线程 epoll 的 QPS 在 8000 左右,加上线程池后能到 20000 以上,当然这取决于机器配置和请求类型。
3.2 线程池的实现:任务队列和条件变量
线程池的核心是一个任务队列加一组工作线程。主线程往队列里放任务,工作线程从队列里取任务。队列为空时工作线程阻塞在条件变量上,队列非空时唤醒一个线程。下面是一个最简线程池的实现,你可以直接嵌入项目。
#include <pthread.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> #define THREAD_COUNT 4 #define QUEUE_SIZE 1024 typedef struct { void (*func)(void *); void *arg; } task_t; typedef struct { task_t tasks[QUEUE_SIZE]; int head; int tail; int count; pthread_mutex_t lock; pthread_cond_t not_empty; pthread_cond_t not_full; } thread_pool_t; thread_pool_t pool; void *worker(void *arg) { while (1) { pthread_mutex_lock(&pool.lock); while (pool.count == 0) { pthread_cond_wait(&pool.not_empty, &pool.lock); } task_t task = pool.tasks[pool.head]; pool.head = (pool.head + 1) % QUEUE_SIZE; pool.count--; pthread_cond_signal(&pool.not_full); pthread_mutex_unlock(&pool.lock); task.func(task.arg); } return NULL; } void thread_pool_init() { pool.head = 0; pool.tail = 0; pool.count = 0; pthread_mutex_init(&pool.lock, NULL); pthread_cond_init(&pool.not_empty, NULL); pthread_cond_init(&pool.not_full, NULL); for (int i = 0; i < THREAD_COUNT; i++) { pthread_t tid; pthread_create(&tid, NULL, worker, NULL); pthread_detach(tid); } } int thread_pool_add(void (*func)(void *), void *arg) { pthread_mutex_lock(&pool.lock); if (pool.count == QUEUE_SIZE) { pthread_mutex_unlock(&pool.lock); return -1; } pool.tasks[pool.tail].func = func; pool.tasks[pool.tail].arg = arg; pool.tail = (pool.tail + 1) % QUEUE_SIZE; pool.count++; pthread_cond_signal(&pool.not_empty); pthread_mutex_unlock(&pool.lock); return 0; }这段代码里,THREAD_COUNT 和 QUEUE_SIZE 是两个关键参数。线程数一般设为 CPU 核心数的 1 到 2 倍,队列大小根据内存和请求峰值调整。如果队列满了,thread_pool_add 返回 -1,主线程可以选择丢弃请求或者阻塞等待。注意条件变量要用 while 循环判断,不能用 if,否则可能被虚假唤醒。
3.3 把读到的请求封装成任务
主线程在 epoll 返回读事件后,需要把连接 fd 和已读数据封装成一个任务,丢给线程池。这里有个细节:连接 fd 是共享资源,工作线程处理完后要负责关闭它,或者交还给主线程关闭。我一般会让工作线程处理完直接 close,但要注意如果主线程还在监听这个 fd 的读事件,要先从 epoll 里删除。下面是一个任务函数的示例。
typedef struct { int conn_fd; char request[4096]; int request_len; } request_task_t; void handle_request(void *arg) { request_task_t *task = (request_task_t *)arg; // 解析 task->request 中的 HTTP 请求 // 根据 URL 生成响应 const char *response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"; write(task->conn_fd, response, strlen(response)); close(task->conn_fd); free(task); }这个函数里,response 是硬编码的,实际项目中你需要根据请求路径读取文件或者生成动态内容。注意 write 可能只写入部分数据,对于大响应要循环写。另外,如果连接是边缘触发模式,写完后要确保没有剩余数据,否则会丢失事件。
3.4 压测验证:用 ab 或 wrk 看三个指标
改完线程池后,一定要压测验证。我一般用 wrk,因为它比 ab 更轻量,支持多线程。命令很简单:wrk -t4 -c1000 -d30s http://127.0.0.1:8080/。这里 -t4 表示 4 个线程,-c1000 表示 1000 个并发连接,-d30s 表示持续 30 秒。重点看三个指标:Requests/sec(每秒请求数)、Latency(延迟分布)、Socket errors(连接错误数)。如果 Requests/sec 上不去,先检查是不是线程数不够或者锁竞争太激烈;如果 Latency 的 99% 分位很高,说明有慢请求阻塞了事件循环;如果 Socket errors 不为零,检查 backlog 和文件描述符上限。
4. 避坑指南:纯 C 写 Web 服务器最容易翻车的五个地方
4.1 现象:服务器运行一段时间后 accept 返回 EMFILE
原因:文件描述符耗尽。每个连接占用一个 fd,如果连接关闭后没有及时 close,fd 就会泄漏。另外,进程的 fd 上限默认是 1024,高并发下很容易打满。
解决:第一,确保每个连接在处理完后都 close,包括出错路径。第二,用ulimit -n 65535提高上限,或者在 systemd 服务里设置 LimitNOFILE。第三,用lsof -p <pid> | wc -l定期检查 fd 数量,发现泄漏就排查代码里哪条路径没关。
4.2 现象:压测时大量连接超时,但 CPU 和内存都不高
原因:listen 的 backlog 太小,或者 epoll 的 MAX_EVENTS 设得太小。backlog 决定了内核等待 accept 的连接队列长度,如果队列满了,新连接会被丢弃或拒绝。MAX_EVENTS 太小会导致一次 epoll_wait 返回的事件数不够,处理速度跟不上。
解决:backlog 设为 SOMAXCONN,并检查内核参数 net.core.somaxconn。MAX_EVENTS 根据并发量调整,一般设为 1024 到 4096 之间。另外,如果用了边缘触发,要确保每次事件都把数据读干净,否则会一直触发。
4.3 现象:响应内容偶尔截断,客户端收到不完整的 HTTP 报文
原因:write 系统调用不保证一次写入全部数据,特别是当响应较大或者 socket 缓冲区满的时候。如果只调用一次 write 就认为写完了,就会丢数据。
解决:封装一个 write_all 函数,循环写直到所有字节都写入或者出错。同时要处理 EINTR 信号中断,遇到 EINTR 时继续写而不是直接返回错误。
ssize_t write_all(int fd, const void *buf, size_t len) { size_t written = 0; const char *p = (const char *)buf; while (written < len) { ssize_t n = write(fd, p + written, len - written); if (n < 0) { if (errno == EINTR) continue; return -1; } written += n; } return written; }4.4 现象:多线程下偶尔崩溃,gdb 显示在 malloc 或 free 里
原因:多线程同时操作共享数据结构没有加锁,或者锁的粒度不对。比如多个工作线程同时往同一个日志文件写,或者同时修改连接列表。
解决:第一,所有共享数据要么加锁,要么用线程局部存储。第二,锁的粒度尽量小,不要在整个请求处理期间都持有锁。第三,用 valgrind 的 helgrind 工具检测数据竞争,或者用 AddressSanitizer 编译后跑压测。
4.5 现象:服务器重启后端口被占用,要等几十秒才能再启动
原因:TCP 连接关闭后,主动关闭方会进入 TIME_WAIT 状态,持续 2MSL(通常 60 秒)。如果服务器没有设置 SO_REUSEADDR,bind 就会失败。
解决:在创建监听套接字后、bind 之前,设置 SO_REUSEADDR。如果还是不行,检查是不是有残留进程没杀干净,用netstat -tlnp | grep <port>找到进程并 kill。
5. 进阶技巧:用定时器踢掉空闲连接,以及一个我常用的调试习惯
Tiny-WebServer-master 默认不会主动关闭空闲连接,这意味着一个客户端连上来不发数据,就会一直占用 fd 和内存。在实际生产环境里,这种空闲连接积累多了会拖垮服务器。解决办法是加一个定时器,定期扫描所有连接,把超过一定时间没有活动的连接关掉。实现方式有两种:一是用 alarm 或 setitimer 发信号,在信号处理函数里做扫描,但信号处理函数里能做的事情有限,容易出玄学问题;二是用 epoll_wait 的超时参数,每次返回后检查当前时间,和每个连接的最后活动时间比较。我一般用第二种,因为它不涉及信号,逻辑更可控。
具体做法是:在连接结构体里加一个 last_active 字段,每次读或写事件发生时更新为当前时间。epoll_wait 的超时设为 1000 毫秒,每次返回后遍历连接列表,如果now - last_active > IDLE_TIMEOUT,就关闭连接并从 epoll 里删除。IDLE_TIMEOUT 一般设为 30 到 60 秒,根据业务调整。注意遍历连接列表时如果有删除操作,要用安全的方式,比如先把要删除的连接标记出来,再统一删除,避免迭代器失效。
下面是一个简单的空闲连接检查函数,你可以放在事件循环的末尾。
#include <time.h> #define IDLE_TIMEOUT 60 typedef struct { int fd; time_t last_active; // 其他字段 } connection_t; void check_idle_connections(connection_t *conns, int max_conns, int epoll_fd) { time_t now = time(NULL); for (int i = 0; i < max_conns; i++) { if (conns[i].fd <= 0) continue; if (now - conns[i].last_active > IDLE_TIMEOUT) { epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conns[i].fd, NULL); close(conns[i].fd); conns[i].fd = -1; } } }这个函数里,max_conns 是连接数组的大小,实际项目中你可能用链表或哈希表来管理连接,遍历方式会不同。关键是每次事件循环都要调用一次,并且 epoll_wait 的超时不能设为 -1,否则空闲检查永远不会执行。
另外分享一个我调试纯 C 网络服务的习惯:在关键路径上加日志,但不要用 printf,因为 printf 是行缓冲的,多线程下会乱序甚至崩溃。我一般用 write 直接写 stderr,或者用 syslog。日志里带上时间戳、线程 ID、连接 fd 和事件类型,这样出问题时能快速定位是哪个连接、哪个线程、在哪个阶段出的错。这个习惯帮我省了很多后悔药,尤其是在压测时出现偶发崩溃的情况下,日志往往是唯一的线索。
希望帮到你。
本文还有配套的精品资源,点击获取