一个请求的一生
主线位置:两条主线都在。本页让一个 hyper 客户端给一个 hyper 服务端发一个请求,客户端主线和服务端主线首尾相接:客户端写出的字节就是服务端读到的字节。每一步只看「在哪个任务里、调了哪个函数」,细节留给后面的章节。
sequenceDiagram participant M as main 任务 participant CC as client-conn 任务 participant SA as server-accept 任务 participant SC as server-conn 任务 Note over M,CC: 客户端主线 M->>M: handshake,不发字节 M->>CC: spawn Connection M->>CC: send_request:请求进通道 CC->>CC: 空闲探读,Pending CC->>CC: poll_msg 取出请求,编码 CC->>SC: socket:70 字节 Note over SA,SC: 服务端主线 SA->>SC: accept,spawn Connection SC->>SC: 解析请求头 SC->>SC: recv_msg:service.call SC->>SC: poll_msg:poll 响应 future SC->>CC: socket:87 字节 CC->>CC: 解析响应头 CC-->>M: 回调送回 Response CC-->>M: body 经 Incoming 送达 M->>CC: drop SendRequest CC->>SC: 关闭写端 SC->>SC: 读到 EOF,结束
程序
一个进程里放四个 tokio 任务:main 当客户端的调用方,client-conn 跑客户端的 Connection,server-accept 接受连接,server-conn 跑服务端的 Connection。为了看清每件事在哪个任务里发生,程序做了两件事:
- 用
task_local!给每个任务起名字,log打印时带上当前任务名。 - 用
LogIo包住TcpStream,每次真正读写 socket 时打一行。它实现 tokio 的AsyncRead/AsyncWrite,外面再套 hyper-util 的TokioIo,这样 hyper 用的就是它。
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"] }
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::{TcpListener, TcpStream};
tokio::task_local! {
// 每个任务自己的名字,日志里用它标出「这一行是哪个任务执行的」
static TASK: &'static str;
}
fn log(msg: impl AsRef<str>) {
let task = TASK.try_with(|t| *t).unwrap_or("?");
println!("[{task:<13}] {}", msg.as_ref());
}
// 只取第一行,Date 头每次都不同,不打印它
fn first_line(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
text.lines().next().unwrap_or("").to_string()
}
// 包住 TcpStream,记录每一次真正碰到 socket 的读写
struct LogIo {
side: &'static str,
inner: TcpStream,
}
impl AsyncRead for LogIo {
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
let before = buf.filled().len();
let side = self.side;
let res = Pin::new(&mut self.inner).poll_read(cx, buf);
match &res {
Poll::Ready(Ok(())) => {
let got = &buf.filled()[before..];
log(format!("{side} socket read {:>3} bytes {:?}", got.len(), first_line(got)));
}
Poll::Pending => log(format!("{side} socket read Pending")),
Poll::Ready(Err(e)) => log(format!("{side} socket read error {e}")),
}
res
}
}
impl AsyncWrite for LogIo {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
let side = self.side;
let res = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &res {
log(format!("{side} socket write {n:>3} bytes {:?}", first_line(&buf[..*n])));
}
res
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
log(format!("{} socket shutdown", self.side));
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
async fn handle(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, hyper::Error> {
log(format!("service future: got {} {}", req.method(), req.uri()));
let body = req.into_body().collect().await?.to_bytes();
log(format!("service future: request body {:?}", body));
Ok(Response::new(Full::new(Bytes::from(format!("echo: {}", String::from_utf8_lossy(&body))))))
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
// 服务端:accept 一条连接,为它 spawn 一个任务去跑 Connection future
let server = tokio::spawn(TASK.scope("server-accept", async move {
let (stream, _) = listener.accept().await.unwrap();
log("accepted, spawn server-conn");
let io = TokioIo::new(LogIo { side: "srv", inner: stream });
let svc = hyper::service::service_fn(|req| {
log("service.call(req)");
handle(req)
});
let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, svc);
tokio::spawn(TASK.scope("server-conn", async move {
let res = conn.await;
log(format!("Connection future done: {res:?}"));
}))
}));
TASK.scope("main", async move {
// 客户端:连上之后握手,得到 SendRequest 和 Connection;Connection 交给另一个任务
let stream = TcpStream::connect(addr).await?;
let io = TokioIo::new(LogIo { side: "cli", inner: stream });
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
log("handshake done, spawn client-conn");
let client_conn = tokio::spawn(TASK.scope("client-conn", async move {
let res = conn.await;
log(format!("Connection future done: {res:?}"));
}));
let req = Request::post("/echo")
.header("host", addr.to_string())
.body(Full::new(Bytes::from_static(b"hello")))?;
log("send_request(req)");
let resp = sender.send_request(req).await?;
log(format!("got response head: {}", resp.status()));
let body = resp.into_body().collect().await?.to_bytes();
log(format!("response body {:?}", body));
drop(sender);
log("dropped SendRequest");
client_conn.await?;
server.await?.await?;
Ok::<_, Box<dyn std::error::Error>>(())
})
.await
}
运行时用的是 current_thread:所有任务在同一个线程上轮流跑,日志的先后就是真实的执行顺序。
输出
M2 Max、rustc 1.99.0,跑了 5 次,输出逐字节相同:
[main ] handshake done, spawn client-conn
[main ] send_request(req)
[client-conn ] cli socket read Pending
[client-conn ] cli socket write 70 bytes "POST /echo HTTP/1.1"
[server-accept] accepted, spawn server-conn
[server-conn ] srv socket read 70 bytes "POST /echo HTTP/1.1"
[server-conn ] service.call(req)
[server-conn ] srv socket read Pending
[server-conn ] service future: got POST /echo
[server-conn ] service future: request body b"hello"
[server-conn ] srv socket write 87 bytes "HTTP/1.1 200 OK"
[server-conn ] srv socket read Pending
[client-conn ] cli socket read 87 bytes "HTTP/1.1 200 OK"
[client-conn ] cli socket read Pending
[client-conn ] cli socket read Pending
[client-conn ] cli socket read Pending
[main ] got response head: 200 OK
[main ] response body b"echo: hello"
[main ] dropped SendRequest
[client-conn ] cli socket read Pending
[client-conn ] cli socket shutdown
[client-conn ] Connection future done: Ok(())
[server-conn ] srv socket read 0 bytes ""
[server-conn ] srv socket shutdown
[server-conn ] Connection future done: Ok(())
先看一个总的规律:main 这一列从头到尾没有碰过 socket。所有 socket read / socket write 都发生在两个 -conn 任务里,而服务端那边,读请求、调用 service、跑 service 的 future、写响应,全在 server-conn 一个任务里。下面按顺序拆开。
握手:只是把零件装好
第一行就是 handshake done,它前面没有任何 socket 读写。client::conn::http1::handshake 只是 Builder::new().handshake(io) 的简写,Builder::handshake 的 async 块里只做组装:
async move {
trace!("client handshake HTTP/1");
let (tx, rx) = dispatch::channel();
let mut conn = proto::Conn::new(io);
conn.set_h1_parser_config(opts.h1_parser_config);
// ... 按配置设置 conn 的各个选项 ...
let cd = proto::h1::dispatch::Client::new(rx);
let proto = proto::h1::Dispatcher::new(cd, conn);
Ok((SendRequest { dispatch: tx }, Connection { inner: proto }))
}
一条请求通道,发送端 tx 放进 SendRequest 交给你,接收端 rx 放进 dispatch::Client;Conn 包着 IO;Dispatcher 把两者绑在一起,就是 Connection。HTTP/1 没有握手报文,所以叫 handshake,却一个字节也不发。服务端的 serve_connection 是同一个形状:建 Conn,按 Builder 的选项设置它,再和 dispatch::Server::new(service) 一起交给 Dispatcher::new。
Connection 是一个 future,不 poll 它什么都不会发生,所以程序把它 spawn 成 client-conn 任务。
send_request:放进通道就返回
main 打印 send_request(req) 之后就在 .await 上挂起了,接下来的写操作出现在 client-conn 里。SendRequest::send_request 第一行就是 self.dispatch.send(req):dispatch::Sender::send 新建一个 oneshot,把请求和 oneshot 的发送端一起塞进 tokio 的无界 mpsc,再把接收端还给 send_request。main 等的就是这个 oneshot:
let sent = self.dispatch.send(req);
async move {
match sent {
Ok(rx) => match rx.await {
Ok(Ok(resp)) => Ok(resp),
Ok(Err(err)) => Err(err),
// this is definite bug if it happens, but it shouldn't happen!
Err(_canceled) => unreachable!("dispatch dropped without returning error"),
},
Err(_req) => {
debug!("connection was not ready");
Err(crate::Error::new_canceled().with("connection was not ready"))
}
}
}
mpsc 是无界的,但 Sender 里还有一个 want::Giver:连接任务没有表示「要下一个请求」之前,最多只能先塞一个进去,第二个会走到 Err(_req) 那一支。这套流控放在 06 客户端 讲。
client-conn:先探一下读,再写请求
client-conn 第一次被 poll,进入 Dispatcher::poll_loop,每圈先 poll_read、再 poll_write、再 poll_flush。
cli socket read Pending:客户端还没写请求,不能读响应头(Conn::can_read_head要求客户端的写状态已经离开Init)。poll_read于是走到poll_read_keep_alive,对空闲的客户端连接调用require_empty_read:真的去读一次 socket,看服务端有没有发来不该有的字节,或者已经关了连接。读到Pending,说明一切正常,同时在反应器上登记了读兴趣。cli socket write 70 bytes:poll_write调用客户端的Dispatch::poll_msg,从mpsc里取出请求,把回调存进self.callback,请求头交给Conn::write_head编码,body 随后写进同一个写缓冲区。poll_flush一次写出 70 字节:65 字节的请求头加 5 字节的hello。合成一次写,是因为LogIo没有实现 vectored 写,hyper 在Buffered::new里据此选了「拼成一块」的写策略;IO 支持 vectored 写时会换成 writev,见 缓冲 IO。
问: 日志里客户端先把请求写出去了,下一行服务端才 accepted。服务端还没 accept,这 70 个字节写到哪里去了?
答: 写进了内核。TcpStream::connect 返回时 TCP 三次握手已经由内核完成,连接排在监听 socket 的队列里,accept 只是把这条已经建好的连接取出来。客户端写的字节进了服务端 socket 的接收缓冲区,等 server-conn 来读。这里的顺序是 current_thread 调度造成的:main 一路跑到 send_request 的 .await 才让出线程,client-conn 又先于 server-accept 被调度。换成多线程运行时,这几行的先后可能不同,但「哪一行在哪个任务里」不会变。
server-conn:读、调用、写,都在一个任务里
server-accept 只做一件事:accept,然后 spawn server-conn。之后的每一行都属于 server-conn,也就是同一个 Dispatcher 的 poll_loop。
读和调用。 Dispatcher::poll_read_head 先问 dispatch.poll_ready(服务端的实现只看有没有在途请求),再让 Conn 读 socket、用 role::Server::parse 解析出请求头(读到的 70 字节一次就够),然后调用 dispatch::Server::recv_msg:
fn recv_msg(&mut self, msg: crate::Result<(Self::RecvItem, IncomingBody)>) -> crate::Result<()> {
let (msg, body) = msg?;
let mut req = Request::new(body);
*req.method_mut() = msg.subject.0;
*req.uri_mut() = msg.subject.1;
*req.headers_mut() = msg.headers;
*req.version_mut() = msg.version;
*req.extensions_mut() = msg.extensions;
let fut = self.service.call(req);
self.in_flight.set(Some(fut));
Ok(())
}
service.call(req) 就是在这里同步调用的,日志里的 service.call(req) 打印于此。它返回的 future 放进 in_flight,这时还没有被 poll。
读 body,探读。 poll_read 接着处理请求 body:5 字节早就和请求头一起在读缓冲区里了,解码后送进 Incoming 的通道(容量为 1,见 Incoming 与通道)。body 读完,这条连接在等自己的响应写出去,poll_read 再探一次 socket,看客户端是不是半路关了连接,得到 srv socket read Pending。
poll 响应 future,写。 poll_write 调用服务端的 Dispatch::poll_msg,它 poll 的正是 in_flight 里的 future:handle 的函数体到这时才第一次执行,所以 service future: got POST /echo 排在 service.call(req) 和一次 socket 读之后。body 已经在通道里,collect 立刻拿到 hello,响应 future 完成。poll_msg 把 Response 拆成头和 body,Conn 编码响应头,body 是 Full,长度已知,poll_flush 一次写出 87 字节:76 字节的响应头(含 content-length: 11 和 date)加 11 字节的 echo: hello。
回到 keep-alive。 响应写完,读写状态都回到初始,poll_read 去读下一个请求头,srv socket read Pending:这条连接空闲了,server-conn 挂起,等客户端的下一个请求或者 EOF。
这一节的所有事情都发生在一个任务里:没有为请求 spawn 新任务,service 的 future 也不是单独的任务,它是 Dispatcher 的一个字段,由连接的 poll_loop 顺带 poll。这就是「HTTP/1 一条连接一个 future」。
Builder 曾经有一个什么也不做的 executor()1.0 的候选版里,client::conn::http1::Builder 和 HTTP/2 的一样可以设置执行器。#3108(「HTTP/1 doesn't need an executor」)把它删了,理由一句话:「HTTP/2 needs to spawn internal tasks in order to process each stream separately, but HTTP/1 does everything in one task and thus doesn't need access to an executor.」这个字段因为标着 pub(super) 躲过了「未使用」的 lint,PR 顺手把服务端 timer 字段上同样的可见性标记也去掉了。
CHANGELOG 记在 v1.0.0-rc.3 的 Breaking Changes 下:「The method hyper::client::conn::http1::Builder::executor() is removed, since it did nothing.」所以今天的 client::conn::http1::Builder 和 server::conn::http1::Builder 都没有执行器参数,HTTP/2 的 builder 则要求传一个,见 Executor 与 Timer。上面的输出里看不到任何 hyper 自己 spawn 的任务,原因就在这里。
响应回到 main
client-conn 被反应器唤醒,读到 87 字节,Dispatcher::poll_read_head 用 role::Client::parse 解析出响应头,交给客户端的 Dispatch::recv_msg:它取出 poll_msg 时存下的回调,cb.send(Ok(res)),main 等着的 oneshot 于是完成。响应 body 的 11 字节同样已经在读缓冲区里,被解码后送进 Incoming 的通道。
之后的三次 cli socket read Pending 都在 main 被调度之前:响应读完以后,连接任务继续探读 socket,等服务端的多余字节或者 EOF,和请求写出之前那一次是同一个目的。
然后才轮到 main:got response head 和 response body 两行都在 main 里,但它们的数据都是 client-conn 早先从 socket 读来的。main 读 body 时拿的是 Incoming 通道里的块,自己从不碰 socket。
收尾
main drop 了 SendRequest,请求通道的发送端没了。client-conn 下一次 poll 时,Dispatch::poll_msg 从 mpsc 收到 None(代码里的注释是「user has dropped sender handle」),Dispatcher 关闭读写两端,cli socket shutdown 关掉写方向,Connection 以 Ok(()) 结束。
服务端读到 0 字节,也就是 EOF。这时连接是空闲的,不在一个请求中间,所以这是正常结束:srv socket shutdown,Connection 同样以 Ok(()) 结束。如果 EOF 出现在一个请求的中间,结果就是 IncompleteMessage 错误,见 hyper 解决什么问题 末尾的实验。
把这几节合起来,就是本页开头的时序图:四个任务,main 只和通道打交道,两个 -conn 任务各跑一个 Dispatcher,所有 socket 读写都在它们里面。
Connection,send_request 会等多久?把上面的程序简化:服务端照常,客户端握手之后不 spawn conn,直接发请求,最多等 1 秒。平台 M2 Max、rustc 1.99.0,依赖同上,tokio 再加上 time。输出会是 200 OK、一个错误,还是超时?
let stream = TcpStream::connect(addr).await?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
// 故意不 spawn conn,直接发请求,最多等 1 秒
let req = Request::get("/").header("host", "x").body(Empty::<Bytes>::new())?;
let res = tokio::time::timeout(Duration::from_secs(1), sender.send_request(req)).await;
println!("without conn: {}", match &res {
Err(_) => "timed out after 1s".to_string(),
Ok(r) => format!("{:?}", r.as_ref().map(|resp| resp.status())),
});
drop(conn);
动脑筋答案
without conn: timed out after 1s
跑了 3 次,结果相同。send_request 既没有返回响应,也没有返回错误,而是一直等,直到外面的 timeout 放弃。
原因就是本页第二节:send_request 只把请求放进通道,然后等 oneshot。从通道里取请求、写 socket、读响应、调用回调,全是 Connection 里的 Dispatcher 干的。conn 一直没被 poll,请求就一直躺在通道里,oneshot 永远等不到。Builder::handshake 的文档里有一句提醒:「Note, if Connection is not await-ed, SendRequest will do nothing.」
这也是为什么 send_request 没有报错:SendRequest 并不知道 conn 有没有人在跑,只要 conn 还没被 drop,通道就是开着的。要是在发请求之前先 drop 了 conn,通道的接收端随之消失,send_request 立刻返回错误(同一程序改成先 drop(conn) 再发,跑 3 次都是这一行):
conn dropped first: Ok(Err(hyper::Error(Canceled, "connection was not ready")))
外层的 Ok 是 timeout 没有超时,里面才是 send_request 的结果。用 hyper-util 的 legacy::Client 时不会遇到这个问题,它握手后总是用执行器把 Connection spawn 出去。
小结
handshake和serve_connection都只组装:一个Conn包住 IO,加上Dispatch的客户端或服务端实现,合成Dispatcher,也就是Connectionfuture。HTTP/1 握手不发字节。send_request只把请求和一个oneshot放进通道;调用方任务从不碰 socket。- 客户端的
Connection任务负责取请求、编码写出、读响应头,再通过回调交回;空闲时它也在读 socket,以便发现对端关闭。 - 服务端一条连接一个任务:
service.call在读到请求头时同步调用,返回的 future 存进in_flight,由同一个poll_loop在写响应前 poll。hyper 不为请求 spawn 任何任务。 Connection必须有人 poll;忘了 spawn,send_request会一直等下去。