Mongoose 网络库之事件驱动核心设计
Mongoose 的整个运行时构建在单线程、非阻塞的事件循环基础之上——这种设计使其在裸机微控制器和完整的 POSIX 系统上均具备极高的适用性。本页将深入剖析该循环的三大支柱:事件分发机制、定时器子系统,以及将二者紧密相连的基于轮询的连接管理器。
事件分发架构
Mongoose 的核心在于一个单一的函数——mg_call(),它作为库产生的所有事件的通用分发器。它在 src/event.c 中的实现异常简洁,却编码了一个关键的设计决策:在每次事件触发时,协议处理函数优先于用户处理函数执行。第 19 行的注释明确引用了 issue #2559,证实了这种顺序是刻意为之且经过实战检验的。其签名void mg_call(struct mg_connection *c, int ev, void *ev_data)接收一个连接指针、一个整型事件代码,以及一个不透明数据指针(其类型取决于具体事件)。
事件类型本身在 src/event.h 中被定义为一个普通的 C 枚举,以MG_EV_ERROR(值为 0)开始,以MG_EV_USER结束,后者作为应用层自定义事件的起始 ID。该枚举刻意保持紧凑——仅用 23 个值便涵盖了从连接创建(MG_EV_OPEN)到数据传输(MG_EV_READ、MG_EV_WRITE)再到终止(MG_EV_CLOSE)的完整生命周期,此外还包含了针对 HTTP、WebSocket、MQTT、SNTP、mDNS 的协议特定事件,以及一种通用的唤醒机制。每个事件都携带明确记录的有效载荷类型:例如MG_EV_READ传递long *bytes_read,MG_EV_HTTP_MSG携带struct mg_http_message *等。
配套的错误处理函数mg_error()位于 src/event.c,展示了事件系统在连接生命周期管理中的作用。它将错误信息格式化到栈缓冲区中,记录日志,在分发MG_EV_ERROR之前设置c->is_closing = 1,然后调用mg_call()。这种顺序至关重要:用户处理函数在接收到错误事件时,连接已被标记为即将关闭,但处理函数仍有机会检查状态并执行清理工作。第 31 行的注释明确指出了这一点——is_closing是在事件触发前刻意设置的。
双处理函数模式:协议与用户
src/net.h 中的mg_connection结构体包含两个独立的处理函数指针:fn(用户处理函数)和pfn(协议处理函数)。它们各自拥有对应的fn_data/pfn_data空指针用于传递上下文。之所以进行这种拆分,是因为 Mongoose 需要同时支持原始连接和协议封装连接(HTTP、MQTT、WebSocket、TLS)。当 src/net.c 中的mg_connect_svc()创建一个感知协议的连接时,它会同时设置c->fn(用户回调)和c->pfn(协议的内部状态机)。协议处理函数处理线缆级别的帧格式,并发出更高级别的事件,交由用户处理函数消费。
这种架构意味着,单个 TCP 连接在其生命周期内可以穿越多个协议层。一个以MG_EV_CONNECT和MG_EV_READ开头的原始 TCP 连接,可以在传输中途升级为 WebSocket,此时pfn会切换为 WebSocket 状态机,用户处理函数开始接收MG_EV_WS_MSG而不再是原始字节。对于原始 TCP/UDP 连接,pfn指针也可以为NULL,此时mg_call()会完全跳过协议处理步骤。
协议处理函数(pfn)始终在用户处理函数(fn)之前执行。这意味着,当你的用户回调运行时,协议级状态(如 HTTP 解析或 MQTT 会话跟踪)必定已经是最新的。绝不要做其他假设——如果你需要在处理函数中检查协议状态,它此时必然是一致的。
事件目录
src/event.h 中完整的事件枚举揭示了一种分层设计。核心传输事件负责处理套接字的生命周期:
| 事件 | 有效载荷 | 触发条件 |
|---|---|---|
MG_EV_ERROR | char * | 任何致命错误;连接将关闭 |
MG_EV_OPEN | NULL(客户端) / URL 字符串 | 连接对象已创建 |
MG_EV_POLL | uint64_t *uptime_millis | 每次mg_mgr_poll()迭代 |
MG_EV_RESOLVE | NULL | DNS 解析完成 |
MG_EV_CONNECT | NULL | TCP 连接已建立 |
MG_EV_ACCEPT | NULL | 监听器接受了新连接 |
MG_EV_READ | long *bytes_read | c->recv中有可用数据 |
MG_EV_WRITE | long *bytes_written | 数据已从c->send刷新 |
MG_EV_CLOSE | NULL | 连接正在被销毁 |
MG_EV_WAKEUP | struct mg_str * | 通过mg_wakeup()发送了数据 |
协议事件构建在这些传输原语之上。MG_EV_HTTP_MSG和MG_EV_HTTP_HDRS携带解析后的 HTTP 消息;MG_EV_WS_OPEN、MG_EV_WS_MSG和MG_EV_WS_CTL代表 WebSocket 生命周期;MG_EV_MQTT_CMD、MG_EV_MQTT_MSG和MG_EV_MQTT_OPEN覆盖了 MQTT 客户端状态机。MG_EV_USER哨兵值(值为 23)为应用程序定义自定义事件提供了一个稳定的基准偏移量。
定时器子系统
Mongoose 在 src/timer.c 和 src/timer.h 中的定时器实现刻意追求轻量级——这是一个通过指针的指针操作来管理的单链表。每个mg_timer结构体存储了以毫秒为单位的周期、过期时间戳、回调函数、用户参数、标志字段,以及用于链表链接的next指针。src/timer.c 中的mg_timer_init()函数只需将新定时器 prepend 到链表头部,使得注册操作的时间复杂度为 O(1)。
src/timer.c 中mg_timer_expired()的过期逻辑以极其优雅的方式处理了三种边缘情况。首先,如果now + prd < *t,说明系统时钟发生了回绕(或向后跳跃),因此将过期时间重置为零。其次,如果*t == 0,说明这是初始化后的首次轮询,因此将过期时间设置为now + prd。第三,下一次过期时间的计算公式*t = (now - *t) > prd ? now + prd : *t + prd防止了定时器漂移:如果回调执行时间超过了一个周期,定时器会重新同步到当前时间,而不是试图通过多次快速触发来“赶上进度”。
src/timer.c 中的mg_timer_poll()函数遍历链表,为每个定时器调用mg_timer_expired(),并对已过期或需要立即调度的定时器执行回调。它还处理了MG_TIMER_AUTODELETE标志:对于至少被调用过一次且携带自动删除标志的非重复定时器,系统会自动将其从链表中释放。这种模式常用于一次性延迟操作,调用者无需保留对定时器结构体的引用。
| 标志 | 值 | 行为 |
|---|---|---|
MG_TIMER_ONCE | 0 | 触发一次(默认) |
MG_TIMER_REPEAT | 1 | 周期性触发 |
MG_TIMER_RUN_NOW | 2 | 在首次轮询时立即触发 |
MG_TIMER_CALLED | 4 | 内部使用:回调至少触发过一次 |
MG_TIMER_AUTODELETE | 8 | 一次性完成后释放定时器结构体 |
连接管理器:mg_mgr
src/net.h 中的mg_mgr结构体是整个事件循环唯一的协调中心。它持有一个活跃连接链表(conns)、一个定时器链表(timers)、用于 IPv4 和 IPv6 的 DNS 客户端状态、用于 Linux 的 epoll 文件描述符、指向内置 TCP/IP 接口的指针(在无操作系统环境下使用)、用于跨线程唤醒的 socketpair 管道,以及各种配置字段。src/net.c 中的mg_mgr_init()函数首先将结构体置零,然后执行平台特定的设置:在 Linux 上创建 epoll 实例,在 FreeRTOS 上初始化套接字集,在 POSIX 上忽略SIGPIPE信号,或在裸机目标上调用驱动初始化宏。DNS 服务器默认设置为 Google 的8.8.8.8和2001:4860:4860::8888。
src/net.c 中的mg_mgr_free()函数展示了有序的关闭序列:它首先遍历定时器链表并释放所有定时器,然后将每个连接标记为正在关闭,接着以零超时调用mg_mgr_poll(mgr, 0)以刷新关闭事件。这确保了每个连接在内存被回收前都会接收到其MG_EV_CLOSE事件——mg_close_conn()中第 155 行的注释明确引用了 issue #1331,指出MG_EV_CLOSE必须在接收到的数据被释放之前触发。
连接生命周期事件
每个连接从创建到销毁都会经历一个确定的事件序列。对于客户端连接,src/net.c 中的mg_connect_svc()会分配一个mg_connection,将其添加到管理器的链表中,设置is_client = true,以 URL 作为ev_data触发MG_EV_OPEN,然后启动异步 DNS 解析。对于服务端连接,src/net.c 中的mg_listen()会分配连接,打开一个监听套接字,设置is_listening = true,将其添加到链表,并以 NULL 有效载荷触发MG_EV_OPEN。
src/net.h 中的mg_connection结构体对其状态标志采用了密集的位域布局——17 个独立的布尔标志被打包到无符号位域中。这些标志并非随意设置的状态位,它们构成了一个状态机。is_resolving→is_connecting→is_tls_hs跟踪了客户端连接的握手进度。is_draining和is_closing提供了两种截然不同的关闭模式:draining 会等待直到c->send被刷新,而 closing 则是立即执行。is_full通过在接收缓冲区达到容量上限时暂停读取,实现了 TCP 反压机制。
src/net.c 中的mg_alloc_conn()函数使用mg_calloc(1, sizeof(*c) + mgr->extraconnsize)来分配连接,其中extraconnsize被内置 TCP/IP 栈用于追加特定于驱动的每连接数据。对recv、send和rtls这三个 iobuf 应用的MG_IO_SIZE对齐,确保了为嵌入式网络硬件提供最佳的 DMA 对齐。
mg_mgr_poll()事件循环
声明于 src/net.h 并在平台特定网络层实现的mg_mgr_poll()函数,是驱动一切的引擎。其签名void mg_mgr_poll(struct mg_mgr *mgr, int ms)接收一个以毫秒为单位的超时时间,并在每次迭代中执行四个独立的阶段:
- 定时器轮询:调用
mg_timer_poll(&mgr->timers, now_ms)触发所有已过期的定时器回调,确保周期性任务和一次性延迟操作在 I/O 处理之前执行。 - I/O 多路复用:使用平台原生机制(Linux 上的 epoll,其他 POSIX 系统上的 poll 或 select,RTOS 上的套接字事件),在给定的超时时间内等待任意连接套接字描述符的可读或可写状态。
- 连接处理:对于每个就绪的连接,执行相应的操作——在监听器上接受新连接、将数据读入
c->recv并触发MG_EV_READ、从c->send写出数据并触发MG_EV_WRITE、完成非阻塞连接并触发MG_EV_CONNECT,或处理接受错误并触发MG_EV_ERROR。 - 清理阶段:遍历所有连接,关闭任何标记了
is_closing或is_draining(且发送缓冲区为空的 draining 连接)的连接。关闭过程会触发MG_EV_CLOSE,释放 TLS 状态,释放 iobuf,将结构体置零,并释放内存。
正是由于这种单函数循环,Mongoose 不需要线程、不需要单独的读写任务,也不需要外部事件库。整个系统——DNS 解析、TLS 握手、HTTP 解析、MQTT 保活、WebSocket 帧处理——全部馈入同一个轮询与分发周期。
传递给mg_mgr_poll()的ms参数既是睡眠超时时间,也是响应性控制参数。传入0会使循环以最大速度空转(适用于关闭期间的排空处理),而传入较大的值则能降低 CPU 占用。定时器子系统会自动调整实际超时时间,使其不超过下一个定时器的过期时间,因此你无需手动进行计算。
无锁跨线程唤醒
Mongoose 被设计为单线程运行,但实际系统通常需要从其他线程注入数据(GUI 回调、传感器中断、RTOS 任务)。声明于 src/net.h 的mg_wakeup()机制通过向 socketpair 管道写入数据解决了这一问题。mg_mgr中的pipe字段保存了该管道对的一端;另一端则注册到了 I/O 多路复用器中。当mg_wakeup()向管道写入数据时,下一次mg_mgr_poll()迭代会检测到可读状态,读取数据,并向目标连接分发带有struct mg_str *有效载荷的MG_EV_WAKEUP事件。
src/queue.c 中的底层队列实现使用编译器内存屏障(MG_MEMORY_BARRIER())提供了一个无锁环形缓冲区。mg_queue_book()函数预留写入空间,mg_queue_add()提交写入,mg_queue_next()推进读取指针,mg_queue_del()释放已读数据。这种模式允许不同线程中的生产者和消费者在无需互斥锁的情况下操作共享内存,完全依赖于内存排序保证。
数据流:IO 缓冲区契约
每个连接都携带三个在 src/net.h 中定义的mg_iobuf结构体:recv(传入的应用数据)、send(传出的应用数据)和rtls(等待 TLS 解密的传入加密数据)。事件契约是明确无歧义的:当MG_EV_READ触发时,新接收的数据会被追加到c->recv,long *bytes_read有效载荷会告诉你添加了多少字节。你的处理函数从c->recv中处理数据,通常使用协议解析函数来消耗缓冲区头部的字节。当MG_EV_WRITE触发时,long *bytes_written会告诉你从c->send中刷新了多少字节。
输出操作通过调用mg_printf()或mg_send()进行排队,这些函数会向c->send追加数据。实际的套接字写入发生在mg_mgr_poll()内部——你永远不需要直接调用 write。这种排队与传输的解耦正是非阻塞模型得以实现的原因:你的处理函数可以在单次MG_EV_HTTP_MSG回调中排队数兆字节的响应数据,而轮询循环会在多次迭代中逐步排空缓冲区,并且仅在c->send达到容量极限时才会施加反压。
协议组合
双处理函数模式意味着协议是通过链式调用来组合的。TLS 连接的pfn被设置为 TLS 状态机,它将数据从c->rtls解密到c->recv,并在传输前将c->send中的数据加密。HTTP 服务端连接的pfn被设置为 HTTP 解析器,它从c->recv中积累数据,解析出完整的消息,并触发MG_EV_HTTP_MSG。建立在 HTTP 之上的 WebSocket 连接将 WebSocket 处理函数设置为pfn,由它管理帧格式并触发MG_EV_WS_MSG。每一层只能看到来自其下层的事件和数据。
src/net.c 中的mg_connect_svc()函数是协议组合的入口点。虽然mg_connect()会为pfn和pfn_data传递NULL(产生原始连接),但感知协议的函数如mg_ws_connect()或mg_mqtt_connect()会在调用mg_connect_svc()时预装其协议处理函数。这意味着你的用户处理函数看到的第一个事件已经是经过协议过滤的——无需手动编写升级逻辑。