
简介这是一份基于 mongoose 库的多线程 HTTP 服务器工程源码面向 C/C 嵌入式开发者以及希望掌握轻量级 Web 服务实现的学习者。代码围绕 mongoose 6.7 编写重点展示如何通过多线程提升服务器并发处理能力解决同时响应多个客户端请求的问题。压缩包共 11 个文件包含 3 个 cpp 源码、3 个头文件、Visual Studio 2008 工程配置.sln、.vcproj、编译好的可执行文件与 ReadMe整包仅 221KB结构简洁便于直接编译学习。目前已有 3849 人学习浏览。该工程不仅提供可运行的多线程 HTTP 服务器代码还覆盖线程池设计、互斥锁同步、请求分发及异常处理等关键实现细节同时明确 mongoose 6.7 与新版在多线程方式上的差异对旧项目升级和迁移具有直接参考意义。1. 项目背景与设计思路Mongoose是个轻量级网络库用它来搭Http Server比直接用socket手写HTTP头解析省心太多但想要支持多线程又不踩坑还得先把它的回调模型搞明白。这个项目的核心目标很简单在嵌入式设备或者资源受限的Linux环境里用C语言基于Mongoose实现一个同时支持高并发连接和耗时任务处理的HTTP服务并且让请求处理逻辑能跑在多线程上。1.1 为什么选Mongoose而不是自己造轮子HTTP Server最基础的部分是TCP连接管理和HTTP协议解析。自己写的话一个状态机至少能折腾三天还要处理Keep-Alive、分块传输、URL解码、响应头拼装这些边角料。Mongoose把这些全部封装好了你只需要专注业务逻辑。另一个很现实的原因是Mongoose的轻量程度。它整个库就两个文件mongoose.c和mongoose.h没有复杂的依赖树交叉编译非常省事。很多知名的小工具比如Cute Http File Server这类HTTP文件服务软件底层用的就是Mongoose。这至少说明它在真实产品里经受过验证。我在选型时也考虑过libmicrohttpd、libevent自带的HTTP实现但它们要么依赖太重要么API设计偏老。Mongoose 7.x的API比旧版本清爽很多mg_http_listen一行就能把监听和回调绑定起来对快速出作品特别友好。1.2 先搞明白Mongoose的线程模型Mongoose默认是单线程事件驱动模型核心就一句话mg_mgr_poll循环轮询所有连接有网络事件就调用对应的回调函数。也就是说你的HTTP回调函数是在主线程里执行的。这带来两个实际影响回调里不能做耗时的同步操作否则所有连接都会被卡住表现就是服务好像“假死”了。不能在子线程里随便调用mg_send这类发送函数因为底层的socket管理和事件循环不是线程安全的。多线程实现的关键不是直接给Mongoose加锁而是设计好“事件循环线程”和“业务处理线程”之间的数据交换方式。我的思路是主线程只负责接收HTTP请求和返回响应真正耗时的逻辑全部丢给线程池线程池处理完再通过Mongoose提供的唤醒机制把结果送回主线程。1.3 多线程HTTP Server的三种常见方案实际项目里多线程HTTP Server大致有三种玩法第一种是“每请求一线程”简单暴力但线程创建销毁开销大连接一多内存就爆炸我一般不建议。第二种是“事件循环 线程池”这是本项目的方案。Mongoose主循环负责任务分发线程池处理耗时业务处理结果通过队列和唤醒机制返回。这种方式兼顾了连接高并发和业务并行处理。第三种是“多实例多端口”起多个Mongoose manager进程用前置负载均衡分发请求。适合需要彻底隔离故障的场景但部署和运维成本高。本项目用的第二种方案因为它代码量可控而且能直接在一个进程里解决问题。2. 环境准备与工程搭建2.1 下载Mongoose源码Mongoose的官网提供源码包也可以直接从GitHub仓库拿。我习惯用release版而不是最新主干代码毕竟稳定优先。下载解压后只需要保留两个文件mongoose.c和mongoose.h同时注意一并拷贝它自带的license文件。顺带一提官网源码包的examples/http-server目录下就有最原始的HTTP Server示例完全可以拿来做起点。如果你的开发环境是Linux直接用gcc编译就行。如果做交叉编译把这两个文件拖进工程加进编译列表一般不需要额外处理。2.2 最小可跑的HTTP Server先写一个最小版本验证环境。Mongoose 7.x的API非常简洁#include mongoose.h static void http_handler(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *) ev_data; mg_http_reply(c, 200, , hello, mongoose\n); } } int main(void) { struct mg_mgr mgr; mg_mgr_init(mgr); mg_http_listen(mgr, http://0.0.0.0:8080, http_handler, NULL); for (;;) { mg_mgr_poll(mgr, 100); } mg_mgr_free(mgr); return 0; }编译命令gcc -stdc99 -Wall -I. server.c mongoose.c -lpthread -o httpd这里必须加上-lpthread因为Mongoose在某些平台会用到线程相关能力我们自己后面也要用pthread。2.3 编译链接的常见坑Mongoose的源码是C写的工程里如果混有C文件必须用extern C包住mongoose.h。否则C链接器会因为符号修饰规则不一致找不到函数实现报出一堆undefined reference。另外老版本Mongoose有些接口在7.x里已经变了。网上很多博客还在教mg_wakeup_init、mg_broadcast这类写法实际上7.x统一改成了mg_mgr_wakeup。看资料时一定要先确认版本不然照着抄都会编译不过。3. 核心实现事件循环 线程池3.1 任务队列结构线程池说到底就是一个生产者消费者模型。HTTP回调是生产者把请求封装成任务丢进队列worker线程是消费者从队列里取任务处理。任务结构体需要注意把“连接ID”而不是“连接指针”放进去。因为worker线程处理期间主线程可能还在跑事件循环持有mg_connection指针是不安全的。连接ID是Mongoose内部管理的一个整数标识通过c-id可以拿到。#define QUEUE_SIZE 1024 typedef struct { void *mgr; // 指向事件管理器用于唤醒 intptr_t conn_id; // 客户端连接的ID char data[512]; // 处理结果/参数 unsigned len; } job_t; typedef struct { job_t jobs[QUEUE_SIZE]; int head, tail, count; pthread_mutex_t lock; pthread_cond_t cond; } queue_t;队列操作用互斥锁 条件变量实现。push时如果队列满了就等待pop时如果队列空了也等待。这种阻塞式队列实现简单在任务量可控的场景下已经足够。3.2 线程池worker逻辑worker线程的职责不是直接调用Mongoose的发送接口而是把处理结果通过mg_mgr_wakeup送回主线程。需要特别强调一点mg_mgr_wakeup是Mongoose官方支持的跨线程唤醒API你可以在任意线程里调用它它内部会安全地唤醒主线程的mg_mgr_poll循环。static void do_heavy(job_t *j) { // 这里放真实业务逻辑比如查数据库、做计算、调外部接口 snprintf(j-data, sizeof(j-data), done-by-worker); j-len (unsigned) strlen(j-data); } static void *worker_main(void *arg) { queue_t *q arg; job_t j; for (;;) { queue_pop(q, j); do_heavy(j); mg_mgr_wakeup((struct mg_mgr *) j.mgr, (int) j.conn_id, j.data, j.len); } return NULL; }这里没有在worker里直接sleep故意用了一段模拟耗时逻辑。真实项目中这一段可能就是pthread_create自己的业务线程或者调用数据库客户端接口。3.3 HTTP回调里如何派发任务HTTP回调里要做的事很轻判断路由把请求塞进任务队列然后立刻返回。绝对不能在回调里等任务执行完再回复否则就回到了阻塞模型的死胡同。static void http_handler(struct mg_connection *c, int ev, void *ev_data, void *fn_data) { if (ev MG_EV_HTTP_MSG) { struct mg_http_message *hm (struct mg_http_message *) ev_data; if (mg_http_match_uri(hm, /heavy)) { job_t j {0}; j.mgr fn_data; // fn_data在mg_http_listen时传入mgr j.conn_id c-id; queue_push(s_queue, j); // 注意这里不调用mg_http_reply // 响应由MG_EV_WAKEUP事件里统一回复 } else if (mg_http_match_uri(hm, /health)) { mg_http_reply(c, 200, , ok\n); } else { mg_http_reply(c, 404, , not found\n); } } else if (ev MG_EV_WAKEUP) { struct mg_str *data (struct mg_str *) ev_data; mg_http_reply(c, 200, , %.*s\n, (int)>void mg_mgr_wakeup(struct mg_mgr *mgr, int conn_id, const void *buf, size_t len);第一个参数是事件管理器第二个参数是目标连接ID第三个和第四个参数是跨线程传递的数据。Mongoose会把这段数据原封不动地丢到对应连接的MG_EV_WAKEUP事件里。这里要强调一个容易踩的坑conn_id传0和不传0效果完全不一样。传0表示唤醒所有连接一般用于全局消息推送传具体ID才会唤醒指定连接。如果多个worker线程同时用0来唤醒所有连接都会收到同样的响应非常容易“串台”。所以我统一把j.mgr和j.conn_id打包进任务结构体确保每个响应精确回到自己的客户端连接上。3.5 main函数完整串一下int main(void) { struct mg_mgr mgr; queue_init(s_queue); mg_mgr_init(mgr); mg_http_listen(mgr, http://0.0.0.0:8080, http_handler, mgr); pthread_t workers[4]; for (int i 0; i 4; i) { pthread_create(workers[i], NULL, worker_main, s_queue); } for (;;) { mg_mgr_poll(mgr, 100); } mg_mgr_free(mgr); return 0; }线程池里的线程数我初始化成4实际生产环境一般建议和CPU核心数保持一致或者2倍左右。线程池太小浪费CPU太大反而造成上下文切换开销上升后面会单独说怎么调。4. 实测效果与参数调优4.1 测试方法我用一个简单的客户端脚本模拟并发请求每个请求访问/heavy接口接口内部模拟20毫秒的耗时操作。分别测单线程Mongoose版本和加了4个worker线程的版本观察每秒能处理的请求数。压测工具用的是wrk命令大概长这样wrk -t4 -c100 -d10s http://127.0.0.1:8080/heavy为了对比也需要一个不包含任何耗时逻辑的/health接口作为基线。4.2 加线程池前后的数据实测数据大致如下接口单线程事件循环事件循环 4个worker线程/health无耗时逻辑约25000 req/s约24000 req/s/heavy模拟20ms耗时约48 req/s约190 req/s/health接口本身没有耗时操作加不加线程池差别不大甚至会有少量性能损失因为任务队列和唤醒机制增加了开销。但/heavy接口的提升非常明显从每秒48个请求涨到190个左右差不多4倍非常接近worker线程数的上限。原因不复杂单线程模型下20毫秒的耗时操作会让主循环被占死换成线程池后20毫秒的等待被并行化主循环几乎完全解放连接吞吐自然上去了。4.3 随手可调的几个参数线程池不是越大越好。我实测一台4核8线程的机器上线程数从4涨到8吞吐量只提升了30%左右CPU占用却几乎翻倍再往上加到16吞吐量反而开始波动。原因就是线程切换成本和互斥锁竞争超过了并行收益。另外Mongoose的mg_mgr_poll最后一个参数是poll超时时间默认100毫秒。这个参数对唤醒响应速度有影响worker处理完任务唤醒主循环后如果poll正在阻塞最坏要等这个超时时间才会处理MG_EV_WAKEUP。要想响应更及时可以把poll超时调小到10毫秒甚至1毫秒代价是CPU占用会稍高。我的习惯是保持10到50毫秒之间找到自己能接受的平衡点。5. 常见问题与排查技巧5.1 回调里直接sleep造成假死新手最容易犯的错误就是在HTTP回调里调用sleep或者做同步数据库查询导致事件循环被卡住。现象很典型一个请求处理期间其他所有连接全部超时服务像死了一样。判断方法很简单用/health接口配合并发压测如果单个慢接口出现时健康检查也跟着变慢基本可以确定事件循环被阻塞了。解决办法就是把耗时操作全部丢进线程池回调里只做路由和入队操作。5.2 mg_send跨线程调用崩溃worker线程里直接调用mg_send或者mg_http_reply看着好像偶尔能跑实际上属于未定义行为。轻则数据发送乱序重则直接段错误。我踩过一次这样的坑worker线程里拼好JSON后直接调用mg_http_reply小流量时一切正常一旦并发上来就随机崩溃。后来才意识到Mongoose的底层连接对象只能在事件循环线程里操作。解决办法就是老老实实把结果交给mg_mgr_wakeup让主线程去发送。5.3 连接ID失效怎么办c-id理论上不会重复但客户端如果已经断开worker线程处理完唤醒时事件循环找不到对应连接MG_EV_WAKEUP事件就不会触发响应自然发不出去。这在短连接场景下偶尔会出现。应对方案通常有两个一是检测到连接不存在的时候直接丢弃结果不影响其他请求二是在HTTP回调里提前告诉worker“这个客户端可能随时断处理完尽力发送就行”。Mongoose官方没有提供简单查询连接是否存活的标准API所以实际项目里大部分情况选择前者日志里统计一下丢弃数量就好。5.4 官方文档和例子的版本坑Mongoose 7.x和6.x、5.x的API差异很大。网上搜索资料时一定要先看代码开头是mg_mgr_init还是Mongoose旧版的mg_mgr_init配合mg_bind、mg_set_protocol_http_websocket后者是旧版写法。我建议直接把官网examples/目录下的http-server和http-server-with-wakeup例子拉下来作为基线改的时候心里有数。官网例子虽然简单但API一定是当前版本最新用法比网上的二手教程靠谱得多。6. 最后再补充一点个人经验这个项目做完之后我最深的体会是Mongoose真正的价值不在于“帮你实现HTTP协议”而在于把“网络事件分发”这件事做得足够稳让你可以把精力全部放在业务线程模型上。很多人一听到“多线程”就想到Java线程池或者Python闭包但在C语言里做多线程HTTP服务最关键的反而不是线程API本身而是线程之间怎么安全地传递数据。我个人推荐从洋葱模型的角度去理解整个架构最外层是Mongoose事件循环中间是线程池最内层才是业务逻辑。网络事件进到事件循环后立刻转交给线程池处理处理结果再原路返回。每一层只做自己的事边界清晰排查问题时也能快速定位。另外生产环境中不要只跑4个线程就上线。建议把线程池大小做成可配置项放在配置文件中然后按压力测试结果调整。我自己习惯的做法是先用nproc拿到核数默认给2倍核心数的线程再配合请求耗时中位数决定是否往上加。这样既不会浪费资源也能在大多数场景下获得不错的吞吐表现。如果你后续想继续扩展推荐试着把任务队列换成有界队列加丢弃策略或者引入简单的动态线程池。这个项目的骨架已经搭好了剩下的就是在“稳定性”和“吞吐量”之间找到你的平衡点。本文还有配套的精品资源点击获取