从零手写C语言WebServer:epoll与线程池实战

发布时间:2026/10/21 12:27:16
从零手写C语言WebServer:epoll与线程池实战 简介这份资源围绕C语言实现简易Web服务器展开面向具备一定C基础、希望理解HTTP协议与网络编程的开发者适合作为课程设计、练手项目或面试知识梳理的参考。压缩包为7z格式整体约17.85MB内含webserver_f.c等核心源码文件与若干html示例页面源码覆盖socket创建、bind监听、accept连接、recv读取请求、HTTP请求解析与响应生成、fopen/fread文件读取及send回传等关键环节html文件则用于本地测试静态资源访问效果。已有241人学习关注说明该主题在入门网络编程群体中具备一定参考价值。读者可借此理清从TCP套接字到HTTP响应的完整链路掌握请求行与头部的解析思路、状态行与Content-Type等响应头的构造方式并了解多线程或异步I/O处理并发请求的改进方向为后续深入网络编程打下基础。1. 从零手写一个 C 语言 webserver为什么我劝你先别急着上框架很多做嵌入式的朋友第一次接触网络编程都是从“能不能自己写个 webserver”开始的。市面上的 Nginx、Lighttpd 确实成熟但如果你连一次epoll的边沿触发和水平触发都没亲手调过看再多源码也只是隔靴搔痒。这个项目就是一份用纯 C 实现的轻量级 webserver不依赖任何第三方网络库核心就是 socket、epoll、线程池和 HTTP 协议解析。它适合两类人一是刚学完《UNIX 网络编程》想找个完整项目练手的在校生二是需要在嵌入式设备上跑一个极简 HTTP 服务、又不想引入庞大依赖的开发者。整个项目编译出来只有一个可执行文件静态资源、动态请求、长连接都能处理代码量控制在两千行以内读起来不费劲。2. 架构拆解从 main 到 epoll 事件循环的完整链路2.1 整体模块划分与数据流向这个 webserver 的架构并不复杂但麻雀虽小五脏俱全。启动后主线程负责监听端口、接受新连接然后把连接分发给工作线程去处理读写和业务逻辑。模块上大致分为四层最底层是网络层封装了 socket 的创建、绑定、监听以及epoll的增删改查往上是连接管理层用http_conn结构体表示一个客户端连接维护读缓冲区、写缓冲区、解析状态机再往上是 HTTP 协议层负责解析请求行、请求头生成响应报文最上层是业务逻辑层根据 URL 决定是返回静态文件还是执行 CGI 或 FastCGI 请求。数据流向是这样的epoll_wait返回就绪事件后主线程判断是监听套接字还是连接套接字。如果是新连接就accept出来把新 fd 注册到 epoll 中并初始化对应的http_conn对象。如果是已连接套接字可读就把数据读到读缓冲区然后交给线程池中的工作线程去解析和处理。工作线程处理完后把响应写入写缓冲区再注册EPOLLOUT事件等可写时把数据发出去。整个过程是异步非阻塞的一个线程可以同时处理多个连接。2.2 核心结构体与关键参数先看连接结构体它承载了一个客户端从连接到断开的全部状态// http_conn.h 中定义的核心结构 #define READ_BUFFER_SIZE 2048 #define WRITE_BUFFER_SIZE 1024 struct http_conn { int m_sockfd; // 客户端套接字 sockaddr_in m_address; // 客户端地址 char m_read_buf[READ_BUFFER_SIZE]; // 读缓冲区 int m_read_idx; // 读缓冲区中已读入的字节数 int m_checked_idx; // 当前正在解析的位置 int m_start_line; // 当前解析行的起始位置 char m_write_buf[WRITE_BUFFER_SIZE]; // 写缓冲区 int m_write_idx; // 写缓冲区中待发送字节数 METHOD m_method; // 请求方法GET 或 POST char m_url[256]; // 请求的 URL char m_version[16]; // HTTP 版本 char m_host[256]; // 主机名 bool m_linger; // 是否保持长连接 int m_content_length; // 请求体长度 bool m_iv_count; // 是否使用 writev 分散写 struct iovec m_iv[2]; // 用于 writev 的 iovec 数组 int m_state; // 当前状态机状态 };这里有几个参数值得注意。READ_BUFFER_SIZE设为 2048 是权衡后的结果太小会导致一次请求读不完需要多次epoll_wait太大则浪费内存尤其在高并发下每个连接都占一块。m_checked_idx和m_start_line配合使用用来逐行解析 HTTP 请求头避免每次解析都从头扫描。m_iv数组配合writev可以把响应头和响应体一次性发出减少系统调用次数这是提升吞吐量的常见做法。2.3 epoll 事件注册与线程池协作主线程的事件循环是整个服务器的引擎代码不长但逻辑密度高// main.c 中的事件循环片段 while (!stop_server) { int num epoll_wait(m_epoll_fd, events, MAX_EVENT_NUMBER, -1); if (num 0 errno ! EINTR) { break; } for (int i 0; i num; i) { int sockfd events[i].data.fd; if (sockfd m_listen_fd) { // 新连接到达 struct sockaddr_in client_address; socklen_t client_addrlength sizeof(client_address); int connfd accept(m_listen_fd, (struct sockaddr*)client_address, client_addrlength); if (connfd 0) { continue; } // 初始化连接对象并注册到 epoll http_conn* conn new http_conn(); conn-init(connfd, client_address); m_users[connfd] conn; addfd(m_epoll_fd, connfd, true, false); // 开启 ET 模式 } else if (events[i].events EPOLLIN) { // 可读事件交给线程池 threadpool_append(m_pool, events[i].data.ptr); } else if (events[i].events EPOLLOUT) { // 可写事件直接发送 http_conn* conn (http_conn*)events[i].data.ptr; conn-write(); } } }这段代码里epoll_wait的超时设为 -1 表示永久阻塞直到有事件发生。MAX_EVENT_NUMBER一般设为 1024 或更大取决于预期并发量。新连接进来后addfd函数会把 fd 设为非阻塞并且注册EPOLLIN | EPOLLET也就是边沿触发模式。边沿触发要求一次把数据读干净所以http_conn::read里必须循环recv直到返回EAGAIN或EWOULDBLOCK否则会丢事件。线程池那边threadpool_append把任务放进工作队列唤醒一个空闲线程去执行http_conn::process。注意边沿触发模式下如果读缓冲区满了还没读完剩余数据会留在内核缓冲区但 epoll 不会再通知你除非对端又发了新数据。所以读缓冲区要足够大或者用m_checked_idx做流式解析边读边处理。3. 编译与运行从源码到可执行文件的每一步3.1 环境准备与依赖检查这个项目只依赖 Linux 系统调用和 pthread 库不需要额外安装第三方包。编译前确认几件事gcc 或 clang 已安装make 工具可用系统头文件里有sys/epoll.h。在 Ubuntu 或 CentOS 上通常只需要build-essential包。可以用下面命令快速检查# 检查编译器版本 gcc --version # 检查 epoll 头文件是否存在 ls /usr/include/sys/epoll.h # 检查 pthread 库 ldconfig -p | grep pthread如果epoll.h不存在说明系统太老或者不是 Linux 环境这个项目就跑不起来。另外项目里用到了writev和mmap这些在标准 Linux 上都有不需要额外配置。3.2 Makefile 解析与编译参数项目根目录下有一个 Makefile内容不长但把编译规则写得很清楚CXX g CFLAGS -g -Wall -O2 -pthread TARGET server OBJS main.o http_conn.o threadpool.o $(TARGET): $(OBJS) $(CXX) $(CFLAGS) -o $(TARGET) $(OBJS) main.o: main.c http_conn.h $(CXX) $(CFLAGS) -c main.c http_conn.o: http_conn.c http_conn.h $(CXX) $(CFLAGS) -c http_conn.c threadpool.o: threadpool.c threadpool.h $(CXX) $(CFLAGS) -c threadpool.c clean: rm -f $(OBJS) $(TARGET)-g保留调试符号方便用 gdb 排查问题-Wall打开所有警告写 C 代码时这个很重要能提前发现类型不匹配、未使用变量等问题-O2是常用的优化级别既不会像-O3那样激进导致调试困难又能保证性能-pthread链接 pthread 库线程池和互斥锁都依赖它。编译时直接make就行生成的可执行文件叫server。3.3 启动参数与运行验证编译完成后运行方式很简单# 默认监听 9006 端口可以在 main.c 里改 ./server 9006启动后终端不会有太多输出这是正常的因为日志都写到了文件或者只打印错误信息。验证服务是否正常可以用 curl 或者浏览器# 请求根路径应该返回 index.html curl -v http://127.0.0.1:9006/ # 请求一个不存在的路径应该返回 404 curl -v http://127.0.0.1:9006/notfound.html # 测试长连接连续发两个请求 curl -v http://127.0.0.1:9006/ http://127.0.0.1:9006/test.html如果返回 200 并且内容正确说明静态文件服务没问题。再测试一下 POST 请求可以用curl -d发数据看服务器是否能正确读取Content-Length并解析请求体。如果一切正常这个 webserver 就算跑起来了。提示如果启动时报 “bind: Address already in use”说明端口被占用了换一个端口或者用lsof -i:9006找到占用进程杀掉。4. 避坑与排查那些年我踩过的 epoll 和 HTTP 解析的坑4.1 边沿触发下数据读不干净导致连接卡死现象客户端发了一个较大的 POST 请求服务器只收到一部分剩下的数据迟迟不到连接一直挂着直到超时。原因epoll用的是EPOLLET边沿触发http_conn::read里只调用了一次recv没有循环读到EAGAIN。内核缓冲区里还有数据但 epoll 不会再通知因为边沿触发只在状态变化时触发一次。解决把read函数改成循环读直到recv返回 -1 且errno EAGAIN或EWOULDBLOCK。同时要处理读缓冲区满的情况如果缓冲区满了还没读完需要动态扩容或者把剩余数据暂存到临时缓冲区等下一轮继续读。4.2 HTTP 请求头解析越界导致段错误现象用ab压测时服务器偶尔崩溃gdb 显示段错误发生在parse_request_line函数里。原因解析请求行时直接用strchr找空格但没有检查字符串是否以\0结尾。如果读缓冲区里的数据不是完整的请求行strchr会一直往后找越过缓冲区边界访问到非法内存。解决解析前先确保读缓冲区里有完整的行也就是找到\r\n。如果没找到就返回LINE_OPEN状态等下次数据到了再解析。另外所有字符串操作都要带上长度参数或者手动在缓冲区末尾补\0但要注意不要覆盖有效数据。4.3 线程池任务队列满导致连接被丢弃现象高并发下部分客户端连接被 accept 了但一直没有响应最后超时断开。原因线程池的任务队列有最大长度限制当队列满了之后threadpool_append直接返回失败但主线程没有处理这个失败连接对象既没被处理也没被关闭成了僵尸连接。解决在threadpool_append返回失败时主线程应该直接关闭这个连接或者给客户端返回 503。更好的做法是动态调整线程数量或者用更高效的任务分发机制比如每个线程自己维护一个队列减少锁竞争。4.4 长连接超时时间设置不当导致资源泄漏现象服务器运行一段时间后内存占用越来越高lsof显示大量 CLOSE_WAIT 状态的连接。原因HTTP/1.1 默认是长连接但服务器没有设置超时时间客户端断开后服务器端没有及时关闭套接字导致 fd 和内存泄漏。解决给每个连接设置一个最后活跃时间用一个定时器或者epoll_wait的超时参数定期检查超过一定时间比如 60 秒没有数据交互就主动关闭连接。同时在http_conn的析构函数里确保关闭 fd 并释放资源。注意CLOSE_WAIT 状态表示被动关闭方没有调用close通常是代码里漏了关闭操作。用netstat -natp | grep CLOSE_WAIT可以快速定位。5. 二次开发从能跑到好用我改过的三个地方5.1 把静态文件服务换成 FastCGI 转发原项目只支持静态文件但实际场景里往往需要跑 PHP 或者 Python 脚本。我做的第一个改造是接入 FastCGI。思路是当 URL 以.php结尾时不直接读文件而是把请求转发给本地的 PHP-FPM。核心是构造 FastCGI 协议包通过 Unix Socket 发给 FPM然后解析返回的响应。// 简化的 FastCGI 请求构造 void build_fcgi_request(http_conn* conn, int fcgi_fd) { FCGI_BeginRequestBody body; body.role htons(FCGI_RESPONDER); body.flags 0; // 发送 BEGIN_REQUEST 记录 send_fcgi_record(fcgi_fd, FCGI_BEGIN_REQUEST, 1, body, sizeof(body)); // 发送 PARAMS 记录包含 REQUEST_METHOD、SCRIPT_FILENAME 等 send_fcgi_params(fcgi_fd, conn); // 发送 STDIN 记录POST 请求体放在这里 send_fcgi_stdin(fcgi_fd, conn); }改造后服务器既能返回静态 HTML也能执行动态脚本适用面广了很多。需要注意的是FastCGI 的响应可能分多个记录返回要循环读取并拼接直到收到FCGI_END_REQUEST。5.2 增加简单的访问日志和错误日志原项目几乎没有日志出了问题只能靠 gdb。我加了一个日志模块用宏封装fprintf支持日志级别和按天切割。关键代码如下#define LOG_DEBUG(format, ...) log_write(LOG_LEVEL_DEBUG, __FILE__, __LINE__, format, ##__VA_ARGS__) #define LOG_ERROR(format, ...) log_write(LOG_LEVEL_ERROR, __FILE__, __LINE__, format, ##__VA_ARGS__) void log_write(int level, const char* file, int line, const char* format, ...) { // 获取当前时间格式化到缓冲区 // 根据级别决定是否写入文件 // 用互斥锁保证多线程安全 }日志加上后排查问题快了很多。比如某个请求返回 400直接看日志就知道是请求行解析失败还是请求头缺了 Host。建议日志文件按天切割避免单个文件过大。5.3 用定时器优化长连接的超时管理原项目用alarm或者epoll_wait的超时来检查连接精度不高。我改成了基于最小堆的定时器每个连接对应一个定时器节点超时时间到了就触发回调关闭连接。这样既能精确控制超时又能高效管理大量连接。// 定时器节点结构 struct timer_node { int fd; time_t expire; // 绝对过期时间 void (*callback)(int fd); // 堆相关字段 }; // 检查并处理过期定时器 void tick() { time_t now time(NULL); while (!heap_empty() heap_top()-expire now) { timer_node* node heap_top(); node-callback(node-fd); heap_pop(); } }这个改造让长连接的管理变得清晰也避免了轮询所有连接带来的性能损耗。后来我又把定时器和 epoll 的timeout参数结合epoll_wait的超时设为堆顶定时器的剩余时间这样既不会空转又能及时处理超时。从那以后我每次写网络服务都会先把边沿触发的读写循环和超时管理这两块代码反复检查一遍确认没有漏读、漏关的情况才继续往下写。希望这些经验能帮到你少走一些弯路。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询