Read OSS

多状态机——每次传输是如何执行的

高级

前置知识

  • 第 1 篇:架构概览与 curl 代码库导读
  • 了解事件驱动编程与状态机模式
  • 熟悉非阻塞 I/O 的基本概念

多状态机——每次传输是如何执行的

无论你是使用简单的阻塞式 curl_easy_perform(),还是通过 curl_multi_perform() 管理数百个并发下载,每一次 curl 传输背后驱动它的都是同一个状态机。这可以说是 libcurl 中最重要的架构决策:整个库只有一个执行引擎,所有代码路径最终都汇聚于此。

本文将通过阅读源码来验证这一点,然后逐一解析传输过程中经历的 16 个状态,最后深入剖析负责协调每次状态转换的 multi_runsingle() 函数。

curl_easy_perform 的秘密:底层全是 multi

大多数开发者最初接触 libcurl 都是从 easy API 开始的:创建句柄、设置选项、调用 curl_easy_perform()。这看起来是一次简单的同步函数调用。但打开 lib/easy.c,这层表象便不攻自破:

sequenceDiagram
    participant App as Application
    participant EP as curl_easy_perform
    participant IP as easy_perform (static)
    participant Multi as Hidden Curl_multi
    participant ET as easy_transfer

    App->>EP: curl_easy_perform(handle)
    EP->>IP: easy_perform(data, FALSE)
    IP->>IP: Check for existing multi_easy
    alt No existing multi
        IP->>Multi: Curl_multi_handle(16, 1, 3, 7, 3)
    end
    IP->>Multi: curl_multi_add_handle(multi, data)
    IP->>ET: easy_transfer(multi)
    loop until done
        ET->>Multi: curl_multi_perform()
        ET->>Multi: curl_multi_poll()
    end
    IP->>Multi: curl_multi_remove_handle(multi, data)
    IP-->>App: CURLcode result

源码第 722 行的注释一语道破:

"设计思路:该函数创建一个 multi 句柄,将 easy 句柄添加进去,循环调用 curl_multi_perform() 直到传输完成,然后移除 easy 句柄、销毁 multi 句柄,并返回 easy 句柄的结果码。"

公开入口 curl_easy_perform() 只有三行,全部委托给静态函数 easy_perform()。后者会复用之前创建的 multi 句柄(存储在 data->multi_easy 中),或者新建一个。接着将 easy 句柄加入 multi,运行阻塞式的传输循环,完成后再移除句柄。

这一设计有一个深远的影响:对 multi 状态机的每一次缺陷修复、性能优化或功能增强,easy API 的用户都能自动受益。 根本不存在需要单独维护的"easy 模式"代码路径。

提示: easy_perform() 创建的 multi 句柄刻意保持精简——Curl_multi_handle(16, 1, 3, 7, 3) 使用最小的哈希表尺寸,因为它始终只管理一个传输。但它仍是一个功能完整的 multi 句柄,拥有独立的连接池、DNS 缓存和定时器树。

CURLMstate:传输的 16 个状态

CURLMstate 枚举 定义了传输可能处于的所有状态。让我们把它们映射到真实的网络阶段:

stateDiagram-v2
    [*] --> INIT
    INIT --> SETUP: pretransfer init
    SETUP --> CONNECT: set timeouts
    CONNECT --> PENDING: no connections available
    PENDING --> CONNECT: slot opens up
    CONNECT --> CONNECTING: DNS + TCP in progress
    CONNECTING --> PROTOCONNECT: TCP connected
    PROTOCONNECT --> PROTOCONNECTING: async protocol handshake
    PROTOCONNECTING --> DO: protocol ready
    PROTOCONNECT --> DO: immediate completion
    DO --> DOING: async request send
    DOING --> DOING_MORE: need 2nd connection (FTP)
    DOING --> DID: request sent
    DOING_MORE --> DID: 2nd connection ready
    DID --> PERFORMING: transfer data
    PERFORMING --> RATELIMITING: rate limit hit
    RATELIMITING --> PERFORMING: timer expired
    PERFORMING --> DONE: transfer complete
    DONE --> COMPLETED: cleanup
    DONE --> INIT: wildcard/redirect
    COMPLETED --> MSGSENT: result posted
状态 阶段 发生了什么
INIT 准备 Curl_pretransfer() — 校验配置、重置计数器
SETUP 准备 设置操作计时器和连接超时
CONNECT 连接 DNS 解析 + 查找/创建连接
PENDING 等待 已排队——已达最大连接数
CONNECTING 连接 TCP/QUIC 连接通过连接过滤器进行中
PROTOCONNECT 协议 协议层连接(如 FTP 登录)
PROTOCONNECTING 协议 异步协议连接的延续
DO 请求 发送请求(HTTP GET、FTP RETR 等)
DOING 请求 异步请求发送的延续
DOING_MORE 请求 辅助连接建立(FTP 数据通道)
DID 请求 请求完全发出,准备接收响应
PERFORMING 传输 数据流式传输——主传输循环
RATELIMITING 传输 因超出速率限制而暂停
DONE 清理 传输后处理,决定是否复用连接
COMPLETED 完成 传输结束,结果可读取
MSGSENT 完成 完成消息已发布给应用程序

INITSETUP 是瞬态——multi_runsingle() 会立即穿越它们。真正的核心工作发生在 CONNECTING(连接过滤器构建 I/O 栈)、DO(协议处理器发送请求)以及 PERFORMING(实际数据传输)这三个状态中。

multi_runsingle:核心 switch 语句

multi_runsingle() 是 libcurl 的心脏所在。这个约 320 行的函数,本质上是一个 do { switch(data->mstate) { ... } } while(...) 循环,每次调用都会将某个传输在当前状态中向前推进,并可能将其推入下一个状态。

flowchart TD
    Entry[multi_runsingle called] --> Check{Check mstate}
    Check --> INIT[INIT: Curl_pretransfer]
    INIT --> SETUP[SETUP: set timeouts]
    SETUP --> CONNECT[CONNECT: state_connect]
    CONNECT --> |connected| PROTOCONNECT
    CONNECT --> |async| CONNECTING[CONNECTING: Curl_conn_connect]
    CONNECTING --> |done| PROTOCONNECT[PROTOCONNECT: protocol_connect]
    PROTOCONNECT --> |immediate| DO[DO: state_do]
    PROTOCONNECT --> |async| PCING[PROTOCONNECTING]
    PCING --> DO
    DO --> |immediate| DID
    DO --> |async| DOING[DOING: protocol_doing]
    DOING --> |more needed| DOING_MORE[DOING_MORE: multi_do_more]
    DOING --> DID[DID: check sockets]
    DOING_MORE --> DID
    DID --> PERFORMING[PERFORMING: state_performing]
    PERFORMING --> |rate limit| RATELIMITING[RATELIMITING]
    RATELIMITING --> PERFORMING
    PERFORMING --> |done| DONEST[DONE: multi_done]
    DONEST --> COMPLETED[COMPLETED]
    COMPLETED --> MSGSENT[MSGSENT: handle_completed]

接下来我们逐一追踪 switch 语句中的关键状态转换,从 第 2519 行 开始:

INIT → SETUP → CONNECT:这三个状态通过 FALLTHROUGH() 宏连续穿透。MSTATE_INIT 调用 Curl_pretransfer() 校验配置;MSTATE_SETUP 启动计时器;MSTATE_CONNECT 调用 state_connect(),后者再调用 url.c 中的 Curl_connect() 来查找连接池中的现有连接或新建一个。

CONNECTING:在 第 2551 行,传输等待连接过滤器完成工作。Curl_conn_connect() 驱动过滤器链(DNS 解析、TCP 连接、TLS 握手——均由第 3 篇介绍的连接过滤器架构处理)。当 connected 返回 TRUE 时,状态推进到 PROTOCONNECT

DO:辅助函数 state_do() 调用协议处理器的 do_it 方法——对 HTTP 来说是发送请求头,对 FTP 来说是发送 RETR 命令。如果协议支持 do_more 机制(由 conn->bits.do_more 标识),状态机会经由 DOING_MORE 路由。

PERFORMING:在 第 2702 行state_performing() 调用 Curl_sendrecv() 驱动实际的数据传输。响应体的流式接收、上传数据的发送以及进度回调的触发,都发生在这里。

底部的 do/while 循环(第 2771 行)在 mresult == CURLM_CALL_MULTI_PERFORM 时持续迭代,使瞬态状态得以连续穿透,无需返回给调用方。

传输循环与 SingleRequest

当状态机进入 PERFORMING 时,控制权转移到 lib/transfer.c 中的数据传输引擎。Curl_sendrecv() 是核心工作函数——它检查传输需要什么样的 I/O(发送、接收或两者兼有),并驱动数据在连接中流动。

每个传输的请求级状态存储在 struct SingleRequest 中,以 data->req 的形式内嵌。这个结构体追踪以下信息:

  • 字节计数bytecount(已接收)、writebytecount(已发送)、size(预期大小)、maxdownload(限制)
  • 响应元数据httpcodehttpversion、头部计数
  • 客户端栈writer_stack(处理响应数据,包括解压缩、分块解码等)和 reader_stack(处理请求数据,包括分块编码等)
  • 完成标志donedownload_doneupload_doneeos_written
sequenceDiagram
    participant SM as State Machine
    participant SR as Curl_sendrecv
    participant CW as Client Writers
    participant CF as Connection Filters
    participant CR as Client Readers

    SM->>SR: state_performing() → Curl_sendrecv()
    SR->>CF: Curl_cf_recv(data, buf)
    CF-->>SR: received bytes
    SR->>CW: Curl_client_write(data, BODY, buf)
    CW->>CW: decompress → decode chunked → write callback
    SR->>CR: Curl_client_read(data, buf)
    CR->>CR: encode chunked → read callback
    SR->>CF: Curl_cf_send(data, buf)
    SR-->>SM: CURLE_OK or done

每次传输开始时,Curl_req_hard_reset() 会重置 SingleRequest。发生重定向时,状态机从 DONE 循环回到 INIT(或 SETUP),触发一个全新的 SingleRequest,同时可能复用原有连接。

并发、待处理传输与速率限制

multi 句柄不只是驱动单个传输——它同时协调多个传输。Curl_multi 结构体 维护四个位集合来对传输进行分类:

  • process:正在被处理的传输
  • dirty:由事件触发、需要立即执行的传输
  • pending:因连接数达到上限而等待中的传输
  • msgsent:结果已发布、传输已完成的句柄

每次调用 curl_multi_perform() 时,它会遍历所有 dirtyprocess 状态的传输,为每个传输调用 multi_runsingle()process_pending_handles() 函数会周期性地检查待处理的传输是否可以晋升为活跃状态——例如,当一个多路复用的 HTTP/2 连接建立成功后,就可以唤醒那些等待连接的传输。

当传输尝试连接但 Curl_cpool_check_limits() 返回 CPOOL_LIMIT_DESTCPOOL_LIMIT_TOTAL 时,传输进入 MSTATE_PENDING 状态,空闲等待,直到另一个传输释放出连接槽位。

MSTATE_RATELIMITING 的用途则有所不同。当设置了 CURLOPT_MAX_RECV_SPEED_LARGECURLOPT_MAX_SEND_SPEED_LARGE 且传输速率超过限制时,state_performing() 会带着一个计算好的定时器跳转到 RATELIMITING 状态。定时器触发后,传输回到 PERFORMING 并继续传输数据。

提示: 调试并发传输问题时,可以启用 CURLOPT_VERBOSE 并观察日志中的状态转换记录。每次状态变化都会通过 multistate() 写入日志,该函数调用 Curl_trc_M()——这是近期版本引入的 curl 结构化追踪系统。

完成路径:DONE → COMPLETED → MSGSENT

Curl_sendrecv() 发出完成信号(接收到全部预期字节或遇到错误),状态机进入 DONE。在 第 2706 行multi_done() 负责处理传输后的清理工作:

  1. 调用协议处理器的 done() 方法
  2. 决定连接是否可以复用(归还连接池)还是必须关闭
  3. 将传输从连接上解绑

对于 FTP 通配符传输,DONE 可以循环回到 INIT 继续处理通配符匹配的下一个文件。其他情况则转入 COMPLETED

COMPLETED 是一个终止状态,传输在此等待。当 multi_runsingle() 检测到 MSTATE_COMPLETED 时,会调用 handle_completed(),向 multi 的消息列表投递一条 CURLMSG_DONE 消息,并将传输移入 MSGSENT。应用程序通过 curl_multi_info_read() 获取这些消息。

下一步

现在我们已经了解了每一次传输——无论通过 easy API 还是 multi API 发起——是如何流经同一个状态机的。但我们对最有趣的部分一笔带过了:CONNECTING 状态下调用 Curl_conn_connect() 时,内部究竟发生了什么?一个连接是如何从"一无所有"走到"TCP 连接建立 + TLS 握手完成 + HTTP/2 协商成功"的?

答案是连接过滤器——curl 的可组合 I/O 架构——这正是第 3 篇的主题。