ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

mongoose多线程改造:别让主线程背锅,事件驱动下的高性能HTTP服务

mongoose多线程改造:别让主线程背锅,事件驱动下的高性能HTTP服务

简介:基于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 导致的内存泄漏,运行一周后系统内存耗尽重启。

从那以后,我每次做多线程改造都会强制走一遍完整流程:先明确线程边界、再用日志还原时间线、最后用压测验证背压机制。任何一个环节缺失,上线后都要拿生产事故来还债。希望这篇笔记能帮你少走这些弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进