Rust in Everything
基于 hyper 1.12.0

一个请求的一生

主线位置:两条主线都在。本页让一个 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。
📝NOTE
没有蠢问题

问: 日志里客户端先把请求写出去了,下一行服务端才 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」。

📝NOTE
冷知识:HTTP/1 客户端的 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 读写都在它们里面。

❗IMPORTANT
动脑筋:如果忘了 spawn 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,也就是 Connection future。HTTP/1 握手不发字节。
  • send_request 只把请求和一个 oneshot 放进通道;调用方任务从不碰 socket。
  • 客户端的 Connection 任务负责取请求、编码写出、读响应头,再通过回调交回;空闲时它也在读 socket,以便发现对端关闭。
  • 服务端一条连接一个任务:service.call 在读到请求头时同步调用,返回的 future 存进 in_flight,由同一个 poll_loop 在写响应前 poll。hyper 不为请求 spawn 任何任务。
  • Connection 必须有人 poll;忘了 spawn,send_request 会一直等下去。

💬 关联讨论

发起讨论
加载中...