Futures Nostalgia:从 hyper 老派写法看懂 async Rust、Tower 与 Backpressure

本文是对 Futures Nostalgia 的整理与翻译。

内容结构概览

  1. 文章背景 :作者怀念早期 hyper 的可见设计感,虽然代码很长,但 Tower Service trait 把结构暴露得很清楚。
  2. 第一个 hyper server :用 Server::bind(...).serve(MyServiceFactory) 起一个 HTTP 服务,返回 Hello World!
  3. 两层 Service :一层把连接变成 request service,一层把 Request<Body> 变成 Response<Body>
  4. poll_ready 的意义 :调用 call 前先询问服务是否 ready,这是 backpressure 的入口。
  5. HTTP/1.1 连接复用:同一个 TCP 连接可以连续发多个请求,不一定一请求一连接。
  6. 统计连接数 :用 Arc<AtomicU64> 在创建 service 时加一,Drop 时减一。
  7. 连接并发限制 :用 tokio::sync::Semaphoretokio_util::sync::PollSemaphorepoll_ready 里获取 permit。
  8. permit 必须保存起来poll_ready 只是准备阶段,真正创建 service 在 call,所以 permit 要暂存到 Option<OwnedSemaphorePermit>
  9. permit 是"权限证明":把 permit 存在 service 里,service drop 时 permit 自动释放。
  10. 模拟真实延迟 :把立刻返回的 Ready future 换成带 Sleep 的自定义 PretendFuture
  11. Pin 与手写 Future :手写 poll 会碰到 pin projection;先用 unsafe,再用 pin-project-lite 消除 unsafe。
  12. 连接数限制影响尾延迟:请求处理 250ms,但连接上限太低时,p99 可能变成 10 秒。
  13. 请求并发限制:不仅要限制连接数,还要限制 in-flight requests。
  14. 请求 permit 放进 Future:只有持有 permit 的请求 future 才能运行;future 完成或 drop 后释放 permit。
  15. "忘掉刚才学的" :现代 Rust 有 async block 和 .await,不必手写那么多 future。
  16. BoxFuture 简化复杂类型 :用 BoxFuture<'static, Result<...>> 替代巨长的 Pin<Box<dyn Future<...>>>
  17. Tower 复用组件ConcurrencyLimit 可以直接包住 service,限制 in-flight request。
  18. 连接限制不能简单套 ConcurrencyLimit:连接 service factory 产生的 future 很短,限制它不等于限制连接生命周期。
  19. make_service_fn / service_fn:hyper 提供闭包适配器,可以大幅减少样板代码。
  20. Layer 和 ServiceBuilder :Tower 的 Layer 负责装饰 service,ServiceBuilder 负责组合多个 layer。
  21. 局部并发限制 vs 全局并发限制concurrency_limit 默认是每个 service 的限制,想要全局限制要用 GlobalConcurrencyLimitLayer
  22. 连接限制的"邪门写法" :用 then 捕获连接 permit,在响应完成后释放;能跑,但作者自己也说像凌晨四点犯罪现场。
  23. 回到最小 Hello World:删掉限制和 sleep 后,最终得到 hyper 官方风格的 hello world,并且知道每一层发生了什么。
  24. 真正想讲的部分:接受连接Server::bind 隐藏了 socket 的 bind / listen / accept 细节。
  25. 为什么要拆开 bind 和 listen:测试里想先占住端口但暂不 listen,让客户端看到 connection refused。
  26. socket2 登场 :用 BSD socket API 手动 bind,延迟 listen,再转成 TcpListener
  27. 实现 hyper Accept traitpoll_accept 返回下一个连接,是连接入口的异步抽象。
  28. 手写状态机很痛苦 :为了"先等 2 秒再 listen",需要 Waiting / Listening enum、Sleep、pin projection 和 unsafe。
  29. 尝试用 async 方法包装Listener::accept(&mut self) 看起来更清楚,但适配到 Accept 时会遇到自引用 future。
  30. 自引用 future 的坑 :future 借用了 listener,而 struct 又同时持有 listener 和 future,移动后破坏不变量。
  31. "Just move stuff" :改成把 listener move 进 future,future 返回 (listener, stream),避免自引用结构。
  32. unfold 模式:这种"状态 + async 产生下一个值"的模式本质上就是 stream unfold。
  33. hyper::server::accept::from_stream:hyper 已经提供了从 stream 到 acceptor 的适配器。
  34. async-stream 进一步简化 :用 try_stream! 写出最直观的"延迟 listen 后循环 accept"。
  35. 最终结论 :底层 trait 很重要,因为它让你理解系统;高层封装也很重要,因为没人想天天手写 poll、Pin 和状态机。

这篇文章的标题叫 Futures Nostalgia,可以理解成"对 Futures 时代的怀旧"。

这里的 "Futures" 不是金融期货,而是 Rust async 生态里的 Future。标题里的 nostalgia 也不是单纯怀旧,而是一种很微妙的情绪:早期 Rust async / hyper / tower 代码很啰嗦,需要你手写 poll_readycallFuturePinPollContext,看起来像是把内脏都翻出来了。但也正因为内脏都露在外面,你能清楚看到系统到底怎么工作。

作者一开始说,直到不久之前,hyper 还是他最喜欢的 Rust HTTP 框架。它低层,但给你很多控制权。然后他写了一个非常"老派"的 hyper server:一个 MyServiceFactory 负责接受连接,一个 MyService 负责处理请求。代码很长,长到读者可能已经关掉页面。

但作者的重点不是"你看 Rust 多啰嗦"。恰恰相反,他想说的是:这些啰嗦的结构背后,藏着非常重要的抽象。

尤其是 Tower 的 Service trait。

这篇文章一路从一个简单的 "Hello World" HTTP server 讲到连接并发限制、请求并发限制、手写 Future、Pin、BoxFuture、Tower LayerServiceBuilderConcurrencyLimitGlobalConcurrencyLimitLayer,最后进入更底层的连接接受逻辑:手动 bind、延迟 listen、实现 hyper 的 Accept trait,再把手写状态机一步步简化成 unfoldfrom_streamasync-stream

如果你平时只是用 axum、warp、actix-web、reqwest 这类高层框架,这篇文章很适合补一层底层心智模型:HTTP 服务不是魔法,async 也不是魔法。它们是 service、future、poll、backpressure、semaphore、stream、acceptor 和状态机组合出来的。


一、一个看起来很啰嗦的 hyper Hello World

文章开头写了一个 hyper server。

大致结构是:

rust 复制代码
#[tokio::main]
async fn main() {
    Server::bind(&([127, 0, 0, 1], 1025).into())
        .serve(MyServiceFactory)
        .await
        .unwrap();
}

然后定义 MyServiceFactory

rust 复制代码
struct MyServiceFactory;

impl Service<&AddrStream> for MyServiceFactory {
    type Response = MyService;
    type Error = Infallible;
    type Future = Ready<Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Ok(()).into()
    }

    fn call(&mut self, req: &AddrStream) -> Self::Future {
        println!("Accepted connection from {}", req.remote_addr());
        ready(Ok(MyService))
    }
}

再定义 MyService

rust 复制代码
struct MyService;

impl Service<Request<Body>> for MyService {
    type Response = Response<Body>;
    type Error = Infallible;
    type Future = Ready<Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Ok(()).into()
    }

    fn call(&mut self, req: Request<Body>) -> Self::Future {
        println!("Handling {req:?}");
        ready(Ok(Response::builder()
            .body("Hello World!\n".into())
            .unwrap()))
    }
}

这代码确实多。用别的语言或框架,可能十行就写完了。比如 Go 的 http.HandleFunc,Node 的 Express,Python 的 Flask,甚至 Rust 的 axum,也都能更短。

但作者说,这不是重点。

重点是:hyper 的设计几乎肉眼可见。

整个服务实际上是两层 Service

text 复制代码
第一层:连接 -> 一个处理请求的 service
第二层:请求 -> 响应

MyServiceFactory 处理的是连接。每当 hyper 接受一个新的 TCP 连接时,它调用这个 factory,得到一个用于该连接的 MyService

MyService 处理的是 HTTP 请求。每当连接上来一个 Request<Body>,它返回一个 Response<Body>

这就是 Tower Service trait 的美感:它非常抽象,但又非常统一。


二、Service trait:核心是 poll_readycall

Tower 的 Service trait 可以粗略理解成:

rust 复制代码
trait Service<Request> {
    type Response;
    type Error;
    type Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>;

    fn call(&mut self, req: Request) -> Self::Future;
}

它像一个异步函数:

text 复制代码
Request -> Future<Output = Result<Response, Error>>

但它比普通 async function 多了一个关键阶段:poll_ready

调用者不能想调用 call 就调用。按照 Tower 的约定,必须先把 service drive 到 ready,也就是先调用 poll_ready,等它返回 ready 之后,再调用 call

这就是 backpressure 的入口。

Backpressure 可以理解为"系统告诉上游:我现在吃不下了,你先别继续喂"。如果没有 backpressure,高负载时你可能不断接受连接、不断读请求、不断创建 future、不断分配内存,最后服务不是变慢,而是直接爆掉。

poll_ready 的存在让 service 有机会说:

text 复制代码
我现在还不能处理新请求。
等我释放一点资源,再来 call。

这就是为什么作者说:poll_ready 不是样板,它是稳定应用服务器的基石。


三、同一个 HTTP/1.1 连接可以处理多个请求

文章接着用 curlsocat 做实验。

一个简单请求当然能得到:

text 复制代码
Hello World!

但有意思的是,HTTP/1.1 连接可以复用。同一个 TCP 连接里可以连续写多个请求:

http 复制代码
GET / HTTP/1.1

GET /ahAH HTTP/1.1

服务端日志显示:

text 复制代码
Accepted connection from 127.0.0.1:50420
GET /
GET /ahAH
Dropped connection

也就是说,一个连接里处理了两个请求。

这点对后文很重要。因为我们之后要区分两种限制:

text 复制代码
限制连接数
限制正在处理的请求数

它们不是同一个东西。

在 HTTP/1.1 里,一个连接通常一次只有一个 in-flight request,但连接可以复用。到了 HTTP/2,一个连接可以同时跑多个 stream,连接数和请求并发数就更不是一回事了。


四、先统计连接数:Arc<AtomicU64> 和 Drop

作者先做一件简单的事:统计当前有多少连接。

MyServiceFactory 里放一个共享计数器:

rust 复制代码
#[derive(Default)]
struct MyServiceFactory {
    num_connected: Arc<AtomicU64>,
}

每次接受连接时加一:

rust 复制代码
let prev = self.num_connected.fetch_add(1, Ordering::SeqCst);
println!("⬆️ {} connections", prev + 1);

然后把这个计数器 clone 到 MyService 里:

rust 复制代码
struct MyService {
    num_connected: Arc<AtomicU64>,
}

MyService drop 时减一:

rust 复制代码
impl Drop for MyService {
    fn drop(&mut self) {
        let prev = self.num_connected.fetch_sub(1, Ordering::SeqCst);
        println!("⬇️ {} connections", prev - 1);
    }
}

这能工作。跑一个 load testing tool,比如 oha,可以看到并发连接数峰值。默认情况下,oha 的并发 worker 是 50,所以服务端日志里最高连接数也大概是 50。

但统计只是观察。真正要做的是限制。


五、连接并发限制:Semaphore 和 poll_ready

限制并发最直接的工具是 semaphore。

Tokio 有:

rust 复制代码
tokio::sync::Semaphore

它维护一组 permits。获取 permit 才能继续执行;释放 permit 后,别人才能进入。

因为 Tower 的 poll_ready 是 poll 风格接口,不是 async function,不能直接 .await 一个 semaphore permit。于是作者使用:

rust 复制代码
tokio_util::sync::PollSemaphore

它提供 poll_acquire,可以在 poll_ready 里使用。

一开始可能想这么写:

rust 复制代码
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    let permit = futures::ready!(self.semaphore.poll_acquire(cx)).unwrap();
    Ok(()).into()
}

这里用到了 futures::ready! 宏。它可以把 Poll<T> 拆开:如果是 Pending,当前函数也返回 Pending;如果是 Ready(value),继续执行。

但问题来了:permit 存哪?

poll_ready 只是表示"我准备好了"。真正创建 MyService 是在 call 里。你不能在 poll_ready 里拿到 permit 然后马上丢掉,否则限制根本不起作用。

所以 MyServiceFactory 需要增加字段:

rust 复制代码
struct MyServiceFactory {
    semaphore: PollSemaphore,
    permit: Option<OwnedSemaphorePermit>,
}

poll_ready 里,如果还没有 permit,就尝试获取:

rust 复制代码
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    if self.permit.is_none() {
        self.permit = Some(futures::ready!(self.semaphore.poll_acquire(cx)).unwrap());
    }
    Ok(()).into()
}

call 里把 permit 拿出来,交给 MyService

rust 复制代码
fn call(&mut self, _req: &AddrStream) -> Self::Future {
    let permit = self.permit.take().expect(
        "you didn't drive me to readiness did you? you know that's a tower crime right?",
    );

    ready(Ok(MyService {
        _permit: permit,
    }))
}

MyService 持有这个 permit:

rust 复制代码
struct MyService {
    _permit: OwnedSemaphorePermit,
}

这非常 Rust:permit 本身就是权限证明。只要 service 活着,permit 就被占用。service drop 后,permit 自动释放。你不用手写 release(),所有权和 Drop 帮你做了。

这也是 poll_ready 的意义:service factory 可以通过 readiness 控制是否接受更多连接。


六、这还不像真实服务:让请求慢一点

到这里,服务还是立刻返回 Hello World!。它是 async server,但没有真实异步工作。

作者于是模拟一个真实业务:每个请求处理 250ms。

之前 MyService::call 返回的是:

rust 复制代码
Ready<Result<Response<Body>, Infallible>>

也就是立即 ready 的 future。

如果要 sleep,就不能再用 Ready。作者先手写了一个 PretendFuture

rust 复制代码
struct PretendFuture {
    sleep: Sleep,
    response: Option<Response<Body>>,
}

实现 Future

rust 复制代码
impl Future for PretendFuture {
    type Output = Result<Response<Body>, Infallible>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // poll sleep
        // sleep 完成后拿出 response
    }
}

这里会遇到 Pin。

因为 tokio::time::Sleep 是一个 Future,它通常需要被 pin 后 poll。手写 Future::poll 时,如果结构体里有一个需要 pin 的字段,就要做 pin projection:从 Pin<&mut PretendFuture> 投影出 Pin<&mut Sleep>

作者一开始用了 unsafe:

rust 复制代码
self.as_mut().map_unchecked_mut(|this| &mut this.sleep)

然后又用:

rust 复制代码
self.get_unchecked_mut()

把 response 取出来。

这当然能写,但很扎眼。于是作者立刻换成 pin-project-lite

rust 复制代码
pin_project_lite::pin_project! {
    struct PretendFuture {
        #[pin]
        sleep: Sleep,
        response: Option<Response<Body>>,
    }
}

然后 poll 里可以安全地:

rust 复制代码
let this = self.project();
futures::ready!(this.sleep.poll(cx));
Ok(this.response.take().unwrap()).into()

这段看起来偏底层,但很重要。它解释了为什么手写 future 很容易碰到 Pin,而现代 async/await 的价值就是把这类状态机和 projection 自动化。


七、连接数限制会影响尾延迟

加上 250ms sleep 后,再跑 load test。

如果连接数上限是 5,请求处理时间是 250ms,那么同时只能服务 5 个连接。oha 默认 50 个 worker,后面的请求就要排队。

于是延迟分布会变得很奇怪:

text 复制代码
中位数大概 250ms
p90 / p95 / p99 可能接近 10 秒

这不是服务单个请求突然变慢,而是排队造成的尾延迟。系统吞吐量由并发限制和单请求耗时共同决定:

text 复制代码
并发上限 5
每个请求 250ms
最大吞吐约 20 req/s

如果把连接上限提高到 50,p99 就回到 250ms 左右。

这个小实验非常真实。线上服务的尾延迟经常不是因为某个请求本身处理慢,而是因为排队、资源池、连接池、线程池、semaphore、流控、backpressure 叠加出来的。


八、不只限制连接,还要限制 in-flight requests

接下来问题来了:连接数限制不等于请求数限制。

如果每个连接只能处理一个请求,二者差别不大。但 HTTP/1.1 有 keep-alive,HTTP/2 有 multiplexing。更一般地说,你真正想控制的可能是"正在处理的请求数量",而不是"连接数量"。

作者给 MyServiceFactory 加两个 semaphore:

rust 复制代码
const MAX_CONNS: usize = 50;
const MAX_INFLIGHT_REQUESTS: usize = 5;

struct MyServiceFactory {
    conn_semaphore: PollSemaphore,
    reqs_semaphore: PollSemaphore,
    permit: Option<OwnedSemaphorePermit>,
}

连接 permit 仍然在连接 service 生命周期里持有。

请求 permit 则要在 MyService::poll_ready 里获取:

rust 复制代码
struct MyService {
    _conn_permit: OwnedSemaphorePermit,
    semaphore: PollSemaphore,
    reqs_permit: Option<OwnedSemaphorePermit>,
}

poll_ready

rust 复制代码
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    if self.reqs_permit.is_none() {
        self.reqs_permit = Some(futures::ready!(self.semaphore.poll_acquire(cx)).unwrap());
    }
    Ok(()).into()
}

call 里取出请求 permit,并放进请求 future:

rust 复制代码
let permit = self.reqs_permit.take().expect("poll_ready first");
PretendFuture {
    sleep,
    response,
    permit,
}

这和连接限制的思想一致:permit 是权限证明。只有拿到请求 permit,才能创建请求 future。future 完成或被 drop,permit 自动释放。

这样最多只能有 5 个请求同时 in-flight。如果每个请求 250ms,最大吞吐就是 20 req/s。作者用 oha 验证,结果大概是 19.88 requests/sec。

到这里,我们已经看懂了 Tower 的核心价值:poll_ready + call 提供了 backpressure 的协议。你可以把资源限制、队列、semaphore 都接进去。


九、忘掉刚才手写的一切:现代 Rust 有 async block

文章中间突然来一句:忘掉你刚学的。

因为前面那些手写 future、pin projection、permit 传递,都是为了让你理解底层结构。但现代 Rust 已经有 async block 和 .await,很多情况下根本不用手写 Future

比如 PretendFuture 可以直接换成:

rust 复制代码
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

fn call(&mut self, req: Request<Body>) -> Self::Future {
    let permit = self.reqs_permit.take().expect("poll_ready first");

    Box::pin(async move {
        let _permit = permit;
        tokio::time::sleep(Duration::from_millis(250)).await;
        Ok(Response::builder()
            .body("Hello World!\n".into())
            .unwrap())
    })
}

async block 会被编译器变成一个实现 Future 的状态机。它会捕获需要的变量,比如这里的 permit。只要 future 还活着,permit 就还活着;future 完成或 drop 后,permit 释放。

这比手写 PretendFuture 简单很多。

接着再把巨长类型换成 BoxFuture

rust 复制代码
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

这里有一个细节:Box<dyn T> 默认其实是 Box<dyn T + 'static>。用 BoxFuture<'static, ...> 把生命周期写出来,更清楚。

这就是从底层理解回到高层便利:你知道 async block 背后是 future,也知道 permit 如何被捕获,所以可以放心使用更简洁的写法。


十、Tower 已经有现成的并发限制组件

既然请求并发限制这么常见,Tower 当然已经提供了现成组件:

rust 复制代码
tower::limit::ConcurrencyLimit

它做的事情就是限制某个 service 同时存在多少个 in-flight futures。

原来我们自己在 MyService 里处理请求 permit,现在可以直接包一层:

rust 复制代码
ConcurrencyLimit::with_semaphore(MyService, sem)

这样 MyService 只关心业务逻辑:

rust 复制代码
struct MyService;

impl Service<Request<Body>> for MyService {
    type Response = Response<Body>;
    type Error = Infallible;
    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Ok(()).into()
    }

    fn call(&mut self, req: Request<Body>) -> Self::Future {
        Box::pin(async move {
            tokio::time::sleep(Duration::from_millis(250)).await;
            Ok(Response::builder()
                .body("Hello World!\n".into())
                .unwrap())
        })
    }
}

并发限制交给 Tower。

但作者也指出:不能简单用 ConcurrencyLimit 限制连接数。因为对 MyServiceFactory 来说,call 返回的 future 很短,它只是创建一个 service。连接真正的生命周期发生在 hyper 内部后续任务里。限制 factory future 的并发,不等于限制连接并发。

这就是抽象边界的问题:同样叫 service,不同层的 service 生命周期完全不同。你必须知道自己限制的是哪一层。


十一、make_service_fnservice_fn:把样板代码删掉

前面手写了 MyServiceFactoryMyService。但 hyper 已经提供了两个方便函数:

rust 复制代码
make_service_fn
service_fn

make_service_fn 把闭包变成连接层 service factory。

service_fn 把 async function / closure 变成 request service。

于是代码可以大幅简化:

rust 复制代码
let app = make_service_fn(move |_stream: &AddrStream| {
    let sem = sem.clone();
    async move {
        Ok::<_, Infallible>(ConcurrencyLimit::with_semaphore(
            service_fn(hello_world),
            sem,
        ))
    }
});

业务函数单独写:

rust 复制代码
async fn hello_world(req: Request<Body>) -> Result<Response<Body>, Infallible> {
    println!("{} {}", req.method(), req.uri());
    tokio::time::sleep(Duration::from_millis(250)).await;

    Ok(Response::builder()
        .body(Body::from("Hello World!\n"))
        .unwrap())
}

这时你再看 hyper 官方 hello world,就更容易理解了:它不是魔法,只是把 Service trait 的实现包装成函数适配器。


十二、Layer 和 ServiceBuilder:服务也可以一层层装饰

Tower 还有一个重要抽象:Layer

定义非常短:

rust 复制代码
pub trait Layer<S> {
    type Service;

    fn layer(&self, inner: S) -> Self::Service;
}

可以把 Layer 理解成 service 的装饰器。它拿一个 inner service,返回一个 wrapped service。

比如并发限制、超时、重试、限流、日志、中间件,都可以做成 layer。

ServiceBuilder 则帮助组合多个 layer:

rust 复制代码
let svc = ServiceBuilder::new()
    .layer(ConcurrencyLimitLayer::new(MAX_INFLIGHT_REQUESTS))
    .service(service_fn(hello_world));

这看起来很漂亮。

但作者马上指出:漂亮不等于正确。

如果你在 make_service_fn 里每次连接都创建一个 ConcurrencyLimitLayer::new(MAX_INFLIGHT_REQUESTS),那它限制的是"每个连接 service 的并发",不是全局请求并发。

也就是说,如果每个连接一个 service,每个 service 都有自己的限制 5,那么全局并发并不是 5。

这就是很典型的抽象陷阱:代码更短了,但语义变了。

要全局限制请求并发,需要:

rust 复制代码
GlobalConcurrencyLimitLayer::new(MAX_INFLIGHT_REQUESTS)

并且把同一个 layer clone 给每个 service:

rust 复制代码
let reqs_limit = GlobalConcurrencyLimitLayer::new(MAX_INFLIGHT_REQUESTS);

let app = make_service_fn(move |_stream: &AddrStream| {
    let reqs_limit = reqs_limit.clone();

    std::future::ready(Ok::<_, Infallible>(
        ServiceBuilder::new()
            .layer(reqs_limit)
            .service_fn(hello_world),
    ))
});

这段很有现实意义:中间件和 layer 组合确实强大,但要非常清楚它的作用域。是每个连接一个限制?每个服务实例一个限制?还是全局共享限制?写法差一点,语义就变了。


十三、用 then 捕获连接 permit:凌晨四点犯罪现场

作者接着想把连接数限制也用更高层的 Tower 组件写回来。

他发现 ServiceBuilder 有一个 then 方法,可以在 service 执行之后运行一个函数。

于是他产生了一个很糟糕但能跑的想法:在 make_service_fn 里先 acquire 一个连接 permit,然后用 then 闭包捕获这个 permit,在响应完成后 drop 它。

大概是:

rust 复制代码
let conns_limit = Arc::new(Semaphore::new(MAX_CONNS));
let reqs_limit = GlobalConcurrencyLimitLayer::new(MAX_INFLIGHT_REQUESTS);

let app = make_service_fn(move |_stream: &AddrStream| {
    let conns_limit = conns_limit.clone();
    let reqs_limit = reqs_limit.clone();

    async move {
        let permit = Arc::new(conns_limit.acquire_owned().await.unwrap());

        Ok::<_, Infallible>(
            ServiceBuilder::new()
                .layer(reqs_limit)
                .then(move |res| {
                    drop(permit);
                    std::future::ready(res)
                })
                .service_fn(hello_world),
        )
    }
});

作者自己说:这绝对是犯罪现场,不是 ThenLayer 设计者原本希望你这样用的。但如果它很蠢而且能工作,那大概是凌晨四点。

这里有一个隐藏问题:permit 被放进 Arc,被 then 捕获。释放时机依赖响应通过这个 service chain 完成。它很 hacky,而且和连接生命周期、请求生命周期之间的关系并不直观。

作者最后把限制都删掉,回到最小 hyper hello world:

rust 复制代码
let app = make_service_fn(move |_stream: &AddrStream| async move {
    Ok::<_, Infallible>(service_fn(hello_world))
});

现在我们回到了简单版本,但这一次,我们知道它背后发生了什么。

这也是这篇文章的节奏:先把内部拆开,让你看到丑陋细节;再用高层封装收回去。高层不是魔法,只是已经把那些复杂性封装好了。


十四、真正想讲的部分:接受连接

文章走到这里,作者说:其实还有一个我们一直没认真讨论的部分。

就是:

rust 复制代码
Server::bind(&([127, 0, 0, 1], 1025).into())

这行代码背后发生了什么?

通常,服务器 socket 的生命周期可以拆成几步:

text 复制代码
创建 socket
bind 到地址和端口
listen 开始监听
accept 接受连接

但 Rust 标准库和 tokio 的 TcpListener::bind 通常把 bindlisten 合在一起了。你调用 TcpListener::bind,它就已经开始 listening。

这对绝大多数应用很好。但作者的测试场景需要更怪的行为。

他有一个测试套件,里面很多测试都要监听某个端口。为了避免端口冲突,通常绑定端口 0,让操作系统自动挑一个空闲端口:

rust 复制代码
let ln = TcpListener::bind("127.0.0.1:0").await.unwrap();
println!("Listening on {}", ln.local_addr().unwrap());

但有些测试想模拟一种状态:服务已经占住端口,但还没有开始 listen。

这样客户端连接时会收到 connection refused,也就是 TCP RST。端口被占住,所以别的进程不能绑定;但还没 listen,所以连接尝试失败。

用底层 BSD socket API,这是可以做到的:先 bind,晚点再 listen

但 tokio 的高层 TcpListener::bind 做不到。于是作者用 socket2


十五、socket2:手动 bind,延迟 listen

使用 socket2,可以先创建 socket:

rust 复制代码
let socket = Socket::new(
    Domain::for_address(addr),
    Type::STREAM,
    Some(Protocol::TCP),
)?;

然后只 bind:

rust 复制代码
socket.bind(&addr.into())?;

此时端口已经被占用,但还没有 listen。另一个 TcpListener::bind(addr) 会失败。客户端如果 curl 这个端口,会得到 connection refused。

之后再调用:

rust 复制代码
socket.listen(128)?;

这时内核开始接受 TCP 三次握手。连接完成后,会被放进 accept queue,等应用调用 accept 取走。

如果 listen 后还不 accept,客户端可能会连接成功但请求挂住。因为 TCP 层连接建立了,但应用层没人读写。

这个实验非常底层,也非常有用。它把"服务 up 但未 ready"、"端口占用但不 listen"、"listen 但不 accept"这几种状态分开了。

在测试网络服务时,这些状态差异很重要。


十六、实现 hyper 的 Accept trait

如果要把自定义 accept 逻辑交给 hyper,就不能直接用 Server::bind。需要用:

rust 复制代码
Server::builder(acc)

其中 acc 实现 hyper 的 Accept trait。

这个 trait 大概是:

rust 复制代码
pub trait Accept {
    type Conn;
    type Error;

    fn poll_accept(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<Self::Conn, Self::Error>>>;
}

这就是连接入口版的 Stream。每次 poll,可能得到一个新连接,也可能 pending,也可能结束。

作者先写一个简单 Acceptor,内部持有 tokio::net::TcpListener,在 poll_accept 里调用:

rust 复制代码
self.ln.poll_accept(cx)

这能工作。

然后加上真正需求:先 bind,等 2 秒,再 listen,然后开始 accept。

这就要写状态机。


十七、手写 poll 风格状态机:聪明,但累

手写版本大概是:

rust 复制代码
enum Acceptor {
    Waiting {
        sleep: Sleep,
        socket: Socket,
    },
    Listening {
        ln: tokio::net::TcpListener,
    },
}

poll_accept 里:

text 复制代码
如果是 Waiting:
    poll sleep
    sleep 完成后 listen
    socket 转成 tokio TcpListener
    状态切到 Listening
    然后 poll_accept
如果是 Listening:
    直接 poll_accept

因为 Sleep 需要 pin,状态机要从 Pin<&mut Acceptor> 投影到字段。作者这里又用了 unsafe:

rust 复制代码
self.as_mut().get_unchecked_mut()
Pin::new_unchecked(sleep)

这段代码能工作,但作者很不喜欢。

他说:我真的很累了。不想再写 poll-style function 了。写这种代码确实会让你觉得自己很聪明,像在做"额外 nerd 的数独"。但它也很累。我们明明有 async / await,为什么还要手写 pin 和 poll?

于是他尝试把状态机包进一个更自然的 async 方法。


十八、Listener::accept(&mut self):看起来清楚,但适配困难

作者写了一个 Listener

rust 复制代码
enum Listener {
    Waiting { socket: Socket },
    Listening { ln: tokio::net::TcpListener },
}

然后实现 async 方法:

rust 复制代码
impl Listener {
    async fn accept(&mut self) -> Result<TcpStream, Report> {
        match self {
            Listener::Waiting { socket } => {
                tokio::time::sleep(Duration::from_secs(2)).await;
                socket.listen(128)?;
                // 转成 TcpListener
                // 状态切换到 Listening
                // accept 一次
            }
            Listener::Listening { ln } => {
                Ok(ln.accept().await?.0)
            }
        }
    }
}

这看起来清楚多了。

问题是,hyper 需要的是 Accept,也就是 poll-style 接口。我们需要把 listener.accept() 返回的 future 存起来,在 poll_accept 里 poll 它。于是可能写:

rust 复制代码
struct Acceptor {
    listener: Listener,
    fut: BoxFuture<'static, Result<TcpStream, Report>>,
}

但这个 future 是 listener.accept() 返回的,它借用了 &mut listener。也就是说,fut 这个字段借用了 listener 这个字段。

这就是自引用结构:

text 复制代码
Acceptor
  listener
  fut  -- mutable borrow of listener

Rust 不喜欢这种结构。因为 struct 一旦移动,字段地址可能变化;future 里保存的引用就可能指向旧地址。除非使用 Pin、自引用类型库和非常小心的 unsafe,否则很容易出错。

作者尝试强行 transmute 生命周期,把 future 当成 'static,也就是对编译器撒谎。结果程序运行时报错:

text 复制代码
Socket operation on non-socket

问题正是:future 借用了 listener,然后 listener 被移动了,破坏了不变量。

这段非常重要。它展示了为什么编译器阻止你,不是因为它小气,而是因为这类代码真的会坏。


十九、把 listener 放到堆上能修,但代价难看

一种修法是把 listener 放进 Box

rust 复制代码
struct Acceptor {
    listener: Box<Listener>,
    fut: BoxFuture<'static, Result<TcpStream, Report>>,
}

这样移动 Acceptor 时,只是移动指针,堆上的 Listener 地址不变。future 借用的地址也就稳定。

这能工作。

但作者立刻说:代价是什么?这也太丑了。

是的,你可以用 heap allocation 和 unsafe 把自引用结构勉强做对。但这不是好设计。尤其在这里,完全可以换一种思路避免自引用。


二十、Just move stuff:让状态跟着 Future 一起走

文章最关键的一句来了:

text 复制代码
Just move stuff.

也就是:直接 move 状态。别搞自引用。

Listener::accept 改成获取所有权:

rust 复制代码
async fn accept(mut self) -> Result<(Self, TcpStream), Report>

它不再借用 &mut self,而是拿走整个 self。完成一次 accept 后,返回新的 self 和接受到的连接:

rust 复制代码
Ok((Self::Listening { ln }, conn))

这样 future 拥有 listener,不借用外部 listener。future 完成后,把 listener 还给你。

Acceptor 就可以只存一个 future:

rust 复制代码
struct Acceptor {
    fut: BoxFuture<'static, Result<(Listener, TcpStream), Report>>,
}

poll_accept 里:

text 复制代码
poll 当前 future
得到 (listener, stream)
把 listener.accept() 创建成下一个 future
返回 stream

这就没有自引用了。

状态被 move 进 future,future 完成后再把状态吐出来。这个模式非常干净。

作者又指出,即使不改 accept(&mut self) 的签名,也可以用 async block 把 listener move 进去:

rust 复制代码
self.fut = Box::pin(async move {
    let res = listener.accept().await;
    (listener, res)
});

这同样避免了 self-referential struct。future 拥有 listener,而不是借用 Acceptor 的 listener 字段。

这就是 async Rust 中非常实用的技巧:遇到"future 借用某个状态,状态又要存 future 旁边"时,想一想能不能把状态 move 进 future,并让 future 返回状态。


二十一、这个模式叫 unfold

"状态 + async 产生下一个值 + 返回新状态"这个模式非常常见。它有个名字:unfold

futures crate 里有:

rust 复制代码
unfold(state, |state| async move {
    // 产生一个 item
    // 返回 Some((item, new_state))
})

用它可以把 Listener 变成 Stream

rust 复制代码
unfold(self, |mut ln| async move {
    let stream = ln.accept().await;
    Some((stream, ln))
})

然后 Acceptor 只需要 poll 这个 stream。

更进一步,hyper 自己已经提供了:

rust 复制代码
hyper::server::accept::from_stream

可以把一个 stream 直接适配成 Accept

于是:

rust 复制代码
impl Listener {
    fn into_acceptor(self) -> impl Accept<Conn = TcpStream, Error = Report> {
        hyper::server::accept::from_stream(unfold(self, |mut ln| async move {
            let stream = ln.accept().await;
            Some((stream, ln))
        }))
    }
}

这已经很简洁了。

如果你觉得 unfold 还是函数式味道太重,可以用 async-stream

rust 复制代码
fn into_acceptor(mut self) -> impl Accept<Conn = TcpStream, Error = Report> {
    from_stream(async_stream::stream! {
        loop {
            yield self.accept().await;
        }
    })
}

最后,文章又给出进一步简化:用 async_stream::try_stream!,可以直接写一个"延迟 listen 后循环 accept"的流。


二十二、最终版:延迟 listen 的 acceptor

最终简化后的思路大概是:

rust 复制代码
fn delayed_acceptor(
    addr: SocketAddr,
    delay: Duration,
) -> impl Accept<Conn = TcpStream, Error = std::io::Error> {
    from_stream(async_stream::try_stream! {
        let socket = Socket::new(
            Domain::for_address(addr),
            Type::STREAM,
            Some(Protocol::TCP),
        )?;

        socket.bind(&addr.into())?;

        tokio::time::sleep(delay).await;

        socket.listen(128)?;
        socket.set_nonblocking(true)?;

        let ln = tokio::net::TcpListener::from_std(socket.into())?;

        loop {
            yield ln.accept().await?.0;
        }
    })
}

注意最后的 socket.into()。文章更新里提到,后来有人指出不需要 from_raw_fd / into_raw_fd 和 unsafe,因为 socket2::Socket 可以直接转换成 std::net::TcpListener。这样连 unsafe 都没了。

这就是整篇文章的最终路径:

text 复制代码
手写 poll 状态机
尝试 async 方法
踩到自引用 future
用 Box 勉强修
发现可以 move 状态
抽象成 unfold
发现 hyper 有 from_stream
用 async-stream 写成最直观代码
进一步去掉 unsafe

这个过程非常 Rust:一开始很痛苦,但通过换建模方式,最后代码变得既安全又清楚。


二十三、为什么标题叫 Futures Nostalgia

这篇文章叫 Futures Nostalgia,不是因为作者真的想回到手写 futures 的时代。

相反,文章最后他已经用 async block、service_fn、ServiceBuilder、GlobalConcurrencyLimitLayer、from_stream、async-stream 把大量手写代码删掉了。

怀旧的点在于:早期写法虽然啰嗦,但能让你看见每个抽象边界。

你看见:

text 复制代码
连接如何变成 service
请求如何变成 future
poll_ready 如何提供 backpressure
permit 如何表达资源占用
future 如何持有 permit
Pin 为什么会出现
self-referential future 为什么危险
state machine 如何用 enum 表达
stream unfold 如何替代手写 poll

当你直接用 axum 写:

rust 复制代码
Router::new().route("/", get(handler))

这些都被隐藏了。隐藏是好事,因为日常开发不应该被这些细节淹没。但如果你要调性能、写中间件、做协议层、修底层 bug、理解 backpressure,这些细节必须知道。

所以这篇文章不是劝你以后都手写 Service。它是在帮你理解高层框架下面的骨架。


二十四、为什么最后推荐 axum

文章结尾有人问作者:你现在最喜欢的 Rust HTTP framework 是什么?

作者回答:axum。

原因很简单:axum 让他大多数时候不必关心 hyperisms / towerisms,但当他想深入时,又能接入 hyper/tower 生态。

这其实是最理想的抽象层次:

text 复制代码
平时用高层 API,快速写业务。
需要控制时,可以往下钻。
底层仍然是清晰、组合良好的 tower/hyper。

一个好框架不应该把底层完全封死。它应该让常见路径简单,也给高级用户留逃生通道。

axum 的吸引力就在这里:你可以写 handler、extractor、Router,也可以理解它底下仍然是 Tower service。

这也呼应了整篇文章的主题:高层简洁和底层可理解不冲突。关键是抽象设计要好。


二十五、对实际开发的启发

第一,poll_ready 不是历史包袱。它是 backpressure 的接口。

如果你在写 Tower service,不要把 poll_ready 当成永远返回 ready 的样板。它是实现限流、资源池、队列、负载保护的重要入口。

第二,连接并发和请求并发不是同一个概念。

一个连接可能处理多个请求。HTTP/2 更是一个连接多个 stream。限制连接数、限制 in-flight requests、限制每连接请求数,是不同策略。

第三,permit 是非常好的资源所有权模型。

OwnedSemaphorePermit 被谁持有,谁就占用资源。Drop 后自动释放。这比手写 acquire/release 更不容易漏。

第四,手写 Future 能帮你理解 async,但不要沉迷。

手写 pollPin、projection 很适合学习底层机制。实际项目中,优先用 async block、async fn、现成 combinator 和 crate。

第五,BoxFuture 是类型复杂度的逃生口。

Pin<Box<dyn Future<Output = ...> + Send + 'static>> 太长时,用 BoxFuture<'static, ...>。但要知道它意味着 heap allocation 和 dynamic dispatch。

第六,Tower Layer 的作用域要看清。

ConcurrencyLimitLayer 放在哪里,决定限制的是每个 service 还是全局。代码短不代表语义对。

第七,遇到 self-referential future,先想"能不能 move 状态"。

不要急着 transmute lifetime,也不要急着把字段互相借用。很多时候,把状态 move 进 future,让 future 返回状态,就能避开自引用结构。

第八,很多 poll-style 接口其实可以看成 Stream。

如果某个东西不断异步产生值,比如 accept 连接,可以考虑 unfoldfrom_streamasync-stream。这通常比手写 poll_accept 清楚。

第九,高层框架不是耻辱。

理解 hyper/tower 很有用,但日常业务用 axum 这种高层框架完全合理。真正重要的是:你知道需要时能往下钻。


二十六、总结

这篇文章从一个老派 hyper server 开始。第一版代码非常啰嗦:MyServiceFactory 实现 Service<&AddrStream>,把连接变成 MyServiceMyService 实现 Service<Request<Body>>,把请求变成响应。读者可能会觉得 Rust 写 HTTP Hello World 太累,但作者真正想展示的是 Tower Service trait 的结构:连接和请求都可以被看作 service,poll_ready 表示服务是否准备好,call 创建处理工作的 future。

poll_ready 是整篇文章的核心之一。它提供了 backpressure 的入口。在调用 call 前,调用方必须先把 service drive 到 ready。于是我们可以在 poll_ready 里用 semaphore 控制并发。作者先用 Arc<AtomicU64> 统计连接数,再用 tokio::sync::Semaphoretokio_util::sync::PollSemaphore 限制连接并发。因为 poll_ready 只是准备阶段,而真正创建 service 在 call,所以获取到的 OwnedSemaphorePermit 必须暂存在 Option 里,等 call 时交给 MyServiceMyService 持有 permit,drop 时 permit 自动释放。permit 本身就是"我有权限占用这个资源"的证明。

随后,作者把服务变得更像真实应用:每个请求 sleep 250ms 再返回。这样 MyService::Future 不能再是 Ready,于是先手写了一个 PretendFuture,内部持有 tokio::time::Sleep 和 response。手写 Future::poll 会遇到 Pin 和 pin projection,一开始需要 unsafe 的 map_unchecked_mutget_unchecked_mut,后来用 pin-project-lite 消除 unsafe。这个过程展示了 async/await 之前的 futures 时代有多底层,也解释了为什么 Pin 会出现在异步状态机附近。

接着,作者区分了连接并发和请求并发。连接数限制只能控制同时存在多少连接,但真正的业务压力往往来自 in-flight requests。于是他又给请求层加 semaphore,在 MyService::poll_ready 里获取请求 permit,并把 permit 放进请求 future。只有持有 permit 的 future 才能运行,future 完成或 drop 后 permit 释放。实验显示,如果请求上限是 5,每个请求耗时 250ms,那么吞吐大约是 20 req/s,尾延迟也会因排队而升高。

然后文章突然让读者"忘掉刚才学的"。因为现代 Rust 有 async block 和 .await,不需要手写 PretendFuturecall 可以直接返回 Box::pin(async move { ... }),async block 会捕获 permit,使 permit 跟随 future 生命周期。再用 futures::future::BoxFuture<'static, ...> 可以替代很长的 Pin<Box<dyn Future<...>>> 类型。接着,Tower 已经提供 ConcurrencyLimit,可以直接包装 service 限制 in-flight requests。hyper 也提供 make_service_fnservice_fn,把闭包适配成 service。于是大量手写 MyServiceFactory / MyService 样板代码都能删掉。

文章进一步介绍 Tower LayerServiceBuilderLayer 是 service 装饰器,ServiceBuilder 可以组合多个 layer,比如并发限制、超时、重试、日志等。但作者特别提醒:代码更短不代表语义正确。普通 ConcurrencyLimitLayer 如果放在每个连接 service 里,限制的是每个 service 的并发,不是全局并发。要全局请求限制,需要 GlobalConcurrencyLimitLayer。这段是很真实的工程提醒:middleware 的作用域非常重要。

随后作者尝试用 ServiceBuilder::then 捕获连接 permit,在响应完成后释放,以恢复连接限制。他自己承认这是"犯罪现场",不是 Tower 设计者希望的用法。实验虽然能跑,但语义很 hacky。最后他删掉连接限制和 sleep,回到最小 hyper hello world:make_service_fn 返回 service_fn(hello_world)。这一次,代码很短,但读者已经知道每一层背后的机制。

文章真正想讲的部分其实是连接接受。Server::bind 把 socket 的 bindlistenaccept 都隐藏了。但作者的测试场景需要更细粒度控制:先占用一个端口,但暂时不 listen,让客户端连接时得到 connection refused;稍后再 listen,但先不 accept,让连接在 accept queue 里挂住。Rust 标准库和 tokio 的 TcpListener::bind 通常把 bind 和 listen 合在一起,做不到这个。于是作者用 socket2 直接操作 BSD socket API:创建 socket、bind、稍后 listen、再转成 tokio::net::TcpListener

为了把这个自定义连接入口交给 hyper,需要实现 hyper::server::accept::Accept trait。Acceptpoll_accept 类似 stream 的 poll_next:每次可能产出一个连接。第一版 Acceptor 内部持有 tokio::net::TcpListener,直接 poll accept。加入"延迟 2 秒再 listen"后,就需要一个状态机:Waiting { sleep, socket }Listening { ln }。手写 poll_accept 时又要处理 Pin、Sleep、状态切换和 unsafe。代码能跑,但作者非常疲惫:他不想再写 poll-style 函数了。

于是他尝试写一个更自然的 Listener::accept(&mut self) async 方法。这个方法内部根据状态决定是否先 sleep、listen,然后 accept。它看起来清楚很多,但要适配到 Accept 时出现问题:Acceptor 想同时持有 listenerlistener.accept() 返回的 future,而这个 future 借用了 listener。这就变成自引用结构。作者尝试用 transmute 把生命周期强行变成 'static,结果运行时出现 "Socket operation on non-socket"。原因正是移动了被 future 借用的 listener,破坏了不变量。编译器之前阻止这件事,不是无理取闹,而是真的会坏。

一个修法是把 listener 放进 Box,让它在堆上地址稳定。但作者认为这不是好答案。真正好的答案是:不要让 future 借用 listener,而是把 listener move 进 future。把 accept 改成 async fn accept(self) -> Result<(Self, TcpStream)>,让 future 拥有 listener,完成后返回新的 listener 和 stream。或者保留 &mut self 版本,但用 async block 把 listener move 进去,future 完成时再返回 listener。这样就没有自引用结构了。

这个模式本质上就是 unfold:给一个状态,每次异步产生一个 item,同时返回下一轮状态。于是可以用 futures::stream::unfold(listener, |listener| async move { ... }) 把 listener 变成 stream。hyper 又提供 accept::from_stream,可以把 stream 适配成 Accept。进一步用 async-streamstream!try_stream! 宏,代码可以写成最直观的形式:先创建 socket,bind,sleep,listen,转成 listener,然后 loop 里不断 yield ln.accept().await?.0。后来有人指出,socket2::Socket 可以直接转换成 std::net::TcpListener,连 from_raw_fd 和 unsafe 都不需要。

最终,这篇文章的路线非常清楚:先从底层手写一切,让你看到 hyper/tower/future/backpressure/pin/acceptor 的真实结构;再用现代 Rust 的 async block、Tower layer、ServiceBuilder、unfold、from_stream、async-stream 一步步把代码变简单。它不是在鼓励大家每天手写 poll,也不是在嘲笑高层框架。相反,它最后推荐 axum,因为 axum 能让你大多数时候不关心 hyperisms / towerisms,但需要时仍然能往下钻。

这就是 "Futures Nostalgia" 的真正味道:怀念的不是啰嗦代码本身,而是那种能看见抽象骨架的感觉。理解这些骨架之后,再回到高层框架,你会更清楚自己在用什么,也更知道遇到性能、并发、backpressure 或底层网络问题时该往哪里看。

相关推荐
苏三说技术3 小时前
推荐一个牛逼的RAG+KAG双引擎AI项目
后端
从此以后自律3 小时前
Spring 全家桶
java·后端·spring
utmhikari4 小时前
【日常随笔】深入回答纯Vibe Coding写后端项目的几个问题
后端·ai编程·vibecoding
尚早立志4 小时前
Spring Boot 源码研读之ConfigurableEnvironment 环境准备
java·spring boot·后端
布朗克1685 小时前
Go 入门到精通-08-复合类型之数组与切片
开发语言·后端·golang·数组与切片
fliter5 小时前
从手写 HTTP/1.1 到拆开 HTTP/2
后端
CaffeinePro5 小时前
FastAPI自动接口文档定制与美化、权限管控
后端·fastapi
AI人工智能+电脑小能手5 小时前
【大白话说Java面试题 第151题】【06_Spring篇】第11题:说一下 Spring Bean 的生命周期?
java·开发语言·后端·spring·面试