简介:本资源面向嵌入式开发与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. 线程模型选型:从单事件循环到多 Reactor
2.1 为什么不能简单地在多个线程里各起一个 mongoose
最常见的错误做法是:开 N 个线程,每个线程里mg_http_listen一次,绑同一个端口。结果要么第二个线程直接报端口占用,要么靠SO_REUSEPORT勉强起来,但连接分布完全靠内核哈希,你没法控制,也没法做统一的连接计数和优雅退出。
mongoose 的事件循环mg_mgr_poll不是线程安全的,多个线程共享同一个mg_mgr会直接踩内存。所以多线程方案的核心约束是:每个线程一个独立的mg_mgr,线程之间不共享 mongoose 对象。这条约束定下来,剩下的就是怎么把新连接分给各个线程。
2.2 三种可落地的分发模型对比
| 模型 | 监听方式 | 连接分发 | 优点 | 缺点 |
|---|---|---|---|---|
| 主线程 accept + 派发 | 主线程独占监听 | 主线程 accept 后把 fd 传给工作线程 | 连接数可控、易做限流 | 主线程可能成瓶颈,需要线程间传 fd |
| SO_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
下面这份代码是能直接编译运行的骨架,主线程 accept,N 个工作线程各自跑事件循环,每个请求返回一个简单响应。
#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只用来触发 accept,c->is_accepted标记的连接就是内核已经完成三次握手、等待应用层接管的连接。把c->fd取出来、置-1、标记is_closing,mongoose 就会回收这个连接壳而不关闭底层 fd,fd 的所有权转移到工作线程。工作线程用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 和连接分布,两个指标都稳了才敢放量。多线程的坑大多不在代码写没写对,而在连接生命周期和线程调度的交互上,压测是唯一的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取