libcurl 多接口事件驱动机制内幕:解析 Multi Event Based(MULTI-EV)架构
【免费下载链接】curlA command line tool and library for transferring data with URL syntax, supporting DICT, FILE, FTP, FTPS, GOPHER, GOPHERS, HTTP, HTTPS, IMAP, IMAPS, LDAP, LDAPS, MQTT, MQTTS, POP3, POP3S, RTSP, SCP, SFTP, SMB, SMBS, SMTP, SMTPS, TELNET, TFTP, WS and WSS. libcurl offers a myriad of powerful features项目地址: https://gitcode.com/GitHub_Trending/cu/curl
libcurl 的 multi 接口支持"事件驱动(event based)"模式:应用程序借助libuv、libevent等事件库监听 libcurl 使用的 socket 与文件描述符,从而以极低开销驱动大量并发传输。本文基于 docs/internals/MULTI-EV.md 展开,逐层剖析 multi 事件驱动的内部实现——从lib/multi_ev.c与lib/multi_ev.h的职责划分、socket 变更追踪的数据结构,到事件回流的处理路径与资源清理时序,帮助读者真正理解curl_multi_socket_action()背后发生了什么,以及为何 socket 复用会成为事件追踪的关键难点。
事件驱动模式的两层视角
从应用视角看,事件驱动模式并不复杂:应用把 libcurl 关心的 socket 注册到事件库(如 libuv),当 socket 上有可读/可写事件时,事件库回调应用,应用再调用curl_multi_socket_action()通知 libcurl 处理。这套 API 的使用方式由 libcurl-multi(3) 手册描述,对应仓库中的 docs/libcurl/curl_multi_socket_action.md 等文档。
本文关注的是另一层——libcurl 内部如何处理这些事件。所有与事件驱动相关的代码集中在两个文件中:
- lib/multi_ev.c:事件追踪与差分计算的具体实现;
- lib/multi_ev.h:对外暴露一组内部函数,并定义内嵌于每个 multi 句柄的
struct curl_multi_ev结构体。
struct curl_multi_ev的完整定义见 lib/multi_ev.h,它只包含一个哈希表sh_entries,以 socket 为键保存每个 socket 的追踪状态:
struct curl_multi_ev { struct Curl_hash sh_entries; };这个结构体作为字段ev内嵌在struct Curl_multi中(见 lib/multihandle.h),随 multi 句柄的创建与销毁而初始化与释放。生命周期由两个函数管理:
Curl_multi_ev_init():在 multi 句柄创建时初始化事件簿记哈希,调用点位于 lib/multi.c;Curl_multi_ev_cleanup():在 multi 句柄销毁时释放哈希,调用点位于 lib/multi.c 与 lib/multi.c。
事件追踪的前提:socket 回调必须已注册
lib/multi_ev.h中声明的众多函数,只有在应用通过curl_multi_setopt(CURLMOPT_SOCKETFUNCTION, ...)注册了multi->socket_cb回调后才会真正工作。这是理解整个模块的关键前提。
原因在于:multi->socket_cb是 libcurl 与应用之间唯一的"事件通告"通道。每当发生以下变化时,libcurl 都必须调用它:
- 新增了一个 socket;
- 移除了一个现存 socket;
- 某个 socket 上的
POLLIN/POLLOUT关注标志发生了变化。
因此multi_ev必须记录"上一次已经向应用报告过什么",才能检测出"现在有什么变化"。在 lib/multi_ev.c 的mev_assess()中可以看到这个前提被显式检查:
static CURLMcode mev_assess(struct Curl_multi *multi, struct Curl_easy *data, struct connectdata *conn) { struct easy_pollset ps, *last_ps; CURLMcode mresult = CURLM_OK; if(!multi || !multi->socket_cb) return CURLM_OK; ... }值得注意的是,虽然大多数应用从一开始就采用事件驱动模式,但 libcurl 的 API 并不禁止应用先以其他方式(如curl_multi_perform())启动,中途再切换到事件驱动——甚至可以在一次传输进行到一半时切换。multi_ev的差分机制必须能优雅应对这种状态迁移。
传输事件:pollset 的获取与差分
传输(transfer)相关的事件占据了绝大多数场景:一次传输要打开连接、连接打开 socket,例如使用 TCP 时等待 socket 变为可写(POLLOUT)。这时 multi 会调用Curl_multi_ev_assess_xfer(multi, data)(声明见 lib/multi_ev.h),让事件代码检测该传输对哪些 socket 感兴趣。
mev_assess()的处理流程(lib/multi_ev.c)如下:
- 通过
Curl_multi_pollset()获取传输当前的 pollset(即当前关心的 socket 及各自需要的 IN/OUT 标志); - 通过
Curl_meta_get(data, CURL_META_MEV_POLLSET)取出上一次保存的 pollset; - 调用
mev_pollset_diff()对前后两个 pollset 做差分(lib/multi_ev.c),检测出三类变化:- socket 在当前集合中,但不在上一次集合中(新增关注);
- socket 两次都在,但 IN/OUT 标志发生了变化(关注类型变更);
- socket 在上一次集合中,但已不在当前集合中(停止关注)。
差分结束后,Curl_pollset_move(prev_ps, ps)把本次 pollset 保存为新的"上一次 pollset",供下次比对。
mev_sh_entry:每个 socket 的追踪状态
multi_ev.c为每个 socket 在哈希sh_entries中维护一个struct mev_sh_entry(定义见 lib/multi_ev.c):
struct mev_sh_entry { struct uint32_spbset xfers; /* bitset of transfers `mid`s on this socket */ struct connectdata *conn; /* connection using this socket or NULL */ void *user_data; /* libcurl app data via curl_multi_assign() */ unsigned int action; /* CURL_POLL_IN/CURL_POLL_OUT we last told the * libcurl application to watch out for */ unsigned int readers; /* this many transfers want to read */ unsigned int writers; /* this many transfers want to write */ ... BIT(announced); /* this socket has been passed to the socket callback at least once */ };这个条目记录的关键信息包括:
- 哪些传输(以
data->mid标识,保存在uint32_spbset位集中)对这个 socket 感兴趣; - 有多少个传输想读(
readers)、多少个想写(writers); - 上一次通告给应用的汇总动作
action(CURL_POLL_IN/CURL_POLL_OUT组合); - 是否已经通告过(
announced)。
为什么需要按传输计数而不是简单布尔值?因为同一个 socket 可能同时被多个传输使用——典型例子是 HTTP/2 在同一连接上的多路复用。当一个传输结束并从 socket 条目中移除时,它会使readers/writers计数减一,而这可能导致条目的汇总动作发生变化,也可能不变(还有其他传输仍需要该方向)。因此只有基于计数才能真正决定是否值得再次调用multi->socket_cb。
mev_sh_entry_update()(lib/multi_ev.c)实现这个聚合逻辑:先更新单个传输在读写计数上的增减,再根据writers/readers是否非零合成comboaction,只有当汇总动作与上次通告给应用的动作不同时才触发回调。代码中的注释还揭示了一个工程细节:由于curl_easy_pause()被允许在任意回调中调用,它可能重入mev_assess()并释放当前entry,因此回调返回后需要重新从哈希中取回 entry再更新其状态。
回调失败与清理
差分过程中如果发现某个 socket 已无任何传输或连接使用(mev_sh_entry_user_count() == 0),会调用mev_forget_socket()(lib/multi_ev.c)做清理:从各 pollset 中移除该 socket,若该 socket 曾通告给应用(announced为真)则调用multi->socket_cb并传入CURL_POLL_REMOVE,最后从哈希中删除条目。若回调返回 -1,则标记multi->dead = TRUE并返回CURLM_ABORTED_BY_CALLBACK,即回调有权中止整个 multi 的操作。
连接事件:内部句柄与连接级 pollset
并非所有事件都与某个传输绑定。multi 的连接缓存关注连接关闭(shutdown)期间的 socket 事件,以保证连接能够干净地关闭。
关键设计是:连接缓存借助一个内部 easy 句柄(internal easy handle)来复用 libcurl 的基础设施。这个内部句柄本身并不是一次传输,它仅在很短的时间内与某个特定连接绑定,用于完成该连接的所有关闭操作。因此"为内部句柄记录上一次 pollset"是没有意义的——句柄与连接的绑定关系转瞬即逝。
解决办法是让连接缓存调用Curl_multi_ev_assess_conn()(声明见 lib/multi_ev.h,实现见 lib/multi_ev.c),由事件处理代码针对连接自身进行检查,并单独记录一份"连接级的上一次 pollset"。这份 pollset 通过连接元数据键CURL_META_MEV_POLLSET(定义于 lib/multi_ev.h)保存在连接上,与传输级 pollset 互不干扰。
从调用链上看,连接缓存中的关闭流程位于 lib/cshutdn.c:在 lib/cshutdn.c 中,内部句柄multi->admin先Curl_attach_connection()挂接连接,再调用Curl_multi_ev_assess_conn()完成连接级事件评估,随后立即Curl_detach_connection()解除绑定;而在连接真正被释放前(lib/cshutdn.c),会调用Curl_multi_ev_conn_done()清理连接的事件追踪。
事件处理:从 socket 事件到传输执行
当事件库告知应用某个 socket 上有事件时,应用调用curl_multi_socket_action()。该 API 的入口实现位于 lib/multi.c,它内部转发到静态函数multi_socket()(lib/multi.c)。
multi_socket()的核心处理路径(对应文档中"Event Processing"一节)如下:
- 若
s != CURL_SOCKET_TIMEOUT,调用Curl_multi_ev_dirty_xfers(multi, s)把所有对该 socket 感兴趣的传输标记为 dirty(lib/multi.c); - 通过
multi_mark_expired_as_dirty()把已到期的传输也标记为 dirty; - 调用
multi_run_dirty()运行所有 dirty 传输; - 必要时再次运行(处理运行期间新到期者),最后更新定时器。
注:文档中提到的内部函数名为
Curl_multi_ev_expire_xfers()("expire"所有对该 socket 感兴趣的传输,并返回bool告知连接池是否也需处理),在当前仓库版本中该职责已由Curl_multi_ev_dirty_xfers()承担——它通过 lib/multi_ev.c 的实现,把 socket 条目上所有相关传输标记为 dirty,且如果条目上还有连接(entry->conn),则连同内部句柄multi->admin一起标记,等价于"通知连接池"。阅读源码时以Curl_multi_ev_dirty_xfers()为准。
Curl_multi_ev_dirty_xfers()还有一处值得注意的容错逻辑:注释指出在真实世界的测试中,libevent 等事件库可能给出"已经被要求移除"的 socket 上的事件(lib/multi_ev.c),因此对哈希中不存在的 socket,它选择忽略而不是报错——这正是对"迟到事件"的防御。
万物皆会消逝:三类清理与无序性
文档"All Things Pass"一节描述了三个清理时机,当前实现与文档一一对应:
- 传输结束:传输从 multi 句柄移除时,调用
Curl_multi_ev_xfer_done()(lib/multi_ev.h)。实现见 lib/multi_ev.c:先做一次最终评估(确保把传输最后的兴趣变化通知出去),再删除传输上的CURL_META_MEV_POLLSET元数据。调用点位于 lib/multi.c,且调用前传输已被Curl_detach_connection()分离。 - 连接销毁:连接被销毁前调用
Curl_multi_ev_conn_done()(lib/multi_ev.h),清理连接级 pollset 追踪,调用点在 lib/cshutdn.c。 - socket 关闭:socket 即将关闭时调用
Curl_multi_ev_socket_done()(lib/multi_ev.h),通过mev_forget_socket()清理 socket 条目及其中保存的全部信息。调用点位于 lib/multi.c 的Curl_multi_will_close()中。
这三个调用不要求按特定顺序发生。传输进行期间它的 socket 可能一直存在,也可能在传输中途就消失;同时,一个传输可能在同一时刻关心多个 socket——域名解析、Happy Eyeballs 双栈连接探测("eye balling")、FTP 的多连接场景都是典型例子。multi_ev的数据结构必须能容忍这些交错的生命周期。
Come Again:socket 标识符复用难题
文档最后一节"Come Again"点出了事件追踪中最微妙的问题:传输和连接的标识符在 libcurl 应用中几乎唯一,但 socket 标识符(文件描述符数值)不是。操作系统热衷于复用资源——刚关闭的 socket,其标识符很快就会被分配给下一个新建 socket。
这意味着 multi 事件处理必须在 socket 关闭之前被通知(即Curl_multi_ev_socket_done()的调用时机),完成所有追踪信息的清理,并准备好迎接"同一个 socket 标识符立刻再次出现"的情况。如果清理不及时,新 socket 可能会被误认为旧 socket,从而向应用报告错误的事件。
这一设计也解释了mev_pollset_diff()中的一处细节:当传输首次出现在某个 socket 上(first_time)时,会忽略last_poll中记录的旧动作并重置为 0(lib/multi_ev.c),因为该 socket 可能已被销毁并重新打开——尽管sh_entry已被清除,但哈希化的 pollset 中可能仍残留旧引用,必须以全新状态对待。
小结:一条贯穿始终的差分主线
把整个机制串起来,可以看到一条清晰的主线:
- 应用注册
CURLMOPT_SOCKETFUNCTION回调后,multi 进入事件驱动模式; - 每次传输或连接的兴趣集合变化时,
multi_ev通过 pollset 差分识别出新增/变更/移除三类变化,以按传输计数的读写聚合决定是否通告应用; - socket 事件到达时,应用调用
curl_multi_socket_action(),内部把相关传输标记为 dirty 并运行; - 传输、连接、socket 各自结束时,都有对应的
*_done()函数清理追踪状态,且不要求顺序; - 由于操作系统会复用 socket 标识符,所有清理必须在 socket 关闭前完成,并准备好接受同一标识符的"轮回"。
这套机制使得 libcurl 的事件驱动模式既能支撑 HTTP/2 多路复用这种"一 socket 多传输"的高密度场景,又能容忍应用在传输中途切换事件模式、事件库投递迟到事件等现实世界的各种边角情况。想要深入阅读实现细节的读者,可以重点查看 lib/multi_ev.c、lib/multi_ev.h 这两个核心文件,以及 lib/multi.c 中multi_socket()的事件分发逻辑。
【免费下载链接】curlA command line tool and library for transferring data with URL syntax, supporting DICT, FILE, FTP, FTPS, GOPHER, GOPHERS, HTTP, HTTPS, IMAP, IMAPS, LDAP, LDAPS, MQTT, MQTTS, POP3, POP3S, RTSP, SCP, SFTP, SMB, SMBS, SMTP, SMTPS, TELNET, TFTP, WS and WSS. libcurl offers a myriad of powerful features项目地址: https://gitcode.com/GitHub_Trending/cu/curl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考