C++手写Webserver核心实践:从epoll到线程池与HTTP解析
简介这是一份基于C编写的Linux高性能Web服务器源码包面向后端开发学习者演示如何从零搭建支持高并发的HTTP服务。项目采用epoll实现I/O多路复用支持多客户端连接通过线程池与同步I/O模拟Proactor模式利用主从状态机解析HTTP请求并以定时器链表清理非活跃连接代码关键部分均附有详细中文注释便于阅读与二次开发。资源共26个文件以C/C头文件.h和实现文件.cpp/.c为主附带Makefile、配置说明及webbench压力测试相关源码压缩包仅152KB体量轻巧适合快速下载与编译。目前已有1099人学习下载适合具备一定C语法基础、希望深入理解Linux网络编程与高并发模型的开发者。配合游双《Linux高性能服务器编程》阅读可更系统地掌握Reactor/Proactor、定时器与压测等核心知识。 写webserver这个项目我想先聊点实际的如果你正在学C想找一个能同时把网络编程、多线程、内存管理、编译调试全串起来的练手项目手写一个webserver几乎是最佳选择。网上能看到的版本很多但我更推荐那种代码里带详细备注的版本——不是那种“这是socket”的废话注释而是把“为什么这么写”“这里踩过什么坑”“换个方案会怎样”都交代清楚的备注。这篇博文我围绕一个带完整备注的C webserver展开讲清楚整体设计、核心模块的代码细节、环境搭建和调试方法以及我在实际开发中遇到的问题和排查思路。不管你是在准备C面试还是想搞懂React模式和多线程这篇文章都值得你花十分钟读一遍。1. 为什么我推荐用C手写一个webserver1.1 一个项目打通C核心技能栈很多C学习者有一种困扰语法看了一遍又一遍八股文背得滚瓜烂熟但真要让自己独立写一个能跑起来的服务端程序大脑还是会一片空白。手写webserver恰好能把这些割裂的知识点全部串起来。你需要先理解TCP三次握手和四次挥手处理listen套接字和客户端套接字的生命周期接着要接触非阻塞IO和IO多路复用epoll是绕不开的再往上走一个能够同时服务多个请求的服务器必然涉及多线程于是线程池、互斥锁、条件变量全都得出场为了让代码不容易内存泄漏你还会自然引入RAII思想用智能指针管理连接对象最后编译和调试阶段用到的CMake、GDB、VS Code配置又是另一门学问。你会发现很多面试题里背过但从未真正用过的东西在这个项目里全都有落地的场景。1.2 面试与实战的双重价值在C后端相关的面试里webserver几乎是标配项目。面试官通常不会满足于你背出“我用了epoll和线程池”这种话他会追问为什么不用select或pollET和LT模式有什么区别你如何解决线程池退出时的阻塞问题HTTP解析时遇到粘包怎么办这些问题如果只是背答案很容易被连环追问打崩。但如果自己真的从头写过一次哪怕只是一个小型版本回答起来就有底气得多。更关键的是在写的过程中你会真正理解Reactor模式是如何把事件分发和业务处理解耦的这种理解是光看源码和文章替代不了的。从实战角度看webserver是很多中间件、游戏网关、IM服务的基础形态你把这个项目的骨架拿捏住了以后去理解Redis单线程模型、Nginx多进程模型或者Netty的EventLoop都会顺畅不少。1.3 代码备注是项目的第二份说明书单独说说“代码备注”这件事。很多开发者对注释有一种偏见觉得“代码即注释”“好代码不需要注释”。这句话在业务代码里有点道理但在学习项目里是极大的误区。学习项目的注释本质上是你把大脑里的设计思路输出成文字的过程。你写“这里设置非阻塞”和写“listen套接字必须设为非阻塞否则accept会阻塞主线程后续到来的连接只能排队服务器会卡死”这两种备注的学习效果天差地别。我见过不少人的webserver项目代码很精简但过了一个月回头读自己也看不懂当时某个if条件是什么意思。带详细备注的代码是在帮助未来的自己也是在帮助其他想复现这个项目的读者。所以我在这篇博文里会反复强调备注写什么、怎么写比代码本身更需要设计。2. 项目整体设计与技术选型2.1 IO模型为什么选epoll而不是select写webserver第一件事是选IO模型。传统阻塞式socket编程最直观来一个请求就accept一个然后单独起一个线程处理但这种方式连接的创建和销毁成本都很高并发量上来后线程切换的开销会拖垮系统。select和poll能在一个线程里同时监听多个socket但它们有两个硬伤一是每次调用都需要把fd集合从用户态拷贝到内核态连接量大时拷贝成本高二是内核不会告诉你具体是哪个fd就绪了还是需要用户层线性扫描全部fd复杂度是O(n)。epoll的出现解决了这两个痛点注册事件时建立红黑树每次只要有就绪事件就用回调机制放进就绪链表用户层用epoll_wait直接拿走就绪列表复杂度降到O(就绪数)。对比项selectpollepoll支持连接数受FD_SETSIZE限制默认1024无上限但扫描全部fd无上限且有红黑树维护IO效率O(n)线性扫描O(n)线性扫描O(就绪事件数)数据拷贝每次全量拷贝fd集合每次全量拷贝通过epoll_ctl增删避免全量编程复杂度低低中需理解事件机制poll其实可以突破FD_SETSIZE但仍然存在每次拷贝和线性扫描问题。在高并发场景下epoll的扩展性明显更好这也是现代服务端程序的主流选择。我用的是LT水平触发模式因为它的编程模型最简单——只要缓冲区里还有数据epoll_wait就会持续返回可读事件不容易漏掉数据。先跑通后续再往ET边缘触发优化也不迟。2.2 并发模型基于Reactor的事件驱动接着要设计并发模型。这里最经典的方案是Reactor模式一个线程专职负责epoll_wait监听事件拿到就绪事件后分发给工作线程处理。主线程不处理具体业务避免阻塞事件循环工作线程从线程池中取出处理HTTP解析、业务逻辑和响应发送。这套模式的关键在于“事件驱动”而不是“连接驱动”。每个请求有数据来了才处理没有数据就挂起不浪费线程。相比之下一个连接一个线程的模型虽然有简单的好处但线程上下文切换代价高且每个线程默认8MB栈空间连接数一多内存就告急。线程池的引入则进一步控制了线程数量避免频繁创建销毁带来的开销。2.3 项目模块划分与文件结构我的项目文件结构比较清晰每个模块职责单一webserver/ ├── CMakeLists.txt # 构建配置文件 ├── main.cc # 入口启动服务器 ├── src/ │ ├── TcpServer.h/.cc # 服务器核心负责创建套接字、接受连接 │ ├── EventLoop.h/.cc # 事件循环封装epoll逻辑 │ ├── HttpParser.h/.cc # HTTP请求解析器支持GET/POST │ ├── ThreadPool.h/.cc # 线程池 │ ├── Logger.h/.cc # 日志模块其实只有几行 ├── tests/ │ └── http_test.py # 简单压测脚本模块划分的核心原则是依赖单向主程序依赖TcpServerTcpServer依赖EventLoop和ThreadPool业务处理依赖HttpParser。这样后续想做改动时不需要牵一发动全身。3. 代码备注的核心方法论让注释有灵魂3.1 差注释与好注释的对比很多初学者写的注释是“代码翻译机”比如int n socket(AF_INET, SOCK_STREAM, 0); // 创建socket。这句话没有任何信息增量代码本身已经说明在做socket。好的注释应当记录代码背后看不到的设计决策、边界条件和踩过的坑。我做了一个简单对比差注释好注释// 创建socket// 创建TCP socketAF_INET表示IPv4SOCK_STREAM表示字节流类型。// 第三参传0表示让内核根据前两个参数自动选择协议(此时即为TCP协议)// 设置非阻塞// 设置非阻塞模式。这里必须用fcntl而不是ioctl且注意先取oldfl再追加// O_NONBLOCK不能直接用赋值否则会覆盖文件状态标志// 加了锁// 锁的粒度尽量小。这里仅保护任务队列本身而不是保护整个线程池// 因为拷贝出新任务后就可以解锁避免长时间阻塞生产者我一直在写项目时要求自己看到一行注释如果把这行注释删掉后代码依旧能被完全理解那这行注释就没有存在价值。真正有价值的注释是解释约束和权衡的。比如“为什么这里用shared_ptr而不是unique_ptr”因为同一个连接对象在事件循环和业务线程中都会用到生命周期不固定只有引用计数才能保证不悬垂。3.2 好注释要写进哪些内容根据我自己的实践下面四类内容是注释应当重点覆盖的接口契约函数参数取值范围、返回值含义、调用方需要满足的前提条件。比如ParseRequest函数应当说明“buf必须以\n结尾len是buf有效长度如果解析失败返回-1并设置errMsg”。性能代价某段代码是O(n²)只在调试模式启用某次系统调用可能阻塞不能在EventLoop线程里调用。这类信息对后续维护者是救命的。非显而易见的逻辑状态机的状态转移条件、边界处理、断言。比如HTTP解析器中“请求行没有读到\r\n之前不算完整请求”这个判断如果不写注释读者会困惑为什么这里不直接返回完成。坑和踩过的雷一开始我把连接对象的生命周期完全交给EventLoop管理后来发现线程池中的任务还在使用这个连接时事件循环已经把它删了导致崩溃。后来改为shared_ptr事件循环只持有弱引用。这类“为什么”必须记下来。3.3 与RAII和智能指针相关的备注要点C写服务端程序最大的风险就是内存管理。在事件循环和线程池之间传递对象稍不注意就会悬垂指针或内存泄漏。我推荐的做法是统一用shared_ptr管理连接对象注释里就要写清楚// 为什么这里用 shared_ptr // 1. 连接可能同时被【事件循环】和【工作线程】持有 // 2. 事件循环负责监听事件但不负责业务耗时操作工作线程可能会在 // 事件循环已把这个连接从epoll中移除后继续使用 该连接 // 3. 所以不能由其中任何一个线程单独delete必须引用计数。同时在设置非阻塞、设置端口复用、关闭写端等系统调用的地方都要配上“为什么这么做”的解释。系统调用看起来只有一行但背后的语义如果不交代清楚读者只能靠死记硬背。4. 带详细备注的核心模块解读4.1 TcpServer封装与生命周期TcpServer是服务器的门面。它在构造函数里完成监听socket的创建、绑定和监听并把它注册到EventLoop。这里最值得备注的是socket生命周期和listen队列的关系。// create socket int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { LOG_ERROR(socket create failed!); exit(EXIT_FAILURE); } // 端口复用 // 如果不设置 SO_REUSEADDR服务端重启时因为仍有连接处于TIME_WAIT状态 // bind 会报 Address already in use开发时非常常见。 int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 设置非阻塞accept 返回的客户端fd也会继承非阻塞属性。 // 如果主线程阻塞在 accept新连接就只能排队延迟会越来越高 fcntl(listen_fd, F_SETFL, fcntl(listen_fd, F_GETFL) | O_NONBLOCK); // bind listen // listen 的第二个参数是内核已完成队列的长度上限128 在Linux下比较稳妥。 // 不能设得太大否则突发连接会耗尽应用层处理能力造成资源无谓损耗。 bind(listen_fd, (sockaddr*)addr, sizeof(addr)); listen(listen_fd, 128);这里还有一个容易忽略的点listen套接字本身也要注册到epoll。你只监听它上面的EPOLLIN事件每次可读都说明有新的客户端连接到达然后accept就能立刻拿到连接不会阻塞在accept上等待。这样事件循环就不会被一个迟迟不来的连接卡死。4.2 EventLoop事件循环主逻辑EventLoop是整个服务器的发动机。它做的事情非常单纯调用epoll_wait等待事件拿到就绪列表后逐个分发。但实际编写时有几个细节如果不备注清楚后续维护很容易踩雷。void EventLoop::Loop() { while (!stop_) { // timeout -1 表示永久阻塞直到有事件发生。 // 注意这里不能把 -1 写死因为后续如果我们想在主线程定期 // 做某些事情比如检查空闲连接就需要设置一个合理的超时值。 int n epoll_wait(epoll_fd_, events_.data(), static_castint(events_.size()), -1); // n 0 且 errno 为 EINTR 表示被信号打断继续循环即可。 // 这在调试时经常出现比如用 gdb 调试时SIGINT 就会触发。 if (n 0) { if (errno EINTR) { continue; } LOG_ERROR(epoll_wait error: %s, strerror(errno)); break; } for (int i 0; i n; i) { int fd events_[i].data.fd; uint32_t ev events_[i].events; // 注意判断是否可读用 (ev EPOLLIN) 而不是 (ev EPOLLIN) // 因为一个事件可能同时携带 EPOLLIN | EPOLLHUP | EPOLLERR直接用等于判断会漏掉。 if (ev (EPOLLIN | EPOLLHUP | EPOLLERR)) { if (fd listen_fd_) { HandleNewConnection(); } else { HandleClientEvent(fd); } } // 如果要支持写事件用 EPOLLOUT 判断这里暂时省略。 } } }这段代码看起来简单但里面的判断逻辑是容易写错的。直等于判断的问题在于如果一个连接同时遇到读事件和对端关闭事件epoll返回的events里会同时带上EPOLLIN和EPOLLRDHUP你只判断ev EPOLLIN就会错过后面的处理。使用位与操作才能把多种事件类型同时识别出来。4.3 HTTP请求解析状态机思路HTTP解析往往是用C写webserver时最琐碎的部分。如果直接用字符串查找\r\n很多边界情况会被遗漏。我采用的是状态机方式逐字符扫描请求行、请求头、空行、请求体每个状态之间明确转移条件。// 返回 false 表示解析暂未完成等下次有数据再继续解析 // 返回 true 且 out_status 200 表示解析完整且合法。 bool HttpParser::Parse(const char* buf, size_t len, HttpRequest* out_req) { // 状态机的核心一次只推进一个字符 // 这样设计的好处是即使网络包把请求拆成了两半程序也能记录当前状态 // 下一次收到残包时接着往后扫不会因为半包而丢弃已解析的数据。 for (size_t i 0; i len; i) { char c buf[i]; switch (state_) { case kParseRequestLine: // \r 代表一行结束的前一个字节这里不立即结束 // 是为了兼容 Windows 的 \r\n 换行和 Unix 的 \n 换行 if (c \r) { // 先置一个等待状态下一字节必须是 \n 才算真正结束一行 state_ kExpectLineFeed; } else if (c ) { // 空格分隔 method 和 url state_ kParseUrl; } else { cur_line_.push_back(c); } break; case kExpectLineFeed: // 如果上一字节是 \r这一字节必须是 \n如果是裸 \n 也要兼容 // 这种写法对格式错误的请求能够立即返回 400 if (c \n !cur_line_.empty()) { // 解析请求行提取 GET /index.html HTTP/1.1 ParseRequestLine(cur_line_); cur_line_.clear(); state_ kParseHeader; } else { return false; // 非法请求 } break; // ... 后续状态省略 } } return false; // 当前数据还没形成完整请求 }状态机的关键优势就是能够处理半包。因为网络是不稳定的你请求到一半的数据很可能被拆成多个TCP包到达如果每次收到数据就尝试完整解析碰上“只收到了请求行的前几个字符”的情况就会出错。而状态机可以将处理进度保存在成员变量中下次收到新数据时接着上次的位置往下走不需要重置。4.4 线程池任务队列与条件变量线程池是服务器并发能力的来源。核心数据结构是一个互斥锁保护的任务队列工作线程在队列为空时进入条件变量等待生产者事件循环将处理函数扔进队列后通知一个线程取走执行。void ThreadPool::Run() { while (!stop_) { std::functionvoid() task; { // 加锁后检查队列而不是先检查再上锁——这是经典的竞态条件 // 如果不加锁直接检查生产者在“检查为空”和“进入wait”之间入队 // 线程就会一直睡下去错过通知。 std::unique_lockstd::mutex lock(mutex_); cond_.wait(lock, [this]() { return !tasks_.empty() || stop_; }); if (stop_ tasks_.empty()) { return; } task std::move(tasks_.front()); tasks_.pop(); } // 注意此时可以解锁了执行任务不需要持有锁 // 否则长任务会阻塞队列的写入。 if (task) { task(); } } }条件变量一定要配合互斥锁使用并且wait要用带谓词的版本cond_.wait(lock, predicate)。这是因为条件变量存在虚假唤醒问题——线程可能在没有被notify的情况下被唤醒此时队列实际上还是空的。如果用if判断队列是否为空就会取出一个空任务导致崩溃。用while循环或者在谓词里反复检查才能保证线程只有在队列真的非空时才继续执行。5. 环境搭建与编译调试5.1 VS Code CMake配置C环境开发webserver不需要重型IDEVS Code加一个CMake插件就够了。我通常的配置是// .vscode/tasks.json 中核心配置 { type: cppbuild, command: g, args: [ -stdc17, -g, -Wall, -Wextra, -pthread, -o, webserver, main.cc, src/TcpServer.cc, src/EventLoop.cc, src/HttpParser.cc, src/ThreadPool.cc, src/Logger.cc ] }-stdc17启用C17标准-pthread必须显式加上否则链接阶段会报找不到pthread_create-Wall -Wextra打开警告编译阶段就能发现很多潜在问题-g开启调试信息配合GDB使用-O0 -o关闭优化并指定输出文件名。开发阶段不建议开高优化等级因为优化后的代码在调试时变量可能都被优化掉了看着费劲。5.2 手工make与CMakeLists的取舍简单项目直接写上面那种命令没问题但作为学习项目我更推荐从一开始就用CMake。CMake的好处是依赖关系清晰不用每次手动罗列源代码文件。下面这个CMakeLists大约20行cmake_minimum_required(VERSION 3.10) project(webserver VERSION 0.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 不要默认把 debug 和 release 搞混 if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() add_executable(webserver main.cc src/TcpServer.cc src/EventLoop.cc src/HttpParser.cc src/ThreadPool.cc src/Logger.cc ) target_link_libraries(webserver pthread)构建时在项目根目录执行mkdir -p build cd build cmake .. make -j$(nproc)即可。5.3 本地验证服务器能不能跑写完成后不能只看代码觉得没问题。我常用的验证手段有两种。第一种是直接浏览器访问启动服务器后打开http://127.0.0.1:8080/index.html如果能看到页面说明基本链路通了。第二种是用curl发带特定Header的请求curl -v http://127.0.0.1:8080/index.html curl -X POST -d nametest http://127.0.0.1:8080/curl -v会打印详细交互过程包括发送的请求头、接收的响应头、连接是否关闭等排查问题非常直观。如果返回错误则要配合日志和GDB定位。6. 常见问题与排查技巧实录6.1 启动后就崩溃、隐退、无反应怎么查最典型的问题有这几类现象可能原因排查方式程序启动后马上退出listen失败、端口被占用日志里会打印错误码用lsof -i:8080查看端口占用浏览器能打开首页但加载资源卡死事件循环被阻塞通常是业务处理里做了耗时操作在HandleClientEvent入口加日志看是否频繁进入但很久不返回压力测试时偶发崩溃线程安全问题比如多个线程同时操作同一个std::map开启AddressSanitizer-fsanitizeaddress编译崩溃时能直接定位到行号高并发下连接被重置accept返回EAGAIN时没有正确处理检查accept判断是否用了if(connfd 0 errno EAGAIN)跳出循环我自己的项目就经历过一次“偶发崩溃”。当时事件循环把连接注册进epoll线程池也在用同一个连接发送响应两边同时访问连接对象的缓冲区就崩了。后来用ASan一跑直接报出data race才意识到连接生命周期统一交给shared_ptr管理事件循环和工作线程都只引用计数才彻底解决。6.2 TCP粘包与HTTP Keep-Alive的处理HTTP/1.1默认开启Keep-Alive意味着一个TCP连接上可以连续发送多个请求。这时候如果不做正确处理客户端连续请求两次内核缓冲区里可能同时存在两个HTTP请求的数据程序如果只解析一个请求就退出连接剩下的请求就丢了。我的处理手法是解析器返回“解析完成”后检查当前缓冲区是否还有剩余数据如果有就继续以当前连接为上下文解析下一个请求。这样即使多个请求粘在一起也能依次处理。反过来如果只收到半个请求半包则等待下一次可读事件到来后再继续解析。这个处理是否能实现取决于状态机是否设计好了。6.3 压测与性能观察的实用技巧推荐用一个简单的Python脚本做基础的并发测试不要一上来就上wrk。这个测试脚本主要验证连接是否被正确复用、多个并发请求是否都能收到响应。等确认正确性之后再考虑用wrk或者ab做性能对比。性能观察时重点关注三个指标QPS、响应时间P99、系统CPU占用。如果QPS上不去优先排查是不是EventLoop里有阻塞调用如果CPU占用异常高看一下是不是频繁地创建和销毁连接加一个连接复用逻辑通常能好很多。写到这儿我想起自己最初写这个项目时最反感的环节就是给代码加备注。总觉得代码写完能跑就完事备注纯属浪费时间。后来过了一个月回头去改一个bug看着自己写的logic (a b) || !c完全想不起来当初想表达什么才真正明白备注是写给未来自己的信。尤其是webserver这种涉及网络、线程、内存多个制高点交叉的系统细节极多光靠脑子记根本记不住。你如果也想通过这个项目提升C功底建议从一开始就养成“关键逻辑必有备注”的习惯每写一段代码就问自己如果三个月后的我看到这里它能秒懂吗不能的话就现在写清楚。本文还有配套的精品资源点击获取