本文是对 Futures Nostalgia 的整理与翻译。
内容结构概览
- 文章背景 :作者怀念早期 hyper 的可见设计感,虽然代码很长,但 Tower
Servicetrait 把结构暴露得很清楚。 - 第一个 hyper server :用
Server::bind(...).serve(MyServiceFactory)起一个 HTTP 服务,返回Hello World!。 - 两层 Service :一层把连接变成 request service,一层把
Request<Body>变成Response<Body>。 poll_ready的意义 :调用call前先询问服务是否 ready,这是 backpressure 的入口。- HTTP/1.1 连接复用:同一个 TCP 连接可以连续发多个请求,不一定一请求一连接。
- 统计连接数 :用
Arc<AtomicU64>在创建 service 时加一,Drop时减一。 - 连接并发限制 :用
tokio::sync::Semaphore和tokio_util::sync::PollSemaphore在poll_ready里获取 permit。 - permit 必须保存起来 :
poll_ready只是准备阶段,真正创建 service 在call,所以 permit 要暂存到Option<OwnedSemaphorePermit>。 - permit 是"权限证明":把 permit 存在 service 里,service drop 时 permit 自动释放。
- 模拟真实延迟 :把立刻返回的
Readyfuture 换成带Sleep的自定义PretendFuture。 - Pin 与手写 Future :手写
poll会碰到 pin projection;先用 unsafe,再用pin-project-lite消除 unsafe。 - 连接数限制影响尾延迟:请求处理 250ms,但连接上限太低时,p99 可能变成 10 秒。
- 请求并发限制:不仅要限制连接数,还要限制 in-flight requests。
- 请求 permit 放进 Future:只有持有 permit 的请求 future 才能运行;future 完成或 drop 后释放 permit。
- "忘掉刚才学的" :现代 Rust 有 async block 和
.await,不必手写那么多 future。 BoxFuture简化复杂类型 :用BoxFuture<'static, Result<...>>替代巨长的Pin<Box<dyn Future<...>>>。- Tower 复用组件 :
ConcurrencyLimit可以直接包住 service,限制 in-flight request。 - 连接限制不能简单套
ConcurrencyLimit:连接 service factory 产生的 future 很短,限制它不等于限制连接生命周期。 make_service_fn/service_fn:hyper 提供闭包适配器,可以大幅减少样板代码。- Layer 和 ServiceBuilder :Tower 的
Layer负责装饰 service,ServiceBuilder负责组合多个 layer。 - 局部并发限制 vs 全局并发限制 :
concurrency_limit默认是每个 service 的限制,想要全局限制要用GlobalConcurrencyLimitLayer。 - 连接限制的"邪门写法" :用
then捕获连接 permit,在响应完成后释放;能跑,但作者自己也说像凌晨四点犯罪现场。 - 回到最小 Hello World:删掉限制和 sleep 后,最终得到 hyper 官方风格的 hello world,并且知道每一层发生了什么。
- 真正想讲的部分:接受连接 :
Server::bind隐藏了 socket 的bind/listen/accept细节。 - 为什么要拆开 bind 和 listen:测试里想先占住端口但暂不 listen,让客户端看到 connection refused。
- socket2 登场 :用 BSD socket API 手动
bind,延迟listen,再转成TcpListener。 - 实现 hyper
Accepttrait :poll_accept返回下一个连接,是连接入口的异步抽象。 - 手写状态机很痛苦 :为了"先等 2 秒再 listen",需要
Waiting/Listeningenum、Sleep、pin projection 和 unsafe。 - 尝试用 async 方法包装 :
Listener::accept(&mut self)看起来更清楚,但适配到Accept时会遇到自引用 future。 - 自引用 future 的坑 :future 借用了
listener,而 struct 又同时持有 listener 和 future,移动后破坏不变量。 - "Just move stuff" :改成把 listener move 进 future,future 返回
(listener, stream),避免自引用结构。 unfold模式:这种"状态 + async 产生下一个值"的模式本质上就是 stream unfold。hyper::server::accept::from_stream:hyper 已经提供了从 stream 到 acceptor 的适配器。async-stream进一步简化 :用try_stream!写出最直观的"延迟 listen 后循环 accept"。- 最终结论 :底层 trait 很重要,因为它让你理解系统;高层封装也很重要,因为没人想天天手写
poll、Pin 和状态机。
这篇文章的标题叫 Futures Nostalgia,可以理解成"对 Futures 时代的怀旧"。
这里的 "Futures" 不是金融期货,而是 Rust async 生态里的 Future。标题里的 nostalgia 也不是单纯怀旧,而是一种很微妙的情绪:早期 Rust async / hyper / tower 代码很啰嗦,需要你手写 poll_ready、call、Future、Pin、Poll、Context,看起来像是把内脏都翻出来了。但也正因为内脏都露在外面,你能清楚看到系统到底怎么工作。
作者一开始说,直到不久之前,hyper 还是他最喜欢的 Rust HTTP 框架。它低层,但给你很多控制权。然后他写了一个非常"老派"的 hyper server:一个 MyServiceFactory 负责接受连接,一个 MyService 负责处理请求。代码很长,长到读者可能已经关掉页面。
但作者的重点不是"你看 Rust 多啰嗦"。恰恰相反,他想说的是:这些啰嗦的结构背后,藏着非常重要的抽象。
尤其是 Tower 的 Service trait。
这篇文章一路从一个简单的 "Hello World" HTTP server 讲到连接并发限制、请求并发限制、手写 Future、Pin、BoxFuture、Tower Layer、ServiceBuilder、ConcurrencyLimit、GlobalConcurrencyLimitLayer,最后进入更底层的连接接受逻辑:手动 bind、延迟 listen、实现 hyper 的 Accept trait,再把手写状态机一步步简化成 unfold、from_stream 和 async-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_ready 和 call
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 连接可以处理多个请求
文章接着用 curl 和 socat 做实验。
一个简单请求当然能得到:
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_fn 和 service_fn:把样板代码删掉
前面手写了 MyServiceFactory 和 MyService。但 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 通常把 bind 和 listen 合在一起了。你调用 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,但不要沉迷。
手写 poll、Pin、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 连接,可以考虑 unfold、from_stream、async-stream。这通常比手写 poll_accept 清楚。
第九,高层框架不是耻辱。
理解 hyper/tower 很有用,但日常业务用 axum 这种高层框架完全合理。真正重要的是:你知道需要时能往下钻。
二十六、总结
这篇文章从一个老派 hyper server 开始。第一版代码非常啰嗦:MyServiceFactory 实现 Service<&AddrStream>,把连接变成 MyService;MyService 实现 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::Semaphore 和 tokio_util::sync::PollSemaphore 限制连接并发。因为 poll_ready 只是准备阶段,而真正创建 service 在 call,所以获取到的 OwnedSemaphorePermit 必须暂存在 Option 里,等 call 时交给 MyService。MyService 持有 permit,drop 时 permit 自动释放。permit 本身就是"我有权限占用这个资源"的证明。
随后,作者把服务变得更像真实应用:每个请求 sleep 250ms 再返回。这样 MyService::Future 不能再是 Ready,于是先手写了一个 PretendFuture,内部持有 tokio::time::Sleep 和 response。手写 Future::poll 会遇到 Pin 和 pin projection,一开始需要 unsafe 的 map_unchecked_mut 和 get_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,不需要手写 PretendFuture。call 可以直接返回 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_fn 和 service_fn,把闭包适配成 service。于是大量手写 MyServiceFactory / MyService 样板代码都能删掉。
文章进一步介绍 Tower Layer 和 ServiceBuilder。Layer 是 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 的 bind、listen、accept 都隐藏了。但作者的测试场景需要更细粒度控制:先占用一个端口,但暂时不 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。Accept 的 poll_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 想同时持有 listener 和 listener.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-stream 的 stream! 或 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 或底层网络问题时该往哪里看。