☰
C++ WebServer源码精读:epoll事件循环与HTTP解析实战
2026/10/26 5:21:11 网站建设 项目流程

简介:面向C++后端开发学习者的Web服务器实战项目,聚焦网络编程与并发处理能力的进阶训练,适合在掌握Linux基础与套接字编程后,通过完整代码理解高并发服务端的设计取舍。项目以epoll实现I/O复用,支持多客户端连接;采用线程池结合同步I/O模拟Proactor事件处理模式提升响应效率;以主从状态机解析HTTP请求报文,并用定时器链表检测非活跃连接,配套Webbench压力测试可验证上万并发连接下的数据交换表现。压缩包共26个文件,约152KB,包含头文件、cpp与c源文件、makefile构建脚本、html静态页面及json配置等,关键代码均附详细备注,便于对照阅读、编译调试与二次修改。已有1102人学习,适合希望从课程示例走向可运行项目、积累排错与性能调优思路的开发者参考。

1. 一份能逐行读下去的 C++ WebServer 代码,值不值得花两个晚上

做「c++ 后端开发 webserver」这条路上,第一道坎从来不是语法,而是不知道一个 HTTP 请求进到进程之后,到底在哪些函数之间流转。看得懂语法,看得懂 epoll 的函数原型,但把 socket、事件循环、线程池、状态机、定时器拼在一起,就成了黑匣子:能跑,不知道为什么能跑;挂了,不知道挂在哪一层。这份工程是 C++ 实现的一个完整 WebServer,最大的特点是注释密度高——每一处 epoll_ctl 为什么这么调、缓冲区的\r\n 为什么要在末尾单独处理、非阻塞 read 返回到 EAGAIN 意味着什么,注释里都写了。它解决的不是「我需要一个生产级服务器」,而是「我需要一个能从头到尾读懂、并且能自己动手改的完整骨架」。适合两类人:准备后端岗位、被问到「你的服务器 QPS 多少、瓶颈在哪」答不上来的;以及已经会写业务代码,想补上网络编程这一块工程拼图的。

2. 从 CMake 到 epoll 事件循环:先把骨架跑成最小闭环

拿到这种工程,最忌讳上来就改代码。正确顺序是先跑通、再加打印观察、最后才动逻辑。这一章的目标是把进程启动到 accept 第一个连接这条链路走通,并搞清事件循环里每个参数的实际含义。

2.1 目录拆分与构建脚本

这份代码的分层基本是围绕「事件源」划分的:net/放 EventLoop 和 Channel,http/放连接与报文解析,timer/放超时管理,pool/放线程池与数据库连接池。先看一眼顶层 CMakeLists,把编译选项定下来,因为后面的崩溃现场调试全靠它。

cmake_minimum_required(VERSION 3.10) project(webserver CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE RelWithDebInfo) # 压测用 O2,排查还能拿到符号 endif() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Wno-unused-parameter") # 单线程与多线程序列化到同一个二进制,用宏切换,方便对比 option(USE_THREAD_POOL "enable thread pool" ON) if(USE_THREAD_POOL) add_definitions(-DUSE_THREAD_POOL) endif() file(GLOB_RECURSE SRC ${CMAKE_SOURCE_DIR}/src/*.cpp) add_executable(webserver ${SRC}) target_include_directories(webserver PRIVATE ${CMAKE_SOURCE_DIR}/include) target_link_libraries(webserver PRIVATE pthread)

RelWithDebInfo这个选择值得说一句:Debug出来的 QPS 通常只有Release的三分之一到一半,拿它压测得出的数字毫无意义;而纯Release在 core dump 时又几乎没有行号。RelWithDebInfo是压测和排错两头都能兼顾的折中,我一般在整个调试期都保持这个配置。-DUSE_THREAD_POOL的作用是把「单 Reactor 单线程」和「多线程」两个版本编到同一份源码里,靠宏切换,这样后面做性能对比时不用切分支。

cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build -j"$(nproc)" ./build/webserver -p 8080 -t 8 -c 8

启动参数里-p是监听端口,-t是工作线程数,-c是数据库连接池大小。这三个参数在注释里都有对应说明,尤其是-t,它不是越大越好——线程数超过 CPU 物理核数之后,上下文切换的收益会被锁竞争吃掉,后面第 6 章会用压测数据说明这件事。

提示:第一次编译如果报undefined reference to pthread_create,说明target_link_libraries里的 pthread 没生效,检查是否被更早的link_libraries覆盖。

2.2 事件循环主干:epoll_wait 返回之后发生什么

整个服务器的心跳就是一个 while 循环。把它读透,后面所有模块都只是挂在这个循环上的插件。

// EventLoop 主循环:只保留与 epoll 交互的核心逻辑 void EventLoop::loop() { std::vector<epoll_event> events(kMaxEvents); // kMaxEvents 默认 1024 while (!quit_) { // 堆为空返回 -1(永久阻塞);否则返回距最近一个定时器到期的毫秒数 int timeout = timer_mgr_.nextTimeout(); int n = ::epoll_wait(epfd_, events.data(), static_cast<int>(events.size()), timeout); if (n < 0) { if (errno == EINTR) continue; // 被信号中断,重试即可 perror("epoll_wait"); break; } for (int i = 0; i < n; ++i) { auto* ch = static_cast<Channel*>(events[i].data.ptr); uint32_t rev = events[i].events; if (rev & (EPOLLERR | EPOLLHUP)) { ch->handleError(); continue; } if (rev & EPOLLRDHUP) { ch->handleClose(); continue; } if (rev & EPOLLIN) ch->handleRead(); if (rev & EPOLLOUT) ch->handleWrite(); } timer_mgr_.tick(); // 到期连接在这里统一关闭 } }

这段里有三个参数值得掰开。kMaxEvents决定单次epoll_wait最多返回多少个就绪事件,1024 是个经验值:太小会导致高并发下事件被延迟处理,太大则每次循环都要遍历一个空了大半的数组。timeout的取值方式是这份代码的一个细节——它不是写死 -1,而是每次从定时器堆里算最近到期时间,这样即使没有任何 IO 事件,超时连接也能被准时回收。第三处是EPOLLRDHUP,很多人只处理EPOLLIN,结果对端半关闭之后连接要等到超时才释放,白白占用一个 fd。

events[i].data.ptr直接塞了Channel*,这是省事但危险的做法:如果 Channel 在别处被析构而事件还在队列里,就是一次 UAF。工程里用shared_ptr持有 Channel,在handleRead里先拷贝一份shared_from_this(),才算把这条命续上。

2.3 监听 socket 的三件套与非阻塞 accept

监听端的初始化看起来是模板代码,但每一行都有对应的坑位,尤其是 ET 模式下的 accept 必须循环。

int listen_fd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); if (listen_fd < 0) { /* 记录 errno 并退出 */ } int on = 1; ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on); ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof on); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(port); ::bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof addr); ::listen(listen_fd, SOMAXCONN); ::signal(SIGPIPE, SIG_IGN); // 对端提前 RST 时,write 不炸整个进程

SOCK_NONBLOCK | SOCK_CLOEXEC直接在创建时带上,比事后fcntl少两次系统调用,也避免 fork 之后 fd 泄漏到子进程。SO_REUSEPORT允许多个进程/线程各自持有独立的监听 fd,内核做负载分发,配合后面「one loop per thread」可以消掉 accept 惊群。SIGPIPE那行看着不起眼,但漏掉它的后果是压测跑到一半进程直接消失,没有 core、没有日志,典型黑匣子现场。

// ET 模式:必须一次性把已完成连接的队列抽干 for (;;) { sockaddr_in peer{}; socklen_t len = sizeof peer; int fd = ::accept4(listen_fd, reinterpret_cast<sockaddr*>(&peer), &len, SOCK_NONBLOCK | SOCK_CLOEXEC); if (fd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 抽干了,正常退出 if (errno == EINTR) continue; if (errno == EMFILE) { /* 见 5.1 的空闲 fd 占位技巧 */ } break; } // 轮询分配给某个从属 EventLoop,注册 EPOLLIN | EPOLLET | EPOLLONESHOT }

这里的for(;;)不是风格问题,是正确性问题。边沿触发只在状态变化时给一次通知,如果一次通知只 accept 一个连接,剩下的连接会在内核队列里躺着,直到下一个新连接到来才被顺带处理——表现出来就是「并发一高,部分请求延迟几百毫秒」。accept4相比accept多出的 flags 参数,正好可以把非阻塞和 CLOEXEC 一次设好,省掉常见的「先 accept 再设非阻塞」中间态。

3. HTTP 报文解析:状态机怎么把字节流变成请求对象

网络层只会给你一堆可能被 TCP 任意切分的字节。解析层要做的只有一件事:判断「够了没有」。这份代码用的是主从状态机,比正则和find("\r\n\r\n")都稳。

3.1 为什么不用正则一把梭

见过太多 Demo 用一条正则去匹配整个请求行,本地测试全过,一上压测就开始崩。原因有两层:一是正则的匹配代价随输入长度非线性上升,请求行超过两百字节之后,单个请求的 CPU 占比会明显抬头;二是 TCP 粘包会把两个请求塞进同一个缓冲区,正则匹配到第一段就返回,第二个请求被当成残留数据吞掉,表现出来就是「偶发丢请求」。

状态机的思路是把解析拆成「从状态机切行」和「主状态机判语义」两部分。从状态机只关心\r\n,主状态机只关心「我现在在读请求行、读头部、还是读请求体」。数据不够就原样保留,下一次 read 到了接着解析,天然处理粘包和半包。代价是需要维护游标,比正则多写几十行,但这几十行换来的是行为可预测。

3.2 从状态机:按行切分并处理边界

// 从状态机:从读缓冲区中切出一行,返回解析结果 LineState HttpConn::parseLine() { for (size_t i = cur_; i < read_buf_.size(); ++i) { char c = read_buf_[i]; if (c == '\r') { if (i + 1 == read_buf_.size()) { // \r 落在缓冲区最末尾 cur_ = i; // 保留游标,等下一次读 return LINE_OPEN; } if (read_buf_[i + 1] == '\n') { line_end_ = i; cur_ = i + 2; return LINE_OK; } return LINE_BAD; // 孤立的 \r,报文非法 } if (c == '\n') { // 容错:只有 \n 也认 line_end_ = i; cur_ = i + 1; return LINE_OK; } } cur_ = read_buf_.size(); return LINE_OPEN; // 一行还没收全 }

LINE_OPEN那个分支是整个解析器最容易写错的地方。假设\r正好是本次 read 的最后一个字节,简单实现会立刻判定LINE_BAD把连接关掉,而实际上\n就在下一个 TCP 段里。所以必须把游标退回\r的位置并返回「未完成」,等下一批数据到了从这个位置重新判断。\n单独认的容错分支也是必要的——真实客户端里不遵守 CRLF 的实现并不罕见,为此直接 400 掉会让线上错误率莫名其妙地高。

3.3 主状态机与 keep-alive 判定

ParseState HttpConn::parse() { LineState ls = LINE_OK; while ((ls = parseLine()) == LINE_OK) { std::string line(read_buf_.data() + line_start_, line_end_ - line_start_); line_start_ = cur_; switch (state_) { case CHECK_STATE_REQUESTLINE: { // GET /index.html HTTP/1.1 if (!parseRequestLine(line)) return BAD_REQUEST; state_ = CHECK_STATE_HEADER; break; } case CHECK_STATE_HEADER: { if (line.empty()) { // 空行 = 头部结束 // 有 Content-Length 且 > 0 才继续读 body,否则解析完成 state_ = (content_length_ > 0) ? CHECK_STATE_BODY : CHECK_STATE_DONE; } else if (!parseHeader(line)) { // Host/Connection/Content-Length return BAD_REQUEST; } break; } case CHECK_STATE_BODY: { if (body_received_ < content_length_) return LINE_OPEN; // 还没收完 state_ = CHECK_STATE_DONE; break; } default: break; } if (state_ == CHECK_STATE_DONE) return PARSE_DONE; } return (ls == LINE_BAD) ? BAD_REQUEST : LINE_OPEN; }

parseHeader里要额外记两件事:Content-Length决定 body 长度,Connection决定这个连接在响应之后留不留。HTTP/1.1 默认长连接,只有显式Connection: close才关;而 HTTP/1.0 反过来,除非显式Connection: keep-alive。这一条弄反了,用ab压测会看到连接数疯涨、QPS 断崖式下跌,但看代码又觉得「逻辑都对」。

响应拼装部分建议直接writev把状态行、头部、正文三段一起写出去,别分三次write——分三次不仅多两次系统调用,在 ET 模式下还可能因为第一次写满 socket 缓冲区导致后两次的EPOLLOUT状态判断出错。资源文件用mmap加writev的组合,小文件直接读进内存缓存,命中率高的静态资源能省下大量 read 系统调用。

4. 线程池与定时器:并发模型里的分与合

单线程跑通之后再谈并发,顺序不能反过来。这一章拆两件事:任务怎么分给线程,连接怎么被回收。

4.1 one loop per thread:主从 Reactor 的分工

常见的做法是主线程只负责 accept,把新连接按 round-robin 分给 N 个从属 EventLoop,每个从属循环独占一个线程,该连接后续所有的 read、parse、write 都在这个线程里完成。这种「一个连接只属于一个线程」的模型最大的好处是不需要对连接对象加锁——连接状态、读缓冲区、解析状态机全部线程私有,省掉的锁不是一点半点。

代价是负载可能不均:如果某几个连接是长连接且流量大,它们所在的线程会明显更忙。多数实现不去解决这个问题,因为静态资源服务器场景下连接分布本身近似均匀,强行做连接迁移反而引入复杂度。我在压测里见过一种情况:-t 8但实际只有 1 个线程在干活,追下去发现 accept 之后分发用的是fd % thread_num,而 fd 在短连接场景下高度集中在小数值反复复用,取模之后全落在同一个线程。

4.2 线程池的任务队列与唤醒

如果走的是「事件线程 + 工作线程池」的模型,任务投递就是热路径,写法要克制。

class ThreadPool { public: explicit ThreadPool(size_t n) { for (size_t i = 0; i < n; ++i) workers_.emplace_back([this] { workerLoop(); }); } void submit(std::function<void()> task) { { std::lock_guard<std::mutex> lk(mtx_); if (stop_) return; tasks_.push_back(std::move(task)); // 移动,避免 std::function 深拷贝 } cv_.notify_one(); // 解锁之后再通知 } private: void workerLoop() { for (;;) { std::function<void()> task; { std::unique_lock<std::mutex> lk(mtx_); cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop_front(); } task(); // 锁外执行,避免任务里再加锁造成死锁 } } std::vector<std::thread> workers_; std::deque<std::function<void()>> tasks_; std::mutex mtx_; std::condition_variable cv_; bool stop_ = false; };

notify_one放在解锁之后是为了避免「唤醒的线程立刻抢锁失败又被挂起」这种无效唤醒,在高频投递时这点差别能体现在 QPS 上。task()在锁外执行则是硬要求:如果任务内部要访问共享的连接池,而连接池的锁和队列锁是同一把,锁内执行就是死锁。任务对象用std::move传递而非拷贝,是因为std::function捕获大对象时拷贝代价不小,热路径上每一次投递都在付这个成本。

4.3 定时器:小根堆加惰性删除回收空闲连接

长连接必须有过期机制,否则客户端拔网线之后 fd 永远不释放。这份代码用的是小根堆定时器,每个连接插入一个到期时间节点,主循环每次算出最近的到期时间作为epoll_wait的 timeout。

struct TimerNode { int fd; std::chrono::steady_clock::time_point expire; bool operator>(const TimerNode& o) const { return expire > o.expire; } }; // 最小堆:堆顶是最先到期的连接 std::priority_queue<TimerNode, std::vector<TimerNode>, std::greater<>> heap_; void TimerManager::tick() { auto now = std::chrono::steady_clock::now(); while (!heap_.empty() && heap_.top().expire <= now) { int fd = heap_.top().fd; heap_.pop(); if (!conn_pool_.alive(fd)) continue; // 惰性删除:连接已主动关闭,跳过 conn_pool_.closeConn(fd); } } int TimerManager::nextTimeout() { if (heap_.empty()) return -1; auto d = std::chrono::duration_cast<std::chrono::milliseconds>( heap_.top().expire - std::chrono::steady_clock::now()).count(); return d > 0 ? static_cast<int>(d) : 0; }

「惰性删除」是这里的关键词:连接被客户端主动关闭时,不去堆里找那个节点删掉(那是 O(n)),而是只标记连接已失效,等节点自己浮到堆顶时再跳过。代价是堆里会有一些僵尸节点,内存开销可以接受,换来的是关闭连接变成 O(1)。每次连接上有数据往来时,重新 push 一个到期时间更晚的节点(而不是修改原节点),也是同一个思路——用空间换掉「修改堆中元素」这个麻烦操作。

nextTimeout返回 0 时要小心:如果堆顶已经过期,返回 0 让epoll_wait立即返回,此时tick会清掉过期连接,不会死循环;但如果tick因为某些原因没清掉,就会变成忙等把 CPU 打满,这类问题只能靠strace -c看epoll_wait的调用次数才能发现。

5. 避坑与排查:五条真金白银换来的踩坑记录

代码注释写得再细,也不如自己踩一次。这一章按「现象 → 原因 → 解决」记录五个在调试这类服务器时几乎必然遇到的场景,都是血泪经验。

5.1 连接层的三个坑

第一条:客户端一直挂着,服务端 CPU 却很低。现象是压测时部分请求超时,但服务端负载平稳,日志里没有任何错误。原因是把监听 fd 和连接 fd 都设成了边沿触发,但读的时候只read了一次就返回。边沿触发只在状态变化时通知,一次读到缓冲区没读空,剩下的数据不会再触发新事件,连接就悬在那里。解决办法是把读改成循环,直到read返回EAGAIN才算读完;同时确认 socket 确实是非阻塞的,否则最后一次读会永远阻塞住整个事件循环。

第二条:第一个请求正常,keep-alive 的第二个请求没有响应。原因是使用了EPOLLONESHOT防止多线程同时处理一个连接,但事件处理完之后忘了重新注册。EPOLLONESHOT的语义就是「触发一次后自动失效」,处理完必须epoll_ctl(EPOLL_CTL_MOD, ...)把关注的事件重新加上。这个问题在短连接压测下完全看不出来,只有长连接才会暴露。

第三条:并发上来之后accept一直返回 -1,CPU 空转。现象是进程 CPU 100% 但 QPS 掉到个位数,日志刷Too many open files。原因是 fd 用尽(ulimit -n太小),在边沿触发下监听 fd 持续处于可读状态,事件循环被反复唤醒但 accept 永远失败。标准解法是「空闲 fd 占位」:预留一个open("/dev/null")得到的 fd,遇到EMFILE就关掉这个占位 fd,accept 一个连接立刻关闭它(给客户端一个 RST,让它知道服务端撑不住了),再重新打开占位 fd。同时把ulimit -n提到万级,并检查定时器是否真的在回收短连接。

5.2 内存与并发的两个坑

第四条:偶发段错误,core 里栈指向memcpy。原因是读缓冲区固定大小,一次read的数据加上已有残留数据超过容量时直接覆写。修法有两条并行走:读之前判断剩余空间,不够就扩容(按 2 倍增长,设上限比如 64KB),超过上限直接返回 413 并关连接,别为了「兼容大请求」把上限开到几 MB,那是被慢速攻击打穿的第一步。另外,响应回写时用writev保证状态行、头、正文一次写出,避免部分写导致的报文错位。

第五条:压测跑到一半进程直接消失,无 core 无日志。原因是客户端提前 RST 之后服务端继续write,收到SIGPIPE,默认行为是终止进程。前面 2.3 里的signal(SIGPIPE, SIG_IGN)就是为此。如果用了send,也可以加MSG_NOSIGNAL标志,两种方式选一种,别都不做。排查阶段如果怀疑是这类问题,dmesg里通常能看到对应的信号记录,比翻日志快。

注意:五个坑里有三个的共同根因是「边沿触发 + 非阻塞」这个组合的语义没吃透。如果时间紧,先把触发模式退回水平触发把功能跑通,再逐个连接改成边沿触发做对比,这样出问题时能立刻定位到是哪次改动引入的。

6. 压测验证:怎么确认这份代码不是只能跑 Demo

功能跑通和能扛住压测是两件事。验证这套代码有没有真的把边沿触发和线程池写对,我一般固定跑三组对比。

第一组看触发模式。水平触发和边沿触发各跑 30 秒,连接数 500,观察 QPS 和 CPU。边沿触发正常情况下 QPS 应该高出一截,因为epoll_wait返回的事件数少了;如果两者持平甚至边沿触发更低,八成是读循环没写对,EAGAIN之前就返回了。

第二组看线程数。把-t依次设成 1、2、4、8、16,同一台机器上跑,QPS 通常会先升后平,再往上加还会掉。掉头的位置就是你机器上的合理线程数,这个数字比任何配置建议都准。

第三组看稳定性。wrk或ab连续跑 30 分钟,同时开一路valgrind --leak-check=full或者-fsanitize=address的版本跑低并发,两手一起看。

# 长连接压测,-H 显式声明 keep-alive,避免客户端默认行为干扰对比 wrk -t4 -c500 -d30s --latency -H "Connection: Keep-Alive" http://127.0.0.1:8080/index.html # 对照:短连接,看 accept 路径和定时器回收是否正常 wrk -t4 -c500 -d30s --latency -H "Connection: close" http://127.0.0.1:8080/index.html # 看系统调用分布,epoll_wait 次数异常高说明有空转 strace -c -f -p "$(pgrep webserver)" &

-c是并发连接数,不是 QPS,很多人第一次压测把这两个概念混了。--latency必须开,因为平均 QPS 好看但 P99 爆掉的服务器在真实场景里更糟。

一个表格放这里做参照,同机同参数下三种实现的大致差异方向(绝对值随机器变化,只看相对关系):

配置QPS 趋势CPU 占用主要瓶颈
LT + 单线程基准中每次循环都要遍历所有就绪 fd
ET + 单线程高 20%~40%低单核打满
ET + 8 线程再高 2~4 倍高锁竞争与上下文切换

真正容易翻车的是第三组。线程加到 16 之后 QPS 反而下滑,然后开始怀疑代码有 bug——其实只是锁竞争。这时候用perf top看一眼,如果pthread_mutex_lock排在前几名,就该减线程而不是继续优化代码。

还有一个容易被忽略的验证点:netstat -anp | grep webserver | wc -l在压测结束一分钟后应该回落到接近 0。如果一直挂着几百个CLOSE_WAIT,说明对端关闭之后你没有正确关闭本地 fd,通常是漏处理了EPOLLRDHUP或者读返回 0 的分支没走close。这个问题不解决,跑一天就会 fd 耗尽。

从那以后我每次改完事件处理相关的代码,都会强制走一遍三十分钟压测加一次strace -c看调用分布,不管改动看起来多小——因为这类 bug 从来不会在功能测试里出现,只会在半夜的连接数报警里出现。这份带详细注释的工程我放在下载包里了,拿到之后建议先按第 2 章的步骤跑通,再对着注释一处一处改,别急着改成自己的架构。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询