简介:面向C++后端开发初学者与进阶者的轻量级Web服务器源码,围绕epoll I/O复用、线程池配合同步I/O模拟Proactor、主从状态机解析HTTP请求、定时器链表检测非活跃连接等机制展开,关键代码配有较详细注释,可用于理解Linux高性能服务器编程的主干流程与面试复盘。压缩包共26个文件、约152KB,包含4个h头文件、3个cpp源文件、2个c文件,以及Makefile、HTML页面、JSON配置和webbench压测目录,覆盖连接管理、线程池、定时器与压力测试等模块,分层存放便于按功能检索。已有1102人学习下载。读者可据此梳理事件监听、请求解析到响应生成的完整链路,结合注释理解状态机切换与并发模型取舍,并借助Webbench观察上万并发连接下的数据交换表现,形成可运行的实验底稿与排错思路。
1. 从一次「能跑但扛不住」的现场说起
本地 curl 返回 200,并不等于服务器可用。判断 C++ 后端开发 写出来的 webserver 是否真正可用,标准是连接数到八百之后还能不能稳住:会不会丢连接、会不会内存涨上去了不下来、会不会某个连接卡在那里再也不响应。这份带逐行代码备注的 C++ webserver 源码包,解决的问题就是这几件事——它把 epoll 事件分发、HTTP 报文解析、线程池调度、非活跃连接定时回收串成了一条完整链路,关键实现旁边都写了「为什么这么写」而不是「这行在干嘛」,注释密度高到可以直接当阅读笔记用。适合已经会写类、会编译链接、但没独立做完过一个网络服务端的人;也适合手里有业务协议,想找一份能直接改的骨架的人。
2. 事件循环与 Reactor 分层:先定模型,再写第一行 socket
2.1 为什么模型要先定,而不是边写边改
新手最容易翻车的地方,是一上来就socket → bind → listen → accept → read → 处理 → write顺序写下去,写到第三天发现要做并发,于是在 accept 后面加了个thread,再写两天发现线程数量爆炸,又回头改成线程池。这时解析逻辑已经和 socket 读写搅在一起了,改起来就是把整个文件重写一遍。
这份源码的做法是先把层次切干净,切法就是经典 Reactor:事件循环只负责等事件和分发,不负责业务。具体分三层:
| 层 | 职责 | 该层不该做的事 |
|---|---|---|
| 事件循环层 | epoll_wait、事件分发、连接注册与注销 | 不解析 HTTP、不碰业务字段 |
| 连接层 | 每个 fd 对应的读缓冲区、写缓冲区、解析状态 | 不阻塞、不做耗时计算 |
| 处理层 | 线程池里的任务,解析完成后的路由与响应构造 | 不直接持有 fd 生命周期 |
这样切的好处非常实在:调 bug 时你知道该去哪一层看。连接卡住,先看事件循环有没有把这个 fd 的事件返回上来;返回上来了但没响应,再去看连接层的解析状态机停在哪一步。血泪经验是,只要解析逻辑和epoll_wait写在同一个函数里,你排查问题时永远在猜。
选epoll而不是select/poll的理由不用展开太多:select有FD_SETSIZE1024 的硬上限,poll每次调用都要把整个 fd 数组从用户态拷到内核态,连接数上千之后这部分开销很显眼。epoll用红黑树维护关注集合、用就绪链表回传结果,注册一次就行。
2.2 事件循环骨架:注册、等待、分发
下面这段是整份代码的心脏,注释里标了每个容易写错的点:
// 事件循环骨架:先注册 listen fd,再进入分发循环 int epfd = epoll_create1(EPOLL_CLOEXEC); // CLOEXEC:fork 出的子进程不会继承这个 fd if (epfd < 0) { /* 记录 errno 并退出,不要继续 */ } struct epoll_event ev{}, events[kMaxEvents]; ev.events = EPOLLIN | EPOLLET; // 监听 fd 用 ET,accept 必须循环到 EAGAIN ev.data.fd = listen_fd_; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd_, &ev) < 0) { /* 处理失败 */ } while (!stop_) { // timeout 取「最近一个定时器到期时间 - now」,没有定时器就传 -1 int n = epoll_wait(epfd, events, kMaxEvents, next_timeout_ms()); if (n < 0) { if (errno == EINTR) continue; // 被信号打断是正常的,重来即可 break; } for (int i = 0; i < n; ++i) { const int fd = events[i].data.fd; const uint32_t re = events[i].events; if (fd == listen_fd_) { // ET 模式下这里必须是 while,只 accept 一次会丢新连接 for (;;) { int conn = accept4(listen_fd_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); if (conn < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; break; // EMFILE 等情况,交给上层降级处理 } register_conn(conn); // 内部负责 epoll_ctl ADD + 初始化读写缓冲 } } else if (re & EPOLLIN) { on_readable(fd); // 读事件:尽量一次读空 } else if (re & EPOLLOUT) { on_writable(fd); // 写事件:把写缓冲尽量发完 } else if (re & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) { close_conn(fd); // 对端关闭或出错,直接回收 } } run_expired_timers(); // 回收超时的非活跃连接 }几个参数需要说清楚,因为改错一个就会出现「偶发丢连接」这种最难查的问题:
kMaxEvents:一次epoll_wait最多返回多少个事件。常见取值 1024 起步,连接数上万时可以调到 2048~4096,但别指望它解决吞吐问题,它只是单次批量上限。- 最后一个参数是超时毫秒数。传
-1表示永久阻塞,那就没法做定时器了;正确做法是每次都算成「距离下一个定时器到期还有多少毫秒」,这样定时器和事件等待共用一个循环,不需要额外线程。 EPOLL_CLOEXEC和SOCK_CLOEXEC:防止fork或exec时把监听 fd 泄漏到子进程里。别小看这个,一旦泄漏,父进程退出后端口还被占着,bind会返回EADDRINUSE,表现为「服务停了但端口没释放」。EPOLLET:边沿触发。用了它,读写都必须循环到EAGAIN为止,否则剩下的数据不会再触发事件,连接就永久卡死。这也是新手最容易踩的那个坑,第 5 章会展开。
2.3 连接对象的生命周期:谁 new,谁负责 close
把 fd 裸着传来传去,最终一定会出现「用了已关闭的 fd」或者「fd 关了对象还在」。这份代码用一层薄封装把 fd 和它的状态绑在一起,思路是:
class Connection { public: explicit Connection(int fd) : fd_(fd) {} ~Connection() { if (fd_ >= 0) ::close(fd_); } // 析构兜底,谁持有谁负责 Connection(const Connection&) = delete; // 禁止拷贝,避免 double close Connection& operator=(const Connection&) = delete; int fd() const { return fd_; } bool closed() const { return fd_ < 0; } void mark_closed() { // 延迟关闭:从 epoll 摘除后再 close if (fd_ >= 0) { ::close(fd_); fd_ = -1; } } std::string read_buf; // 累积未解析完的字节 std::string write_buf; // 还没发完的响应 ParseState state = ParseState::RequestLine; std::chrono::steady_clock::time_point last_active; private: int fd_; };要点是两个:禁止拷贝构造和赋值,这样编译器帮你杜绝 double close;关闭分两步,先从epoll里EPOLL_CTL_DEL摘掉,再真正close,否则可能出现在epoll_wait返回的事件数组里还留着这个 fd、但你已经把它关掉的情况,处理到它时就是一个无效 fd。
我一般会在register_conn里把last_active初始化为当前时间,之后每次读到数据都刷新一次。定时器只认这个字段,逻辑就非常单纯。
3. HTTP 报文解析:状态机怎么写才不会越写越乱
3.1 为什么不能用 strstr 一把梭
很多初版实现是这样的:read到缓冲区,然后strstr(buf, "\r\n\r\n")找头部结束位置,再strstr找Content-Length。请求小的时候完全没问题,一旦出现两种场景就废了:
- TCP 粘包:一次
read可能读到 1.5 个请求,第二半截在下一个包里。这时候你按「一个 read 一个请求」处理,第二个请求就被吞了。 - 请求体很大:比如 POST 上传,头部到了但 body 还没到齐,
strstr找到的\r\n\r\n之后数据不完整,直接解析会拿到残缺的 body。
正确做法是状态机 + 累积缓冲区:把收到的字节全部追加到read_buf,然后状态机从当前状态继续往前推,推到数据不够就停下等下一次read。这样粘包、半包都由状态机天然处理掉。
3.2 三段式状态机与缓冲区策略
HTTP 请求在解析层面就三段:请求行、头部、消息体。状态也照这个分:
enum class ParseState { RequestLine, Headers, Body, Done, Error }; // 返回值:本次解析消费了多少字节,剩余留在 read_buf 里下次继续 size_t Connection::parse() { size_t consumed = 0; while (true) { if (state == ParseState::RequestLine || state == ParseState::Headers) { // 只在缓冲区里找行尾,找不到说明半包,直接返回等下次读 size_t pos = read_buf.find("\r\n", consumed); if (pos == std::string::npos) break; std::string line = read_buf.substr(consumed, pos - consumed); consumed = pos + 2; if (state == ParseState::RequestLine) { if (!parse_request_line(line)) { state = ParseState::Error; break; } state = ParseState::Headers; } else if (line.empty()) { // 空行:头部结束,检查有没有 body if (content_length_ > 0) { state = ParseState::Body; } else { state = ParseState::Done; break; } } else { if (!parse_header_line(line)) { state = ParseState::Error; break; } } } else if (state == ParseState::Body) { if (read_buf.size() - consumed < content_length_) break; // body 还没到齐 body_ = read_buf.substr(consumed, content_length_); consumed += content_length_; state = ParseState::Done; break; } else { break; } } return consumed; // 调用方按这个值 erase 已消费部分 }这里的缓存策略是这份代码比较讲究的地方,值得单独说:不要每次解析完就read_buf.erase(0, consumed)一删了事。std::string::erase在头部删除时会把后面的数据整体前移,请求体一大就是一次 O(n) 拷贝。常见做法是维护一个读偏移量read_pos,解析时从read_pos开始看,等read_pos超过缓冲区一半时再做一次压缩,把已消费部分一次性挪掉。这个改动不大,但在压测里能明显看出差别。
另外几个细节必须处理:请求行只接受GET/POST/HEAD之类白名单里的方法,其他一律回 405;URI 长度设上限(比如 8KB),超了直接回 414 并断开,防止有人用超长 URI 撑爆内存;头部字段数量也设个上限,别让它无限累积。
3.3 响应构造与长短连接
响应这块没什么玄学,但有两个地方经常被忽略。第一是状态行必须带正确的Content-Length,不带的话客户端只能靠连接关闭来判断结束,keep-alive 就没法用了。第二是Connection头要和实际行为一致,要么回keep-alive并把连接留着,要么回close并在发完之后主动关。
std::string build_response(int code, const std::string& body, bool keep_alive) { static const std::map<int, std::string> kReason = { {200, "OK"}, {400, "Bad Request"}, {404, "Not Found"}, {405, "Method Not Allowed"} }; std::string out; out.reserve(256 + body.size()); out += "HTTP/1.1 " + std::to_string(code) + " " + kReason.at(code) + "\r\n"; out += "Content-Type: text/html; charset=utf-8\r\n"; out += "Content-Length: " + std::to_string(body.size()) + "\r\n"; out += keep_alive ? "Connection: keep-alive\r\n" : "Connection: close\r\n"; out += "Server: cpp-webserver\r\n\r\n"; out += body; // 简单场景直接拼进去,大文件要改成 sendfile/分块 return out; }写出去的时候有个必须注意的点:不要直接对 socket 写响应字符串然后假设写完了。非阻塞 socket 上write可能只写进去一部分,剩余部分必须挂到write_buf并注册EPOLLOUT,等可写事件来了继续发。判断标准是:write返回值小于待写长度,或者返回 -1 且errno == EAGAIN,都要走「剩余入缓冲 + 关注可写事件」这条路。
keep_alive的判断也不只看请求头,还要看当前连接的请求数和服务端配置。我一般会设一个「同一连接最多处理 100 个请求」的上限,防止长连接把服务端资源长期占住。
4. 线程池与并发边界:锁粒度、任务投递和定时器回收
4.1 单 Reactor 多线程,还是主从 Reactor
这份代码选的是单 Reactor + 线程池:一个事件循环线程负责epoll_wait和所有 fd 的读写,解析和业务处理扔进线程池。另一条路是主从 Reactor(多个事件循环,每个线程管一批连接),扩展性更好,但连接在多个循环间迁移、定时器归属、跨线程关闭 fd 都是麻烦事。
选单 Reactor 的理由很实际:连接数在几千以内时,epoll_wait这一层的开销远小于业务处理,瓶颈几乎总在解析和回调上,而这两块正好被扔进了线程池。等真的到了需要多 Reactor 的量级,你会发现那时候更该做的是拆服务而不是继续优化单机。这里有个判断标准:如果压测时epoll_wait的 CPU 占比超过 30%,说明事件循环线程已经饱和,才需要考虑多循环。
4.2 线程池实现:锁只保护队列,不保护任务执行
线程池写错的典型症状是「压测时 QPS 上不去,但 CPU 也不高」,八成是锁的范围写大了——把任务执行也放在锁里,等于退化成单线程。
class ThreadPool { public: explicit ThreadPool(size_t n) { for (size_t i = 0; i < n; ++i) { workers_.emplace_back([this] { 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(); // 执行在锁外 } }); } } void submit(std::function<void()> f) { { std::lock_guard<std::mutex> lk(mtx_); tasks_.push_back(std::move(f)); } cv_.notify_one(); // 通知放在锁外,避免唤醒后立刻又阻塞在锁上 } ~ThreadPool() { { std::lock_guard<std::mutex> lk(mtx_); stop_ = true; } cv_.notify_all(); for (auto& t : workers_) if (t.joinable()) t.join(); // 必须 join,否则析构时崩溃 } private: std::vector<std::thread> workers_; std::deque<std::function<void()>> tasks_; std::mutex mtx_; std::condition_variable cv_; bool stop_ = false; };几个参数和边界需要说明。线程数不是越多越好,IO 密集型的服务端一般取CPU 核数到CPU 核数 * 2,再多只是让上下文切换变多;这份代码默认从std::thread::hardware_concurrency()取。tasks_用std::deque而不是vector,因为要频繁头删;submit里notify_one放在锁外是个小优化,避免被唤醒的线程立刻又撞在锁上。
还有个坑:任务里如果捕获了连接的裸指针,而连接在另一个线程被关了,就是use-after-free。这份代码的做法是任务只捕获 fd 和一个递增的conn_id,执行前先校验conn_id是否还对得上,对不上就丢弃任务。这个校验很便宜,但能挡掉一大类偶发崩溃。
4.3 定时器:把非活跃连接回收掉
长连接不关,客户端直接拔网线,服务端是感知不到的——TCP 不会立刻告诉你对端没了。不回收的话,fd 和内存就一直挂着,跑一晚上 fd 就到上限了。
最简单的做法是给每个连接记一个last_active,然后主循环里定期扫一遍所有连接,把超时的挑出来关掉。连接数上千时这个「定期全扫」就是 O(n),每秒扫一次也还好,但不够优雅。这份代码用的是小根堆 + 懒删除:
struct TimerEntry { std::chrono::steady_clock::time_point expire; uint64_t conn_id; bool operator>(const TimerEntry& o) const { return expire > o.expire; } }; // 每次读事件后刷新:把新到期时间压进堆,旧条目靠 conn_id 校验丢弃 void TimerQueue::refresh(uint64_t conn_id, int timeout_sec) { heap_.push({std::chrono::steady_clock::now() + std::chrono::seconds(timeout_sec), conn_id}); } void TimerQueue::tick(const std::function<void(uint64_t)>& on_expire) { const auto now = std::chrono::steady_clock::now(); while (!heap_.empty() && heap_.top().expire <= now) { uint64_t id = heap_.top().conn_id; heap_.pop(); on_expire(id); // 回调里校验 id 是否仍然有效,无效就忽略 } }timeout一般设 60 秒,太短会让正常但安静的连接被误杀,太长则回收不及时。堆里会有大量过期条目(同一个连接每读一次就压一条),但每条只占十几字节,相比每次全表扫描的代价是划算的。真正的清理动作发生在on_expire回调里,通过conn_id找连接,找不到就说明已经关了,直接跳过。
5. 避坑与排查:五个能让服务半夜挂掉的细节
5.1 ET 模式下只读一次,连接卡死不响应
现象:压测跑到几百并发后,部分连接一直不返回,也不断开,netstat看还是 ESTABLISHED。
原因:用了EPOLLET,但读处理只read了一次。当一次事件到达时缓冲区里有多于一个read能拿完的数据,剩下的部分不会再有新事件通知,连接就永远停在那儿。
解决:读处理必须循环到EAGAIN。写成while ((n = read(fd, buf, sizeof buf)) > 0) { append; },循环结束后判断errno == EAGAIN || errno == EWOULDBLOCK才算正常退出,读到 0 表示对端关闭要主动回收。这个坑没有后悔药,只能一开始就写对。
5.2 对端提前关闭,进程直接被 SIGPIPE 带走
现象:服务莫名其妙退出,日志最后一行是往某个 fd 写数据,没有任何异常信息。
原因:对端已经close,本端还往里write,内核发SIGPIPE,默认动作是终止进程。这类崩溃在压测工具中途掐断连接时特别容易出现。
解决:启动时signal(SIGPIPE, SIG_IGN)忽略掉,让write返回-1且errno == EPIPE,然后按正常错误路径关连接。另外send可以带MSG_NOSIGNAL标志,双保险。
5.3 惊群与 accept 返回 EAGAIN
现象:多线程或多进程同时accept同一个监听 fd 时,部分线程拿到EAGAIN,日志里刷一片错误。
原因:监听 fd 设了非阻塞,多个执行体同时被唤醒去accept,但连接已经被别人抢走了。
解决:accept返回EAGAIN/EWOULDBLOCK是正常情况,直接break退出循环,不要当错误处理、更不要关监听 fd。真要彻底避免,可以让监听 fd 只在主线程accept,再把新连接分发给工作线程。
5.4 连接关了但任务还在队列里跑
现象:偶发崩溃,gdb回溯指向解析函数里访问某个已经释放的对象。
原因:事件循环线程关闭连接、释放对象,线程池里排队的任务还持有这个对象的指针,执行时就是 use-after-free。
解决:任务不捕获对象指针,改捕获fd + conn_id,执行前在连接表里查一次,查不到或 id 不匹配就丢弃。更好的做法是用shared_ptr配合weak_ptr,任务里lock()成功才继续。
5.5 压测十分钟,内存只涨不降
现象:top看 RSS 一路往上,压测停了也不回落。
原因:常见有三处——read_buf只增不减(大请求过后容量一直留着)、连接对象关闭时没从容器里 erase、定时器堆里的过期条目从来不清理。
解决:read_buf设容量上限,超过阈值且当前空闲时shrink_to_fit;连接关闭走统一出口,先从 fd→连接 的 map 里删、再从 epoll 摘、最后 close;定时器堆设个大小阈值,超过就重建一次堆把无效条目过滤掉。排查时用valgrind --leak-check=full跑一遍短压测,能直接把泄漏点指出来。
6. 用压测和日志把长尾延迟钉死
代码能跑通只是起点,真正的验证是看 P99 而不是平均值。平均 2ms 但 P99 是 800ms 的服务,在实际使用中就是不可用。
我的验证套路固定三步。第一步,用wrk打短连接和长连接两组,各跑 60 秒:
# 长连接:看稳态吞吐和延迟分布 wrk -t4 -c500 -d60s --latency http://127.0.0.1:8080/ # 短连接:加 -H 强制关闭,验证连接建立/销毁路径没有泄漏 wrk -t4 -c500 -d60s --latency -H "Connection: close" http://127.0.0.1:8080/重点看Latency Distribution里的 99% 那一行,以及Socket errors是否为 0。如果 99% 远高于 50%,通常是某处持锁太久或者有同步 IO。
第二步,对着压测同时挂pidstat -w -p <pid> 1,看每秒上下文切换次数。切换次数和 QPS 同数量级说明线程在频繁抢锁,这时该回去看线程池的锁范围。第三步,压测结束后等 70 秒(超过连接超时),再看一次ls /proc/<pid>/fd | wc -l,如果 fd 数没有回到基线,就是回收路径漏了。
日志这块我吃过亏:一开始只在出错时打日志,结果线上偶发卡顿完全没线索。后来改成每条连接关闭时打一行「conn_id、存活时长、请求数、关闭原因」,量不大但足够定位。从那以后我每次改完事件循环相关代码,都会强制走一遍「压测 → 看 P99 → 等超时 → 数 fd」这个流程,不再靠肉眼读代码判断对不对。希望帮到你。
本文还有配套的精品资源,点击获取