简介本资源面向嵌入式开发与C语言网络编程学习者提供一份基于mongoose库实现多线程HTTP服务器的完整工程源码帮助理解高并发场景下线程池设计、线程同步、请求分发与错误处理等核心机制。压缩包共11个文件约221KB以cpp与h源码为主包含mongoose核心库文件、服务器主程序及VS2008工程配置另有可执行文件与说明文档便于直接编译运行与对照调试。目前已有3851人学习下载适合需要掌握mongoose 6.7版本多线程实现方式、并关注旧版代码升级迁移的开发者参考。通过阅读源码读者可快速搭建支持并发请求的轻量级Web服务理解线程池与互斥锁在实际项目中的落地方式为物联网设备或嵌入式系统的服务端开发提供可复用的工程模板与排错思路。1. 单文件也能扛住并发mongoose 多线程 HTTP Server 到底适合谁如果你手头有一个 C/C 项目需要塞进去一个 HTTP 服务端但又不想拖进 nginx、不想链接 OpenSSL、不想处理一堆 CMake 依赖那 mongoose 这个库大概率会被你翻出来。它最反直觉的地方在于整个 HTTP Server 的核心就是一个mongoose.c加一个mongoose.h把这两个文件丢进工程就能编译跨平台、零外部依赖还自带事件循环。但很多人第一次用它写服务端跑起来发现请求一多就排队CPU 多核完全吃不满——因为默认的mg_mgr_poll是单线程事件循环所有连接都在一个线程里处理。这篇要拆的就是怎么在 mongoose 之上做一个真正的多线程 HTTP Server。核心思路不复杂主线程只负责 accept把新连接分发给工作线程每个工作线程跑自己独立的mg_mgr事件循环。适合谁适合那些需要在嵌入式网关、边缘设备、内部工具后端里快速起一个 HTTP 接口又对二进制体积和依赖数量敏感的开发者。如果你已经在用 mongoose 但只跑过单线程 demo这篇的线程模型和踩坑记录能帮你少走一截弯路。2. 线程模型选型为什么不是简单加锁而是 per-thread event loop2.1 mongoose 的事件循环本质mongoose 的网络模型是围绕struct mg_mgr展开的。一个mg_mgr就是一个事件管理器内部维护着连接链表、定时器、以及底层 poll/epoll/kqueue 的封装。你调用mg_mgr_poll(mgr, timeout_ms)它就会阻塞等待网络事件然后回调你注册的fn处理函数。关键点在于一个mg_mgr实例不是线程安全的多个线程同时 poll 同一个 mgr 会导致连接链表被并发修改轻则丢事件重则直接崩溃。所以多线程方案的第一条铁律就是每个线程持有自己的mg_mgr互不共享。主线程acceptor有它自己的 mgr只用来监听和 accept每个工作线程也有自己的 mgr管理分配给它的那些连接。连接一旦从主线程的 mgr 上被 accept 出来就通过mg_mgr_*的迁移机制交给工作线程的 mgr。常见做法是固定线程数比如N sysconf(_SC_NPROCESSORS_ONLN)每个线程一个 mgr主线程 accept 后按轮询或最少连接数分发。不要动态创建线程mongoose 的连接迁移需要目标 mgr 已经在 poll 循环里跑着动态创建会引入同步窗口。2.2 连接迁移的两种实现路径mongoose 本身没有提供「把连接从 A mgr 移到 B mgr」的公开 API但它的内部结构允许你通过mg_wrapfd或者直接操作struct mg_connection的mgr字段来实现。更稳妥的做法是走文件描述符传递主线程 accept 到 fd 后不直接让 mongoose 接管而是把 fd 通过线程间队列发给工作线程工作线程用mg_wrapfd把这个 fd 包装成自己 mgr 上的连接。// 主线程accept 后把 fd 投递到工作线程队列 static void acceptor_fn(struct mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_ACCEPT) { int fd c-fd; // 从 mongoose 手里拿回 fd 的所有权 c-fd -1; c-is_closing 1; // 轮询选一个工作线程投递 fd int idx atomic_fetch_add(g_rr, 1) % g_worker_count; queue_push(g_workers[idx].fd_queue, fd); } }这段代码的逻辑是在MG_EV_ACCEPT事件里连接刚被 accept还没有任何数据读写。此时把c-fd置为 -1 并标记is_closingmongoose 会在本轮 poll 结束后关闭这个连接对象但不会关闭底层 fd因为 fd 已经被置 -1。然后把这个 fd 塞进对应工作线程的队列。工作线程在自己的 poll 循环里检查队列取出 fd 后用mg_wrapfd接管。参数说明g_rr是原子轮询计数器g_worker_count是工作线程数。队列需要是线程安全的可以用简单的互斥锁加链表也可以用无锁环形缓冲。注意c-fd -1之后不要再对这个连接做任何写操作它已经不属于你了。2.3 工作线程的 poll 循环与 fd 接管工作线程的主循环长这样// 工作线程poll 自己的 mgr同时检查 fd 队列 static void *worker_thread(void *arg) { struct worker *w (struct worker *)arg; struct mg_mgr mgr; mg_mgr_init(mgr); while (w-running) { // 先处理队列里新来的 fd int fd; while ((fd queue_try_pop(w-fd_queue)) 0) { struct mg_connection *c mg_wrapfd(mgr, fd, handler_fn, w); if (c NULL) { close(fd); // 包装失败直接关掉 } } // 再 poll 已有连接超时设小一点保证队列及时被检查 mg_mgr_poll(mgr, 10); } mg_mgr_free(mgr); return NULL; }这里有个参数需要权衡mg_mgr_poll的超时时间。设太大比如 1000ms新连接投递进来后要等最多 1 秒才被接管延迟不可接受设太小比如 1ms空转消耗 CPU。10ms 是一个常见折中实际项目里可以根据连接密度调整。如果队列用条件变量通知可以把超时设大但 mongoose 的 poll 不支持被外部事件唤醒所以还是得靠短超时轮询。mg_wrapfd的第三个参数是事件处理函数第四个是用户数据。包装出来的连接和正常 accept 的连接在使用上没有区别MG_EV_READ、MG_EV_WRITE、MG_EV_CLOSE都会正常触发。唯一要注意的是mg_wrapfd不负责设置非阻塞如果 fd 是阻塞模式需要自己调fcntl设成O_NONBLOCK。3. 从零搭一个可编译的多线程 Server文件结构与关键配置3.1 工程文件清单与编译参数这个方案不需要额外的构建系统一个 Makefile 就够。文件结构如下文件作用mongoose.c/mongoose.h从官方仓库获取的单文件库server.c主线程、工作线程、队列、业务 handlerMakefile编译脚本编译时需要注意几个宏CC gcc CFLAGS -O2 -Wall -pthread -DMG_ENABLE_THREADS1 LDFLAGS -pthread server: server.c mongoose.c $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS)MG_ENABLE_THREADS1会启用 mongoose 内部的线程安全机制主要是对mg_mgr的一些全局状态加锁。但注意这个宏并不会让单个 mgr 变成线程安全它只是让不同 mgr 在不同线程里跑的时候不会互相干扰。-pthread必须加在编译和链接两处否则原子操作和线程创建会出问题。3.2 业务 handler 的注册与路由工作线程接管连接后所有 HTTP 请求都会走到你注册的 handler。mongoose 的路由是基于事件回调的没有内置的 path 匹配需要自己写// 业务 handler处理 HTTP 请求 static void handler_fn(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_match(hm-uri, mg_str(/api/status), NULL)) { mg_http_reply(c, 200, Content-Type: application/json\r\n, {\thread\:%d,\ok\:true}\n, ((struct worker *)c-fn_data)-id); } else if (mg_match(hm-uri, mg_str(/api/echo), NULL)) { mg_http_reply(c, 200, Content-Type: text/plain\r\n, %.*s\n, (int)hm-body.len, hm-body.buf); } else { mg_http_reply(c, 404, , not found\n); } } }mg_match是 mongoose 内置的简单模式匹配支持*通配。mg_http_reply会自动组装响应头和 body并设置Content-Length。注意c-fn_data在mg_wrapfd时被设成了 worker 指针所以 handler 里能拿到当前线程的 id方便调试和日志。参数说明hm-uri是请求路径hm-body是请求体。mg_http_reply的第二个参数是状态码第三个是额外的响应头每行以\r\n结尾第四个是格式化 body。如果 body 里有%字符记得用%.*s而不是%s避免格式化漏洞。3.3 优雅退出与资源回收多线程 server 最容易被忽略的是退出流程。主线程收到信号后需要通知所有工作线程停止然后依次 join。工作线程退出前要mg_mgr_free这会关闭所有连接并释放内存。但如果有连接正在处理请求直接 free 会导致对端收到 RST。常见做法是设一个running标志工作线程每轮 poll 检查一次。主线程收到 SIGINT 后把running置 0然后 join 所有线程。如果想让正在处理的请求完成可以在mg_mgr_free之前先遍历连接对每个连接调mg_http_reply发一个 503再等一小段时间。mongoose 没有提供「等待所有连接关闭」的 API所以这一步需要自己控制超时。// 主线程退出流程 static void on_signal(int sig) { g_running 0; for (int i 0; i g_worker_count; i) { g_workers[i].running 0; } } // 在 main 里 join 之后 for (int i 0; i g_worker_count; i) { pthread_join(g_workers[i].tid, NULL); }注意信号处理函数里只能做异步信号安全的操作置标志位是安全的但不要在里面调pthread_join或mg_mgr_free。4. 避坑与排查多线程 mongoose 最容易翻车的五个点4.1 现象请求偶发返回 404 或空响应日志里连接被莫名关闭原因主线程在MG_EV_ACCEPT里把c-fd置 -1 后没有正确设置is_closing或者工作线程的mg_wrapfd失败后没有关闭 fd导致 fd 泄漏。更隐蔽的情况是主线程的 mgr 和工作线程的 mgr 同时 poll 到了同一个 fd因为 fd 在迁移过程中被两边都持有产生竞争。解决确保c-fd -1和c-is_closing 1在同一轮事件里完成中间不要有任何可能让出 CPU 的操作。工作线程mg_wrapfd返回 NULL 时必须close(fd)。可以在迁移前后加日志打印 fd 编号和线程 id确认没有重复。4.2 现象CPU 占用 100%但 QPS 很低原因mg_mgr_poll的超时设得太小比如 0 或 1ms工作线程在空转。或者 fd 队列的检查逻辑放在了 poll 之后导致每轮 poll 都要等满超时才检查队列新连接延迟高线程却在忙等。解决超时设 10ms 左右并且把队列检查放在 poll 之前。如果连接数很少可以用mg_mgr_poll返回后判断是否有事件没有事件就稍微 sleep 一下。但 sleep 会引入延迟更好的做法是用eventfd或 pipe 唤醒 poll不过 mongoose 不直接支持需要改源码或接受 10ms 的延迟。4.3 现象压测时内存持续增长最终 OOM原因mg_wrapfd创建的连接在MG_EV_CLOSE时没有正确释放用户数据或者 fd 队列里的 fd 在 worker 退出时没有被清理。另一个常见原因是mg_http_reply的 body 太大mongoose 内部缓冲没有及时释放。解决在 handler 的MG_EV_CLOSE事件里释放自己 malloc 的内存。worker 退出时遍历 fd 队列把还没处理的 fd 全部 close。用 valgrind 或 ASan 跑一遍压测重点看mg_mgr_free之后还有没有 reachable 的内存块。4.4 现象跨线程调用mg_http_reply导致崩溃原因业务逻辑里可能从其他线程比如定时器线程、消息队列消费者直接拿到struct mg_connection *然后调mg_http_reply。这个连接属于某个工作线程的 mgr从其他线程写它的发送缓冲会破坏 mgr 的内部状态。解决所有对连接的写操作必须在拥有该连接的线程里做。如果其他线程需要推送数据把数据放到该线程的队列里由该线程在 poll 循环中取出并发送。mongoose 没有提供线程安全的跨线程发送 API这是设计上的取舍。4.5 现象编译报错undefined reference to pthread_create原因-pthread只加在了 CFLAGS 没加在 LDFLAGS或者用了-lpthread但顺序不对。在某些旧版 glibc 上-lpthread必须放在源文件之后。解决统一用-pthread编译和链接都加。如果用的是 CMake用find_package(Threads REQUIRED)然后target_link_libraries(server PRIVATE Threads::Threads)。不要手动写-lpthread除非你清楚链接顺序。5. 进阶技巧用连接数动态调线程、用 curl 验证线程亲和性5.1 动态线程数调整固定线程数在连接数波动大的场景下不够灵活。一个实用的技巧是主线程维护一个「待处理连接数」的原子计数当某个工作线程的队列长度超过阈值且总线程数小于上限时动态创建一个新工作线程当线程空闲超过一定时间且总数大于下限时通知该线程退出。实现上动态创建线程需要把新线程的 mgr 注册到一个全局数组里并且 acceptor 的分发逻辑要能感知到新线程。这里有一个坑新线程创建后它的 fd 队列是空的acceptor 需要等它初始化完成才能投递 fd。常见做法是用一个pthread_barrier或者原子标志位acceptor 检查标志位为 1 才把该线程纳入轮询。// 动态扩容检查是否需要新线程 static void maybe_spawn_worker(void) { int total atomic_load(g_worker_count); if (total MAX_WORKERS) return; int pending atomic_load(g_pending_conns); if (pending total * 100) { // 每个线程积压超过 100 连接 struct worker *w g_workers[total]; w-id total; w-running 1; queue_init(w-fd_queue); pthread_create(w-tid, NULL, worker_thread, w); atomic_fetch_add(g_worker_count, 1); } }参数说明MAX_WORKERS建议设为 CPU 核数的 2 倍再多线程切换开销会吃掉收益。pending total * 100这个阈值需要根据实际连接的生命周期调整短连接多的话阈值可以低一些。5.2 用 curl 验证线程亲和性多线程 server 上线前必须验证连接确实被分发到了不同线程。最简单的方法是在 handler 里返回当前线程 id然后用 curl 连续请求多次看返回的 id 是否变化。for i in $(seq 1 20); do curl -s http://127.0.0.1:8000/api/status | grep -o thread:[0-9]* done | sort | uniq -c如果所有请求都返回同一个 thread id说明分发逻辑没生效所有连接都落到了一个工作线程。可能的原因是 acceptor 的轮询计数器没有原子递增或者 fd 队列的 pop 逻辑有 bug 导致只有一个线程能取到 fd。另一个验证点是长连接。用curl的--http1.1和Connection: keep-alive发多个请求观察同一个连接上的多个请求是否始终由同一个线程处理。mongoose 的连接迁移是在 accept 时完成的后续同一连接的所有事件都会在同一个 mgr 上触发所以线程 id 应该保持不变。如果变了说明连接被错误地迁移了两次。5.3 一个我踩过的坑mg_wrapfd之后忘了设is_websocket如果你的 server 同时支持 WebSocket 和普通 HTTPmg_wrapfd包装出来的连接默认按 HTTP 处理。WebSocket 升级请求会走到MG_EV_HTTP_MSG但 mongoose 不会自动完成升级握手需要你在 handler 里判断mg_http_is_websocket并调mg_ws_upgrade。忘了这一步的话客户端会一直停在 101 之前表现为连接挂起。if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *)ev_data; if (mg_match(hm-uri, mg_str(/ws), NULL)) { mg_ws_upgrade(c, hm, NULL); // 必须显式升级 } }从那以后我每次用mg_wrapfd接管连接都会先确认业务里有没有 WebSocket有的话在 handler 第一层就做升级判断不然后面查起来就是黑匣子。希望帮到你。本文还有配套的精品资源点击获取