
简介基于mongoose的多线程HTTP服务器实现源码包面向C语言与嵌入式开发者演示如何借助mongoose库构建支持高并发的Web服务。项目在VS2008环境下编译包含线程池设计、线程安全队列分发请求、互斥锁同步、异常处理等关键实现有助于理解旧版mongoose 6.7的多线程机制为升级或移植提供参考。压缩包共11个文件以cpp/h源码、sln/vcproj工程文件为主另含可执行程序和说明文档整包仅221KB代码精简、结构清晰适合快速阅读与二次开发。读者可直接运行示例观察服务器如何并发响应多个客户端并据此扩展静态文件服务或自定义接口。已有3851人学习对希望掌握嵌入式HTTP服务器搭建或C语言多线程编程的开发者具有较高的实用价值。1. 多线程改造 mongoose别总让主线程背锅嵌入式设备或者边缘网关要对外提供 HTTP 接口时裸写 socket 的坑实在太多HTTP 解析、连接管理、静态文件服务、WebSocket 握手哪一个单独拎出来都够折腾一周。mongoose 的价值在于把这些全部封装成事件回调你只需要关心业务逻辑。但它默认的事件驱动模型有一个致命短板在回调里做任何耗时操作整个服务的响应都会跟着卡住。把 mongoose 接入多线程就是在不改动其核心事件机制的前提下把阻塞型业务从主循环里剥离出去。这篇笔记适合正在用 C 做设备服务端、或者刚把 mongoose 跑通但不敢上真实业务的开发者我会直接把线程模型、任务投递方式和五个高频踩坑点讲透。2. 先把事件驱动模型看透mongoose 主循环在替你干什么2.1 mg_mgr_poll 与回调机制一切响应都来自这个循环mongoose 的工作方式不是每个连接一个线程而是单线程事件循环。你在 main 函数里初始化一个 mg_mgr 结构体然后反复调用 mg_mgr_poll让它去检查所有 socket 上有没有新事件。有事件就调用你注册的回调函数。#include mongoose.h static void ev_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; mg_http_reply(c, 200, Content-Type: text/plain\r\n, ok); } } int main(void) { struct mg_mgr mgr; mg_mgr_init(mgr); mg_http_listen(mgr, http://0.0.0.0:8080, ev_handler, NULL); for (;;) { mg_mgr_poll(mgr, 200); } mg_mgr_free(mgr); return 0; }这段代码是整个服务的最小骨架。mg_http_listen 做完的事比你想的多创建监听 socket、绑定端口、注册 accept 处理逻辑。之后每次有 HTTP 请求到达ev_handler 就会被调用一次。mg_mgr_poll 的第二个参数 200 是轮询超时毫秒数它决定主循环多久醒来检查一次事件。注意 mg_http_message 里已经有了解析好的 method、uri、body。不用自己拆 HTTP 报文是 mongoose 最大的省心点代价是它的回调默认跑在主线程上。你在这个回调里干了多久后续所有连接的响应就等多久。2.2 单线程模型的天花板阻塞回调会拖死整个服务单线程事件循环适合 IO 密集但单个操作很快的场景。比如简单的状态查询、转发一个短报文几十微秒搞定一个循环里处理几千个请求也没问题。但真实业务总会冒出三类老鼠屎第一类是磁盘 IO。设备端要记录日志、读写配置文件、保存上报数据一次 flash 写入可能几十毫秒。第二类是外部依赖调用访问数据库、调用另一个服务接口对方慢一点就是秒级。第三类是 CPU 密集计算比如图像处理、加密签名、协议解析占用主循环期间其他请求全部排队。你把这三类操作放进回调里压测时就会发现并发一上来响应时间不是线性增长而是断崖式恶化。原因很简单事件循环被长任务卡住socket 缓冲区持续堆积TCP 窗口收缩客户端开始重传整个服务的吞吐直接崩塌。我一般把这种情况叫“主线程背锅”。责任不完全在 mongoose任何单线程事件驱动框架都有这个天花板。解决的思路也不是推翻 mongoose 的事件模型而是把慢操作搬出去。2.3 多线程的职责边界谁该进工作线程谁必须留在事件线程动手写代码前先把边界划清楚能省掉后面一大半的调试时间。事件线程只做网络读写和协议解析工作线程只做业务计算两者之间用任务队列通信。操作类型归属线程原因读写 socket、解析 HTTP 报文事件线程mongoose 的非阻塞连接只能在这个线程安全操作mg_http_reply / mg_send 发送响应事件线程连接结构体内部状态受事件循环管理数据库查询、文件写盘、加密计算工作线程耗时操作不能阻塞主循环访问连接结构体中的请求数据工作线程只读拷贝不能直接使用事件线程的指针最核心的一条铁律mongoose 的 mgr 不是线程安全的绝不允许两个线程同时调用 mg_mgr_poll。所有网络相关的操作包括回复响应都必须回到事件线程执行。工作线程能碰的是从请求里拷贝出来的业务数据而不是连接结构体本身。3. 动手实现多线程 Http Server从队列到线程池的落地3.1 线程池与任务队列的结构设计多线程部分我用最通用的方案pthread 线程池加有界阻塞队列。线程数量按设备核数配置一般 2 到 4 个工作线程足够。队列要设上限防止任务堆积把内存吃光。#define TASK_QUEUE_CAPACITY 1024 struct task { int type; // 任务类型0业务处理, 1发送响应 struct mg_str body; // 拷贝出来的请求体数据 uint64_t conn_id; // 连接的唯一标识 char uri[256]; // 请求路径 }; struct task_queue { struct task *buf; int head, tail, count; pthread_mutex_t lock; pthread_cond_t not_empty, not_full; }; static void queue_init(struct task_queue *q) { q-buf calloc(TASK_QUEUE_CAPACITY, sizeof(struct task)); q-head q-tail q-count 0; pthread_mutex_init(q-lock, NULL); pthread_cond_init(q-not_empty, NULL); pthread_cond_init(q-not_full, NULL); }task 结构里没有直接放 mg_connection 指针而是放 conn_id。这是个关键设计连接结构体的生命周期完全由事件线程掌控工作线程持有一个已经释放的指针是灾难性的。通过 conn_id 在事件线程里重新查找连接才安全。body 用 mg_str 是 mongoose 自己定义的结构体拷贝数据时注意它包含指针和长度不能只拷结构体要连带数据一起拷贝。线程池的 worker 逻辑就是循环从队列取任务根据 type 执行不同的函数。取不到任务就阻塞在条件变量上不占 CPU。static void *worker_loop(void *arg) { struct thread_pool *tp (struct thread_pool *) arg; for (;;) { struct task t; pthread_mutex_lock(tp-queue.lock); while (tp-queue.count 0) { pthread_cond_wait(tp-queue.not_empty, tp-queue.lock); } t tp-queue.buf[tp-queue.head]; tp-queue.head (tp-queue.head 1) % TASK_QUEUE_CAPACITY; tp-queue.count--; pthread_cond_signal(tp-queue.not_full); pthread_mutex_unlock(tp-queue.lock); process_task(tp, t); } return NULL; }process_task 里根据业务类型做实际计算。计算完成后不能直接在这个函数里发响应得构造一个新的响应任务放回队列交给事件线程去发送。这个“二次投递”是 mongoose 多线程改造里最容易忽略的环节。3.2 在 HTTP 回调里投递任务keepalive 与连接句柄的坑事件回调这边要做的事很简单把请求数据拷贝成 task扔进队列然后立即返回。这样主循环就能马上处理下一个连接。static void ev_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; struct task t {0}; t.conn_id c-id; t.body mg_strdup(hm-body); snprintf(t.uri, sizeof(t.uri), %.*s, (int) hm-uri.len, hm-uri.ptr); queue_push(g_tp.queue, t); mg_http_reply(c, 202, Content-Type: text/plain\r\n, accepted); } }代码里用 mg_strdup 拷贝 body用 snprintf 拷贝 uri都是为了把请求数据从事件线程的内存区域复制到工作线程可以安全访问的独立内存里。c-id 是连接的全局唯一标识替代直接使用 c 指针。mg_http_reply 先回一个 202表示任务已受理后续业务完成后会由事件线程再发送真正的业务响应。这里有个 keepalive 的隐藏问题HTTP 连接默认是保持活着的同一个 TCP 连接可能被多个请求复用。这意味着在你投递任务时连接还在但等待工作线程处理完再回复时这个连接可能已经被后续请求占用了。所以每个任务必须带独立的 conn_id而不是存 c 指针。发送响应的函数要在事件线程上下文里通过 conn_id 查询当前连接是否仍然有效。3.3 处理慢业务与异步响应让请求处理不再阻塞轮询业务任务处理完成后响应动作必须送回事件线程。常见做法是维护一个“完成队列”工作线程把计算结果放入完成队列事件线程在每次 mg_mgr_poll 之后检查一次。static void process_task(struct thread_pool *tp, struct task *t) { char result[1024]; // 模拟耗时业务文件读取、数据聚合、加密等 do_business(t-uri, t-body, result, sizeof(result)); struct task reply {0}; reply.type 1; reply.conn_id t-conn_id; reply.body mg_strdup(mg_str(result)); queue_push(tp-reply_queue, reply); }事件循环主函数里在 mg_mgr_poll 返回之后遍历完成队列逐个发送响应。for (;;) { mg_mgr_poll(mgr, 200); struct task reply; while (queue_pop_nonblock(g_tp.reply_queue, reply)) { struct mg_connection *c mg_conn_find(mgr, reply.conn_id); if (c ! NULL) { mg_http_reply(c, 200, Content-Type: application/json\r\n, {\code\:0,\data\:\%.*s\}, (int) reply.body.len, reply.body.ptr); } free((void *) reply.body.ptr); free((void *) reply.body.len); // 修正mg_strdup 分配的内存 } }注意 mg_conn_find 是通过连接 id 查找连接结构体的 API如果连接已经关闭返回 NULL此时响应就丢弃这是正确的行为。异步响应的时序是事件线程先回 202 告知已受理业务完成后再回 200 带实际数据。客户端如果只支持同步请求需要调整超时时间或者改用短轮询模式。这里还要提醒一个内存细节mg_strdup 返回的 mg_str 里的 ptr 是 malloc 出来的用完必须 free否则长时间运行内存必然上涨。free 的时候释放的是 ptr 指向的缓冲区不是 mg_str 结构体本身。4. 日志、超时与并发验证多线程服务不能只是看起来能跑4.1 用日志还原线程时间线多线程服务的调试难度比单线程高一个量级难点在于问题无法稳定复现。我自己的习惯是给每个任务打上时间戳把事件线程入队、工作线程取任务、工作线程完成、事件线程发送响应四个时间点全部记录。struct task { // ...已有字段 struct timespec t_enqueue; // 入队时刻 struct timespec t_dequeue; // 工作线程取出时刻 struct timespec t_done; // 业务完成时刻 };在四个关键节点调用 clock_gettime 记录时间。事后分析日志时只需要算两个差值t_dequeue 减 t_enqueue 是队列积压时间反映线程池吞吐t_done 减 t_dequeue 是业务耗时反映业务本身质量。如果积压时间持续增长说明任务产生速度大于消费速度此时加线程数没有意义应该优化业务逻辑或者做流量控制。日志输出时带上线程 id用 pthread_self 获取后取低几位来识别。格式固定成 tab 分隔方便导入其他工具分析。生产环境不要用 printf 直接打日志多线程环境下它内部有锁高并发时反而成为新的瓶颈点建议直接写 ring buffer 日志或者用一个独立日志线程异步刷盘。4.2 超时参数体系别让慢客户端拖死线程池HTTP 服务和底层 TCP 的超时是两套机制。mongoose 通过 mgr_poll 的超时参数控制事件循环自身的响应频率而连接空闲超时需要单独配置。mongoose 提供了几种连接超时控制手段。最常用的是在连接回调里处理 MG_EV_TIMER 事件或者给每个连接设置轮询超时时间。更简洁的方案是直接读取连接结构体中的 idle_timeout 字段。static void ev_handler(struct mg_connection *c, int ev, void *ev_data) { if (ev MG_EV_ACCEPT) { c-idle_timeout 30; // 30 秒无数据则断开 } if (ev MG_EV_HTTP_MSG) { // 投递任务逻辑 } }idle_timeout 的粒度是秒0 表示不限制。对于长连接的 API 服务一般设置 30 到 60 秒比较合理。工作线程池这边的任务队列也需要超时机制比如入队时如果队列已满可以选择丢弃任务并返回 503而不是无限阻塞。这个拒绝策略在设备内存有限的环境下特别重要。压测工具推荐 wrk 和 h2load。wrk 适合测 HTTP/1.1 的短连接和 keepalive 场景h2load 适合验证后续接 HTTP/2 时的表现。服务端这边建议监听两个端点一个返回静态字符串作为基准另一个走线程池异步处理对比两者的最大并发和延迟曲线。4.3 压测验证三个指标决定能否上线能跑起来和能扛住是两回事。上线前至少要做三轮验证第一轮跑功能正确性用 curl 连续发 1000 个请求确认响应数据没有串号、没有丢响应。检查方式是每个请求附带自增序号服务端在响应里带回这个序号客户端校验。串号说明任务队列里的 conn_id 映射错了丢响应说明连接查找逻辑有缺陷。第二轮跑并发能力用 wrk 固定 100 并发持续 60 秒观察平均延迟、P99 延迟和错误率。平均延迟高不要紧P99 高出天际才说明调度有问题。错误率超过千分之一就要检查超时参数和连接关闭逻辑。第三轮跑 CPU 和内存稳定性。用 top 观察 30 分钟以上CPU 占用率波动应该平稳内存不能持续上涨。如果内存涨优先检查任务结构体有没有泄漏。连接池、线程池、ring buffer 的初始化参数要固化到配置文件中这样每个版本之间可以横向对比参数改动的效果。5. 避坑排查多线程 mongoose 服务最常踩的五个坑5.1 工作线程直接调 mg_http_reply 导致崩溃现象在 worker 线程里调用 mg_http_reply 发送响应程序运行不稳定有时直接段错误有时响应内容错乱。原因mg_connection 内部状态机由事件线程维护。工作线程操作连接结构体时事件线程可能正在同一个连接上收发数据结构体内部指针和缓冲区被并发修改崩溃是必然结果。解决所有对连接结构体的操作全部回到事件线程执行。工作线程只计算结果通过完成队列把响应内容回传事件循环在每次 poll 后统一发送。这条规则要写进团队代码规范里靠自觉容易翻车。5.2 keepalive 连接复用导致响应串台现象客户端使用 HTTP keepalive 复用连接时A 请求的响应内容被 B 请求收到。原因keepalive 下同一个 TCP 连接上顺序跑多个请求。投递任务时连接还好好的业务处理完回来时客户端已经在这个连接上发了新请求。此时如果用的是老的 c 指针发送响应就发到新请求上了。本质是异步响应和连接复用的时序冲突。解决不要在任务里保存 c 指针改用 conn_id。发送响应前用 id 重新查找连接同时检查当前连接上是否有未完成的请求序列号有则必须按序响应。HTTP/1.1 的 pipelining 场景尤其要注意这一点。5.3 任务队列无限增长拖垮内存现象并发压力大时内存占用持续上涨操作延迟急剧升高最终服务无响应。原因任务队列设计成无界生产者是事件线程消费者是线程池。一旦业务耗时长生产者速度远大于消费者速度队列积压数以万计的任务每个任务的 body 拷贝都是独立 malloc 的内存。解决给队列设置硬上限比如 1024。入队时先检查容量已满则直接回 503 或者丢弃。压力高峰过去后队列自动排空恢复正常。用条件变量在队列空时阻塞工作线程满时阻塞事件线程形成天然的背压机制。5.4 poll 超时参数设置不当导致主循环空转现象CPU 占用率很高但吞吐量很低。事件循环像是忙等待而不是休眠。原因mg_mgr_poll 的超时参数设得太小比如 0 或 1 毫秒。主循环每次都立即返回没有事件时也在空转白白消耗 CPU。多个 socket 的事件响应频率也会受影响导致整体吞吐下降。解决超时参数设为 100 到 200 毫秒。事件到达时会立即唤醒没有事件时让出 CPU。手头服务对响应延迟要求极高的话最低也不要低于 20 毫秒。必要时在事件线程里用 eventfd 和 mg_mgr_poll 配合实现真正的事件驱动唤醒。5.5 大请求体导致任务拷贝内存暴涨现象收到几十 MB 的 POST 请求时内存瞬间飙升系统进入不稳定状态。原因ev_handler 里用了 body mg_strdup(hm-body)。大请求体在事件回调里先被 mongoose 缓冲成完整的一块再被拷贝进任务队列等于同一份数据在内存中有多份副本。解决对 Content-Length 做前置校验超过上限比如 1 MB直接回 413。分块传输的场景不要一次性缓存全部 body改为事件线程接收完一块就投递一块工作线程边收边处理。mongoose 有 MG_EV_HTTP_CHUNK 事件可以按块拿数据流式处理的模式不会积累大缓冲区。6. 进阶优雅关停与资源生命周期管理的收尾技巧上线跑了一段时间之后你迟早会遇到一个尴尬问题怎么安全地关掉这个多线程服务。直接 kill -9 会把正在处理的请求全部打断任务队列里未完成的任务白白丢光。我后来在服务里加了一套两阶段关停流程第一阶段通知线程池不再接收新任务第二阶段等待已有任务全部完成再退出。static void signal_handler(int sig) { if (sig SIGINT || sig SIGTERM) { g_shutdown 1; queue_shutdown(g_tp.queue); // 唤醒所有等待中的 worker mg_mgr_poll(mgr, 1); // 让事件线程退出阻塞 } }信号处理器里的逻辑只做标记和唤醒不做真正的清理。实际的内存释放动作要放在主线程里等事件循环退出后逐项完成。线程池的释放要注意顺序先停止 worker 线程再销毁任务队列最后释放线程池结构体本身。mongoose 的 mg_mgr_free 必须放在最后因为完成队列里可能还有待发送的响应。内存生命周期控制是另一个值得养成习惯的地方。所有进入任务队列的数据从哪 alloc 的就从哪 free。事件线程入队时拷贝的 body由入队函数负责分配处理完成后的清理也要在队列结构里带上对应的 free 函数指针。我见过太多因为 mg_strdup 后忘记 free 导致的内存泄漏运行一周后系统内存耗尽重启。从那以后我每次做多线程改造都会强制走一遍完整流程先明确线程边界、再用日志还原时间线、最后用压测验证背压机制。任何一个环节缺失上线后都要拿生产事故来还债。希望这篇笔记能帮你少走这些弯路。本文还有配套的精品资源点击获取