简介:这是一份基于mongoose库实现多线程HTTP服务器的完整工程源码,适合有一定C/C++基础、希望了解嵌入式Web服务器与并发处理机制的开发者。压缩包共11个文件,以cpp与h源码为核心,含mongoose 6.7库文件、项目配置及工程解决方案,另附生成的exe与说明文本,便于直接查看效果和对照学习。目前已有3851人学习下载。资源重点展示了如何在VS2008环境下利用线程池、互斥锁与请求分发构建高并发服务,并对旧版mongoose多线程实现做了详细说明;通过阅读main等工程文件,可了解线程同步、错误处理及服务器配置等关键细节,对理解新版API差异、迁移旧代码或自研轻量级Web服务均有实用参考价值。
1. 嵌入式Http Server是刚需:mongoose多线程方案两天落地
某次拿到一个资源受限设备的服务端需求:要支持 HTTP 接口、静态文件下发、多客户端并发访问,但整个交付物不能超过几百 KB,跑不了重框架。当时第一反应是直接用 socket 手写 HTTP 解析,后来发现 mongoose 这个嵌入式网络库天生就是干这个的——它把 HTTP 协议栈、URI 路由、WebSocket、TLS 这些能力全部收敛进两个 C 文件里,API 风格像 Node.js 的事件循环,回调注册完交给mg_mgr_poll驱动。这个项目就是在 mongoose 基础上实现一个多线程 Http Server,单进程内起多个 worker 线程,每个 worker 持有独立的事件管理器,主线程只负责 accept 连接并分发,解决单线程事件循环在高并发下处理能力受限的问题。适合两类人:一类是想在嵌入式设备或服务端中间件里快速落地 HTTP 能力的 C 开发者,另一类是好奇多线程网络服务怎么和事件驱动库共处的人。资源小、无第三方依赖、代码结构清晰,是理解并发 HTTP 服务端非常合适的模板。
2. 技术栈与整体架构:单进程多线程的 worker 分发模型
2.1 为什么是 mongoose:代码量最小的 HTTP 协议栈
mongoose 不是框架,是一个嵌入式网络库,核心就mongoose.c和mongoose.h两个文件。它内置了 HTTP/1.1 服务端和客户端能力、WebSocket、MQTT、TLS(编译时开关)、文件上传下载、URL 路由匹配,甚至还有一部分网络调试工具。对做嵌入式和服务端中间件的人来说,最直观的价值是它把 HTTP 解析这种容易出细节问题的活全部封装掉了,不需要自己处理\r\n、chunked 编码、keep-alive,也不需要应付跨平台 socket 差异。
这个项目选择 mongoose 的核心原因有三个。第一,单一集成。代码里涉及 HTTP 的部分全部在协议栈内部处理,业务代码只需要面向struct mg_connection和struct mg_http_message编程。第二,事件驱动接口足够简洁,注册一个回调函数,mg_mgr_poll循环驱动事件分发,和 Node.js 的 EventEmitter 思路基本一致,学过事件循环的人上手很快。第三,跨平台,Windows/Linux/macOS 都能编,嵌入式 Linux 上也可以直接用,不需要引入庞大的运行时依赖。
对比直接用 libmicrohttpd 或者自己手写 epoll + HTTP 解析,mongoose 的优势在于:HTTP 解析、长连接管理、WebSocket 升级这些公共能力是现成的,项目自己的代码只需要关注业务逻辑和线程模型。这个项目的定位不是做一个 Nginx 的替代品,而是嵌入式环境里要一个可裁剪、可维护、可快速二次开发的 HTTP 服务底座,mongoose 正是这个底座。
2.2 多线程模型选型:共享 mgr 还是独立 worker mgr
单线程 mongoose 服务的写法是:初始化一个mg_mgr,调用mg_http_listen绑定端口并注册回调,然后死循环mg_mgr_poll。这种写法在连接数少、每个请求处理很快的场景下完全够用,但有一个天然约束:事件回调在单线程里执行,只要一个回调里出现耗时操作,后面排队的请求全部遭殃。
想做多线程,常见的路线有三条。
路线 A:整个进程只创建一个mg_mgr,多个线程同时调用mg_mgr_poll。mongoose 内部提供了加锁编译选项可以支持这种用法,但问题在于所有连接的事件会在多个线程里并发回调,业务代码必须对全部共享状态加锁,而且事件处理的顺序不可控,调试起来比较痛苦。
路线 B:每个 worker 线程持有自己独立的mg_mgr,监听 socket 由主线程统一管理,accept 到新连接后把客户端 fd 通过mg_mgr_add_sock注册到某个 worker 的 mgr 里。这个方案的好处是每个连接从注册到销毁只归属某一个 worker,事件回调天然隔离,worker 之间不需要抢锁,扩展线程数几乎线性。
路线 C:所有 worker 共享同一个监听 socket,各自在自己的 mgr 上重复调用mg_mgr_add_sock注册同一个 listen fd。这个方案在 Linux 上能跑,但多个线程同时 accept 同一个 fd 会出现惊群效应,内核做负载均衡,连接分配的均匀性不可控。
这个项目采用的是路线 B:主线程负责 listen 和 accept,worker 线程各自跑mg_mgr_poll事件循环。每个 worker 只处理自己名下的连接,互不干扰。业务代码只需要保证全局共享数据(比如统计计数、缓存表)上锁即可,连接级别的状态天然线程私有。
2.3 工程目录结构:六个文件组成一个并发服务器
整个项目的源码结构如下:
multi_http_server/ ├── mongoose.c # 开源网络库源码,HTTP/TLS/WebSocket 协议栈 ├── mongoose.h # 网络库头文件 ├── multi_server.c # 项目主代码:主线程 accept + worker 事件循环 + HTTP 路由 ├── Makefile # 构建脚本 ├── web/ │ ├── index.html # 静态页面 │ └── assets/ └── README.mdmongoose.c和mongoose.h是完整的网络库实现,不需要修改。真正自己写的只有一个multi_server.c,包含了入口函数、socket 初始化、worker 线程管理、连接分发、HTTP 回调路由所有逻辑。web/目录是静态资源根目录,mg_http_serve_file直接基于它做文件服务。
2.4 构建方式:mongoose 的 amalgamation 版本怎么编
mongoose 官方发布的是合并后的源码文件,也就是常说的 amalgamation 版本,两个文件直接放进工程即可,不需要安装第三方依赖。编译时要注意两点:一是项目用了多线程,必须链接 pthread;二是mongoose.c可以单独编成目标文件再参与链接,这样在多个文件引用它时不会重复编译。
CC ?= gcc CFLAGS ?= -Wall -O2 -pthread TARGET = multi_server OBJS = mongoose.o multi_server.o $(TARGET): $(OBJS) $(CC) -o $@ $(OBJS) -pthread mongoose.o: mongoose.c mongoose.h $(CC) -c mongoose.c $(CFLAGS) multi_server.o: multi_server.c mongoose.h $(CC) -c multi_server.c $(CFLAGS) clean: rm -f $(TARGET) $(OBJS)-pthread放在编译参数和链接参数里都要有,编译期定义_REENTRANT,链接期引入libpthread。mongoose 默认会在 Linux 上启用 epoll 后端,在 macOS 上是 kqueue,Windows 上是 IOCP,这些后端选择都是编译期自动完成的,不需要业务代码关心。
3. 核心代码拆解:连接分发到请求处理的完整路径
3.1 主线程入口:配置解析与 worker 拉起
入口函数负责解析命令行参数、初始化监听 socket、创建 worker 线程,然后进入 accept 循环。参数解析直接用getopt_long,支持--port、--threads、--root三个参数,默认端口 8080、线程数 4、根目录 web。
#include "mongoose.h" #include <pthread.h> #include <getopt.h> #include <netinet/in.h> #include <sys/socket.h> #define DEFAULT_PORT 8080 #define DEFAULT_THREADS 4 #define DEFAULT_ROOT "web" #define FD_QUEUE_CAP 1024 static volatile int g_stop = 0; static pthread_mutex_t s_mutex = PTHREAD_MUTEX_INITIALIZER; static int s_fd_queue[64][FD_QUEUE_CAP]; // 每个 worker 一个 fd 队列 static int s_fd_head[64], s_fd_tail[64]; static int s_thread_count = DEFAULT_THREADS; struct worker_ctx { struct mg_mgr *mgr; int index; }; static struct worker_ctx s_workers[64]; static void parse_args(int argc, char *argv[]) { int opt; while ((opt = getopt(argc, argv, "p:t:r:")) != -1) { switch (opt) { case 'p': /* --port */ break; case 't': s_thread_count = atoi(optarg); break; case 'r': /* --root */ break; } } }参数解析完成后创建监听 socket。这个项目没有直接使用mg_http_listen,而是自己 bind/listen,然后由主线程 accept,再把客户端 fd 交给 worker 线程注册到各自的 mgr。这样做的原因是:多线程模型下,监听 fd 的 accept 需要集中在一个线程完成,连接分配策略才能做到完全可控。
static int init_listen_socket(int port) { int fd = socket(AF_INET, SOCK_STREAM, 0); int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_ANY), }; if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { perror("bind"); exit(EXIT_FAILURE); } if (listen(fd, SOMAXCONN) != 0) { perror("listen"); exit(EXIT_FAILURE); } return fd; }SO_REUSEADDR必须加上,否则服务重启时端口还处于 TIME_WAIT 状态会直接 bind 失败。SOMAXCONN使用了系统允许的最大 backlog,配合多线程消费连接,短暂的高并发 accept 积压不会丢请求。
主线程流程是:启动 worker 线程,然后循环 accept,把 fd 放入某个 worker 的队列。这里主线程只做 accept 和入队,不参与任何 HTTP 事件处理,所以即使连接速率很高,主线程也不会成为瓶颈,最多是队列满时暂时丢弃新连接。
int main(int argc, char *argv[]) { parse_args(argc, argv); int listen_fd = init_listen_socket(DEFAULT_PORT); pthread_t tids[64]; for (int i = 0; i < s_thread_count; i++) { s_workers[i].index = i; s_workers[i].mgr = (struct mg_mgr *)calloc(1, sizeof(struct mg_mgr)); pthread_create(&tids[i], NULL, worker_main, &s_workers[i]); } while (!g_stop) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int fd = accept(listen_fd, (struct sockaddr *)&peer, &len); if (fd < 0) continue; enqueue_fd(fd); } for (int i = 0; i < s_thread_count; i++) pthread_join(tids[i], NULL); return 0; }calloc初始化mg_mgr结构体,保证所有字段零值。pthread_create传入worker_ctx指针,worker 线程启动后会立即初始化自己的事件管理器并进入 poll 循环。主线程的 accept 是阻塞式的,g_stop置 1 后可以通过 shutdown 监听 fd 来打断 accept 回归退出流程。
3.2 连接分发器:队列缓冲与 worker 注册
enqueue_fd按轮询策略选择 worker,然后加锁写入队列。线程安全靠一把全局互斥锁保护所有 worker 的队列,因为入队操作只在主线程发生,出队操作只在各自 worker 线程发生,这个锁实际上只保护 tail 指针的写入和 head 指针的读取,竞争窗口非常小。
static int pick_worker(void) { static int next; return (next++) % s_thread_count; } static void enqueue_fd(int fd) { int idx = pick_worker(); pthread_mutex_lock(&s_mutex); if (s_fd_tail[idx] - s_fd_head[idx] < FD_QUEUE_CAP) { s_fd_queue[idx][s_fd_tail[idx] % FD_QUEUE_CAP] = fd; s_fd_tail[idx]++; } else { close(fd); // 队列满直接丢弃,避免主线程阻塞 } pthread_mutex_unlock(&s_mutex); }队列满时的处理策略是直接关闭连接。HTTP 客户端在连接被关闭后会立刻重试,这种降级方式在连接突发时保护了 worker 的负载,比让主线程停下来等 worker 消费更合理。
worker 线程是项目的核心。每个 worker 初始化自己的mg_mgr,然后循环做两件事:把队列里新到达的 fd 通过mg_mgr_add_sock注册进自己的事件管理器,再调用mg_mgr_poll驱动事件处理。
static void *worker_main(void *arg) { struct worker_ctx *ctx = (struct worker_ctx *)arg; struct mg_mgr *mgr = ctx->mgr; mg_mgr_init(mgr); while (!g_stop) { pthread_mutex_lock(&s_mutex); while (s_fd_head[ctx->index] < s_fd_tail[ctx->index]) { int fd = s_fd_queue[ctx->index][s_fd_head[ctx->index] % FD_QUEUE_CAP]; s_fd_head[ctx->index]++; mg_mgr_add_sock(mgr, fd, worker_http_handler, NULL); } pthread_mutex_unlock(&s_mutex); mg_mgr_poll(mgr, 500); } mg_mgr_free(mgr); return NULL; }mg_mgr_add_sock是 mongoose 提供的接口,作用是把一个已存在的 socket fd 纳入某个mg_mgr的事件循环管理。调用后,该 fd 上后续的读事件、写事件、关闭事件都会触发传入的回调函数。这里把 accept 到的客户端 fd 交给 worker 时,msdn worker mgr 会负责 fd 后续的生命周期,worker 线程退出时mg_mgr_free会统一清理。
mg_mgr_poll(mgr, 500)的第二个参数是 poll 超时毫秒数。500 毫秒的含义是:没有任何网络事件时,线程最多睡 500 毫秒然后醒来处理一次新入队的 fd;有事件时立即唤醒处理。这个超时值也决定了 fd 从主线程入队到 worker 真正开始处理的最大延迟。
3.3 HTTP 事件回调:路由与静态文件如何分工
worker 线程注册的回调worker_http_handler会在四种事件上被触发:连接建立、HTTP 消息到达、WebSocket 消息、连接关闭。项目只关心 HTTP 消息和关闭事件。
static void worker_http_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; route_request(c, hm); } else if (ev == MG_EV_CLOSE) { // 客户端断开,连接资源由 mongoose 释放 } }MG_EV_HTTP_MSG表示一个完整的 HTTP 请求已经解析完成,ev_data指向struct mg_http_message,里面有 method、uri、body、headers 等字段。mongoose 负责底层解析,业务只面向结构体字段。
路由函数按 URI 前缀做分发。/api/开头的接口走 JSON 逻辑,其他路径按静态文件处理。mg_http_match_uri是 mongoose 内置的路径匹配函数,支持*通配符。
static void route_request(struct mg_connection *c, struct mg_http_message *hm) { if (mg_http_match_uri(hm, "/api/hello")) { mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"msg\":\"hello,world\",\"worker\":%d}", current_worker_id(c)); } else if (mg_http_match_uri(hm, "/api/echo")) { mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"code\":0,\"data\":\"%.*s\"}", (int)hm->body.len, hm->body.ptr); } else if (mg_http_match_uri(hm, "/static/*")) { serve_static_file(c, hm); } else { mg_http_reply(c, 404, "Content-Type: application/json\r\n", "{\"code\":404,\"msg\":\"not found\"}"); } }mg_http_reply是 mongoose 封装的响应函数,自动补充 HTTP 状态行、Content-Length、Connection 头。JSON 格式直接用字符串拼接,%.*s可以安全输出 body 中任意长度的内容,避免%s遇到非\0结尾字符串越界。
静态文件服务单独封装了一个函数,核心是mg_http_serve_file。这个函数会自动处理文件类型映射、Last-Modified、ETag、Range 请求,一个文件下载功能不需要自己写协议逻辑。
static void serve_static_file(struct mg_connection *c, struct mg_http_message *hm) { char path[256]; snprintf(path, sizeof(path), "%s%.*s", DEFAULT_ROOT, (int)hm->uri.len, hm->uri.ptr); struct mg_http_serve_opts opts = { .root_dir = DEFAULT_ROOT, }; mg_http_serve_file(c, hm, path, &opts); }路径拼接时直接使用 URI 的原始字符串,mongoose 在mg_http_serve_file内部会做 URL 解码和路径穿越检测,业务层不需要自己过滤../。如果文件不存在,该函数会自动回 404,不需要额外判断。
3.4 业务处理细节:全局计数与线程安全底线
业务代码里最容易翻车的不是协议,而是多线程下的共享状态。项目里加了一个全局请求计数器,每个请求进来加一,用于在压测时验证线程分发是否均衡。实现上不能直接count++,因为多线程并发读写同一个变量属于未定义行为,必须加锁或使用原子操作。
static size_t s_total_requests; static int s_worker_requests[64]; static void record_request(int worker_id) { pthread_mutex_lock(&s_mutex); s_total_requests++; s_worker_requests[worker_id]++; pthread_mutex_unlock(&s_mutex); } static int current_worker_id(struct mg_connection *c) { // 通过 mgr 指针反查 worker id for (int i = 0; i < s_thread_count; i++) { if (s_workers[i].mgr == c->mgr) return i; } return -1; }record_request放在route_request开头调用。压测结束后,通过s_worker_requests数组可以直接看到每个 worker 实际处理了多少请求,如果分布差异很大,说明分发策略有问题,需要调整节流或改用最小负载优先算法。
注意current_worker_id是 O(n) 遍历,在高频请求下会拉低性能。实际项目中建议把 worker id 放在连接的用户数据里,mongoose 的mg_connection自带用户数据指针,注册连接时直接写入,查询就是 O(1)。
4. 编译、运行与并发验证:让多线程真正扛住压力
4.1 三步编译:从源码到可执行文件
编译流程直接用 Makefile,不需要额外工具链,操作系统有 gcc 和 make 就能编。
make clean make ls -lh multi_server编译通过后会在当前目录生成multi_server可执行文件。如果编译报undefined reference to pthread_*,说明链接时漏了-pthread;如果报mg_mgr_init未定义,说明mongoose.o没有参与链接,检查 Makefile 的依赖关系。
第一次编译建议用make CFLAGS="-Wall -Wextra -O0 -g -pthread"开调试信息,后续用-O2跑性能测试。mongoose 默认编译级别是 Release,不开调试日志,需要看 HTTP 协议细节时,编译期可以加-DMG_LOG_LEVEL=MG_LL_DEBUG。
4.2 运行参数说明:端口、线程数、根目录
启动命令:
./multi_server -p 8080 -t 4 -r web参数含义如下:
| 参数 | 默认值 | 含义 |
|---|---|---|
-p | 8080 | 监听端口 |
-t | 4 | worker 线程数 |
-r | web | 静态文件根目录 |
线程数建议按 CPU 核心数设置。嵌入式设备双核就设 2,开发机八核可以试 8,线程数超过核心数不会提升吞吐,反而增加上下文切换开销。多线程模型下每个 worker 是独立的事件循环,线程数就是同时能阻塞的事件循环数量,所以线程数不能太少,否则一个 worker 里某个请求变慢,会拖累同一 worker 上其他连接。
启动后可以用ss -lntp | grep 8080确认端口监听状态,再用top -H -p观察线程数量和 CPU 占用。
4.3 用 curl 和 ab 验证并发能力
接口功能验证直接用 curl:
curl http://127.0.0.1:8080/api/hello curl -d 'hello mongoose' http://127.0.0.1:8080/api/echo curl -I http://127.0.0.1:8080/static/index.html/api/hello返回 JSON 里带有worker字段,这是验证多线程分发最直接的依据。连续发十个请求,观察 worker 编号是否变化:
for i in $(seq 1 10); do curl -s http://127.0.0.1:8080/api/hello echo done正常情况多个 worker 编号交替出现。如果所有请求都落在一个 worker 上,说明分发参数配置有问题,检查 worker 数量和轮询计数。
并发压测用 Apache ab:
ab -n 20000 -c 200 http://127.0.0.1:8080/api/hello-n是总请求数,-c是并发数。重点关注Requests per second和Time per request两个指标。单线程版本在同样参数下会明显吞吐更低、平均响应时间更长,多线程版本则可以把每个 worker 的 CPU 用起来。如果压测结果出现大量连接失败,先看系统的ulimit -n,嵌入式环境默认文件描述符上限经常只有 1024,并发超过这个数就会 accept 失败。
4.4 日志与性能定位:从日志看 worker 是否均衡
项目里没有引入日志库,直接用 mongoose 自带的分级日志。mongoose.h里定义了MG_LOG_LEVEL宏,编译时设为MG_LL_DEBUG可以看到每次 accept、每次 HTTP 请求解析的详细日志;设为MG_LL_INFO只输出连接建立和关闭事件。生产环境用MG_LL_ERROR减少日志量。
// multi_server.c 里增加一个简单的日志开关 static void log_request(struct mg_http_message *hm, int wid) { fprintf(stderr, "[worker %d] %.*s %.*s\n", wid, (int)hm->method.len, hm->method.ptr, (int)hm->uri.len, hm->uri.ptr); }每个请求打印 worker 编号、method、uri,跑一轮压测后重定向到文件,然后用awk '{print $2}' log | sort | uniq -c看每个 worker 的请求分布。分发均衡时各 worker 数量相近,如果某个 worker 的数字明显偏高,就检查pick_worker的轮询逻辑。
5. 避坑记录:多线程Http Server最容易翻车的五个细节
5.1 回调里做耗时操作:一个慢请求拖垮整个 worker
现象:压测时平均响应时间正常,但随机出现个别请求耗时超过几秒;或者某个接口里有一次数据库查询,查询期间该 worker 上所有连接全部无响应。
原因:worker 线程的事件循环是单线程的,HTTP 回调在 poll 线程里直接执行。回调里一旦有 sleep、同步数据库查询、大文件读盘,整个 poll 循环就卡住了,会阻塞当前 worker 上所有连接的事件处理。
解决:耗时操作必须移出事件回调。标准的做法是回调里只做解析和入队,把请求塞给业务线程池处理。mongoose 提供了跨线程唤醒接口mg_mgr_wakeup,业务线程算完后通过它把结果投递回事件循环,在MG_EV_WAKEUP回调里做真正的mg_http_reply。
5.2 mg_mgr_poll 超时设成 0:CPU 直接打满
现象:服务启动后单核 CPU 占用 100%,但请求量很小,看起来什么也没做。
原因:mg_mgr_poll(mgr, 0)表示轮询超时为 0,事件循环在没有网络事件时不会休眠,而是立刻返回再次循环,形成一个忙等。请求少的时候,这就是纯空转。
解决:poll 超时设置为 100~1000 毫秒。事件到达时 poll 会立即响应,不会因为超时值增大而增加延迟。空转时线程会进入系统休眠,CPU 占用降到接近零。如果业务对连接接入延迟敏感,可以缩短到 100 毫秒,但不建议设成 0。
5.3 全局变量没加锁:并发请求下脏数据随机复现
现象:压测时接口返回值偶发错误,比如一个计数器一会是 999,一会是 1001,完全不符合单调递增的预期。
原因:多个 worker 线程同时读写同一个全局变量,整型变量的自增操作在 CPU 层面是读、改、写三步,两个线程交错执行时一个线程的写入会被另一个覆盖。
解决:给共享状态加互斥锁,或者改用 C11 原子操作。计数器这类简单场景可以用__atomic_add_fetch(&counter, 1, __ATOMIC_SEQ_CST)避免锁开销;复杂结构体必须走pthread_mutex_lock。写代码时建立一个约定:所有static可变变量都要考虑线程安全,连接级别的数据不要放全局,放mg_connection的用户数据指针里。
5.4 静态文件 404:工作目录不是你以为的目录
现象:明明web/index.html存在,curl -I http://127.0.0.1:8080/static/index.html返回 404,日志里显示文件路径是/www/web/index.html或者带了奇怪的拼接路径。
原因:serve_static_file里用相对路径web拼接 URI,而进程实际工作目录取决于服务从哪里启动。用 systemd 启动时工作目录可能是根目录,路径就拼不对了。
解决:启动时把根目录参数转成绝对路径。在main函数里用realpath解析-r参数,传给路由函数;或者干脆在初始化时chdir到资源根目录。排查这个问题最快的方式是打开MG_LOG_DEBUG,mongoose 会打印实际尝试打开的文件路径。
5.5 客户端断开后写数据:SIGPIPE 直接把进程带崩
现象:压测跑到一半进程突然退出,没有 core dump,shell 提示信号 13。
原因:客户端在 RST 状态下关闭连接,服务端还往这个 fd 上写数据,内核直接发 SIGPIPE 信号,默认行为是终止进程。多线程下更常见,因为连接可能刚入队还没被 worker 注册,客户端就断开了。
解决:进程启动时忽略 SIGPIPE:
signal(SIGPIPE, SIG_IGN);然后在mg_http_reply返回值上判断连接状态,写入失败时直接关闭连接,不用继续重试。mongoose 内部对写失败做了处理,但忽略 SIGPIPE 是 C 网络服务必须做的一步,属于血泪经验。
6. 二次开发思路:从静态服务器长成动态框架
6.1 路由改注册式:告别 if-else 堆积
当前路由用mg_http_match_uri加 if-else 实现,接口多了以后维护成本高。可以改成路由表注册模式,定义结构体数组,每个元素绑定 method、路径模式和处理函数:
struct route_entry { const char *method; const char *pattern; void (*handler)(struct mg_connection *, struct mg_http_message *); }; static const struct route_entry routes[] = { { "GET", "/api/hello", handle_hello }, { "POST", "/api/echo", handle_echo }, { "GET", "/static/*", handle_static }, }; static void route_request(struct mg_connection *c, struct mg_http_message *hm) { for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { if (mg_http_match_uri(hm, routes[i].pattern)) { routes[i].handler(c, hm); return; } } mg_http_reply(c, 404, "Content-Type: application/json\r\n", "{\"code\":404}"); }新增接口时只需在routes数组里加一行,代码结构更清晰,也方便以后把路由表挪到配置文件或脚本里动态加载。
6.2 业务线程池:耗时操作的正确归宿
最值得做的改造是把业务处理从事件回调里剥离,引入独立业务线程池。回调收到 HTTP 请求后,把 method、uri、body 拷贝到一个小结构体,丢进业务队列;业务线程从队列取任务,处理完后调用mg_mgr_wakeup找到这个连接。具体来说,每个 worker 对应一个mg_mgr,业务线程需要知道该投递给哪个 mgr,所以任务结构体里要记录 worker id。
struct task { int worker_id; struct mg_connection *c; char body[1024]; }; static void *business_pool(void *arg) { struct task *t = (struct task *)pop_task(); // 计算结果写入 t->body mg_mgr_wakeup(s_workers[t->worker_id].mgr, t->c, t->body, strlen(t->body)); free(t); return NULL; }mongoose 在MG_EV_WAKEUP事件里会把ev_data指向的数据传过来,事件回调里再调用mg_http_reply返回结果。注意这时不能直接访问任务里的mg_connection指针做写操作,因为连接状态由 worker 线程管理,跨线程读写是未定义行为。mg_mgr_wakeup是官方支持的跨线程回调机制,是安全的投递通道。
6.3 WebSocket 与优雅退出:两个高性价比扩展
WebSocket 扩展成本很低,mongoose 内置支持。回调里增加MG_EV_WS_MSG事件分支,收到消息后调用mg_ws_send回数据,连接握手和协议升级都由协议栈完成。如果要做一个设备状态推送服务,这个事件分支就是现成的双向通道。
优雅退出改造也建议加上。当前主线程阻塞在 accept 上,g_stop置 1 后无法立刻退出。标准做法是初始化时创建一个socketpair,把它也注册进主线程的等待集合,退出时向其中一个 fd 写入数据,主线程 accept 被唤醒后返回,再 join 所有 worker。整个过程代码改动不超过二十行,但能避免进程被杀时连接没释放、端口未及时回收的问题。
从第一次写这个多线程 Http Server 到现在,我养成了一个习惯:每写一个网络服务,先画一张线程与事件关系图,标清楚哪些 fd 在哪个线程里 poll、哪些回调可以并发执行,再动手写代码。这次踩过的 SIGPIPE、忙轮询、回调阻塞这几个坑,本质都是没想清楚线程边界。希望这个项目的拆解和分析能帮到你,少走几次弯路。
本文还有配套的精品资源,点击获取