尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

mongoose httpserver 浅析:从 mg_http_listen 到 mg_mgr_poll 的事件循环骨架

mongoose httpserver 浅析:从 mg_http_listen 到 mg_mgr_poll 的事件循环骨架 1. 从 mg_http_listen 到 mg_mgr_poll一个单线程 HTTP 服务的骨架长什么样mongoose 内置的 HTTP server 是很多嵌入式项目和轻量级 C 服务的第一选择原因很直接整个网络库只有 mongoose.c 和 mongoose.h 两个文件拖进工程就能编译不依赖一堆第三方库。它用单线程加非阻塞 socket 的方式跑事件循环在资源受限的板子上也能撑起一个可用的 HTTP 服务。如果你正在找 mg_http_listen 怎么注册监听、mg_mgr_poll 到底在循环里干了什么、epoll 事件是怎么被翻译成 is_readable / is_writable 标志位的这篇就按这条链路走一遍。适合的读者是写过 C、想搞明白 mongoose HTTP server 启动流程的人被 mg_mgr_poll 里那一大坨 if-else 绕晕的人以及想自己搭一个最小可运行骨架、再往上加路由和业务逻辑的人。下面给出一份可以直接复制编译的 C 骨架然后逐段拆开讲 mg_http_listen 注册监听、mg_mgr_poll 驱动 epoll 事件循环这两步到底发生了什么。整个过程不涉及任何网络环境配置纯本地编译运行。2. 前置准备拿到 mongoose 两个文件并确认编译环境mongoose 的获取方式很朴素官方仓库里就是 mongoose.c 和 mongoose.h 两个文件把它们放到你的工程目录即可。不需要包管理器不需要 CMake 的复杂配置一个 gcc 命令就能编。我试过在 Linux 上直接用系统自带的 gcc 编译唯一要注意的是链接时带上 -lpthread因为 mongoose 内部会用到线程相关的符号即使你的 HTTP server 是单线程跑的。另外如果你用的是较新的内核epoll 是默认开启的mongoose 会走 epoll 分支而不是 select 分支这也是本文关注的重点。注意mongoose 的版本更新比较频繁不同版本里 mg_mgr 结构体的字段可能有增减。本文基于常见的 7.x 版本如果你用的是更早的 6.x部分字段名会不一样但 mg_http_listen 和 mg_mgr_poll 这两个核心 API 的用法基本稳定。如果你后续要把这套骨架接到真实的模型服务或编码 Agent 上做联调可以先把 API Key 和接入文档准备好方便本地服务起来之后直接对接。API Keys 管理入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 需要看模型对话效果的话模型对话页在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 。这些是后面联调阶段的事现在先把本地 HTTP 骨架跑通。3. 可复制配置最小 HTTP server 骨架与编译命令先上完整代码。这份骨架只做一件事监听 8000 端口收到任何请求都回一句 hello。它的价值在于把 mg_http_listen 和 mg_mgr_poll 的调用位置固定下来你后面加路由、加 JSON 响应、加文件服务都是在这个骨架上改。#include mongoose.h static void fn(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; // 打印请求方法和 URI方便观察事件触发顺序 MG_INFO((request: %.*s %.*s, (int) hm-method.len, hm-method.ptr, (int) hm-uri.len, hm-uri.ptr)); mg_http_reply(c, 200, Content-Type: text/plain\r\n, hello from mongoose\n); } } int main(void) { struct mg_mgr mgr; mg_mgr_init(mgr); // 初始化事件管理器内部会创建 epoll_fd // 注册监听绑定 8000 端口回调统一走 fn struct mg_connection *c mg_http_listen(mgr, http://0.0.0.0:8000, fn, NULL); if (c NULL) { MG_ERROR((listen failed)); return 1; } // 事件循环1000ms 超时驱动 epoll_wait 和连接状态机 for (;;) { mg_mgr_poll(mgr, 1000); } mg_mgr_free(mgr); return 0; }编译命令gcc -o http_server http_server.c mongoose.c -lpthread -DMG_ENABLE_LOG1这里 -DMG_ENABLE_LOG1 是为了打开日志方便你看到 MG_INFO 的输出。如果你不想看日志去掉这个宏定义即可。运行./http_server然后在另一个终端里curl -v http://127.0.0.1:8000/hello你应该能看到 curl 返回 hello from mongoose同时服务端终端打印出 request: GET /hello。这一步跑通说明 mg_http_listen 注册监听和 mg_mgr_poll 驱动事件循环这条链路是通的。4. mg_http_listen 内部socket、bind、listen 到 epoll 注册mg_http_listen 的签名是struct mg_connection *mg_http_listen(struct mg_mgr *mgr, const char *url, mg_event_handler_t fn, void *fn_data);它做的事情可以拆成四步。第一步是解析 url从 http://0.0.0.0:8000 里提取出地址和端口。第二步是创建 socket调用 socket() 拿到一个 fd。第三步是 setsockopt 设置 SO_REUSEADDR然后 bind 到指定地址再 listenbacklog 传的是 128。第四步最关键把监听 fd 设置成非阻塞模式然后通过 MG_EPOLL_ADD 宏把这个 fd 注册到 mgr-epoll_fd 上。MG_EPOLL_ADD 宏展开后是这样的#define MG_EPOLL_ADD(c) \ do { \ struct epoll_event ev {EPOLLIN | EPOLLERR | EPOLLHUP, {c}}; \ epoll_ctl(c-mgr-epoll_fd, EPOLL_CTL_ADD, (int) (size_t) c-fd, ev); \ } while (0)注意这里注册的事件是 EPOLLIN | EPOLLERR | EPOLLHUP而且是水平触发模式没有加 EPOLLET。这意味着只要监听 fd 上有新连接进来epoll_wait 就会持续通知直到你调用 accept 把连接取走。这个设计对新手比较友好不容易因为漏读而丢事件。同时mg_http_listen 会为这个监听 fd 分配一个 struct mg_connection把 is_listening 标志位置 1然后把它挂到 mgr-conns 这个单向链表的头部。所以 mgr-conns 链表里既有监听连接也有后续 accept 出来的客户端连接mg_mgr_poll 遍历链表时靠 is_listening 这个位来区分。这里有个容易忽略的点监听 fd 对应的 mg_connection 的 pfn 字段被设置成了 http_cb这是协议处理函数。后续 accept 出来的客户端连接会继承这个 pfn所以客户端连接上的数据会被 http_cb 解析成 HTTP 报文。5. mg_mgr_poll 内部epoll_wait 与连接状态机mg_mgr_poll 是整个事件循环的心脏。它的结构可以概括为先调 mg_iotest 做一次 epoll_wait 并把结果翻译成每个连接的 is_readable / is_writable 标志位然后遍历 mgr-conns 链表根据标志位和连接状态分派到 accept_conn、read_conn、write_conn 等处理函数。mg_iotest 里先遍历一遍链表把每个连接的 is_readable 和 is_writable 清零同时统计链表长度 max。然后如果某个连接的 send.len 0说明有数据要发就调 MG_EPOLL_MOD 把 EPOLLOUT 加上去。接着用 alloca 在栈上分配一个 max 大小的 epoll_event 数组调用 epoll_wait。epoll_wait 返回后遍历返回的事件数组对每个事件做判断如果是 EPOLLERR调 mg_error 标记连接出错否则根据事件类型设置 is_readable 和 is_writable。这里有个细节can_read 和 can_write 会检查连接是否处于可读可写状态比如 is_closing 的连接就不再标记可读。回到 mg_mgr_poll 的主循环遍历链表时按优先级处理for (c mgr-conns; c ! NULL; c tmp) { tmp c-next; if (c-is_resolving || c-is_closing) { // 跳过 } else if (c-is_listening c-is_udp 0) { if (c-is_readable) accept_conn(mgr, c); } else if (c-is_connecting) { if (c-is_readable || c-is_writable) connect_conn(c); } else { if (c-is_readable) read_conn(c); if (c-is_writable) write_conn(c); } if (c-is_draining c-send.len 0) c-is_closing 1; if (c-is_closing) close_conn(c); }监听连接走 accept_conn 分支。accept_conn 调用 accept 拿到客户端 fd为它分配一个新的 mg_connection设置 is_accepted 1继承监听连接的 pfn 和 fn把新连接加到链表头部然后 MG_EPOLL_ADD 注册到 epoll。注意 accept 出来的 fd 也被设置成非阻塞。客户端连接走 read_conn 和 write_conn。read_conn 调用 recv 把数据读到 c-recv.buf然后调 iolog 处理返回值。如果 recv 返回 EWOULDBLOCK 就什么都不做返回 0 或负数就标记 is_closing返回正数就调 mg_call(c, MG_EV_READ, n)这会触发 http_cb 解析 HTTP 报文。http_cb 解析完成后调 mg_call(c, MG_EV_HTTP_MSG, hm)把解析结果交给你的 fn 回调。你的 fn 里调 mg_http_reply 把响应写进 c-send.buf。write_conn 调用 send 把 c-send.buf 发出去发完后如果 send.len 变成 0就调 MG_EPOLL_MOD(c, 0) 把 EPOLLOUT 去掉避免 epoll 一直通知可写。整个流程下来一个请求的生命周期是epoll_wait 通知监听 fd 可读 → accept_conn 建立客户端连接 → 下一轮 epoll_wait 通知客户端 fd 可读 → read_conn 读数据 → http_cb 解析 → fn 回调生成响应 → epoll_wait 通知客户端 fd 可写 → write_conn 发数据 → 响应发完连接保持或关闭。6. 验证请求与观察事件顺序跑起来之后用 curl 发几个请求观察服务端日志。你会看到类似这样的输出1234 2 accept 127.0.0.1:54321 - 0.0.0.0:8000 1235 3 request: GET /hello第一行是 accept_conn 里的 MG_DEBUG 打的第二行是你 fn 里的 MG_INFO。这说明事件顺序是 accept 先于 read符合预期。再试一个带 body 的 POSTcurl -X POST -d {name:test} http://127.0.0.1:8000/api服务端会打印 request: POST /api你的 fn 里可以通过 hm-body 拿到请求体。如果想验证并发可以用 ab 或 wrk 压一下ab -n 1000 -c 50 http://127.0.0.1:8000/观察服务端是否稳定有没有连接泄漏。mongoose 单线程处理50 并发下应该能正常跑完但如果你的 fn 里有阻塞操作吞吐会明显下降因为整个事件循环被卡住了。7. 本篇常见错排查第一个常见错是编译时报 undefined reference topthread_create这是因为没加 -lpthread。mongoose 内部有些地方会用到线程即使你的 HTTP server 是单线程跑的链接时也得带上。第二个错是运行时报 listen failed。先检查端口是不是被占用了用 ss -tlnp | grep 8000 看一下。如果端口没被占检查 url 格式必须是 http://0.0.0.0:8000 这种带协议前缀的形式写成 0.0.0.0:8000 会解析失败。第三个错是 curl 能连上但一直没响应。这通常是 fn 回调里没调 mg_http_reply或者调了但 Content-Length 和实际 body 长度不一致。mongoose 的 mg_http_reply 会自动算 Content-Length但如果你手动拼响应头就得自己保证长度正确。如果 Content-Length 写大了客户端会一直等剩余数据写小了客户端会截断。第四个错是关于 is_closing 何时被置 1 的疑问。在正常的一问一答场景里客户端发一次、服务端回一次连接不会立刻关闭is_closing 保持 0。只有当客户端主动断开触发 EPOLLHUP或者服务端在 fn 里显式设置 c-is_closing 1或者 recv 返回 0对端关闭才会进入关闭流程。如果你在 fn 里调了 mg_http_reply 之后又调 mg_printf 追加数据但没更新 Content-Length客户端可能收不全数据因为 Content-Length 决定了客户端读多少字节。要追加数据得用 mg_http_reply 一次性生成或者手动管理 Content-Length。第五个错是压测时出现 EPOLLERR。这通常和 send.len 与 Content-Length 不匹配有关也可能是对端提前关闭了连接。排查时打开 MG_ENABLE_LOG看日志里有没有 socket error 的输出结合请求内容定位。8. 把骨架接到真实服务上本地骨架跑通之后下一步通常是让它对接真实的模型服务或编码 Agent。这时候你需要一个稳定的 API 入口把 HTTP 请求转发过去。API 接入地址是 https://taotoken.net/api 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite API Keys 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 。如果你要长期跑编码类 Agent可以看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 模型对话验证在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contenthttp_server_skeletonutm_campaignrewrite 。回到 mongoose 本身这个骨架的扩展点很清晰在 fn 回调里根据 hm-uri 做路由分发用 mg_http_reply 返回 JSON或者用 mg_http_serve_dir 直接服务静态文件。事件循环那部分不用动mg_mgr_poll 会一直帮你驱动 epoll。唯一要记住的是fn 回调里不要做阻塞操作否则整个单线程事件循环都会被卡住这是 mongoose 单线程模型的代价也是它轻量的原因。
返回列表