Rust in Everything
基于 reqwest 0.13.5

阻塞客户端

主线位置:请求主线的一条旁路。调用线程上的 send() 不走 tower 栈,而是把请求交给后台线程;后台线程上的异步 Client 照常走完请求主线和连接主线(一次请求),再把 Response 交回来。响应主线分在两个线程上:text()、Read::read 在调用线程上 poll 异步 Response 的体(解压也在这里做),套接字上的字节则由后台运行时里的连接任务读出来,再经 hyper 的 Incoming 交到调用线程。

sequenceDiagram
  participant U as 调用线程
  participant Q as mpsc 通道
  participant R as 运行时线程
  participant A as async Client
  U->>Q: send 请求和 oneshot
  U->>U: wait timeout 里 park
  Q->>R: recv
  R->>A: execute 再 spawn forward
  A-->>R: Response
  R-->>U: oneshot 发送并 unpark
  U->>U: 包成 blocking Response

reqwest::blocking 不是另一套 HTTP 实现。blocking::ClientBuilder 里装着一个异步的 async_impl::ClientBuilder 和一个超时值,87 个公开方法里除了 new、build、timeout,全是经 with_inner 转给异步 builder 的一行转发;blocking::Client 里只有一个 ClientHandle。src/blocking/ 共 4217 行(按 tag 统计 .rs,含文档注释和测试),真正做事的是三处:ClientHandle 的后台线程、wait::timeout 的停车等待、请求体从 Read 到异步流的转接。它在源码里的位置见源码地图。

这个 feature 只多拉 futures-channel、futures-util 和 tokio/sync;运行时用的是 current_thread,不需要 rt-multi-thread。

实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5(模板 feature),tokio 1.54.1。服务端是 testsrv 的明文 HTTP/1.1,跑在自己的线程和运行时上;客户端一律 .no_proxy()。实验 crate 在 exp-reqwest/bw-blocking,0.12.28 的对照在 exp-reqwest/bw-012max。

一个 Client,一个线程,一个运行时

ClientHandle::new 起一个名叫 reqwest-internal-sync-runtime 的线程,在里面建 current_thread 运行时,再在运行时里 build 异步客户端:

        let (tx, rx) = mpsc::unbounded_channel::<(async_impl::Request, OneshotResponse)>();
        let (spawn_tx, spawn_rx) = oneshot::channel::<crate::Result<()>>();
        let handle = thread::Builder::new()
            .name("reqwest-internal-sync-runtime".into())
            .spawn(move || {
                use tokio::runtime;
                let rt = match runtime::Builder::new_current_thread()
                    .enable_all()
                    .build()
                    .map_err(crate::error::builder)
                // ...
                let f = async move {
                    let client = match builder.build() {
                    // ...
                    if let Err(e) = spawn_tx.send(Ok(())) {
                    // ...
                    let mut rx = rx;

                    while let Some((req, req_tx)) = rx.recv().await {
                        let req_fut = client.execute(req);
                        tokio::spawn(forward(req_fut, req_tx));
                    }
                    // ...
                };
                // ...
                rt.block_on(f);
                // ...
                drop(rt);
                // ...
            })
            .map_err(crate::error::builder)?;

        // Wait for the runtime thread to start up...
        match wait::timeout(spawn_rx, None) {
            Ok(Ok(())) => (),
            Ok(Err(err)) => return Err(err),
            Err(_canceled) => event_loop_panicked(),
        }
  • 异步 build 在后台线程上跑。 它需要的东西(TokioTimer、hyper-util 池的后台清理任务、DNS)都在这个运行时里。build 失败(比如 TLS 配置自相矛盾)时错误经 spawn_tx 送回,调用方的 build() 照样拿到 Err。
  • 调用方等后台线程报「好了」才返回。 等待用的是 wait::timeout(spawn_rx, None),和等响应是同一个函数,下面会看到它在 debug 构建里先做一次检查。
  • 请求通道是无界的 tokio::sync::mpsc,每个元素是「异步请求 + 一个 oneshot::Sender」。后台循环收到一个就 client.execute(req) 再 tokio::spawn,不等它完成就去收下一个,所以多个调用线程共享一个 blocking::Client 时,请求在后台线程上是并发的,和异步客户端一样能共用连接池和 HTTP/2 连接。
  • 克隆共享线程。 ClientHandle 里是 Arc<InnerClientHandle>,克隆不起新线程;最后一个引用丢掉时,Drop 先丢掉发送端让后台的 rx.recv() 返回 None,再 join 线程。

线程数用 ps -M 数(bw-blocking/src/bin/lat.rs)。起点是主线程 1 个,测试服务端占 3 个:

threads at start: 1
threads with server: 4
...
threads with 1 blocking client: 5
threads with 2 blocking clients (+1 clone): 6
threads after dropping the second: 5

每个 blocking::Client 一个线程,克隆不加,丢掉就回收。同一个程序里默认的 #[tokio::main] 多线程运行时有 12 个 worker(本机 12 核),对比一下:同一份输出里 threads with multi_thread rt: 16。

build 的代价也随之变了。同一个进程里交替建 50 次(都是 .no_proxy(),先建一次排除首次初始化),release:

build async    min=1.125µs median=1.25µs
build blocking min=19.75µs median=23.458µs
last blocking drop took 23.416µs

异步 build 去掉系统代理后不到 2 µs(和一个 Client 测的 0.6 µs 同一量级,这里是在 block_on 里建的);阻塞版多出的约 20 µs 是起线程、建运行时、两次跨线程交接。丢掉最后一个引用同样要 join,约 23 µs。另外两遍在负载更高时测得 39 / 38 µs(min),量级不变。对只建一次的 Client 无所谓;reqwest::blocking::get 每次调用都建一个 Client,每个请求就多付一次起线程和收线程。

请求过去,响应回来

execute_request 在调用线程上运行:

    fn execute_request(&self, req: Request) -> crate::Result<Response> {
        let (tx, rx) = oneshot::channel();
        let (req, body) = req.into_async();
        let url = req.url().clone();
        let timeout = req.timeout().copied().or(self.timeout.0);

        self.inner
            .tx
            .as_ref()
            .expect("core thread exited early")
            .send((req, tx))
            .expect("core thread panicked");

        let result: Result<crate::Result<async_impl::Response>, wait::Waited<crate::Error>> =
            if let Some(body) = body {
                let f = async move {
                    body.send().await?;
                    rx.await.map_err(|_canceled| event_loop_panicked())
                };
                wait::timeout(f, timeout)
            } else {
                let f = async move { rx.await.map_err(|_canceled| event_loop_panicked()) };
                wait::timeout(f, timeout)
            };
        // ...

后台线程上,forward 同时 poll 请求 future 和 tx.poll_closed:调用方因为超时放弃、丢掉了 oneshot::Receiver,forward 就返回,请求 future 随之被丢掉。调用方的超时因此不只是「不等了」,后台的请求也停了。

请求体分两种(Body::into_async)。Vec<u8>、String、&'static str 之类变成异步的 Body::reusable,和异步客户端的完整 body 一样(两种形态见请求体与 multipart)。File 或 Body::new(reader) 是 Read,异步客户端没法直接读它,于是建一条容量为 0 的 futures_channel::mpsc:异步那一侧拿到 Body::stream(rx),调用线程上的 send_future 每次 read 最多 8192 字节往通道里塞。read 是同步调用,它在调用线程上做,不占后台运行时。验证一下(bodythread.rs,Read 实现每次打印线程名,connector_layer 打印连接器在哪个线程被调用):

read() on thread Some("main") buf.len()=8192
connector called on thread Some("reqwest-internal-sync-runtime")
read() on thread Some("main") buf.len()=8188
caller thread Some("main")
server saw: transfer-encoding: chunked
server saw: body: 8 bytes

第一次 read 发生在连接建立之前:send_future 先读一块再等通道有空位。长度未知的 Read 在线上是 chunked。

wait::timeout:调用线程怎么等

wait::timeout 是一个最小的 block_on:拿当前线程的 Thread 做 waker,poll 一次,没好就 thread::park 或 park_timeout,被 unpark 再 poll:

    let thread = ThreadWaker(thread::current());
    // Arc shouldn't be necessary, since `Thread` is reference counted internally,
    // but let's just stay safe for now.
    let waker = Waker::from(Arc::new(thread));
    let mut cx = Context::from_waker(&waker);

    futures_util::pin_mut!(fut);

    loop {
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(Ok(val)) => return Ok(val),
            Poll::Ready(Err(err)) => return Err(Waited::Inner(err)),
            Poll::Pending => (), // fallthrough
        }

        if let Some(deadline) = deadline {
            let now = Instant::now();
            if now >= deadline {
                log::trace!("wait timeout exceeded");
                return Err(Waited::TimedOut(crate::error::TimedOut));
            }
            // ...
            thread::park_timeout(deadline - now);
        } else {
            log::trace!("({:?}) park without timeout", thread::current().id());
            thread::park();
        }
    }

它 poll 的 future 有三种:等响应头的 oneshot::Receiver,「读 Read、塞通道」的 send_future,以及 text()、bytes()、Read::read 里读体的 future。套接字 IO 和 reqwest 自己的超时计时器都由后台线程的运行时驱动,它们就绪时唤醒的是这里的 ThreadWaker;wait::timeout 自己不需要 IO 驱动,它的超时就是 park_timeout 加一次比较。

30 秒默认超时,按段计

异步客户端默认没有任何超时(连接器一页实测过卡住的请求永远不结束)。阻塞客户端不一样,Timeout 的 Default 是 30 秒:

impl Default for Timeout {
    fn default() -> Timeout {
        // default mentioned in ClientBuilder::timeout() doc comment
        Timeout(Some(Duration::from_secs(30)))
    }
}

服务端 40 秒后才回响应头,两种客户端都不设超时(tmo.rs --default):

default client, head at 40s                  30.010s  Err: error sending request for url (http://127.0.0.1:61967/?head_ms=40000) | is_timeout=true
async default client, head at 40s            40.011s  Ok(200)

这个 30 秒不是总超时。ClientBuilder::timeout 的值存在 ClientHandle 里,不交给异步客户端;它只是每次 wait::timeout 的上限。而 wait::timeout 在一个请求里会被调用好几次:send() 等响应头一次,text() / bytes() / json() 读完整个体一次,Read::read 每读一块一次,每次都重新从零计时。RequestBuilder::timeout 则不同:into_async 把它放进异步请求,由异步客户端当总超时用(从连接到读完体,见超时),同时它也替换掉调用方每次等待的上限。

❗IMPORTANT
动脑筋:blocking::Client::builder().timeout(Duration::from_secs(1)),服务端 0.7 秒后发响应头,响应体分两块,第二块再晚 0.7 秒。client.get(url).send()?.text() 成功还是超时?用了多久?平台:M2 Max、macOS 27,reqwest 0.13.5,本机明文 HTTP/1.1。

服务端每块之间隔 0.7 秒、一共 5 块,同样 1 秒的客户端超时(tmo.rs):

client timeout 1s: copy_to 5 chunks           2.816s  Ok(5)
client timeout 1s: text 5 chunks              1.010s  Err: error decoding response body | is_timeout=true
  • copy_to 走 std::io::copy,每次 read 只等下一块,每块 0.7 秒都没超过 1 秒,2.8 秒读完。一个每 29 秒吐一个字节的服务器,用 Read 读阻塞响应,默认配置下永远不会超时。
  • text() 是一次 wait::timeout 包住读完整个体,1 秒到就失败。这个错误是阻塞层自己造的 decode 错误,Display 里没有 URL;同样的超时如果来自 RequestBuilder::timeout(异步那一侧),错误带 URL,见下面的动脑筋答案。

blocking::ClientBuilder 没有 read_timeout。和异步 builder 的方法名逐个比,阻塞版少了三个:read_timeout、tls_early_data、windows_named_pipe。需要读超时可以先配好异步 builder 再 blocking::ClientBuilder::from(async_builder),但这个 From 会把阻塞层的超时重置成默认的 30 秒。

📝NOTE
冷知识:timeout(Duration::MAX) 在 0.13.5 之前会 panic

#3060「Fix blocking client timeout panic if duration is massive」(ea3d9ca,2026-06-23,首次发布在 v0.13.5)。之前 wait::timeout 算截止时间是 Instant::now() + d,Duration::MAX 一加就溢出。站点锁的 0.12.28 还是这一行,用 bw-012max 复现,不需要服务器,send() 一调用就 panic:

thread 'main' (7989760) panicked at .../tokio-1.54.1/src/time/instant.rs:161:27:
overflow when adding duration to instant

修法是上面引用的 checked_add:溢出就当成没有超时,并打一条 warn 日志「timeout ... overflows system clock, disabling timeout」;提交同时加了测试 big_timeout_duration_does_not_overflow。0.13.5 上同样的调用正常返回:timeout(Duration::MAX) 1.381ms Ok(200)。想关掉超时,两个版本都可以直接传 None。

在异步上下文里用它

模块文档说 reqwest::blocking「must not be executed within an async runtime, or it will panic when attempting to block」,build 的文档也写着「This method panics if called from within an async runtime」。实际的检查在 wait::enter:

fn enter() {
    // Check we aren't already in a runtime
    #[cfg(debug_assertions)]
    {
        let _enter = tokio::runtime::Builder::new_current_thread()
            .build()
            .expect("build shell runtime")
            .enter();
    }
}

它建一个空的运行时、进入、立刻丢掉。在异步上下文里丢掉一个运行时,tokio 会 panic(tokio 卷的 Cannot start a runtime from within a runtime 一页列了这一族报错)。reqwest 借这个 panic 当检查,而且只在 debug_assertions 下做。

panic.rs 在一个多线程运行时的 block_on 里分别做六件事,debug 和 release 各跑一遍。send、drop、text 三项的 Client / Response 是在进运行时之前准备好的:

在运行时里做debugrelease
blocking::Client::builder().build()panic正常
client.get(url).send()panicOk(200)
drop(client)正常正常
response.text()panic正常
reqwest::blocking::get(url)panicOk(200)
tokio::task::spawn_blocking 里建、发、丢正常正常

四个 panic 都是同一句,回溯里的 reqwest 帧是 wait::enter(build 那一次从 ClientHandle::new 等后台线程启动时进来):

thread 'main' (7932824) panicked at .../tokio-1.54.1/src/runtime/blocking/shutdown.rs:51:21:
Cannot drop a runtime in a context where blocking is not allowed. This happens when a runtime is dropped from within an asynchronous context.
...
   7: reqwest::blocking::wait::enter
             at .../reqwest-0.13.5/src/blocking/wait.rs:88:21
...
   9: <reqwest::blocking::client::ClientHandle>::new
             at .../reqwest-0.13.5/src/blocking/client.rs:1476:15

这句话说的是「丢掉运行时」,读者自己的代码里并没有丢运行时,这是这个报错让人困惑的地方。drop(client) 不经过 wait::timeout,所以 debug 下也不 panic,但它会 join 后台线程,同样是在 worker 线程上阻塞。

release 下没有检查,调用线程就在 worker 上 park。多线程运行时里别的 worker 还能干活,上面的表全都「正常」;只占住了一个 worker。换成 current_thread 运行时,并且服务端也跑在这个运行时上(#[tokio::test] 默认就是这种形状),就成了死锁:唯一的线程停在 park 里,服务端的任务没人 poll。samert.rs,客户端超时设 2 秒:

current_thread: Display: error sending request for url (http://127.0.0.1:63129/a)
current_thread: Err(reqwest::Error { kind: Request, url: "http://127.0.0.1:63129/a", source: TimedOut }) after 2.012808666s
current_thread: server reqs=0
multi_thread: Ok(200) after 1.703875ms
multi_thread: server reqs=1

服务端一个请求都没收到,请求停够超时才报错;用默认配置是卡 30 秒,timeout(None) 则永远卡住。错误看起来和服务端真的超时一模一样。

结论:在异步代码里要么用异步 Client,要么像模块文档说的那样把阻塞调用放进 spawn_blocking(表里最后一行)。debug 下的 panic 是一个提醒,不是保证。worker 线程被阻塞的一般后果见 tokio 卷的在异步代码里阻塞。

📝NOTE
冷知识:0.10 的两个 alpha 里,这个检查返回的是错误,不是 panic

blocking 模块是 #626(87a0932)建的,首次发布在 v0.10.0-alpha.1。那时 wait::timeout 开头是 enter().map_err(|_| Waited::Executor(crate::error::BlockingClientInAsyncContext))?,用的是 tokio_executor::enter,在任何构建里都能检查,返回的错误 Display 是「blocking Client used inside a Future context」。提交 0f32c4a「Update to hyper 0.13」(首次发布在 v0.10.0)换到 tokio 0.2,tokio_executor 没有了,这个错误类型连同 Waited::Executor 一起删掉,换成了今天这个只在 #[cfg(debug_assertions)] 下「建一个运行时再进去」的 enter()。之后的改动只是跟着 tokio 换 API:v0.10.2 的 #807 让这个空运行时只用一个线程(提交说明:自动探测 CPU 数「adds significant syscall-tracing noise and runtime latency」),tokio 1.0 时改成 new_current_thread()。所以「release 下不检查」从 0.10.0 起一直如此。

开销:单请求延迟

同一个服务端、同一个连接(先发 200 个预热),顺序发 2000 个 GET 并读完体,计每个请求的耗时;每种客户端 5 轮,各统计量取 5 轮里最小的(lat.rs,release,uptime 负载 10.2):

async current_thread         min=   36.0µs p50=   49.6µs p99=   91.3µs
async multi_thread           min=   27.2µs p50=   40.8µs p99=   86.8µs
blocking                     min=   33.0µs p50=   46.3µs p99=   85.3µs

又跑了两遍(负载 11.9 和 19.0),blocking 的 p50 是 97.0 µs 和 46.4 µs,两个 async 的 p50 在 40.0 到 49.5 µs 之间;p99 随负载在 0.09 到 2.7 ms 之间跳。读法:

  • 阻塞客户端没有明显更慢。 它多出来的是两次跨线程交接(调用线程 → 后台线程 → 调用线程)和 oneshot / mpsc 各一次,在回环上一个请求几十微秒里看不出来;第二遍 p50 的 97 µs 和同一轮异步的数字差一倍,更像是负载下调用线程被唤醒得慢,第三遍又回到 46 µs。
  • 阻塞客户端的后台运行时是 current_thread,单请求时它和异步 current_thread 做的事一样,差别在等待方式:调用线程 park,而不是在同一个线程里 poll。
  • 真实网络上一个往返是毫秒级,这几微秒可以忽略。值得在意的是线程数和上一节的超时语义,而不是延迟。

动脑筋答案

client timeout 1s: send+text                  1.408s  Ok(2)
request timeout 1s: send+text                 1.002s  Err: error decoding response body for url (http://127.0.0.1:61912/?head_ms=700&chunks=2&size=1&body_ms=700) | is_timeout=true

第一行是题目:成功,用了 1.4 秒,比设的 1 秒超时还长。send() 等响应头用了 0.7 秒,没超过 1 秒;text() 是另一次 wait::timeout,重新从零计时,又等了 0.7 秒,也没超过。两段各自都在 1 秒以内,加起来 1.4 秒。

第二行是对照:同一个 URL,把 1 秒设在 RequestBuilder::timeout 上。这个值进了异步请求,成为从连接到读完体的总超时,1.0 秒时在读体的途中触发。错误来自异步那一侧,所以 Display 带着 URL。

小结

  • blocking::Client 是一个 Arc 包着「发送端 + 线程句柄」:每个 Client 一个 reqwest-internal-sync-runtime 线程,里面一个 current_thread 运行时跑异步 Client;克隆共享线程,最后一个引用丢掉时 join。
  • send() 把异步请求和一个 oneshot 塞进无界 mpsc,后台 spawn 一个 forward;调用线程在 wait::timeout 里 park 等。调用方放弃时 forward 也丢掉请求。
  • Read 做的请求体在调用线程上读,经容量为 0 的通道交给后台,线上是 chunked。
  • 默认 30 秒超时(异步客户端默认没有),但它是每次等待的上限:等头一次、text() 一次、Read::read 每块一次;RequestBuilder::timeout 才是覆盖整个请求的总超时。阻塞 builder 没有 read_timeout。
  • 在运行时里调用:debug 下 build / send / text panic「Cannot drop a runtime in a context where blocking is not allowed」,drop 不 panic;release 不检查,多线程运行时里只是占住一个 worker,current_thread 加同运行时的服务端就死锁到超时。用 spawn_blocking。
  • 起一个阻塞 Client 约 20 µs、一个线程;单请求延迟在回环上和异步客户端同一量级。

💬 关联讨论

发起讨论
加载中...