☰
从epoll到线程池:C++ WebServer高并发链路拆解
2026/10/26 5:20:10 网站建设 项目流程

简介:这是一份面向C++后端开发初学者与求职者的轻量级Web服务器项目源码包,围绕epoll I/O复用、线程池配合同步I/O模拟Proactor模式、主从状态机解析HTTP报文、定时器链表清理非活跃连接等核心机制展开,适合用来理解高并发网络服务的请求处理链路,也可作为Linux系统编程与网络编程的练手素材,兼顾课程设计、简历项目与面试复习场景。压缩包共26个文件、约152KB,以4个h头文件和3个cpp源文件为主体,另含Makefile构建脚本、HTML测试页面、Webbench压测目录及若干配置与说明文件,代码关键处附有较详细注释,便于顺着模块阅读和调试。已有1102人学习下载。读者可据此梳理从连接建立、事件分发、线程池调度到HTTP解析与超时管理的完整实现,并借助Webbench观察上万并发连接下的数据交换表现,积累性能测试与排错经验。

1. 从一次「并发上不去」的排查说起:C++ WebServer 能给你什么

有个朋友拿一份 C++ 写的 webserver 去面后端开发,被问「你这个服务能扛多少并发,瓶颈在哪」,答不上来。把服务跑起来压了一下,单机几百连接就开始报错,日志里全是文件描述符耗尽。问题不在代码写得多烂,而在他从没顺着一次请求的路径,把 epoll 就绪、线程池派发、HTTP 解析、响应写回这几个环节串起来看过。

这份带详细代码备注的 C++ WebServer 就是干这个用的。它把 Reactor 事件循环、epoll 多路复用、线程池、HTTP 状态机解析、定时器超时回收这几块拼成一个能跑的最小闭环,注释密度高到几乎每行关键逻辑旁边都有说明。适合两类人:想从「会写 C++ 语法」跨到「懂后端服务怎么转」的新手,以及写过业务但没自己搭过网络层、想补这块底子的开发者。你不需要它多完整,你需要它能被拆开看。

2. 骨架与技术选型:Reactor、epoll 和线程池是怎么拼起来的

2.1 目录结构与模块职责

先看骨架,这个项目的分层很规矩,include 放声明、src 放实现、static 放静态资源,主入口只有一个 main.cpp。这种结构不算花哨,但对读代码的人友好:想知道某个类干什么,头文件扫一遍就够了。

webserver/ ├── CMakeLists.txt # 构建脚本,Release 下开 -O2 ├── config/ │ └── server.conf # 端口、线程数、超时、静态根目录 ├── include/ │ ├── EventLoop.h # 事件循环,整个服务的心脏 │ ├── Epoll.h # epoll_create/add/mod/del/wait 的薄封装 │ ├── Channel.h # fd + 关注事件 + 回调的绑定体 │ ├── ThreadPool.h # 固定线程数 + 有界任务队列 │ ├── HttpConnection.h # 一条连接的生命周期管理 │ ├── HttpRequest.h # 请求解析状态机 │ ├── HttpResponse.h # 响应组装与序列化 │ ├── Buffer.h # 读缓冲区,处理粘包/半包 │ ├── Timer.h # 超时连接回收 │ └── Log.h # 异步日志 ├── src/ # 与 include 一一对应的 .cpp ├── static/ # index.html / css / 图片 └── main.cpp # 解析配置、起 loop、注册 listen fd

几个模块的边界值得说清楚。Channel是「fd 和它关心什么事件、事件来了调谁」的三元组,它不持有 fd 的所有权,只做登记;HttpConnection才是一条连接真正的宿主,持有 socket fd、读缓冲、解析状态和所属的 EventLoop。Epoll只负责和内核打交道,不掺业务。这种切法最大的好处是:想加个 UDP 服务或者定时任务,不用动 HTTP 那一层。

提示:读这类项目别从 main.cpp 一头扎进去,先看 include 下的类声明,把「谁持有谁」理清,再回头看实现,能省一半时间。

2.2 技术选型:为什么是 epoll + 非阻塞 IO + 线程池

网络层选 epoll 而不是 select/poll,理由很实际:select 有 1024 的 fd 上限,每次调用要把整个 fd 集合从用户态拷进内核,返回后还得线性扫一遍才知道谁就绪。epoll 把这两件事都搬到内核里做了,epoll_wait直接返回就绪链表,连接数上千之后差距非常明显。这份代码用的是LT(水平触发)为主、读写循环读到 EAGAIN 为止的写法,比纯 ET 好调试,因为漏读一次下一轮还会提醒你。

IO 模型上,所有 socket 都设成非阻塞。原因很直白:事件循环是单线程的,任何一个read阻塞住,整个服务就卡在那儿,后面几千个连接全等着。非阻塞之后,read返回EAGAIN就代表「这批数据读完了,等下次通知」,事件循环可以立刻回去处理别的 fd。

线程池采用「主线程 accept + 工作线程处理业务」的主从 Reactor 变体。主线程只负责accept拿新 fd、注册读事件,真正的解析和响应扔进任务队列。这样 accept 不会被慢请求拖住。线程数一般给到 CPU 核数或核数 +1,再往上加收益很小,反而增加上下文切换。队列我建议做成有界的,满了就拒绝或者直接回 503,无界队列在高压下只会把内存吃光然后 OOM,这是血泪经验。

2.3 一个 HTTP 请求在服务端走过的完整链路

把链路摊开看,一次请求大概是这么走的:

  1. 主线程epoll_wait返回 listen fd 可读,调accept拿到 connfd;
  2. 给 connfd 设非阻塞,新建HttpConnection,往 epoll 注册EPOLLIN | EPOLLET(或 LT);
  3. 同一个 fd 上下一次可读时,事件循环拿到就绪列表,按 fd 找到对应的Channel,触发它的读回调;
  4. 读回调把数据读进Buffer,交给 HTTP 解析状态机;
  5. 请求行 + 头部解析完整后,如果是静态资源就走文件读取,否则走路由;
  6. 组装HttpResponse,序列化成字节流,注册EPOLLOUT;
  7. 可写事件到来,把缓冲区数据写出去,写不完就保留剩余部分等下次;
  8. 每读到一次数据就刷新该连接的定时器,超时没动静就 close。

这条链路里最容易翻车的是第 4 步和第 7 步。第 4 步涉及半包:TCP 是流,一个 HTTP 请求可能分三次到达,你不能假设一次read就能拿到完整报文。第 7 步涉及写缓冲:write返回值小于要写的长度是常态,尤其在客户端接收慢的时候,剩下没写完的必须挂在连接上等EPOLLOUT,不能直接丢掉。

3. 核心模块走读:一个 HTTP 请求从 epoll_wait 到 write 的完整链路

3.1 Epoll 封装与事件循环

先看最底层的 epoll 封装,这段代码短,但每个返回值都有讲究。

// include/Epoll.h class Epoll { public: Epoll(); ~Epoll(); // events 传 EPOLLIN / EPOLLOUT / EPOLLET 等组合 void addFd(int fd, uint32_t events); void modFd(int fd, uint32_t events); // 比如从只读改成读+写 void delFd(int fd); // 连接关闭时务必调用 // timeoutMs = -1 阻塞等待;= 0 立即返回(用来跑定时器);> 0 最多等这么久 std::vector<epoll_event> poll(int timeoutMs = -1); private: int epfd_; std::vector<epoll_event> events_; // 预分配,避免每次 wait 都扩容 }; // src/Epoll.cpp std::vector<epoll_event> Epoll::poll(int timeoutMs) { int n = ::epoll_wait(epfd_, events_.data(), static_cast<int>(events_.size()), timeoutMs); if (n < 0) { // EINTR 是信号打断,不是真错误,直接返回空继续下一轮 if (errno == EINTR) return {}; LOG_ERROR("epoll_wait failed, errno=%d", errno); return {}; } // 只把前 n 个有效事件交出去,后面的内存是上一轮的脏数据 return std::vector<epoll_event>(events_.begin(), events_.begin() + n); }

这里有两个细节值得单独说。events_预分配成固定大小(常见是 1024 或者配置项),epoll_wait最多填满这么多;如果同一时刻就绪事件超过这个数,下一轮会继续返回剩下的,不会丢,只是多一次系统调用。另一个是EINTR的处理,很多示例代码直接if (n < 0) return,把信号打断也当错误,结果进程收到 SIGCHLD 之类的信号后事件循环就悄悄停摆了,这种 bug 排查起来非常折磨人。

addFd/modFd/delFd三个函数把epoll_ctl的EPOLL_CTL_ADD/MOD/DEL包了一层,顺手把event.data.fd填好。这样上层不用每次都写ev.events = ...; ev.data.fd = ...;这一坨。

事件循环EventLoop的主体是个while (!quit_):

// src/EventLoop.cpp void EventLoop::loop() { while (!quit_) { // 先算出距离最近一个定时器还有多少毫秒,作为 epoll_wait 的超时 int timeout = timerQueue_.nextTimeoutMs(); auto ready = epoll_->poll(timeout); for (const auto& ev : ready) { int fd = ev.data.fd; auto it = channels_.find(fd); if (it == channels_.end()) continue; // fd 已经被回收,跳过 auto ch = it->second; if (ev.events & (EPOLLHUP | EPOLLERR)) { ch->handleError(); // 对端异常关闭,直接清理 continue; } if (ev.events & EPOLLIN) ch->handleRead(); if (ev.events & EPOLLOUT) ch->handleWrite(); } // 处理所有到期连接:关 fd、从 epoll 摘掉、从 map 里删 timerQueue_.handleExpired(); } }

关键点是timeout不是写死的 -1,而是由定时器队列算出来的。如果下一个定时器 3 秒后到期,epoll_wait最多等 3 秒就必须返回,否则超时连接会一直挂着不释放。这是很多手写服务端容易忽略的地方:定时器只在有事件的时候才检查,结果空闲连接永远清不掉。

channels_这个unordered_map<int, shared_ptr<Channel>>也值得注意。用shared_ptr是为了在回调执行期间,即使连接被别的路径删了,对象也不会立刻析构导致 use-after-free。但反过来,如果Channel里又持有shared_ptr指向自己形成环,就会泄漏,所以要配合weak_ptr使用。

3.2 线程池的任务派发与背压

线程池这块,代码量不大,但「队列满了怎么办」这个决策决定了服务在高压下是优雅降级还是直接崩。

// include/ThreadPool.h class ThreadPool { public: ThreadPool(size_t threadCount, size_t maxQueueSize); ~ThreadPool(); // 返回 false 表示队列已满,调用方需要决定是拒绝连接还是回 503 bool submit(std::function<void()> task); private: void workerLoop(); std::vector<std::thread> workers_; std::queue<std::function<void()>> tasks_; std::mutex mtx_; std::condition_variable cv_; // 任务到了,唤醒 worker std::condition_variable notFull_; // 队列有空位,唤醒 submit std::atomic<bool> stop_{false}; size_t maxQueueSize_; }; // src/ThreadPool.cpp bool ThreadPool::submit(std::function<void()> task) { std::unique_lock<std::mutex> lock(mtx_); // 队列满直接返回 false,绝不无限等待,避免 accept 线程被拖死 if (tasks_.size() >= maxQueueSize_) return false; tasks_.push(std::move(task)); cv_.notify_one(); return true; } void ThreadPool::workerLoop() { while (true) { std::function<void()> task; { std::unique_lock<std::mutex> lock(mtx_); cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; // 退出前把残留任务跑完 task = std::move(tasks_.front()); tasks_.pop(); notFull_.notify_one(); } task(); // 锁外执行,避免长任务阻塞队列 } }

参数上,threadCount一般取std::thread::hardware_concurrency(),拿不到就退化成 4。maxQueueSize我习惯设成线程数的几十倍,比如 4 线程配 1024。队列太小会导致稍微有点毛刺就拒绝,太大则起不到背压作用。

注意task()是在锁外执行的,这点很关键。如果在锁内跑业务,那么所有线程会串行化,线程池等于白建。另外stop_的判断放在cv_.wait的谓词里,保证析构时不会漏掉最后一个任务就退出。

注意:submit返回 false 后,调用方别忘了close(connfd)。我见过为了「不丢请求」而无限重试的写法,结果在压测下 accept 队列爆掉,新连接直接被内核丢弃,症状是客户端报连接超时,服务端日志干干净净。

3.3 HTTP 请求解析状态机

HTTP 解析是这类项目里最能体现功底的部分。因为 TCP 是字节流,你不能假设一次读到完整报文,所以必须写状态机。

// include/HttpRequest.h enum class ParseState { REQUEST_LINE, // "GET /index.html HTTP/1.1\r\n" HEADERS, // "Host: xxx\r\n" 逐行解析 BODY, // 按 Content-Length 读满 FINISH }; // src/HttpRequest.cpp bool HttpRequest::parse(Buffer& buf) { while (state_ != ParseState::FINISH) { if (state_ == ParseState::REQUEST_LINE) { std::string line = buf.readUntil("\r\n"); if (line.empty()) return false; // 数据还没到齐,等下一次可读事件 if (!parseRequestLine(line)) return false; // 格式错误,回 400 state_ = ParseState::HEADERS; } else if (state_ == ParseState::HEADERS) { std::string line = buf.readUntil("\r\n"); if (line.empty()) return false; if (line == "") { // 空行代表头部结束 state_ = (contentLength_ > 0) ? ParseState::BODY : ParseState::FINISH; continue; } parseHeaderLine(line); // 填 method_/path_/headers_ } else if (state_ == ParseState::BODY) { if (buf.readableBytes() < contentLength_) return false; body_ = buf.retrieve(contentLength_); state_ = ParseState::FINISH; } } return true; }

Buffer内部维护读指针readIndex_和写指针writeIndex_,readUntil从读指针开始找分隔符,找到就移动读指针并返回子串,找不到就返回空字符串且不动指针。这套设计让「半包」处理变得很自然:数据没到齐就返回 false,解析状态停在原地,下次可读事件来了接着从这里往下走。

contentLength_来自Content-Length头,解析时用std::stoul转,别忘了 catch 异常,客户端传个非数字过来直接让进程崩掉可太亏了。如果要做 chunked 编码,还得加一个CHUNK_SIZE和CHUNK_DATA状态,这就超出最小实现的范畴了,注释里一般会留个 TODO。

一个容易忽略的点:状态机每次进入parse都是从当前state_继续,这意味着同一个连接上的第二个请求(keep-alive)必须在FINISH之后重置状态。常见做法是响应发送完毕后调一次reset(),把state_拨回REQUEST_LINE、清空headers_和body_,只保留keepAlive_标志。

3.4 定时器与超时连接回收

长连接不关,fd 就会越积越多,最后撞到ulimit -n。定时器就是干这个的。

// include/Timer.h struct TimerNode { int fd; // 关联的连接 fd std::chrono::steady_clock::time_point expire; // 到期时间点 std::weak_ptr<HttpConnection> conn; // 弱引用,不延长连接生命周期 bool operator>(const TimerNode& o) const { return expire > o.expire; // 小根堆:最早到期的在堆顶 } }; class TimerQueue { public: void add(int fd, std::weak_ptr<HttpConnection> conn, int timeoutSec); void refresh(int fd, int timeoutSec); // 每读到数据就刷新 int nextTimeoutMs() const; // 给 epoll_wait 算超时 void handleExpired(); // 弹出所有到期节点并关闭连接 private: std::priority_queue<TimerNode, std::vector<TimerNode>, std::greater<TimerNode>> heap_; };

这里用weak_ptr是刻意的。如果存shared_ptr,定时器节点会一直把连接对象吊住,即使channels_里已经删了,对象也析构不掉,压测跑十分钟内存就上去了。用weak_ptr的话,handleExpired里先lock(),拿到才关,拿不到说明连接已经正常销毁,直接把这个节点扔掉就行。

refresh的实现有两种:一是删掉旧节点插入新节点(懒删除,靠lock()失败来过滤),二是用时间轮。前者写起来简单,堆里会有些僵尸节点,但业务规模不大时完全够用。nextTimeoutMs直接取堆顶的expire减去now(),如果堆空就返回 -1(阻塞等)。

超时时间建议给 60 秒左右。太短,正常用户切个标签页回来连接就断了;太长,被恶意连接占着 fd 不撒手。配置化之后按部署环境调,别写死在代码里。

4. 编译运行与压测:把服务在本机拉起并打出第一组 QPS

4.1 依赖与 CMake 构建

这个项目没有第三方依赖,标准库 + POSIX 就够了,所以构建只有一步。CMake 我一般这么写:

cmake_minimum_required(VERSION 3.10) project(webserver CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 默认 Release,忘了加 -O2 的话压测数据会难看到怀疑人生 if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() set(CMAKE_CXX_FLAGS_RELEASE "-O2 -g -pthread -Wall") include_directories(${CMAKE_SOURCE_DIR}/include) # 显式列出源文件,别用 file(GLOB),新增文件后不重新 cmake 会漏编译 set(SRC src/Epoll.cpp src/EventLoop.cpp src/Channel.cpp src/ThreadPool.cpp src/HttpConnection.cpp src/HttpRequest.cpp src/HttpResponse.cpp src/Buffer.cpp src/Timer.cpp src/Log.cpp ) add_executable(webserver main.cpp ${SRC}) target_link_libraries(webserver pthread)

file(GLOB)看着省事,但 CMake 不会感知目录变化,你新建一个.cpp然后直接make,链接阶段会报一堆 undefined reference,得手动cmake ..一次才能好。这个坑我踩过不止一次,所以宁可多写几行显式列表。

构建流程:

mkdir -p build && cd build cmake .. # 生成 Makefile make -j$(nproc) # 并行编译,核多就快 ./webserver ../config/server.conf

-j后面跟核数,$(nproc)在 Linux 上直接取。编译报错集中在include路径找不到的话,先确认include_directories写的是绝对路径还是相对CMAKE_SOURCE_DIR。

4.2 配置文件与日志

配置用最朴素的key=value格式,解析起来十几行就够,没必要上 JSON 库。

# config/server.conf port=8888 threads=4 max_queue_size=1024 static_root=./static keep_alive_timeout=60 log_path=./logs/server.log log_level=info

main.cpp里读进来的顺序一般是:先解析配置拿到 port 和 threads,再初始化日志(因为后面的错误都要写日志),再建 EventLoop 和线程池,最后 listen。顺序错了会出现「日志模块还没起来,绑定端口失败的错误只能 print 到终端」的尴尬。

日志这块建议用异步:业务线程把格式化好的字符串塞进一个无锁队列,单独的写线程负责刷盘。同步日志在压测时非常致命,每次fprintf都带一次write系统调用,QPS 直接从几万掉到几千,而且 CPU 看起来不高,很难往这上面想。

4.3 启动与请求验证

服务起来之后先别急着上压测工具,用 curl 一步步验:

# 1. 健康检查,确认能返回静态页 curl -v http://127.0.0.1:8888/index.html # 2. 验证 keep-alive:同一条连接上发两次请求 curl -v http://127.0.0.1:8888/index.html http://127.0.0.1:8888/index.html # 3. 验证 404 分支 curl -i http://127.0.0.1:8888/not_exist.html # 4. 验证大文件的分块写回(写缓冲是否正常工作) curl -o /dev/null http://127.0.0.1:8888/big.bin

第 4 条特别值得做。找一个几 MB 的文件放到 static 目录,用 curl 下载。如果代码里写响应时假设「一次 write 就能写完」,这里大概率会截断,下载下来的文件大小对不上。正确的做法是write返回多少就移动多少,没写完的挂EPOLLOUT等下次。

4.4 压测与指标解读

压测工具用webbench或者ab都行,前者更轻。

# 1000 并发,持续 30 秒 webbench -c 1000 -t 30 http://127.0.0.1:8888/index.html # 或者用 ab,注意 -k 开 keep-alive 才有意义 ab -n 100000 -c 500 -k http://127.0.0.1:8888/index.html

看三个数:Requests per second、Failed requests、Time per request。QPS 上不去先看 CPU 是否打满,打满了就是真到瓶颈;没打满则大概率卡在 IO 或者锁上,用perf top看热点函数,pthread_mutex_lock排前面就是锁竞争,write排前面就是日志同步刷盘。

压测前先把 fd 上限提上去:

ulimit -n 65535 # 当前 shell 生效 # 永久生效写 /etc/security/limits.conf

ab里-c超过 1024 而没调ulimit的话,客户端自己就先报socket: Too many open files,这时候你看到的是压测工具的问题,不是服务的问题,别搞混。

5. 避坑与排查:编译能过但一压就崩的五处地方

现象一:压到几百并发,日志刷满accept: Too many open files,服务假死。原因:进程 fd 上限默认 1024,每个连接占一个,加上日志文件、epoll fd、监听 fd,实际能撑的连接数远小于 1024。解决:ulimit -n 65535提到当前 shell,同时在/etc/security/limits.conf里给运行用户配nofile,重启会话生效。另外确认连接关闭时epoll_ctl(DEL)和close都调了,只close不DEL的话,fd 号会被复用,epoll 里残留旧事件,触发到已关闭的 fd 上,行为非常诡异。

现象二:用 ET 模式后,偶尔有请求卡住不返回,客户端超时,服务端日志停在「收到请求行」。原因:ET 只在状态变化时通知一次,如果读回调里只read了一次就返回,内核缓冲区里剩下的数据不会再触发事件。解决:读循环必须while (true) { n = read(...); if (n <= 0 && errno == EAGAIN) break; },读到EAGAIN为止。写也是一样,write返回EAGAIN就把剩余数据挂到写缓冲,注册EPOLLOUT。

现象三:客户端在下载大文件途中直接关掉浏览器,服务端进程跟着退出,没有任何错误日志。原因:往一个已被对端关闭的 socket 写数据,内核会发 SIGPIPE,默认动作是终止进程。解决:启动时signal(SIGPIPE, SIG_IGN),或者在send时带MSG_NOSIGNAL标志。处理完之后write会返回 -1 且errno == EPIPE,这时候按正常错误路径清理连接就行。

现象四:跑十几分钟后 RSS 一路涨,valgrind又查不出明确的泄漏点。原因:定时器节点里存了shared_ptr<HttpConnection>,连接正常关闭后channels_里删掉了,但堆里的定时器节点还持有一份引用,对象永远不析构。解决:定时器只存weak_ptr,handleExpired里lock()失败就直接丢弃该节点。同理,任何跨模块持有的长生命周期容器,只要不是「拥有者」,一律用weak_ptr。

现象五:压测 QPS 只有几千,top看 CPU 才 30%,加线程数也没用。原因:日志是同步写的,每个请求打两行,每次都触发write系统调用并等待刷盘。解决:改成异步日志,业务线程只做字符串拼接入队,单独线程批量刷。压测这种场景下,日志级别也调成warn,别在热路径上打info。还有人把std::cout留在回调里,效果一样,而且cout还带锁。

6. 二次开发:把静态服务器改成能落业务的路由框架

静态文件服务器只能验证网络层跑通了,真要拿去写点东西,第一步是加路由。别一上来就上框架,先手写一个几十行的Router把机制搞清楚,后面换什么都快。

// include/Router.h class Router { public: using Handler = std::function<void(const HttpRequest&, HttpResponse&)>; void get(const std::string& path, Handler h) { table_["GET"][path] = std::move(h); } void post(const std::string& path, Handler h) { table_["POST"][path] = std::move(h); } // 返回 false 表示没匹配到,上层回 404 bool dispatch(const HttpRequest& req, HttpResponse& resp) { auto mit = table_.find(req.method()); if (mit == table_.end()) return false; auto pit = mit->second.find(req.path()); if (pit == mit->second.end()) return false; pit->second(req, resp); // 命中后由业务函数负责填 status/headers/body return true; } private: // method -> path -> handler std::unordered_map<std::string, std::unordered_map<std::string, Handler>> table_; };

注册和调用是这样:

Router router; router.get("/api/ping", [](const HttpRequest&, HttpResponse& resp) { resp.setStatus(200); resp.setHeader("Content-Type", "application/json"); resp.setBody(R"({"ok":true})"); }); router.post("/api/echo", [](const HttpRequest& req, HttpResponse& resp) { resp.setStatus(200); resp.setBody(req.body()); // 原样回显,用来验 BODY 状态机对不对 });

改造时有三处要顺着改:第一,HttpConnection处理完解析之后,先查静态文件,查不到再调router.dispatch,两者都失败才回 404;第二,HttpResponse要补setStatus、setHeader、setBody和serialize(),序列化时自动带上Content-Length和Connection: keep-alive;第三,POST 请求体可能有几 MB,别忘了在HttpRequest里加一个最大 body 长度限制,超了直接回 413,不然一个恶意请求就能把内存撑爆。

验证方法很简单,写完之后按这个顺序过一遍:curl -X GET打/api/ping看返回 JSON;curl -X POST -d '{"a":1}'打/api/echo看回显是否完整;再用ab -p body.json -T application/json压 POST 接口,确认并发下 body 不会串包。串包是状态机没重置的典型症状,表现为 A 请求的 body 出现在 B 的响应里,一压就现原形。

更进一步可以加中间件链,把鉴权、CORS、请求耗时统计串成std::vector<Middleware>依次执行,每个中间件拿req、resp和next回调。这套东西写下来不超过两百行,但能让你真正理解业务框架在干什么。

我自己的习惯是,每改完一个模块就先写个最小用例跑通,再往下改下一个,从来不攒着一起测。这类网络代码的 bug 有个特点:单看每一层都对,拼起来就是不对,攒着调只会让你在几百行日志里找那一个EPOLLOUT没注册的地方。希望帮到你。

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

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

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

立即咨询