简介本资源面向嵌入式开发与C/C后端学习者提供一份基于mongoose库实现多线程HTTP服务器的完整工程源码帮助理解轻量级Web服务器在高并发场景下的线程池设计、线程同步与请求分发机制。压缩包共11个文件约221KB以cpp与h源码为主包含mongoose核心库、服务器主程序及VS2008工程文件另附可执行文件与说明文档便于直接编译运行与对照调试。项目采用线程池管理连接、互斥锁保障共享数据一致性并演示了请求队列分发与线程内错误处理思路对理解mongoose 6.7前后多线程实现差异、迁移旧版代码具有参考价值。目前已有3851人学习下载适合希望掌握嵌入式HTTP服务并发处理、线程模型与工程组织方式的开发者研读。1. 多线程 HTTP Server 为什么总在压测时翻车单线程的 HTTP 服务写起来很爽accept一个连接、读请求、写响应、关掉逻辑线性到闭着眼都能跑通。可一旦把它丢进wrk或者ab里跑并发QPS 曲线往往在某个点突然塌掉CPU 却只跑满一个核剩下的核在围观。这不是代码写错了而是模型选错了——单线程事件循环再快也扛不住多核机器上「一个核干活、七个核看戏」的浪费。基于 mongoose 实现多线程 HTTP Server要解决的就是这件事让每个 CPU 核都能独立处理连接把吞吐从单核上限里解放出来。mongoose 本身是个极轻量的网络库单文件、无外部依赖、自带 HTTP 协议解析非常适合嵌进 C/C 项目里做内嵌服务。但它默认是单线程事件循环模型多线程需要自己搭骨架。这篇文章面向的是已经会用 mongoose 起一个单线程 HTTP 服务、现在想把它推到多核并发的开发者。我会把线程模型选型、监听套接字的共享方式、连接分发策略、参数调优和压测排错一条线讲清楚代码可以直接抄坑也提前标出来。2. 线程模型选型从单事件循环到多 Reactor2.1 为什么不能简单地在多个线程里各起一个 mongoose最常见的错误做法是开 N 个线程每个线程里mg_http_listen一次绑同一个端口。结果要么第二个线程直接报端口占用要么靠SO_REUSEPORT勉强起来但连接分布完全靠内核哈希你没法控制也没法做统一的连接计数和优雅退出。mongoose 的事件循环mg_mgr_poll不是线程安全的多个线程共享同一个mg_mgr会直接踩内存。所以多线程方案的核心约束是每个线程一个独立的mg_mgr线程之间不共享 mongoose 对象。这条约束定下来剩下的就是怎么把新连接分给各个线程。2.2 三种可落地的分发模型对比模型监听方式连接分发优点缺点主线程 accept 派发主线程独占监听主线程 accept 后把 fd 传给工作线程连接数可控、易做限流主线程可能成瓶颈需要线程间传 fdSO_REUSEPORT 多监听每线程各自 listen内核四元组哈希实现最简单、无锁分布不均、无法统一控制主线程 accept 管道唤醒主线程独占监听主线程 accept 后写入各线程的唤醒管道兼顾可控与简单需要处理管道读写我一般会选第三种。原因很实际SO_REUSEPORT看着优雅但在连接数少、长连接多的场景下内核哈希会把连接堆到某几个线程上压测时你能明显看到某个核 100%、其他核闲着这种玄学分布排查起来很痛苦。而主线程 accept 管道唤醒分发逻辑完全在自己手里想改成轮询、最少连接数、按来源 IP 哈希都行。2.3 主线程 accept 管道唤醒的最小骨架核心思路主线程只负责accept拿到 fd 后通过 Unix 管道把 fd 的整数值写给某个工作线程工作线程在自己的mg_mgr_poll之前先读管道收到 fd 后用mg_wrapfd把它包成 mongoose 连接。// 主线程accept 后轮询分发 fd 到工作线程的管道 static void distribute_fd(int client_fd, int *worker_pipes, int worker_count) { static int next 0; int target next % worker_count; // 简单轮询可换成最少连接 // 写入 fd 的整数值工作线程读到后自行 wrap if (write(worker_pipes[target], client_fd, sizeof(client_fd)) ! sizeof(client_fd)) { close(client_fd); // 管道满或出错直接丢弃避免 fd 泄漏 } }这里有个关键点write管道可能因为工作线程来不及读而阻塞或返回EAGAIN。生产代码里要把管道设成非阻塞写失败就关掉客户端 fd宁可丢连接也不能让主线程卡死。参数上worker_count一般设成 CPU 核数IO 密集可以设成核数的 1.5 到 2 倍但超过 2 倍后上下文切换的收益就转负了。2.4 工作线程侧的 fd 接管工作线程在每次mg_mgr_poll前先非阻塞地读管道把攒下来的 fd 逐个包成连接。// 工作线程poll 前先收 fd static void drain_pipe(struct mg_mgr *mgr, int pipe_fd) { int client_fd; while (read(pipe_fd, client_fd, sizeof(client_fd)) sizeof(client_fd)) { struct mg_connection *c mg_wrapfd(mgr, client_fd, handler, NULL); if (c NULL) { close(client_fd); // wrap 失败必须自己关mongoose 不接管 } } }mg_wrapfd的第三个参数是事件处理函数第四个是用户数据。注意mg_wrapfd失败时不会关闭 fd必须自己close这是血泪经验——漏一次就是 fd 泄漏跑几小时服务就EMFILE了。另外mg_wrapfd包出来的连接默认是可读的HTTP 请求到达时会触发MG_EV_READ你在 handler 里调mg_http_parse解析即可。3. 把 mongoose 多线程 HTTP Server 跑起来3.1 环境准备与 mongoose 集成mongoose 是单文件库把mongoose.c和mongoose.h放进工程就能用不需要 CMake 的find_package也不需要链接额外库。Linux 下唯一要加的是-lpthread。# 编译一个最小多线程 HTTP 服务 gcc -O2 -pthread server.c mongoose.c -o server-O2不是可选项。mongoose 的解析路径里有不少小函数-O0下 QPS 能差出两三倍。-pthread必须加否则pthread_create链接不过。如果你用的是 C把mongoose.c用extern C包一下再编译避免符号名修饰问题。3.2 完整可运行的多线程 HTTP Server下面这份代码是能直接编译运行的骨架主线程 acceptN 个工作线程各自跑事件循环每个请求返回一个简单响应。#include mongoose.h #include pthread.h #include unistd.h #include string.h #include stdlib.h #define WORKER_COUNT 4 #define LISTEN_URL http://0.0.0.0:8000 struct worker { struct mg_mgr mgr; int pipe_fd; // 读端 pthread_t tid; }; // 每个连接的 HTTP 处理逻辑 static void handler(struct mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *) ev_data; if (mg_strcmp(hm-uri, mg_str(/health)) 0) { mg_http_reply(c, 200, Content-Type: text/plain\r\n, ok\n); } else { mg_http_reply(c, 200, Content-Type: text/plain\r\n, hello from worker %lu\n, (unsigned long) pthread_self()); } } } // 工作线程收 fd 跑事件循环 static void *worker_main(void *arg) { struct worker *w (struct worker *) arg; mg_mgr_init(w-mgr); for (;;) { int client_fd; // 非阻塞读管道把主线程分来的 fd 全部接管 while (read(w-pipe_fd, client_fd, sizeof(client_fd)) sizeof(client_fd)) { struct mg_connection *c mg_wrapfd(w-mgr, client_fd, handler, NULL); if (c NULL) close(client_fd); } // 100ms 超时保证管道有新 fd 时能及时醒来 mg_mgr_poll(w-mgr, 100); } return NULL; } int main(void) { struct worker workers[WORKER_COUNT]; int write_pipes[WORKER_COUNT]; // 1. 创建 worker 和管道 for (int i 0; i WORKER_COUNT; i) { int fds[2]; if (pipe(fds) ! 0) return 1; workers[i].pipe_fd fds[0]; write_pipes[i] fds[1]; // 读端设非阻塞避免 worker 卡在 read 上 fcntl(fds[0], F_SETFL, O_NONBLOCK); pthread_create(workers[i].tid, NULL, worker_main, workers[i]); } // 2. 主线程只做 accept struct mg_mgr mgr; mg_mgr_init(mgr); struct mg_connection *listener mg_http_listen(mgr, LISTEN_URL, NULL, NULL); if (listener NULL) return 1; int next 0; for (;;) { mg_mgr_poll(mgr, 100); // 遍历 listener 上挂载的已 accept 连接 for (struct mg_connection *c mgr.conns; c ! NULL; c c-next) { if (c-is_accepted !c-is_closing) { int fd c-fd; c-fd -1; // 从 mongoose 手里摘出来 c-is_closing 1; // 让 mongoose 回收这个壳 int target next % WORKER_COUNT; if (write(write_pipes[target], fd, sizeof(fd)) ! sizeof(fd)) { close(fd); } } } } return 0; }逻辑说明主线程的mg_mgr_poll只用来触发 acceptc-is_accepted标记的连接就是内核已经完成三次握手、等待应用层接管的连接。把c-fd取出来、置-1、标记is_closingmongoose 就会回收这个连接壳而不关闭底层 fdfd 的所有权转移到工作线程。工作线程用mg_wrapfd重新包装之后所有读写都在本线程的事件循环里完成。参数说明mg_mgr_poll的超时设 100ms 是折中值。设 0 会忙等烧 CPU设太大则管道来了新 fd 也要等下一轮才处理增加延迟。WORKER_COUNT从 4 起步压测时按核数调整。mg_http_listen的 handler 传NULL是故意的主线程不处理 HTTP只 accept。3.3 编译运行与第一次压测# 启动服务 ./server # 另开终端压测4 线程 100 连接跑 10 秒 wrk -t4 -c100 -d10s http://127.0.0.1:8000/health第一次跑先看两件事一是top -H里是不是多个线程都在吃 CPU如果只有一个线程忙说明 fd 分发没生效检查管道写入和工作线程的read循环二是 QPS 是否随WORKER_COUNT增长如果加到 8 线程反而下降多半是锁竞争或者mg_mgr_poll超时设得太小导致空转。4. 参数调优与性能边界4.1 线程数与连接数的匹配关系线程数不是越多越好。每个工作线程的mg_mgr_poll内部是epoll_wait线程数超过核数后epoll_wait的唤醒和调度开销会吃掉收益。经验值短连接、请求处理快的场景线程数等于核数长连接、每连接有状态比如 WebSocket的场景线程数可以等于核数但每线程的连接数要控制因为 mongoose 的连接结构体是常驻内存的。连接数上限受ulimit -n限制。默认 1024压测 100 连接没事上到 1000 就会accept失败。调优时先ulimit -n 65535再考虑内核参数。# 临时提高 fd 上限 ulimit -n 65535 # 查看当前服务的 fd 使用 ls /proc/$(pgrep server)/fd | wc -l4.2 请求处理里不能做的三件事工作线程的 handler 是在事件循环里同步执行的任何阻塞操作都会卡住整个线程上的所有连接。三件事绝对不能做一是同步文件 IO读大文件请用异步或丢给线程池二是sleep或者等待锁三是调用可能阻塞的外部服务。mongoose 的 handler 设计就是让你快速返回重活要么异步化要么拆到独立线程。如果确实需要做耗时计算常见做法是在 handler 里把任务丢进一个任务队列由专门的线程池处理处理完再通过mg_wakeup唤醒对应连接写响应。这个模式 mongoose 官方示例里有但多线程下要注意任务队列本身要加锁。4.3 优雅退出的正确姿势多线程服务的退出比单线程麻烦。主线程收到信号后要通知所有工作线程停止mg_mgr_poll、关闭监听、回收连接。直接exit会丢连接客户端看到的是 RST 而不是正常的连接关闭。// 信号处理里只置标志位不要做复杂操作 static volatile sig_atomic_t g_stop 0; static void on_signal(int sig) { (void) sig; g_stop 1; } // 主循环里检查标志逐个通知 worker if (g_stop) { for (int i 0; i WORKER_COUNT; i) { int zero -1; write(write_pipes[i], zero, sizeof(zero)); // 约定 -1 表示退出 } // 等待 worker join再 mg_mgr_free }工作线程读到-1就跳出循环mg_mgr_free回收本线程所有连接。注意mg_mgr_free会关闭所有还挂着的连接所以退出前给客户端发一个Connection: close的响应会更友好但这取决于你的业务是否允许。5. 多线程 HTTP Server 的避坑与排查清单5.1 连接建立后立刻断开日志里全是 RST现象压测时大量连接刚建立就被重置wrk报Socket errors: connect。原因通常是主线程把 fd 写进管道后mongoose 的连接壳被is_closing回收时顺手关了 fd。检查c-fd -1这行有没有漏漏了的话 mongoose 在回收连接时会close(c-fd)而 fd 已经被工作线程接管等于关了一个正在用的 fd。解决就是确保摘 fd 和置is_closing成对出现。5.2 工作线程收不到 fd管道读一直是空现象主线程 accept 正常但工作线程的read一直返回 0 或EAGAIN连接全堆在主线程。原因多半是管道读端没设非阻塞工作线程卡在read上而mg_mgr_poll根本没机会执行。另一个可能是write端写的是 fd 的地址而不是值工作线程读出来是个野指针。解决读端fcntl设O_NONBLOCK写端确认write(pipe, fd, sizeof(fd))传的是值。5.3 QPS 不随线程数增长CPU 却跑满现象加到 8 线程QPS 和 4 线程一样top看 CPU 全满。原因通常是mg_mgr_poll超时设成了 0每个线程都在忙等空转烧 CPU。或者 handler 里有全局锁所有线程串行化。排查方法把超时改成 100ms 看 QPS 是否变化用perf top看热点是不是在锁或者epoll_wait上。解决超时给合理值handler 里避免全局锁必须共享的状态用无锁结构或分片。5.4 运行几小时后 fd 耗尽报 EMFILE现象服务跑一段时间后accept失败ls /proc/pid/fd数量只增不减。原因是mg_wrapfd失败时没关 fd或者连接关闭时 fd 没被正确回收。mongoose 在连接is_closing后会关 fd但如果你手动改过c-fd回收逻辑就乱了。排查在mg_wrapfd失败分支和连接关闭事件里打日志确认每个 fd 都有对应的close。解决mg_wrapfd返回 NULL 必须自己close不要依赖 mongoose。5.5 压测到高并发时延迟毛刺明显现象平均延迟很低但 P99 延迟突然飙到几百毫秒。原因通常是某个工作线程上的连接数过多单个连接的慢请求拖累了同线程其他连接。mongoose 是单线程事件循环一个连接上的同步处理会阻塞同线程所有连接。排查统计每个线程的连接数分布看是否均匀。解决分发策略从轮询改成最少连接数或者把慢请求异步化避免在 handler 里做重活。6. 用连接数感知分发把 P99 压下来轮询分发在连接生命周期差异大的场景下会失衡有的线程分到的都是长连接有的线程连接早早就关了新连接还按轮询往忙的线程上塞。一个更稳的做法是让主线程感知每个工作线程的当前连接数分发时选连接数最少的那个。实现上每个 worker 维护一个原子计数器mg_wrapfd成功后加一连接关闭事件里减一。主线程分发前读一遍所有计数器选最小值对应的管道写入。计数器用_Atomic int或者__sync_fetch_and_add都行读的时候允许有轻微误差不影响大局。// worker 侧连接建立和关闭时维护计数 static _Atomic int conn_count[WORKER_COUNT]; static void handler(struct mg_connection *c, int ev, void *ev_data) { int idx (int) (intptr_t) c-fn_data; // 绑定的 worker 下标 if (ev MG_EV_HTTP_MSG) { // ... 正常处理 } else if (ev MG_EV_CLOSE) { atomic_fetch_sub(conn_count[idx], 1); } } // 主线程选连接数最少的 worker static int pick_worker(void) { int best 0; int min atomic_load(conn_count[0]); for (int i 1; i WORKER_COUNT; i) { int cur atomic_load(conn_count[i]); if (cur min) { min cur; best i; } } return best; }这里c-fn_data用来存 worker 下标mg_wrapfd的第四个参数传(void *) (intptr_t) i即可。注意MG_EV_CLOSE一定会触发不管连接是正常关闭还是出错所以计数不会漏减。这个改动看起来小但在长连接混合短连接的压测里P99 能从几百毫秒降到几十毫秒。验证方法很简单压测时用ss -tn state established ( sport :8000 )按本地端口统计连接分布看各线程连接数是否接近。如果还是偏说明分发时机和连接关闭之间有窗口可以把计数改成带权重的比如长连接权重高一点。我自己的习惯是任何多线程网络服务上线前先用wrk跑一轮固定连接数、再跑一轮随机连接生命周期两轮都看 P99 和连接分布两个指标都稳了才敢放量。多线程的坑大多不在代码写没写对而在连接生命周期和线程调度的交互上压测是唯一的后悔药。希望帮到你。本文还有配套的精品资源点击获取