conn::http1 客户端
主线位置:客户端主线的中段。
legacy::Client拿到一条连接后,真正发请求的是这里的SendRequest:请求和一个回调放进通道,后台Connection任务里的Dispatcher把它写出去、读回响应、通过回调交回来。本页讲通道两端怎么商量「现在能不能发」,以及请求在各个环节失败时调用方拿到什么。
flowchart TB caller["调用方 send_request"] --> sender["dispatch::Sender"] sender -->|"Envelope:请求和 Callback"| mpsc["无界 mpsc"] mpsc --> recv["dispatch::Receiver"] recv --> dc["dispatch::Client"] dc --> disp["Dispatcher:写请求、读响应"] disp -->|"Callback.send"| shot["oneshot 回到调用方"] recv -.->|"Taker.want:连接空闲"| flag["want 标志"] flag -.->|"Giver.give / poll_want"| sender
一次请求从头到尾经过哪些任务、哪些函数,一个请求的一生已经跑过一遍;Dispatcher 的循环见 Dispatcher。这一页只看客户端特有的三样东西:请求通道、dispatch::Client 这个 Dispatch 实现、回调。
handshake:拿到两个半边
handshake 是 Builder::new().handshake(io) 的简写。Builder::handshake 建一条请求通道,发送端放进 SendRequest,接收端交给 dispatch::Client,再和 Conn 一起装进 Dispatcher,成为 Connection。HTTP/1 没有握手报文,这个 future 一个字节也不发(代码见一个请求的一生)。
Connection 是一个 future,要有人 poll。通常 tokio::spawn(conn);不 poll 它,SendRequest 什么也做不了。HTTP/1 的 Builder 没有 Executor 参数:一条连接一个 future,hyper 自己不需要再 spawn 任何东西。v1.0.0-rc.3 的 CHANGELOG 记着这一点:http1::Builder::executor()「is removed, since it did nothing」(#3108)。HTTP/2 不同,它的 Builder 要一个执行器,见 HTTP/2 客户端。
Connection 自己的文档写了 drop 的后果:「Dropping the Connection will close the underlying IO resource. Any in-flight requests that have not received a response will be interrupted.」在途请求被打断时报什么错,见本页后面的 dispatch task is gone。
Connection::poll 收到 Dispatched::Upgrade 时调 pending.manual(),等升级的一方会收到错误;要把 IO 交出去得用 with_upgrades(),见协议升级。without_shutdown / into_parts 让连接结束时不关 IO,把 IO 和读缓冲里多出来的字节还给你。
SendRequest 的方法都落在通道的发送端上:
| 方法 | 背后是 |
|---|---|
poll_ready / ready | Giver::poll_want:标志是 Want 才 Ready(Ok);接收端没了是 Err(ChannelClosed) |
is_ready | Giver::is_wanting,只看一眼,不注册 waker |
is_closed | Giver::is_canceled |
send_request | Sender::send,回调是 NoRetry |
try_send_request | Sender::try_send,回调是 Retry,失败时可能把请求还回来 |
请求通道:无界 mpsc 加一个 want 标志
channel 把 tokio 的无界 mpsc 和 want crate(0.3.2)的一对 Giver / Taker 绑在一起。Sender:
pub(crate) struct Sender<T, U> {
/// One message is always allowed, even if the Receiver hasn't asked
/// for it yet. This boolean keeps track of whether we've sent one
/// without notice.
#[cfg(feature = "http1")]
buffered_once: bool,
/// The Giver helps watch that the Receiver side has been polled
/// when the queue is empty. This helps us know when a request and
/// response have been fully processed, and a connection is ready
/// for more.
giver: want::Giver,
/// Actually bounded by the Giver, plus `buffered_once`.
inner: mpsc::UnboundedSender<Envelope<T, U>>,
}
能不能发,由 can_send 决定:
fn can_send(&mut self) -> bool {
if self.giver.give() || !self.buffered_once {
// If the receiver is ready *now*, then of course we can send.
//
// If the receiver isn't ready yet, but we don't have anything
// in the channel yet, then allow one message.
self.buffered_once = true;
true
} else {
false
}
}
send 先问 can_send,不行就把请求原样还回去(Err(val)),send_request 把它变成 Canceled, "connection was not ready";行就新建一个 oneshot,把请求和回调装进 Envelope 塞进 mpsc。
want 的标志只有四个值,谁改它:
| 状态 | 谁设的 | 含义 |
|---|---|---|
Idle | 初始值;Giver::give 从 Want 换成 Idle;Taker::unwant | 连接没在要请求 |
Want | Taker::want,在 Receiver::poll_recv 看到队列为空时 | 连接空闲,要下一个 |
Give | Giver::poll_want 在不是 Want 时登记 waker | 发送端在等,want() 时要叫醒它 |
Closed | Taker::cancel,Receiver 被 drop 或 close 时 | 连接没了 |
give() 是一次比较交换:Want 才换成 Idle 并返回真。所以每说一次「要」,只放进来一个请求。
buffered_once 有一个容易看漏的地方:它被设成 true 以后再也不会变回 false。「不等连接开口先放一个」这个特权在整个通道的一生里只有一次,就是连接任务第一次被 poll 之前的那一个请求(handshake 后马上 send_request,Connection 还没 spawn)。之后每个请求都要等连接说「要」。hyper 自己的单元测试 sender_checks_for_want_on_send 写的就是这个:「Even though 1 has been popped, only 1 could be buffered for the lifetime of the channel.」
柜台(连接任务)一次只办一个人。顾客(send_request)看灯:灯亮(Want)就把单子递进去,递的同时把灯按灭(give());灯不亮就被打发走(connection was not ready)。柜台办完一个、低头看见托盘空了,才把灯按亮(Taker::want)。顾客也可以站在旁边等灯亮(poll_ready 登记 waker,状态 Give)。柜台撤了,灯换成「暂停服务」(Closed)。开门前的特例:柜台还没开张时可以先往托盘里放一张单子(buffered_once),只此一次。
比方到这里为止:真实的托盘(mpsc)没有容量上限,限制全在那盏灯上;而且灯是「柜台看见托盘空」时亮的,不是「上一个人走了」时亮的,两者之间的差别正是下面 #4208 修的那个 bug。
下面的程序把这几条都跑一遍:连接还没被 poll 时发两个;连接跑起来、/1 在途时问一次就绪再发一个;/1 完成后连发两个。服务端每个请求睡 200ms。
hyper = { version = "=1.12.0", features = ["client", "server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "time"] }
use std::convert::Infallible;
use std::future::poll_fn;
use std::task::Poll;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{Empty, Full};
use hyper::client::conn::http1::SendRequest;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::net::{TcpListener, TcpStream};
// 只 poll 一次 SendRequest::poll_ready,把结果打印出来
async fn probe(tag: &str, start: Instant, sender: &mut SendRequest<Empty<Bytes>>) {
let r = poll_fn(|cx| Poll::Ready(sender.poll_ready(cx))).await;
let shown = match r {
Poll::Pending => "Pending".to_string(),
Poll::Ready(Ok(())) => "Ready(Ok)".to_string(),
Poll::Ready(Err(e)) => format!("Ready(Err({e}))"),
};
println!("{:>4}ms {tag:<26} is_ready={:<5} poll_ready={shown}", start.elapsed().as_millis(), sender.is_ready());
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let start = Instant::now();
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// 服务端:每个请求睡 200ms 再回 "ok"
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = hyper::service::service_fn(|_req| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
let stream = TcpStream::connect(addr).await?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
let req = |p: &str| Request::get(p).header("host", "x").body(Empty::<Bytes>::new()).unwrap();
// 1. Connection 还没被 poll 过
probe("before conn polled", start, &mut sender).await;
let f1 = sender.send_request(req("/1"));
let f2 = sender.send_request(req("/2"));
println!("{:>4}ms send /2 -> {:?}", start.elapsed().as_millis(), f2.await.err());
// 2. 让 Connection 跑起来;/1 是先前排进去的那一个
tokio::spawn(conn);
let t1 = tokio::spawn(async move { f1.await.map(|r| r.status()) });
tokio::time::sleep(Duration::from_millis(100)).await;
probe("/1 in flight", start, &mut sender).await;
let f3 = sender.send_request(req("/3"));
println!("{:>4}ms send /3 -> {:?}", start.elapsed().as_millis(), f3.await.err());
let r1 = t1.await?;
println!("{:>4}ms /1 -> {:?}", start.elapsed().as_millis(), r1);
probe("after /1 done", start, &mut sender).await;
// 3. 连接在要请求:连着发两个,第二个被拒
let f4 = sender.send_request(req("/4"));
let f5 = sender.send_request(req("/5"));
println!("{:>4}ms send /5 -> {:?}", start.elapsed().as_millis(), f5.await.err());
let r4 = f4.await?.status();
println!("{:>4}ms /4 -> {}", start.elapsed().as_millis(), r4);
Ok(())
}
平台 M2 Max、rustc 1.99.0,跑 3 次,各行顺序和结果相同,毫秒数差 4ms 以内。其中一次:
0ms before conn polled is_ready=false poll_ready=Pending
0ms send /2 -> Some(hyper::Error(Canceled, "connection was not ready"))
104ms /1 in flight is_ready=false poll_ready=Pending
104ms send /3 -> Some(hyper::Error(Canceled, "connection was not ready"))
204ms /1 -> Ok(200)
204ms after /1 done is_ready=true poll_ready=Ready(Ok)
204ms send /5 -> Some(hyper::Error(Canceled, "connection was not ready"))
407ms /4 -> 200 OK
- 第 1 行:连接一次都没被 poll,标志是初始的
Idle,poll_ready是Pending。但/1照样放进去了(buffered_once),/2被拒。 - 第 3、4 行:
/1在途,连接任务不去 poll 通道,没人说「要」。poll_ready是Pending,/3被拒。/3的错误不是等出来的:send_request调用的那一刻can_send就失败了,返回的 future 第一次 poll 就是这个错误。 - 第 6 行:
/1完成,连接回到空闲,再次 poll 空队列,标志变Want。 - 第 7、8 行:标志是
Want时连发两个,/4的give()把它换成Idle,/5被拒,/4正常完成。所以就算连接空闲,同一时刻也只能放进一个。
send_request 被拒时请求就没了(错误里不带请求);换成 try_send_request,被拒时的 TrySendError 带着原来的请求,可以拿去别的连接上发。hyper-util 的连接池就是这么用的,见 legacy Client。
问: 既然要限制成「一次一个」,为什么不直接用容量为 1 的有界 mpsc?
答: 有界通道的「有空位」发生在连接任务取走请求的那一刻,可那时请求才刚开始写,连接要一直忙到响应读完。SendRequest 的就绪要表达的是「连接空闲」,这件事只有连接任务知道,而且只能用「我又来 poll 空队列了」来表达:Dispatcher 只在上一次请求和响应都走完、Conn 回到 Init(can_write_head)、手里没有回调(should_poll)时才 poll 通道。Sender 字段上的注释说的就是这个:Giver 用来知道「a request and response have been fully processed, and a connection is ready for more」。连接池拿 is_ready 判断一条连接能不能借给下一个请求,靠的也是这一点。
Receiver 这一边:什么时候说「要」
pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll<Option<(T, Callback<T, U>)>> {
match self.inner.poll_recv(cx) {
Poll::Ready(item) => {
// A want signaled after finding the queue empty can land after
// the Sender's `give()` for the message taken here, as can one
// signaled on a coop-budget Pending with a message queued.
// Withdraw it, or the Sender reports this connection as ready
// while it is still serving this message.
self.taker.unwant();
Poll::Ready(item.map(|mut env| env.0.take().expect("envelope not dropped")))
}
Poll::Pending => {
self.taker.want();
Poll::Pending
}
}
}
队列空(Pending)就 want();取到东西就 unwant(),把可能残留的 Want 撤回去。这个函数只在 Dispatcher::poll_write 里经 Dispatch::poll_msg 调用,而那里的前提是 body_rx 为空、can_write_head()、should_poll()(手里没有回调),三个条件合起来就是「上一个请求写完了、响应也读完了」。
Receiver 被 drop 时先 taker.cancel(),再 drop 里面的 mpsc 接收端(Drop for Receiver)。顺序是故意的:先让 Giver 看到 Closed,is_closed() 和 poll_ready 就不会把一条正在消失的连接报成可用。
unwant() 是 v1.12.0 才加的issue #4207 的标题是「HTTP/1 client: SendRequest::is_ready() can stay true while a request is in flight」。#4208(随 v1.12.0 发布)的提交说明给了两种走法:连接任务「finds its request queue empty」之后、调 want() 之前,另一个线程上的 send_request 先 give() 了;或者「tokio's coop budget can make the queue report Pending with a request already queued」。两种情况下请求被取走时标志仍是 Want,而请求在途期间没人再 poll 通道,is_ready() 一直是真,直到响应结束。后果在 hyper-util:「hyper-util's legacy pool returns connections on is_ready, so it hands a busy connection to the next request, which then waits behind the whole response.」
修法就是上面代码里取到请求时那句 self.taker.unwant(),一次从 Want 到 Idle 的比较交换。Taker::unwant 是 want 为此新加的方法,所以 hyper 1.12.0 的 Cargo.toml 把 want 提到了 0.3.2。PR 也解释了为什么不改发送端的顺序(先入队再 give()):「The task's late want() can still land after the give(), and the coop path doesn't depend on the order at all.」本站锁的 hyper 1.10.1 没有这一句。
dispatch::Client:取请求、交响应
Dispatch 的客户端实现 Client 只有三个字段:callback(在途请求的回调)、rx(通道接收端)、rx_closed。
poll_msg 从通道取一个请求,先看调用方是否已经放弃:
match this.rx.poll_recv(cx) {
Poll::Ready(Some((req, mut cb))) => {
// check that future hasn't been canceled already
match cb.poll_canceled(cx) {
Poll::Ready(()) => {
trace!("request canceled");
Poll::Ready(None)
}
Poll::Pending => {
// ... 拆成 RequestHead 和 body
this.callback = Some(cb);
Poll::Ready(Some(Ok((head, body))))
}
}
}
Poll::Ready(None) => {
// user has dropped sender handle
trace!("client tx closed");
this.rx_closed = true;
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
两种 None 在 Dispatcher::poll_write 里的处理一样:self.close(),读写都关。也就是说,请求还在通道里没写出去、调用方就 drop 了 future,连接同样会关,而不是跳过这个请求接着用。下面的程序验证这一点:请求放进通道后,连接任务一次都还没跑,就把 send_request 返回的 future 扔掉(依赖同上一段程序)。
use std::convert::Infallible;
use bytes::Bytes;
use http_body_util::Empty;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::net::{TcpListener, TcpStream};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = hyper::service::service_fn(|req: Request<hyper::body::Incoming>| async move {
println!("server: got {}", req.uri());
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
});
hyper::server::conn::http1::Builder::new().serve_connection(TokioIo::new(stream), svc).await
});
let stream = TcpStream::connect(addr).await.unwrap();
let (mut tx, conn) = hyper::client::conn::http1::handshake::<_, Empty<Bytes>>(TokioIo::new(stream)).await.unwrap();
let conn = tokio::spawn(conn);
let req = |p: &str| Request::get(p).header("host", "x").body(Empty::new()).unwrap();
// 请求进了通道,连接任务还没跑,调用方就把 future 扔了
drop(tx.send_request(req("/never")));
println!("ready: {:?}", tx.ready().await);
println!("client conn task: {:?}", conn.await.unwrap());
println!("server conn task: {:?}", server.await.unwrap());
}
跑 3 次,输出相同:
ready: Err(hyper::Error(ChannelClosed))
client conn task: Ok(())
server conn task: Ok(())
服务端没有打印 server: got,请求一个字节也没写;客户端连接任务正常结束,SendRequest 再问就绪得到 ChannelClosed;服务端看到的是一条空闲时被关掉的连接,Ok(())。请求已经写出去之后再 drop 的情况,hyper 解决什么问题里跑过:同样关连接,服务端那边是 IncompleteMessage。负责发现「已经写出去的请求没人要了」的是 poll_ready:回调的接收端没了就返回 Err,poll_read_head 随即关闭连接。
recv_msg 收到读出来的东西,分三支:
match msg {
Ok((msg, body)) => {
if let Some(cb) = self.callback.take() {
let res = msg.into_response(body);
cb.send(Ok(res));
Ok(())
} else {
// ...
Err(crate::Error::new_unexpected_message())
}
}
Err(err) => {
if let Some(cb) = self.callback.take() {
cb.send(Err(TrySendError {
error: err,
message: None,
}));
Ok(())
} else if !self.rx_closed {
self.rx.close();
if let Some((req, cb)) = self.rx.try_recv() {
trace!("canceling queued request with connection error: {}", err);
// in this case, the message was never even started, so it's safe to tell
// the user that the request was completely canceled
cb.send(Err(TrySendError {
error: crate::Error::new_canceled().with(err),
message: Some(req),
}));
Ok(())
} else {
Err(err)
}
} else {
Err(err)
}
}
}
- 读到响应头:从
callback里取出回调,把Response<Incoming>交回去。回调在这里就被取走了,should_poll()变真,但下一个请求还要等Conn回到Init才会被取(body 读完)。 - 连接出错、有在途请求:错误交给这个请求,
message: None,请求已经写出去(至少开始写了),不能还。 - 连接出错、没有在途请求:关掉通道,看看里面有没有排着的请求。有的话它从没开始写,用
Canceled包住原错误、连同请求一起还回去。这是「可以安全换一条连接重发」的一个来源。
两种回调,以及每种失败报什么
Callback 有两个变体,区别只在 oneshot 里装什么:
pub(crate) enum Callback<T, U> {
#[allow(unused)]
Retry(Option<oneshot::Sender<Result<U, TrySendError<T>>>>),
NoRetry(Option<oneshot::Sender<Result<U, crate::Error>>>),
}
try_send 用 Retry,错误是 TrySendError,可能带着请求;send 用 NoRetry,Callback::send 发送前把 TrySendError 拆成里面的 hyper::Error,请求就扔了。连接任务内部不区分两者,一律按 TrySendError 构造错误。TrySendError 的文档说明了带不带请求的分界:「If an error occurs after the message has been serialized onto the connection, it will not be available here.」
把请求可能失败的地方排一遍:
| 情形 | 调用方拿到的错误 | try_send_request 能拿回请求吗 |
|---|---|---|
| 发的时候连接没在要 | Canceled,"connection was not ready" | 能 |
| 排在通道里,连接任务结束或被 drop | Canceled,"connection closed"(Envelope 的 Drop) | 能 |
| 排在通道里,连接空闲时出错 | Canceled,原因是那个错误(recv_msg 第三支) | 能 |
| 已在途,连接出错(对端关闭、IO 错误、解析失败) | 那个错误本身,比如 IncompleteMessage | 不能 |
| 已在途,连接任务被 drop | User(DispatchGone),见下一节 | 不能 |
| 连接已经没了,再问就绪 | poll_ready 返回 ChannelClosed | 不涉及 |
Envelope 是通道里装的那一项,它的 Drop 在请求还没被取走时触发:
impl<T, U> Drop for Envelope<T, U> {
fn drop(&mut self) {
if let Some((val, cb)) = self.0.take() {
cb.send(Err(TrySendError {
error: crate::Error::new_canceled().with("connection closed"),
message: Some(val),
}));
}
}
}
dispatch task is gone
回调被 drop 时如果还没发过结果,Drop for Callback 补发一个错误,错误由 dispatch_gone 构造:
#[cold]
fn dispatch_gone() -> crate::Error {
// ...
crate::Error::new_user_dispatch_gone().with(if std::thread::panicking() {
"user code panicked"
} else {
"runtime dropped the dispatch task"
})
}
正常路径上回调总是通过 send 交出结果的,走到 Drop 只有一种情况:在途请求的回调还在 dispatch::Client 里,整个 Dispatcher 被 drop 了。也就是 Connection 这个 future 没跑完就没了:任务被 abort、所在的运行时被关、或者任务里 panic。Display 只打印 dispatch task is gone,后半句在 source() 里;判断它用 is_user(),没有专门的 is_* 方法。原因只分两种:drop 发生在 panic 展开过程中是「user code panicked」,其他情况都写「runtime dropped the dispatch task」,哪怕是你自己 abort 的。
请求还没被取走时 Dispatcher 被 drop,回调还在通道里的 Envelope 里,走的是上一节的 Envelope::drop,报的是 Canceled,不是 DispatchGone。
下面的程序把四种情况各做一次。服务端每个请求睡 200ms。
hyper = { version = "=1.12.0", features = ["client", "server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt", "rt-multi-thread", "net", "time"] }
use std::convert::Infallible;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Empty;
use hyper::body::{Body, Frame};
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::net::{TcpListener, TcpStream};
// 一个 poll 就 panic 的请求 body
struct PanicBody;
impl Body for PanicBody {
type Data = Bytes;
type Error = Infallible;
fn poll_frame(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
panic!("body exploded");
}
}
async fn connect<B>(addr: std::net::SocketAddr) -> (hyper::client::conn::http1::SendRequest<B>, hyper::client::conn::http1::Connection<TokioIo<TcpStream>, B>)
where
B: Body + 'static + Send,
B::Data: Send,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
let stream = TcpStream::connect(addr).await.unwrap();
hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.unwrap()
}
fn get() -> Request<Empty<Bytes>> {
Request::get("/").header("host", "x").body(Empty::new()).unwrap()
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
std::panic::set_hook(Box::new(|_| {})); // 不打印 panic 信息
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// 服务端:每个请求睡 200ms 再回
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let svc = hyper::service::service_fn(|_req| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
});
let _ = hyper::server::conn::http1::Builder::new().serve_connection(TokioIo::new(stream), svc).await;
});
}
});
// A: 请求已写出、在等响应时,abort 连接任务
let (mut tx, conn) = connect::<Empty<Bytes>>(addr).await;
let task = tokio::spawn(conn);
let fut = tx.send_request(get());
let waiter = tokio::spawn(fut);
tokio::time::sleep(Duration::from_millis(50)).await;
task.abort();
println!("A abort in flight: {:?}", waiter.await.unwrap().err());
// B: 连接任务跑在另一个运行时上,请求在途时那个运行时被关掉
let other = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let (mut tx, conn) = connect::<Empty<Bytes>>(addr).await;
other.spawn(conn);
let waiter = tokio::spawn(tx.send_request(get()));
tokio::time::sleep(Duration::from_millis(50)).await;
other.shutdown_background();
println!("B runtime shut down: {:?}", waiter.await.unwrap().err());
// C: 请求 body 在连接任务里 panic
let (mut tx, conn) = connect::<PanicBody>(addr).await;
let task = tokio::spawn(conn);
let res = tx.send_request(Request::post("/").header("host", "x").body(PanicBody).unwrap()).await;
println!("C body panicked: {:?}", res.err());
println!(" conn task: panic = {}", task.await.unwrap_err().is_panic());
// D: 请求还在通道里(连接任务一次都没跑过)就 abort
let (mut tx, conn) = connect::<Empty<Bytes>>(addr).await;
let task = tokio::spawn(conn);
let fut = tx.send_request(get());
task.abort();
println!("D abort before run: {:?}", fut.await.err());
}
平台 M2 Max、rustc 1.99.0。A 在请求写出后 50ms abort 连接任务;B 的连接任务在另一个运行时上,那个运行时在请求在途时被关掉;C 的请求 body 一被 poll 就 panic;D 在连接任务第一次运行之前就 abort 了它。哪几行是 DispatchGone?它们的原因字符串分别是什么?
hyper-util 的 legacy::Client 把每条连接的 Connection 交给你给它的执行器(TokioExecutor 就是 tokio::spawn),连接任务跑在建立连接时所在的运行时上。一个 static 的 Client 被多个 #[tokio::test] 共用时,池里的连接可能属于一个已经结束的测试的运行时,在途请求就会碰到 B 这种情况。这个坑放到常见坑一章复现。
issue #2649(「Consider removing "dispatch dropped without returning error"」)写道:「Hyper panics with "dispatch dropped without returning error" if the background dispatch task goes away, either because the runtime was dropped or some user-provided code such as a HttpBody implementation panicked」,希望「return a proper error instead」。修复提交「fix(client): send an error back to client when dispatch misbehaves」给 Callback 里的发送端套上 Option,加了上面那个 Drop 和 User::DispatchGone,panic 和运行时 drop 两种原因就是那时定下的字符串。它在 0.14 线上随 v0.14.21 发布,1.x 线上首见于 v1.0.0-rc.2 的 CHANGELOG。send_request 里那句 unreachable!("dispatch dropped without returning error") 留到了今天,按注释,现在走到它就是 hyper 的 bug。
连接什么时候结束
Dispatcher 对客户端的 is_done 只看读端:读端关了就结束(见 Dispatcher)。客户端连接结束的几条路:
| 起因 | 走到哪里 |
|---|---|
所有 SendRequest 都 drop 了 | poll_msg 收到 None,close()(一个请求的一生的收尾一节) |
| 调用方放弃了请求 | 排队中:poll_msg 里 poll_canceled 为 Ready;在途:poll_ready 返回 Err。两者都 close() |
请求带 Connection: close | encode_head 在写请求头时就把 KA 设成 Disabled,响应走完后关闭(#4110,v1.11.1) |
| 服务端关了空闲连接 | poll_read_keep_alive 走 require_empty_read,读到 EOF 就 close_read,正常结束 |
| 空闲时读到多余字节 | require_empty_read 返回 unexpected_message 错误,经 recv_msg 的第三支处理排队中的请求 |
| 在途时读到 EOF | IncompleteMessage,交给在途请求 |
Connection: close 那一行的背景是 #4110 的说明:「hyper's client derives connection reuse from the response alone」,后端如果不理会请求里的 close、响应里也不带,连接就会留在池里被复用;现在写请求头时就禁止复用。头里的 close 怎么合进去,见 Conn 的读写状态机。
「服务端关了空闲连接」那一行有一个时间窗:客户端要等反应器把 EOF 事件送到、连接任务再跑一次,才知道连接已经没了。在这之前它的 is_ready() 还是真,请求还会被放进来。放进来以后是在发现 EOF 之前还是之后写出去,决定了请求能不能被安全重发。这件事放在 legacy Client 里用实验看。
动脑筋答案
A abort in flight: Some(hyper::Error(User(DispatchGone), "runtime dropped the dispatch task"))
B runtime shut down: Some(hyper::Error(User(DispatchGone), "runtime dropped the dispatch task"))
C body panicked: Some(hyper::Error(User(DispatchGone), "user code panicked"))
conn task: panic = true
D abort before run: Some(hyper::Error(Canceled, "connection closed"))
跑了 3 次,输出相同。
- A:请求已经被取走,回调在
dispatch::Client::callback里。abort让 tokio 在下一次调度时 drop 这个任务的 future(见 JoinHandle、取消与 JoinError),回调随之 drop。不是 panic,所以是「runtime dropped the dispatch task」,虽然动手的是我们自己。 - B:
shutdown_background关闭运行时,运行时逐个 drop 还活着的任务(见 OwnedTasks 与关闭),结果和 A 一样。这是这句报文字面上说的情形。 - C:
poll_frame在连接任务里 panic,展开时 dropDispatcher,std::thread::panicking()为真,于是是「user code panicked」。panic 被 tokio 接住,JoinError::is_panic()为真,调用方的线程没有崩。 - D:连接任务一次都没跑,请求还躺在通道里。drop 的是
Envelope,报Canceled, "connection closed",这种错误里带着请求,try_send_request能拿回来。
小结
handshake只是把通道、Conn、Dispatcher装起来,不发字节;HTTP/1 不需要执行器,但Connection必须有人 poll。- 请求通道是无界
mpsc加want标志。连接任务只有在上一次请求和响应都走完、再去 poll 空队列时才want();poll_ready就是等这个标志,在途期间一直Pending。 - 每次「要」只放进一个请求;不等「要」先放的只有连接第一次被 poll 之前的那一个,
buffered_once置位后不复原。被拒的请求得到Canceled, "connection was not ready"。 - 取到请求时
unwant()撤回残留的Want,否则is_ready()会在请求在途时为真,连接池会把忙着的连接借出去(#4208,v1.12.0)。 - 调用方放弃请求,无论请求写没写出去,连接都会关。
- 请求没被取走时失败报
Canceled,try_send_request能拿回请求;在途时失败报真实错误,请求拿不回来;连接任务被 drop 时在途请求报dispatch task is gone,原因只分「user code panicked」和「runtime dropped the dispatch task」。