资讯详情

纯C手写微型Web服务器:epoll与线程池实战

📅 2026/10/15 15:49:35 | 华诺云谱 👁 阅读
纯C手写微型Web服务器:epoll与线程池实战
简介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 还是 epollTiny-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?qtest你需要把路径和参数分开。我一般会写一个状态机用枚举表示「正在解析请求行」「正在解析请求头」「解析完成」三个状态每次读入数据就推进状态这样比一次性解析整个缓冲区更可靠。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如果连接关闭后没有及时 closefd 就会泄漏。另外进程的 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_REUSEADDRbind 就会失败。解决在创建监听套接字后、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 和事件类型这样出问题时能快速定位是哪个连接、哪个线程、在哪个阶段出的错。这个习惯帮我省了很多后悔药尤其是在压测时出现偶发崩溃的情况下日志往往是唯一的线索。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑