尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++ webserver源码解析:Reactor、epoll、线程池与HTTP状态机

C++ webserver源码解析:Reactor、epoll、线程池与HTTP状态机 简介这是一份面向C后端开发学习者与网络编程进阶者的WebServer项目源码包适合用来理解高并发服务器从事件驱动到请求解析的完整实现也可作为课程设计或求职项目的参考底稿。项目以epoll实现I/O复用并支持多客户端连接结合线程池与同步I/O模拟Proactor事件处理模式提升响应效率同时用主从状态机解析HTTP请求、定时器链表检测非活跃连接并附Webbench压测配置可观察上万并发连接下的数据交换表现。压缩包共26个文件、约152KB以h头文件、cpp/c源码、Makefile、HTML、JSON等配置与页面资源为主目录划分对应线程池、连接处理、定时器等模块关键代码均带详细备注便于按模块阅读、调试和二次改造。已有1102人学习适合对照游双《Linux高性能服务器编程》及相关课程内容做项目实践与查漏补缺。1. 从一份能跑的 C webserver 说起很多人接的第一份 C 后端开发 源码就是这种单机 webserver解压、cmake、make三步之后程序确实起来了但接下来就卡住了——epoll 在哪注册、线程池什么时候被唤醒、一个 HTTP 请求从 fd 变可读到 handler 返回响应中间过了几层全是黑匣子。这份带详细代码备注的 webserver 正好补这一课它把 IO 多路复用、线程池、HTTP 状态机、空闲连接回收这几件事拆成了互相独立的类注释写在大段逻辑旁边不是那种“函数名翻译”式的无效注释。适合两类人会写 C 语法但没有完整服务端工程经验的拿它当第一个能跑通、能改动的后端项目准备后端岗位面试的对着源码把 Reactor 模型的每一跳走一遍比背八股扎实。2. 工程骨架与编译运行从 CMakeLists 到第一个 200 OK这类项目最容易劝退新手的不是代码是工程组织。头文件放哪、源文件怎么进构建、链接要不要加线程库任何一个环节漏了报错信息都能把人劝退。先把骨架摸清后面看代码才有坐标。2.1 目录结构与模块分层拿到源码先别急着make花两分钟把树状结构过一遍。这类项目的目录基本固定成下面这个样子模块划分对得上说明作者是有意识地在分层webserver/ ├── CMakeLists.txt ├── build.sh ├── include/ # 对外暴露的头文件 │ ├── EventLoop.h # 事件循环主线程的调度中心 │ ├── Epoll.h # epoll 的薄封装 │ ├── Channel.h # fd 关注事件 回调的绑定体 │ ├── Buffer.h # 用户态读写缓冲区 │ ├── ThreadPool.h # 固定线程池 │ ├── HttpParser.h # HTTP 请求解析状态机 │ └── Timer.h # 定时器与超时回收 ├── src/ # 与头文件一一对应的实现 ├── tests/ # 压测脚本或客户端 Demo └── logs/ # 运行期日志输出目录分层的核心思路是「谁持有 fd谁负责它的生命周期」。Epoll只认整数 fd不关心业务Channel把 fd、关注事件、可读可写回调捆在一起是 IO 层的原子单位EventLoop持有Epoll和一组Channel负责epoll_wait返回后把事件分发给对应的ChannelHttpParser和ThreadPool在更上层只处理字节流和任务不碰 fd。看代码时只要认准这条线就不会在两个类之间来回跳。提示如果include/里出现HttpConn这种把 fd、解析器、定时器全揉在一起的类说明分层没那么干净读的时候要额外留意对象析构时机后面避坑章节会专门讲这个。2.2 CMakeLists 与编译参数的含义构建脚本通常是手写CMakeLists.txt不长但每个参数都值得看一眼因为后面调性能、上 gdb 都要动它cmake_minimum_required(VERSION 3.10) project(webserver CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # -O2 开常规优化-g 保留符号方便 gdb 和 perf 定位 # -Wall -Wextra 把可疑代码暴露出来比如未使用变量、隐式截断 set(CMAKE_CXX_FLAGS ${CMAKE_CXX_FLAGS} -O2 -g -Wall -Wextra) add_compile_options(-pthread) file(GLOB SRC_FILES ${CMAKE_SOURCE_DIR}/src/*.cpp) add_executable(webserver ${SRC_FILES}) target_include_directories(webserver PRIVATE ${CMAKE_SOURCE_DIR}/include) target_link_libraries(webserver pthread)参数里有两个容易踩的点。第一是-pthread必须同时出现在编译和链接阶段只加-lpthread在部分工具链下std::thread会链接失败第二是-O2和-g可以共存很多人调试时把优化关掉结果压测数据和线上差一大截其实是编译选项变了。file(GLOB ...)写法省事但新增源文件后要重新执行cmake才会被发现这一点在改造阶段经常绊人。编译流程就是常规三步在项目根目录执行mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)如果cmake阶段报找不到某些头文件八成是系统缺依赖或者include/路径没写对如果链接阶段报undefined reference to pthread_create就是target_link_libraries里漏了pthread。这两种报错信息都很直白照字面修即可。2.3 启动参数与第一次访问验证main.cpp一般用getopt解析端口和线程数代码不长但决定了后面怎么调参int main(int argc, char* argv[]) { int port 8080; int threadNum 4; int opt; // -p 指定监听端口-t 指定工作线程数 while ((opt getopt(argc, argv, p:t:)) ! -1) { switch (opt) { case p: port atoi(optarg); break; case t: threadNum static_castint(atoi(optarg)); break; default: fprintf(stderr, usage: %s -p port -t threads\n, argv[0]); return 1; } } EventLoop loop(port, threadNum); loop.run(); // 内部是 while(true) epoll_wait return 0; }threadNum建议先取nproc的值再上下调。IO 密集型服务的线程数不是越大越好超过核数太多会带来上下文切换开销压测时反而掉 QPS这一点在后面线程池那章会展开。端口选 8080 是因为 1024 以下需要 root 权限本地验证没必要给自己找麻烦。跑起来之后用两条命令确认服务真的活着./webserver -p 8080 -t 4 curl -i http://127.0.0.1:8080/curl -i会把响应头一起打出来重点看三样状态行是不是HTTP/1.1 200 OK、Content-Length和实际 body 长度是否一致、Connection是keep-alive还是close。这三项对得上说明请求解析和响应构造这两条路径都通了。如果curl卡住不返回先别改代码用ss -lntp | grep 8080确认端口确实在 listen再tail -f logs/看有没有异常日志能省掉一半的瞎猜。3. Reactor 主循环拆解epoll 事件从内核返回到回调的那一跳单机 webserver 的性能上限基本由这一层决定。epoll 本身的接口只有三个难的是怎么把「内核通知 → 用户态分发 → 业务回调」这条链路的对象生命周期管住尤其是 ET 模式下漏读一次就可能让连接彻底卡死。3.1 EventLoop 与 Epoll 的职责边界Epoll这一层我一般写成纯粹的 syscall 封装只干四件事创建实例、增删改注册项、等待事件、关闭。它不持有任何业务对象也不做日志以外的判断。class Epoll { public: Epoll() : epollFd_(::epoll_create1(EPOLL_CLOEXEC)) {} void add(int fd, uint32_t events) { epoll_event ev{}; ev.data.fd fd; ev.events events; // EEXIST 通常意味着上层重复 add属于逻辑错误要打日志 if (::epoll_ctl(epollFd_, EPOLL_CTL_ADD, fd, ev) 0) { perror(epoll_ctl ADD); } } void mod(int fd, uint32_t events) { epoll_event ev{}; ev.data.fd fd; ev.events events; if (::epoll_ctl(epollFd_, EPOLL_CTL_MOD, fd, ev) 0) { perror(epoll_ctl MOD); } } void del(int fd) { // 这里忽略返回值连接可能已经被对端关掉删不掉不算错 ::epoll_ctl(epollFd_, EPOLL_CTL_DEL, fd, nullptr); } int wait(std::vectorepoll_event active, int timeoutMs) { int n ::epoll_wait(epollFd_, active.data(), static_castint(active.size()), timeoutMs); if (n 0 errno ! EINTR) { perror(epoll_wait); } return n; } private: int epollFd_; };epoll_create1(EPOLL_CLOEXEC)里的标志位经常被忽略它保证 fork 出来的子进程不会继承这个 fd虽然单机服务一般不开子进程但养成习惯没坏处。wait里对EINTR的处理也是必须的信号打断属于正常情况不能当成错误退出循环。active用外部传进来的 vector 而不是每次内部新建是为了避免每轮epoll_wait都做一次内存分配这个细节在压测时能看出差别。EventLoop的职责就重一些它持有Epoll实例和一个unordered_mapint, Channel*epoll_wait返回后按 fd 查表分发void EventLoop::run() { std::vectorepoll_event active(1024); while (!quit_) { // 超时时间由最近的定时器决定没有定时器就是 -1 无限等 int timeout static_castint(getNextTimeoutMs()); int n epoll_.wait(active, timeout); for (int i 0; i n; i) { int fd active[i].data.fd; uint32_t ev active[i].events; auto it channels_.find(fd); if (it channels_.end()) continue; // 已关闭的连接跳过 if (ev EPOLLIN) it-second-handleRead(); if (ev EPOLLOUT) it-second-handleWrite(); if (ev (EPOLLERR | EPOLLHUP)) it-second-handleClose(); } // 处理完事件再清理到期定时器避免回调里操作的连接刚被删掉 handleExpiredTimers(); } }这段代码的关键顺序是「先分发事件、再处理超时」。如果反过来刚超时被回收的连接可能还会收到一条内核已经报上来的可读事件分发时对象已经析构直接踩野指针。epoll_wait返回的事件数组是复用的遍历时不要再做增删channels_的操作要么延迟到循环外统一处理要么用shared_ptr把生命周期托管给引用计数这个坑下面还会再说。3.2 ET 模式下的读事件处理与 EPOLLONESHOT注册监听 fd 时通常用「非阻塞 ET」组合代码里常见的是EPOLLIN | EPOLLET | EPOLLRDHUP。ET 的语义是状态变化才通知一次所以读事件必须循环读到返回EAGAIN为止void Channel::handleRead() { while (true) { // ensureWritable 保证至少有 64KB 可写避免 beginWrite 越界 buf_.ensureWritable(65536); ssize_t n ::read(fd_, buf_.beginWrite(), buf_.writableBytes()); if (n 0) { buf_.hasWritten(static_castsize_t(n)); // 推进写索引 continue; // 继续读直到读空 } if (n 0) { // 对端关闭连接 handleClose(); return; } if (errno EAGAIN || errno EWOULDBLOCK) { break; // 内核缓冲区读空了这是正常出口 } if (errno EINTR) { continue; // 被信号打断重试 } handleError(); // 其他错误比如 ECONNRESET return; } onMessageCallback_(shared_from_this()); // 交给上层做 HTTP 解析 }EAGAIN是 ET 模式下的正常退出条件不是错误。很多人第一次写 ET 会把EAGAIN打成 error 日志结果日志里刷满误报。另一边如果循环只读一次就 break剩下的字节就留在内核缓冲区里而 ET 不会再通知第二次连接的后续请求会永远卡在那里——这是新手最常见的翻车点症状是「小请求正常稍大一点的 body 就超时」。再加上EPOLLONESHOT是为了解决这个场景可读事件被分发到工作线程后fd 在 ET 模式下不会再触发所以同一个连接的多个事件不会并发派给多个线程。但这也意味着处理完一次事件后必须重新mod注册否则这个 fd 从此哑火void Channel::enableRead(bool oneshot) { uint32_t ev EPOLLIN | EPOLLET | EPOLLRDHUP; if (oneshot) ev | EPOLLONESHOT; epoll_-mod(fd_, ev); }EPOLLRDHUP用来感知对端的半关闭curl发完请求就关写端的情况很常见没有这个标志读循环要靠read返回 0 才能发现链路会长一点。加上它之后epoll_wait会直接把EPOLLRDHUP报上来处理更及时。3.3 Buffer 的读写索引设计HTTP 解析依赖一个能在尾部追加、头部消费的缓冲区所以Buffer里是两个索引而不是一个sizeclass Buffer { public: size_t readableBytes() const { return writerIndex_ - readerIndex_; } size_t writableBytes() const { return buffer_.size() - writerIndex_; } char* beginWrite() { return buffer_.data() writerIndex_; } void hasWritten(size_t len) { writerIndex_ len; } // 按长度消费消费完如果读写指针重合就归零避免 buffer_ 无限增长 void retrieve(size_t len) { readerIndex_ len; if (readerIndex_ writerIndex_) { readerIndex_ writerIndex_ 0; } } std::string retrieveAllAsString() { std::string s(buffer_.data() readerIndex_, readableBytes()); retrieve(readableBytes()); return s; } // 扩容前先尝试把已读空间挪到前面复用 void ensureWritable(size_t len) { if (writableBytes() len) return; if (readableBytes() writableBytes() len) { size_t readable readableBytes(); std::copy(buffer_.data() readerIndex_, buffer_.data() writerIndex_, buffer_.data()); readerIndex_ 0; writerIndex_ readable; } else { buffer_.resize(writerIndex_ len); } } private: std::vectorchar buffer_ std::vectorchar(65536); size_t readerIndex_ 0; size_t writerIndex_ 0; };ensureWritable里先尝试「原地挪」再「真扩容」是因为长连接场景下缓冲区会被反复读写每次扩容都走resize会让峰值内存很难控。retrieve在读写索引重合时归零也是同一个考虑不归零的话writerIndex_会一直涨buffer_只增不减一天跑下来内存能翻几十倍。这属于血泪经验压测跑几小时后 RSS 一直涨九成问题出在这个函数没写对。4. 线程池与 HTTP 解析一个请求从可读到响应的完整链路IO 层只负责把字节搬进用户态真正的解析和业务处理要甩给工作线程否则单线程再快也会被一个慢请求堵住。这一章把「可读事件触发 → 任务入队 → 解析 → 响应 → 记一个定时器」整条链路串起来。4.1 固定大小线程池与任务入队线程池的实现是教科书级的但几个细节决定它稳不稳class ThreadPool { public: explicit ThreadPool(size_t threads) { for (size_t i 0; i threads; i) { workers_.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(mtx_); // 带谓词的 wait防止虚假唤醒拿到空队列 cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ tasks_.empty()) return; task std::move(tasks_.front()); tasks_.pop(); } task(); // 关键任务在锁外执行 } }); } } void enqueue(std::functionvoid() task) { { std::lock_guardstd::mutex lock(mtx_); if (stop_) return; tasks_.push(std::move(task)); } cv_.notify_one(); // 通知放在解锁之后减少一次无谓的抢占 } ~ThreadPool() { { std::lock_guardstd::mutex lock(mtx_); stop_ true; } cv_.notify_all(); for (auto t : workers_) { if (t.joinable()) t.join(); } } private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex mtx_; std::condition_variable cv_; bool stop_ false; };有两处必须做对。第一cv_.wait一定要带谓词裸wait遇上虚假唤醒会拿到空队列然后在tasks_.front()上崩掉第二task()放在锁外执行如果拿着锁跑业务整个线程池就退化成单线程了QPS 直接腰斩。notify_one放解锁之后是顺带优化标准并没有强制要求但在高并发下能少几次「醒来发现没活干」的空转。线程数怎么定IO 和解析的工作量都不大nproc或nproc 1是稳妥起点。我在 4 核机器上从 2 调到 16 做过对比8 之后 QPS 基本持平再往上ctx_switch涨得很快这个拐点跟具体机器和请求大小有关压一遍比查资料快。4.2 HTTP 请求解析状态机HTTP 是流式协议一次read可能只读到半个请求行所以解析器必须是状态机不能假设缓冲区里已经有完整报文enum ParseState { kExpectRequestLine, kExpectHeaders, kExpectBody, kGotAll, kBadRequest }; bool HttpParser::parse(Buffer buf) { bool ok true; while (state_ ! kGotAll state_ ! kBadRequest) { if (state_ kExpectRequestLine) { const char* crlf buf.findCRLF(); if (crlf nullptr) break; // 没收到完整行等下一次读 ok parseRequestLine(buf); // 解析 METHOD PATH VERSION } else if (state_ kExpectHeaders) { const char* crlf buf.findCRLF(); if (crlf nullptr) break; ok parseHeaderLine(buf); // 逐行解析 Key: Value // 遇到空行表示头部结束根据 Content-Length 决定要不要读 body if (ok headers_.count(Content-Length)) { contentLen_ std::stoul(headers_[Content-Length]); state_ kExpectBody; } else if (ok) { state_ kGotAll; } } else if (state_ kExpectBody) { if (buf.readableBytes() contentLen_) break; // body 还没收全 body_ buf.retrieveAsString(contentLen_); state_ kGotAll; } } return ok; }findCRLF找不到时返回空指针并 break代表「数据不够继续等」而不是解析失败。这个区分非常关键把「没收全」当成「坏请求」去关连接大请求就会时好时坏而且是随机概率出现的玄学问题排查起来特别费劲。Content-Length用std::stoul前最好 catch 一下invalid_argument恶意请求发个非数字会直接把线程抛异常搞崩。parseRequestLine里按空格切三段要注意处理rfind( )的情况因为 URL 里可能带查询串甚至编码后的空格用find会把路径切碎bool HttpParser::parseRequestLine(Buffer buf) { const char* crlf buf.findCRLF(); std::string line(buf.peek(), crlf - buf.peek()); buf.retrieve(crlf - buf.peek() 2); // 连同 \r\n 一起消费掉 size_t sp1 line.find( ); size_t sp2 line.rfind( ); // 从后往前找避免被 URL 里的空格干扰 if (sp1 std::string::npos || sp2 sp1) { state_ kBadRequest; return false; } method_ line.substr(0, sp1); path_ line.substr(sp1 1, sp2 - sp1 - 1); version_ line.substr(sp2 1); state_ kExpectHeaders; return true; }4.3 响应构造与长短连接响应体拼装本身简单容易出错的是一致性Content-Length必须和 body 字节数完全相等否则浏览器会一直等或者把下一个响应当成本次 body 的一部分。我一般把响应写成一个函数头字段集中拼std::string HttpResponse::toString() const { std::string out; out HTTP/1.1 std::to_string(statusCode_) statusText_ \r\n; out Content-Type: contentType_ \r\n; out Content-Length: std::to_string(body_.size()) \r\n; out Connection: (keepAlive_ ? keep-alive : close) \r\n; out \r\n; out body_; return out; }Connection字段要和服务器实际行为对上。如果声明keep-alive但代码里写完就close客户端复用连接时就会读到 EOF日志里一片connection reset。反过来声明close却把 fd 留着连接会一直挂在CLOSE_WAITss -tan | grep CLOSE-WAIT | wc -l越跑越多。这两条都是典型的不一致问题检查一遍响应头和服务端关闭逻辑是否同源就能定位。4.4 定时器回收空闲连接长连接最大的风险是 fd 泄漏客户端不发 FIN 直接断网服务端就永远等着。解决办法是每个连接挂一个超时任务到期还没数据就主动关struct TimerEntry { int64_t expireMs; // 绝对到期时间毫秒 std::weak_ptrHttpConn owner; // 弱引用避免定时器吊住连接 std::functionvoid() cb; // 小根堆要最小的在堆顶所以比较方向反过来 bool operator(const TimerEntry o) const { return expireMs o.expireMs; } }; void EventLoop::addTimer(int64_t delayMs, std::weak_ptrHttpConn owner, std::functionvoid() cb) { timers_.push(TimerEntry{getNowMs() delayMs, owner, std::move(cb)}); } void EventLoop::handleExpiredTimers() { int64_t now getNowMs(); while (!timers_.empty() timers_.top().expireMs now) { TimerEntry e timers_.top(); timers_.pop(); // owner 已销毁说明连接早就关了跳过即可不能直接 cb() if (!e.owner.expired()) e.cb(); } }用weak_ptr而不是裸指针是因为连接可能因为对端关闭被提前销毁定时器到期时对象已经没了。expired()判断是这里唯一的安全阀漏掉它就是一个定时炸弹平时不出事压测并发一高就开始随机段错误。每个可读事件处理完之后重新挂一次超时就是所谓的「续期」把旧的定时任务留成惰性删除也无所谓堆里多几个过期项不影响正确性只是handleExpiredTimers要多弹几次。5. 避坑与排查跑起来之后最容易翻车的五个位置代码能编过、curl能通只是及格线。真正的坑都在并发、异常路径和长跑之后才浮现。下面五条都是这类项目里反复出现的按「现象 → 原因 → 解决」记出问题时对照着查比翻文档快。5.1 启动报 Address already in use但进程已经退了现象重新./webserver时bind失败提示地址被占用ps里却找不到旧进程。原因上一次进程退出时连接处于TIME_WAIT端口还被内核占着更隐蔽的情况是旧进程变成了僵尸或被nohup挂着ps aux的关键字没匹配到。解决监听 fd 创建后立刻设置SO_REUSEADDR这是标配别省。排查时用ss -lntp | grep 8080看是谁占着真找不到就lsof -i :8080补一刀。改代码时注意setsockopt必须在bind之前调用放在bind之后完全无效这个顺序错了不会报错只会让你以为设了没用。int on 1; ::setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); // 必须在 bind 之前顺序反了不生效也不报错5.2 小请求正常稍大的 POST 直接超时现象curl发 GET 秒回发几 KB 的 POST body 就一直挂着服务端日志显示请求行解析成功但 body 永远收不全。原因ET 模式下读事件只读了一次就 break剩余数据留在内核缓冲区epoll_wait不会再通知或者Content-Length解析出来之后缓冲区里已有的字节不够解析器 break 了却没人再触发解析。解决handleRead必须循环读到EAGAIN解析器在「数据不够」时返回 false 而不是错误并且要把「待解析」状态保留到下一次可读事件。日志里加一行readableBytes的实际值能立刻分辨是「一次没读全」还是「解析后没续上」。5.3 压测跑到一半随机段错误gdb 栈指向析构函数现象wrk压几十秒后进程崩core 文件里栈顶在~HttpConn或std::function的调用上重启后又能跑一阵。原因连接对象被工作线程和主线程同时持有主线程超时回收时析构了对象工作线程手里还是裸指针或者epoll_wait返回的事件数组里包含了本轮已经被 close 的 fd分发时对象已失效。解决连接一律用shared_ptr管理Channel里用weak_ptr回指连接epoll_wait拿到事件后先拷贝一份 fd 列表分发时用shared_from_this()提升生命周期保证回调期间对象不被析构。这类问题靠代码 review 很难看出来上-fsanitizeaddress编一版压一遍报错位置比 core 栈精确得多。5.4 CPU 打满但 QPS 没涨现象top显示进程 100% 占一个核perf top里epoll_wait和read占比很高QPS 却纹丝不动。原因典型是「水平触发 没读完」或者「对端关闭没处理」。read返回 0 之后没有close并从 epoll 摘除fd 一直可读epoll_wait立刻返回形成忙等空转。解决read返回 0 要当成关闭事件处理delclose一条龙错误分支里ECONNRESET、EPIPE也要走关闭路径。用strace -c -p pid看几秒钟的系统调用分布如果epoll_wait调用次数远大于read基本就能锁定是空转。5.5 跑几小时后内存只涨不降现象RSS 曲线一路向上重启后回落valgrind又报不出明确泄漏。原因多半不是真泄漏是缓冲区只增不减或者定时器堆里堆了大量已过期但没被弹出的条目也有可能是日志对象每次请求都新建字符串拼接没复用。解决Buffer的读写索引在消费完时归零ensureWritable先尝试原地挪定时器到期及时 pop别指望惰性删除兜底日志改成固定格式 级别判断if (level currentLevel) return;放在格式化之前。用perf record -g采样一段时间看分配热点比盲猜快。6. 二次开发给这套骨架补一个路由层和压测验证骨架跑通之后最自然的改造是加一层路由把HttpParser解析出的path映射到具体处理函数这样加接口不用改解析器。做法很简单在EventLoop或独立的HttpServer里放一张表using Handler std::functionstd::string(const HttpRequest); class Router { public: void add(const std::string path, Handler h) { routes_[path] std::move(h); } std::string dispatch(const HttpRequest req) { auto it routes_.find(req.path()); if (it routes_.end()) { return buildResponse(404, text/plain, not found); } return it-second(req); // 返回完整响应体头由外层统一拼 } private: std::unordered_mapstd::string, Handler routes_; };注册的地方放在main里业务代码和网络层就彻底分开了Router router; router.add(/, [](const HttpRequest) { return std::string(hello from c webserver); }); router.add(/health, [](const HttpRequest) { return std::string(ok); });改造时有三处连带要动别漏。第一HttpParser解析完成后要把结果交给Router而不是直接拼死响应第二Router是无状态的多个工作线程并发调用没问题但一旦 handler 里写了共享状态比如计数器就要加锁或者换成原子量第三404 和 500 也要走统一的响应构造别在 handler 里手拼字符串否则Content-Length迟早对不上。验证方法我习惯分三步走。先curl -i逐个接口过一遍看状态码和头字段再上wrk -t4 -c100 -d30s http://127.0.0.1:8080/重点看Non-2xx responses是不是 0、延迟分布的 p99 有没有异常跳变最后跑十分钟长连接压测同时开另一个终端watch -n1 ss -tan | awk {print \$1} | sort | uniq -c盯CLOSE-WAIT和TIME-WAIT的数量。数字稳住了这次改造才算完数字不稳就回到第 5 章那五条里找基本都在射程内。性能调优的先后顺序也有讲究。先确认没有忙等空转和 fd 泄漏再调线程数最后才考虑改 ET 为 LT 或者上多 Reactor。顺序反了会白忙一场因为前一类问题不解决后面的调参数据全是噪音。从那以后我每次接手这类单机服务跑之前都强制先ss -lntp确认端口归属、ulimit -n确认 fd 上限再开压测。这两条十秒钟能省掉后面半小时的自我怀疑。希望帮到你。本文还有配套的精品资源点击获取
返回列表