资讯动态

C++从零搭建轻量HTTP服务器:epoll+线程池与报文解析实战

发布时间:2026/10/26 3:02:11 来源:尧图企业网站定制
简介这份资源是一个基于 C 的异步 HTTP 服务器库面向具备一定 C 基础、希望深入理解高性能网络编程与协程应用的中高级开发者。它使用 epoll 事件驱动机制与 C20 协程构建解决传统同步服务器在高并发场景下阻塞多、吞吐低的问题适合用于学习异步 IO 模型、请求路由与参数校验等后端核心环节也可作为小型 Web 服务或接口原型的开发起点。压缩包共 50 个文件约 56KB以 18 个 .h 头文件与 2 个 .cpp 源文件构成服务端核心另有 14 个 .js、1 个 .html、1 个 .css 与 1 个 .ico 组成示例前端页面3 个 .json 配置、2 个 .md 说明文档以及 CMakeLists、yml、sh 等构建与自动化脚本配套代码中还演示了基于 JSON 指针的请求体长度校验与路由注册写法。资源附带的便笺共享示例应用完整呈现了从路由定义、参数校验到前端调用的链路便于读者对照源码理解协程调度与 epoll 封装的组织方式并借助构建脚本快速编译运行。目前已有 198 人学习关注。1. 为什么用 C 从零搭一个 http-server一份能跑通的资源拆解本地联调时最烦的是为了一个静态页或两个 mock 接口把整套服务框架拉起来。这份 http-server 资源是一个 C 实现的轻量 HTTP 服务骨架核心链路是 socket、epoll、线程池、HTTP 报文解析、路由和静态文件响应代码量不大边界清楚。它适合三类人想从字节流层面看懂一次 GET 请求怎么变成响应的 C 开发者需要一个能直接改 handler 的本地调试服务的人以及准备在嵌入式或边缘设备上跑一个极简 HTTP 入口的工程师。它不替代生产级网关但拿来拆结构、补日志、做压测基线比从零搭省事。2. 先把事件循环立住epoll、非阻塞 accept 和线程池的骨架2.1 为什么 http-server 的默认模型是 Reactor 而不是一连接一线程C 写 HTTP 服务第一层选型不是“用哪个库”而是连接模型。HTTP/1.1 短连接比例高如果 accept 到一个 fd 就起一个线程线程创建、栈内存和上下文切换会把小请求的成本放大。常见做法是主线程负责 accept 和 epoll_wait把就绪的 fd 交给线程池做读写和业务处理这就是 Reactor 的简化版。它不追求极致吞吐但结构透明出了问题能一层层查。模型连接数上限上下文切换实现复杂度适合场景一连接一线程几百到几千高低内部工具、低并发进程池受进程数限制中中隔离要求高的老服务epoll 线程池万级 fd 起步可控中高学习骨架、边缘入口、轻量 API这张表不是让谁替代谁而是给 http-server 定边界。资源里的实现走 epoll 线程池主线程不碰业务工作线程不直接 accept。这样压测时 QPS 上不去至少能判断是事件循环空转、线程池排队还是锁争用而不是一锅粥。2.2 目录骨架与编译参数先把可调试性固定下来拿到源码包先别急着改业务代码先看目录和编译参数。我一般会要求 C 标准、线程库、调试符号和 sanitizer 开关都在构建文件里显式写出来否则后面查内存越界会变成玄学。http-server/ ├── CMakeLists.txt ├── include/ │ ├── event_loop.h │ ├── http_parser.h │ ├── router.h │ └── thread_pool.h ├── src/ │ ├── main.cpp │ ├── event_loop.cpp │ ├── http_parser.cpp │ ├── router.cpp │ └── thread_pool.cpp ├── www/ │ └── index.html └── tests/ └── parser_test.cpp目录不用花哨关键是头文件和实现分离。www/是静态文件根目录tests/放解析器单测。编译时先开-O2 -g需要查内存问题时再切-fsanitizeaddress。cmake_minimum_required(VERSION 3.16) project(http_server LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_compile_options(-Wall -Wextra -Wpedantic -O2 -g) find_package(Threads REQUIRED) add_executable(http_server src/main.cpp src/event_loop.cpp src/http_parser.cpp src/router.cpp src/thread_pool.cpp ) target_include_directories(http_server PRIVATE include) target_link_libraries(http_server PRIVATE Threads::Threads)-Wall -Wextra别嫌吵HTTP 解析里类型窄化和未使用返回值早期报警比压测时翻车便宜。Threads::Threads是线程池和多线程日志的硬依赖。-O2 -g同时保留优化和符号压测和调试不用来回切构建目录。要是准备跑 sanitizer单独建一个build-asan目录把-fsanitizeaddress,undefined加进去别和生产构建混在一起。2.3 epoll 注册与 accept 闭环fd 必须非阻塞事件循环的最小闭环就四件事创建监听 socket、bind/listen、创建 epoll、把监听 fd 注册进去。代码不长但每个参数都影响后面排查。int createListenSocket(int port) { int fd ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); if (fd 0) return -1; int yes 1; ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, yes, sizeof(yes)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port); if (::bind(fd, reinterpret_castsockaddr*(addr), sizeof(addr)) 0) { ::close(fd); return -1; } if (::listen(fd, SOMAXCONN) 0) { ::close(fd); return -1; } return fd; } void eventLoop(int listenFd) { int epfd ::epoll_create1(EPOLL_CLOEXEC); epoll_event ev{}; ev.events EPOLLIN | EPOLLET; ev.data.fd listenFd; ::epoll_ctl(epfd, EPOLL_CTL_ADD, listenFd, ev); epoll_event events[1024]; while (true) { int n ::epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { if (events[i].data.fd listenFd) { while (true) { int conn ::accept4(listenFd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); if (conn 0) break; // 交给连接管理器注册 EPOLLIN | EPOLLONESHOT } } } } }SOCK_NONBLOCK和SOCK_CLOEXEC在 socket 创建阶段就带上避免后面 fork 或 exec 时泄漏 fd。SO_REUSEADDR解决重启时 TIME_WAIT 占端口的问题但它不是“端口被别的进程占着”的后悔药。SOMAXCONN是 listen backlog压测短连接时可以调大但别当成万能参数。EPOLLET是边缘触发意味着 accept 也必须循环读到EAGAIN否则一次就绪只处理一个连接剩下排队的连接会卡住。2.4 线程池任务边界fd 不能裸丢进去很多 http-server 的翻车点不在 epoll而在“把 fd 直接丢进线程池”。工作线程拿 fd 去 read主线程同时也在 epoll 里监听同一个 fd读写时序就乱了。常见做法是连接对象持有 fd注册EPOLLONESHOT事件触发后先摘掉监听再交给线程池线程处理完重新注册。class ThreadPool { public: explicit ThreadPool(size_t n) { for (size_t i 0; i n; i) { workers_.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(mu_); 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(mu_); tasks_.push(std::move(task)); } cv_.notify_one(); } private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex mu_; std::condition_variable cv_; bool stop_ false; };这个线程池只负责执行任务不负责管理连接生命周期。任务队列长度要设上限否则慢客户端会把内存顶上去。工作线程数量也不是越多越好HTTP 解析和静态文件发送偏 I/O常见起点是 CPU 核数到两倍核数压测后看线程池排队时间再调。连接对象释放时统一 close fd别在多个路径各写一次 close否则 CLOSE_WAIT 会教你做人。3. HTTP 报文解析与路由从字节流到可维护的 handler3.1 请求行、头部、body 的状态机解析HTTP 请求不是一次 read 就能收全的。可能先到半行请求行也可能头部和 body 粘在一起。用正则整段匹配短请求能跑长请求和分片就会翻车。常见做法是状态机请求行、头部、body 三个状态缓冲区里找\r\n找到就推进找不到就等下一次读。enum class ParseState { RequestLine, Headers, Body, Done }; struct HttpRequest { std::string method; std::string path; std::string version; std::unordered_mapstd::string, std::string headers; std::string body; }; bool parseRequestLine(const std::string line, HttpRequest req) { auto first line.find( ); auto second line.find( , first 1); if (first std::string::npos || second std::string::npos) return false; req.method line.substr(0, first); req.path line.substr(first 1, second - first - 1); req.version line.substr(second 1); return req.version HTTP/1.1 || req.version HTTP/1.0; } bool parseHeaders(const std::string block, HttpRequest req) { size_t pos 0; while (pos block.size()) { auto end block.find(\r\n, pos); if (end std::string::npos) break; std::string line block.substr(pos, end - pos); auto colon line.find(:); if (colon ! std::string::npos) { std::string key line.substr(0, colon); std::string val line.substr(colon 1); while (!val.empty() val.front() ) val.erase(val.begin()); req.headers[key] val; } pos end 2; } return true; }请求行解析失败直接回 400不要继续等。头部解析时 key 一般统一转小写避免Content-Length和content-length当成两个头。body 长度从Content-Length取取不到且方法可能带 body 时按 411 或 400 处理。缓冲区要设上限比如 8KB 请求行加头部、1MB body超过就回 413 并关闭连接。这个上限不是拍脑袋是防止慢速攻击把内存吃光。3.2 路由表设计method path 到 handler路由别写成 if-else 长链短则无所谓接口一多就难查。用unordered_map把GET /api/hello映射到 handlerkey 由 method 和 path 拼成。路径参数先不做复杂匹配静态前缀和精确路径足够撑起一个 http-server 骨架。using Handler std::functionvoid(const HttpRequest, HttpResponse); class Router { public: void add(const std::string method, const std::string path, Handler h) { table_[method path] std::move(h); } bool dispatch(const HttpRequest req, HttpResponse res) { auto it table_.find(req.method req.path); if (it table_.end()) { res.status 404; res.body Not Found; return false; } it-second(req, res); return true; } private: std::unordered_mapstd::string, Handler table_; };method统一大写path去掉 query string 再查表。查不到时不要一律 404路径存在但方法不对回 405 并带Allow头调试时能少猜半天。handler 里不要做阻塞读文件静态文件走单独分支业务 handler 只负责拼响应。路由表在启动时注册完就不再写读多写少不加锁也能跑如果要做热更新另说。3.3 响应构造Content-Length、Connection 和错误页响应构造最容易埋雷的是Content-Length。HTTP/1.1 默认 keep-alive如果长度算错客户端会把下一个响应当成当前 body 的一部分现象是“第一个请求正常第二个请求 404 或乱码”。长度必须按字节算不是字符数。std::string buildResponse(const HttpResponse res, bool keepAlive) { std::string out; out HTTP/1.1 std::to_string(res.status) statusText(res.status) \r\n; out Content-Length: std::to_string(res.body.size()) \r\n; out Content-Type: res.contentType \r\n; out Connection: std::string(keepAlive ? keep-alive : close) \r\n; out \r\n; out res.body; return out; }res.body.size()返回字节数中文按 UTF-8 多字节算正好是客户端要的长度。Connection头要和实际行为一致决定关闭就写close别嘴上 keep-alive 手上 close。错误页也走同一个构造函数不要手写裸字符串否则 404 页面漏Content-Lengthkeep-alive 立刻错位。日期头、Server 头可以加但别在每次响应里做重格式化缓存成字符串更稳。3.4 解析层的三个边界URI 编码、重复头、超大 bodyURI 里的%20要解码后再查路由但解码只做一次别在静态文件映射时再解一次否则%252e这类输入会把路径校验绕过去。重复头常见于Cookie和X-Forwarded-For简单骨架可以后者覆盖前者但Content-Length出现两个不同值必须直接 400。超大 body 不要先resize再读按已收字节和声明长度做上限判断超过就回 413 并标记连接关闭。解析器要能返回“还需要多少字节”事件循环才知道下一次读多少不然容易在短读上打转。4. 静态文件与连接管理sendfile、keep-alive 和路径校验4.1 sendfile 与 mmap 的选型小文件别过度设计静态文件服务是 http-server 最容易被低估的部分。读文件再 write数据要从内核到用户态再到内核大文件时白搬一次。Linux 下常见做法是sendfile直接把文件页写到 socketmmap适合要频繁改内容或做内容处理的场景纯静态发送反而多一层映射管理。方式数据拷贝适用文件注意点read write两次以上小文件、需加工简单但大文件 CPU 高sendfile少一次用户态拷贝大文件、纯静态注意偏移量和返回值mmap write一次映射频繁访问、可共享要处理 SIGBUS 和映射释放bool sendFile(int sock, int fileFd, off_t offset, size_t size) { size_t sent 0; while (sent size) { ssize_t n ::sendfile(sock, fileFd, offset, size - sent); if (n 0) { sent static_castsize_t(n); continue; } if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { // 非阻塞 socket 写满交回事件循环等 EPOLLOUT return false; } return false; } return true; }sendfile的offset是传入传出参数循环发送时要持续更新返回值小于请求长度不一定是错误可能只是内核缓冲区满了。非阻塞 socket 下必须处理EAGAIN否则大文件会把工作线程卡住。小文件比如几 KB 的 HTMLread write 反而少一次系统调用分支不用为了“看起来高级”全上 sendfile。静态文件还要根据扩展名给Content-Type未知类型统一application/octet-stream别让浏览器猜。4.2 keep-alive 超时回收最小堆定时器比扫链表稳HTTP/1.1 默认长连接连接不关就要有超时回收否则空闲连接把 fd 耗光。简单做法是每个连接记lastActive定时线程扫一遍所有连接连接一多就是 O(n) 空转。更稳的是最小堆每次活跃更新过期时间堆顶最早过期事件循环每轮检查堆顶过期就关。struct TimerEntry { std::chrono::steady_clock::time_point expire; int fd; bool operator(const TimerEntry other) const { return expire other.expire; } }; void closeExpired(std::priority_queueTimerEntry, std::vectorTimerEntry, std::greaterTimerEntry timers, std::unordered_mapint, std::shared_ptrConnection conns) { auto now std::chrono::steady_clock::now(); while (!timers.empty() timers.top().expire now) { int fd timers.top().fd; timers.pop(); auto it conns.find(fd); if (it ! conns.end() it-second-lastActive std::chrono::seconds(30) now) { it-second-close(); conns.erase(it); } } }堆里会有旧条目弹出时用连接当前的lastActive再判一次避免把刚活跃的连接误关。超时时间常见 15 到 60 秒按业务定压测 keep-alive 时把超时调大能减少握手但空闲 fd 也涨。Connection: close的请求处理完立即关不要等定时器。关闭前尽量把响应发完发不完就放弃别为了优雅把线程堵死。4.3 静态目录映射三个校验挡住路径穿越静态文件路由最危险的是../。请求/static/../../etc/passwd这类路径如果直接拼接根目录再 open文件就出去了。校验分三层先做 URL 解码后的路径规范化去掉.和..再用根目录前缀做字符串校验最后用realpath确认最终路径仍在根目录下。std::optionalstd::string resolveStaticPath(const std::string root, const std::string urlPath) { std::string decoded urlDecode(urlPath); if (decoded.find(\0) ! std::string::npos) return std::nullopt; std::vectorstd::string parts; std::stringstream ss(decoded); std::string seg; while (std::getline(ss, seg, /)) { if (seg.empty() || seg .) continue; if (seg ..) return std::nullopt; parts.push_back(seg); } std::string full root; for (const auto p : parts) full / p; char resolved[PATH_MAX]; if (::realpath(full.c_str(), resolved) nullptr) return std::nullopt; std::string realRoot root; if (std::string(resolved).rfind(realRoot, 0) ! 0) return std::nullopt; return std::string(resolved); }urlDecode要把%2e也解成.否则..的变体会漏过去。realpath要求文件存在404 和越权可以统一回 404别回 403 告诉对方“路径存在但你不能看”。根目录用启动参数传入不要硬编码。符号链接要单独决策默认不跟随跟随就必须再校验一次真实路径。4.4 连接关闭的 RAII别让异常路径漏 fd连接对象里放 fd析构时 close这是最基本的后悔药。但 accept 之后、连接对象构造之前如果抛异常fd 就漏了。常见做法是用一个小 guard 包住裸 fd交接给连接对象后再 release。class FdGuard { public: explicit FdGuard(int fd) : fd_(fd) {} ~FdGuard() { if (fd_ 0) ::close(fd_); } FdGuard(const FdGuard) delete; FdGuard operator(const FdGuard) delete; int release() { int fd fd_; fd_ -1; return fd; } private: int fd_; }; void onAccept(int listenFd) { int raw ::accept4(listenFd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); if (raw 0) return; FdGuard guard(raw); auto conn std::make_sharedConnection(guard.release()); // 注册 epoll、插入连接表 }EPOLL_CTL_ADD失败、连接表插入失败、线程池 enqueue 失败都要让 guard 或连接析构接管 close。最怕的是“这里不会失败”的假设压测时 fd 一紧失败路径全暴露。日志里记 fd 编号和关闭原因CLOSE_WAIT 堆积时能快速定位是哪条路径漏了。5. 避坑与排查五个把 http-server 拖死的现场5.1 端口占用SO_REUSEADDR 不是万能后悔药现象是启动时bind返回EADDRINUSE重启服务必现。原因分两种上一次进程没退干净或者端口处于 TIME_WAIT。SO_REUSEADDR能解决后者但解决不了“另一个进程还在监听”。我一般先看ss -lntp | grep 端口确认是谁占着再决定杀进程还是换端口。别顺手加SO_REUSEPORT多进程监听同一端口时请求会分散调试时你以为只改了一个进程实际另一个还在接流量。SO_REUSEADDR要在 bind 之前设置accept 之后设置没用。5.2 CLOSE_WAIT 堆积不是内核问题是代码漏 close现象是ss -tan state close-wait越来越多服务 fd 耗尽。原因通常是某条错误路径没有 close解析失败直接 return、sendfile 失败只打日志、线程池任务异常退出。内核收到 FIN 后进入 CLOSE_WAIT等应用 close应用不 close 就永远在那。解决不是调内核参数是把连接生命周期收口到 RAII所有异常路径都走析构。压测时重点看 4xx/5xx 分支正常 200 反而少漏。日志里加 fd 和关闭原因堆上一段时间就能看出规律。5.3 ET 模式只读一次客户端等到超时现象是浏览器或 curl 一直挂起服务端 CPU 不高抓包看请求已经到网卡。原因多半是EPOLLET下 read 只调用一次没读到EAGAIN内核不会再次通知。边缘触发要求“读到不能再读”循环 read 直到返回EAGAIN或 0。void onReadable(int fd, std::string buffer) { char buf[4096]; while (true) { ssize_t n ::read(fd, buf, sizeof(buf)); if (n 0) { buffer.append(buf, static_castsize_t(n)); continue; } if (n 0) { // 对端关闭 closeConnection(fd); return; } if (errno EAGAIN || errno EWOULDBLOCK) break; closeConnection(fd); return; } // 交给解析器判断是否请求完整 }EAGAIN是正常出口不是错误。读到的数据先追加缓冲区再尝试解析解析出完整请求后如果缓冲区还有剩余要保留给下一个请求别清空。ET 下写也一样EPOLLOUT触发后要写到EAGAIN否则大响应会只发一半。5.4 Content-Length 按字符算中文响应把连接搞乱现象是第一个请求正常第二个请求返回 404或者浏览器一直转圈。原因可能是响应 body 是中文代码用strlen或字符数算长度和实际字节数不一致。HTTP 的Content-Length是字节数UTF-8 下一个汉字三字节差一个字节后面的报文就全错位。解决是统一用std::string::size()不要用strlen处理可能含\0的 body。错误页、JSON、静态文件都走同一个响应构造函数别手拼。压测短连接不一定暴露keep-alive 下必现。5.5 同步日志QPS 上不去CPU 还不高现象是压测 QPS 卡在一个数CPU 使用率不高线程栈看大多在 write 日志。原因是每个请求同步写磁盘磁盘延迟直接变成请求延迟。解决是异步日志业务线程只把日志行塞进无锁或有锁队列单独线程批量落盘队列满时采样丢弃别阻塞请求。日志级别可运行时调压测时把 debug 关掉。黑匣子要留但别让黑匣子拖慢主链路。6. 压测与调优用 wrk 和日志把 QPS 拆成可解释的指标调 http-server 最怕凭感觉改参数。我一般先跑两组对照短连接和 keep-alive各 30 秒再看日志里的解析耗时、线程池排队和错误码分布。短连接能暴露 accept 和 close 路径的问题长连接能暴露 Content-Length 和超时回收的问题两组都跑完再动代码。# 短连接每次请求新建连接重点看 accept 和 fd 回收 wrk -t4 -c200 -d30s --latency http://127.0.0.1:8080/ # keep-alive复用连接重点看解析器和 Content-Length wrk -t4 -c200 -d30s --latency -H Connection: keep-alive http://127.0.0.1:8080/-t4是压测线程-c200是并发连接-d30s是持续时间。先看Requests/sec再看Latency的 p99。如果短连接 QPS 低但长连接正常优先查 accept 循环和 close 路径如果长连接反而低查 keep-alive 超时和响应长度。日志里至少留请求方法、路径、状态码、解析字节数、处理耗时别只打一行“收到请求”。我吃过一次亏压测 QPS 上不去一直调线程池大小最后发现是日志里每次都在格式化时间戳CPU 全耗在localtime上。从那以后我每次改完事件循环都先跑一轮短连接加长连接对照再看 p99 和错误码不再凭感觉调参。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑