Appearance
第15章 Serve:监听、接受连接与优雅关闭
前 14 章讨论的都是"一次请求如何被处理"——从路由、提取器、handler、响应到中间件。这些都是单个请求内的机制。但一个 Web 服务器还要回答一个更根本的问题:怎么接受请求。怎么监听端口?怎么从 OS 的 TCP accept 拿到一个连接?怎么在多个连接之间调度?怎么优雅地停机?
这些是 axum 运行层的职责。对用户而言就是三行代码:
rust
let app = Router::new().route("/", get(handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();简洁得看不出里面有多少工作。但深入看,axum::serve 背后做的事情包括:tokio 的 accept-loop、hyper 的 connection parser、tokio::spawn 每个连接任务、watch channel 协调优雅关闭、executor 扩展点让用户定制任务调度。本章拆开每个环节。
先理清"serve"到底干了几件事
按时间顺序拆开 serve 启动后发生的事情:
- 绑定 Router 和 Listener:用户传给
serve的两个参数,从此绑在一起 - 接受连接:
listener.accept()从 OS 拿到新的 TCP stream - 为每连接生成 Service:
make_service.call(IncomingStream)产出一个独立的 Service - spawn tokio 任务:每个连接一个独立任务跑 hyper 的连接 serve loop
- hyper 解析 HTTP:读字节、parse HTTP/1.1 或 HTTP/2、调 Service::call
- 处理响应:tower service 返回 Response、hyper 编码成字节写回 TCP
- 等待或关闭:无限 loop,直到外部信号触发优雅关闭
这七件事分属三个层级:1-4 是 axum 自己做的粘合、5-6 是 hyper 做的 HTTP 协议工作、2/4 用的调度和通道是 tokio 提供的原语。用户只看到 serve(listener, app).await——三层协作对用户透明。
本章重点是 1-4 + 7——axum 自己负责的那部分。5-6 在《Hyper 与 Tower》讨论、tokio 原语在《Tokio 源码深度解析》讨论。理解了 axum 在中间这层做什么,你就能解答常见问题:"为什么我的 axum server 不支持某个 hyper 选项?"(答案:axum::serve 用 hyper 默认值、想定制要绕过 serve 直接调 hyper)、"能自定义每个连接的行为吗?"(答案:实现自定义 MakeService)。
serve 函数签名
axum/src/serve/mod.rs:102-119 是 serve 的入口:
rust
// axum/src/serve/mod.rs:102-119
pub fn serve<L, M, S, B>(listener: L, make_service: M) -> Serve<L, M, S, B, TokioExecutor>
where
L: Listener,
M: for<'a> Service<IncomingStream<'a, L>, Error = Infallible, Response = S>,
S: Service<Request, Response = Response<B>, Error = Infallible> + Clone + Send + 'static,
S::Future: Send,
B: HttpBody + Send + 'static,
B::Data: Send,
B::Error: Into<Box<dyn StdError + Send + Sync>>,
{
Serve {
listener,
make_service,
executor: TokioExecutor,
_marker: PhantomData,
}
}几个要点:
一、L: Listener:不限定于 TcpListener——任何实现了 Listener trait 的类型都能用。这让 Unix socket、TLS 封装、测试用的 mock listener 都能当 serve 的第一参数。第 16 章详讲 Listener trait。
二、M: for<'a> Service<IncomingStream<'a, L>, ...>:make_service 是一个Service 的 Service——接收 IncomingStream(每个新连接生一个)、产出 Service(处理该连接的请求)。这个"两层 Service"结构让每个连接能有独立的 Service 实例——支持 per-connection state(比如 ConnectInfo)。
三、S: Service<Request, Error = Infallible>:第二层 Service——就是 Router / MethodRouter / Handler 等的 Service 实现。注意 Error 必须是 Infallible(第 12 章讲过的收敛契约)。
四、TokioExecutor:默认的任务调度器——直接 tokio::spawn。可以通过 Serve::with_executor 替换成自定义。
五、注意 serve 是同步的:它只返回一个 Serve 结构体,并不真的开始接受请求——需要 await 这个结构才触发。这是标准的 "future not started until polled" 模式。
Serve 结构和 IntoFuture
Serve 结构实现了 IntoFuture(Rust 2024 稳定的 trait),所以 await 会自动把它转成 Future 运行:
rust
// axum/src/serve/mod.rs:372-391
impl<L, M, S, B, E> IntoFuture for Serve<L, M, S, B, E> {
type Output = Infallible;
type IntoFuture = private::ServeFuture;
fn into_future(self) -> Self::IntoFuture {
private::ServeFuture(Box::pin(async move { self.run().await }))
}
}几个关键细节:
一、type Output = Infallible:Serve 的 await 永远不返回——它 loop accept 连接直到进程被外部信号终止。返回类型是 Infallible 表达"这个 future 永远 pending"。
二、ServeFuture(Box::pin(...)):用 Box::pin 包装——因为 run() 返回的具体 future 类型没法命名(async move {...} 产生 anonymous type)。Box::pin 做类型擦除让 IntoFuture::IntoFuture 是一个具体 public 类型。
三、async move { self.run().await }:实际工作都在 run() 里。
accept-loop:最简形态
Serve::run 是不带优雅关闭的 accept-loop(axum/src/serve/mod.rs:321-344):
rust
// axum/src/serve/mod.rs:321-344
async fn run(self) -> ! {
let Self { mut listener, mut make_service, executor, _marker } = self;
let (signal_tx, _signal_rx) = watch::channel(());
let (_close_tx, close_rx) = watch::channel(());
loop {
let (io, remote_addr) = listener.accept().await;
handle_connection(
&mut make_service,
&signal_tx,
&close_rx,
io,
remote_addr,
&executor,
).await;
}
}极简:无限循环 listener.accept().await,每次拿到连接就交给 handle_connection。注意 handle_connection 自己会 tokio::spawn 任务 — 所以 accept-loop 不会被单个连接的处理耗时阻塞。
两个 watch channel (signal_tx / close_rx) 在这里几乎没用——signal 和 close 是为优雅关闭设计的、这个 run 函数里没人关闭它们(_signal_rx 和 _close_tx 都是下划线前缀占位),传给 handle_connection 也只是让后者编译通过。真实的运行逻辑只是 "accept 后 spawn 任务"。
关键洞察:accept-loop 是单线程的——每次 accept 拿到连接后马上 spawn 一个独立任务、循环立即继续。这让 accept 速度只受 OS 限制,不被连接处理拖慢。典型场景下 accept-loop 每秒能接受几万个新连接。
handle_connection:每连接的处理
axum/src/serve/mod.rs:559-633:
rust
// axum/src/serve/mod.rs:559-633 (简化)
async fn handle_connection<L, M, S, B, E>(
make_service: &mut M,
signal_tx: &watch::Sender<()>,
close_rx: &watch::Receiver<()>,
io: <L as Listener>::Io,
remote_addr: <L as Listener>::Addr,
executor: &E,
) where /* bounds */
{
let io = TokioIo::new(io);
// 从 make_service 为这个连接拿一个专用 Service
make_service.ready().await.unwrap_or_else(|err| match err {});
let tower_service = make_service
.call(IncomingStream { io: &io, remote_addr })
.await
.unwrap_or_else(|err| match err {})
.map_request(|req: Request<Incoming>| req.map(Body::new));
let hyper_service = TowerToHyperService::new(tower_service);
let signal_tx = signal_tx.clone();
let close_rx = close_rx.clone();
let hyper_executor = HyperExecutor(executor.clone());
executor.execute(async move {
let mut builder = Builder::new(hyper_executor);
builder.http1().timer(TokioTimer::new());
builder.http2().enable_connect_protocol();
let mut conn = pin!(builder.serve_connection_with_upgrades(io, hyper_service));
let mut signal_closed = pin!(signal_tx.closed().fuse());
loop {
tokio::select! {
result = conn.as_mut() => {
if let Err(_err) = result { trace!("failed: {_err:#}"); }
break;
}
_ = &mut signal_closed => {
conn.as_mut().graceful_shutdown();
}
}
}
drop(close_rx);
});
}看几个关键点。
make_service.call 为连接生成 Service
rust
make_service.call(IncomingStream { io: &io, remote_addr }).await这一步把 make_service(类型 M)调用一次、产出一个专门给这个连接用的 S: Service<Request>。对 Router 来说:普通 Router 的 make_service 实现会忽略 IncomingStream、返回克隆的 Router。但对 IntoMakeServiceWithConnectInfo 来说,make_service.call 会把 remote_addr 提取出来塞进 Extension——这是第 8 章讨论过的 ConnectInfo<SocketAddr> 能工作的原因。
TokioIo:hyper 和 tokio 的 bridge
let io = TokioIo::new(io) —— TokioIo 是 hyper_util 提供的 adapter。为什么需要?
hyper 使用它自己的 IO traits(hyper::rt::Read / hyper::rt::Write),tokio 用它自己的(tokio::io::AsyncRead / AsyncWrite)。两者不兼容——tokio 的 stream 不能直接给 hyper。TokioIo::new 是 adapter,把 tokio 流转成 hyper 能吃的形式。
这个隔离是 hyper 的设计决策——hyper 不强制绑定 tokio(理论上可以跑在其他 runtime 上),所以它有自己的 trait。axum 默认用 tokio、用 TokioIo 桥接。
TowerToHyperService:tower 和 hyper 的 bridge
TowerToHyperService::new(tower_service)——同样是 adapter。tower 用 tower::Service(poll_ready / call),hyper 用 hyper::service::Service(签名类似但细节不同)。adapter 让 tower service 能作为 hyper connection 的 handler。
两层桥接(IO 层 + Service 层)让 axum / tower / hyper / tokio 四个库解耦——每层可以独立演进,adapter 负责对接。
serve_connection_with_upgrades:hyper 的核心
builder.serve_connection_with_upgrades(io, hyper_service)——把 TCP 连接交给 hyper 解析。hyper 开始:
- 读字节流、parse HTTP/1.1 或 HTTP/2 帧
- 对每个 request,调
hyper_service.call(req)拿 response - 把 response 编码成 HTTP 字节流写回
- 处理 keep-alive 复用、HTTP/2 多路复用、Upgrade(WebSocket)
with_upgrades 表示支持协议升级——第 8 章讲 WebSocket 时用到的 hyper::upgrade::OnUpgrade 机制就来自这里。
tokio::select! 与优雅关闭
rust
loop {
tokio::select! {
result = conn.as_mut() => { /* 连接正常结束或错 */ break; }
_ = &mut signal_closed => { conn.as_mut().graceful_shutdown(); }
}
}select 两条路:
conn.as_mut():connection future 返回(正常结束 / 错误)—— break loopsignal_tx.closed():signal channel 关闭(表示整个 server 要停机)—— 调conn.graceful_shutdown()
注意:graceful_shutdown 后 loop 继续(没 break)——下次迭代的 conn.as_mut() 会等 inflight 请求完成后 finish(hyper 内部会拒绝新请求、等现有请求完成)。这让 graceful shutdown 的语义清晰:不再接新请求、等现有完成、然后 connection 正常结束。
drop close_rx:信号连接已完成
drop(close_rx) —— 在 spawn 的任务结束时 drop 这个 receiver。close channel 的 sender(close_tx)会通过 receiver_count 感知所有 receiver 都 drop。后面 WithGracefulShutdown 会用这个来判断"所有连接都结束了"。
IncomingStream:每连接的元数据
axum/src/serve/mod.rs:641-662:
rust
pub struct IncomingStream<'a, L>
where L: Listener,
{
io: &'a TokioIo<L::Io>,
remote_addr: L::Addr,
}
impl<L> IncomingStream<'_, L> where L: Listener {
pub fn io(&self) -> &L::Io { self.io.inner() }
pub fn remote_addr(&self) -> &L::Addr { &self.remote_addr }
}一个小结构体——持有 IO 的引用 + 远端地址。主要用途:
IntoMakeServiceWithConnectInfo读remote_addr,塞进 Extension 供 handler 的ConnectInfo<SocketAddr>提取器用- 自定义 MakeService 可以根据 IO 或 addr 给每个连接做不同配置——比如"localhost 连接打开 debug mode"
大多数用户看不到 IncomingStream——只有用 .into_make_service_with_connect_info::<T>() 时隐式用到。
两层 Service 的深入理解
serve 的类型签名里有个微妙设计值得拆开讨论:make_service: M where M: Service<IncomingStream<'a, L>, Response = S>, S: Service<Request, ...>。两层 Service 嵌套——第一层产出第二层。
为什么这样设计?几个原因:
一、per-connection 状态:每次 accept 新连接时调一次 make_service.call(IncomingStream)——返回一个独立的 S。这个 S 可以持有针对这个连接的 state。IntoMakeServiceWithConnectInfo 就是利用这个——每次 call 时把 remote_addr 塞进返回的 S 的 extensions。
二、连接级配置隔离:一个连接上的请求共享同一个 S——对 HTTP/1.1 keep-alive 或 HTTP/2 多路复用的场景,连接内请求的处理方式一致、跨连接可以不同。
三、和 Tower 生态契合:Tower 的 MakeService 概念就是"每次 call 生成一个专用 Service"——axum 沿用 Tower 的约定,让 axum 的 serve 能接受任何 Tower MakeService。
看几种常见的 make_service 类型:
Router:Router 自己实现MakeService<IncomingStream, Response = Self>——每次 call 返回一个 Router 克隆。连接间共享 handler 和 middleware 配置、没有 per-connection stateIntoMakeService<T>:T.into_make_service()的包装——每次 call 返回 T 的克隆IntoMakeServiceWithConnectInfo<T, C>:每次 call 额外提取 IncomingStream 的 addr 塞进 Extension- 自定义 MakeService:实现
Service<IncomingStream, Response = YourService>——完全控制每连接的 Service 生成
这种"两层 Service"在 tower 里是标准模式——hyper-util 的 hyper_util::service::service_fn、tonic 的 gRPC server、甚至 HTTP client 都用类似结构。理解后你能在 tower 生态里自由迁移。
watch channel 作为关闭信号
axum 用两个 tokio::sync::watch::channel<()> 做关闭协调:
signal_tx/_rx:外部信号 → 通知 accept-loop 和 connection tasks "该关了"close_tx/_rx:connection tasks 各自 → 通知 main "我结束了"
watch channel 的特点:
- 只携带最新值:传统 mpsc 是 FIFO 队列,watch 只保留一个"最新快照"
- receiver 可以无限复制(clone)
- receiver count 可被 sender 查询(
receiver_count、closed/subscribe) - close 事件:所有 receiver drop 后、
closed()future resolve
axum 利用 #2-4 三点:
- signal channel:主 run 创建
signal_tx,每个 connection task 拿signal_tx.clone().closed()监听——收到信号就触发graceful_shutdown - close channel:主 run 创建
close_tx,每个 connection task 拿close_rx.clone()——任务结束时 drop。所有 drop 后close_tx.closed()resolve——主 run 知道"所有连接处理完了"
watch channel 在这里被用作"关闭事件广播"——不是传消息、而是用 drop 和 close 作信号。这种 pattern 在 tokio 生态广泛用。《Tokio 源码深度解析》第 10 章讲 watch channel 实现时会详讨论。
为什么不用 broadcast 或 mpsc
broadcast channel(多生产多消费)也能做类似事情——但 broadcast 重在"多份消息广播",watch 重在"最新状态"。关闭信号只有一种状态("没关"或"关了"),watch 更合适。
mpsc(多生产单消费)不适合——需要给每个 connection task 一份"能监听关闭"的 receiver,mpsc 单消费者不行。
watch 还有一个便利:克隆 receiver 是零成本的(内部是 Arc<RwLock>)——每个 connection task clone 一份没负担。
WithGracefulShutdown:优雅关闭
真正有意义的 serve 是 WithGracefulShutdown——响应外部信号(通常是 SIGTERM)做优雅关闭。用法:
rust
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.ok();
}shutdown_signal 是一个 Future<Output = ()>——await 完成就触发关闭。最常见是 tokio::signal::ctrl_c——监听 SIGINT。
核心 run 实现(mod.rs:447-494):
rust
// axum/src/serve/mod.rs:447-494
async fn run(self) {
let Self { mut listener, mut make_service, executor, signal, _marker } = self;
let (signal_tx, signal_rx) = watch::channel(());
executor.execute(async move {
signal.await;
trace!("received graceful shutdown signal");
drop(signal_rx);
});
let (close_tx, close_rx) = watch::channel(());
loop {
let (io, remote_addr) = tokio::select! {
conn = listener.accept() => conn,
_ = signal_tx.closed() => {
trace!("signal received, not accepting new connections");
break;
}
};
handle_connection(&mut make_service, &signal_tx, &close_rx, io, remote_addr, &executor).await;
}
drop(close_rx);
drop(listener);
trace!("waiting for {} task(s) to finish", close_tx.receiver_count());
close_tx.closed().await;
}分三段看。
段一:signal 监听任务
rust
let (signal_tx, signal_rx) = watch::channel(());
executor.execute(async move {
signal.await;
drop(signal_rx);
});spawn 一个独立任务 await signal future。信号到来后 drop signal_rx——让 watch channel 的 receiver 数变成 0。signal_tx.closed() 会 resolve(因为没 receiver)——其他地方的 signal_tx.closed() future 就会完成。
这是 watch channel 作"信号量"的惯用法:没消息、只用"关闭事件"作信号。
段二:accept-loop with select
rust
loop {
let (io, remote_addr) = tokio::select! {
conn = listener.accept() => conn,
_ = signal_tx.closed() => break,
};
handle_connection(...).await;
}每次 accept 时同时监听 signal——信号到来就 break loop 不再接新连接。这是优雅关闭的第一步:停止接收新请求。
段三:等待现有连接完成
rust
drop(close_rx);
drop(listener);
trace!("waiting for {} task(s) to finish", close_tx.receiver_count());
close_tx.closed().await;accept-loop break 后:
drop(close_rx):主 run 不再持有 close receiverdrop(listener):主 run 不再监听端口close_tx.closed().await:等所有 handle_connection spawn 出去的任务 drop 各自的 close_rx(spawn 任务结束时drop(close_rx))——当最后一个 drop 时 close_tx.closed() resolve
这是优雅关闭的第二步:等现有请求处理完。每个 connection 任务都持有一份 close_rx——任务结束时 drop——主任务通过 close_tx.closed() 等所有任务结束。
这个机制用两个 watch channel 实现了三步协调:
- signal channel:外部信号 → 内部传播
- accept-loop 内部:select 上监听 signal,触发 break
- close channel:counting 活跃连接数、全部结束后 resolve
accept 错误怎么处理
listener.accept().await 很少失败——但可能发生:
- Too many open files(file descriptor 耗尽):严重的资源问题
- Interrupted system call:被信号打断、通常是误报
- Network unreachable(某些特殊 listener 实现)
axum::serve 当前的 Listener trait 设计把这些错误吞了——Listener::accept 返回 (Io, Addr),不返回 Result。实现者自己决定错误处理——标准的 tokio TcpListener 实现会在错误时 sleep 1 秒再重试(源码在 axum/src/serve/listener.rs):
rust
// axum/src/serve/listener.rs (simplified)
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
loop {
match self.accept().await {
Ok((stream, addr)) => return (stream, addr),
Err(e) => {
tracing::error!("accept error: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}"错误 sleep 重试"是服务端的标准做法——fd 耗尽时每毫秒 retry 会把 CPU 打爆、sleep 让系统有机会恢复。1 秒是一个合理的 cooldown。
但这也意味着accept 错误对 serve 不可见——你拿不到 fd 耗尽的警报。生产监控要靠 OS 级的 metrics(/proc/sys/fs/file-nr 或 Prometheus node exporter)。
Executor trait:可插拔的任务调度
axum 0.8+ 的一个新特性是 Executor trait——把 tokio::spawn 抽象掉让用户能替换:
rust
// axum/src/serve/mod.rs:159-165
pub trait Executor: Clone + Send + Sync + 'static {
fn execute<Fut>(&self, fut: Fut) -> JoinHandle<Fut::Output>
where
Fut: Future + Send + 'static,
Fut::Output: Send + 'static;
}默认实现 TokioExecutor:
rust
// axum/src/serve/mod.rs:170-181
pub struct TokioExecutor;
impl Executor for TokioExecutor {
fn execute<Fut>(&self, fut: Fut) -> JoinHandle<Fut::Output>
where Fut: Future + Send + 'static, Fut::Output: Send + 'static,
{
tokio::spawn(fut)
}
}用法:
rust
#[derive(Clone)]
struct InstrumentedExecutor;
impl Executor for InstrumentedExecutor {
fn execute<Fut>(&self, fut: Fut) -> JoinHandle<Fut::Output>
where Fut: Future + Send + 'static, Fut::Output: Send + 'static,
{
let span = tracing::info_span!("axum.serve.task");
tokio::spawn(fut.instrument(span))
}
}
axum::serve(listener, app).with_executor(InstrumentedExecutor).await;每个连接任务、每个 signal 任务、每个 graceful shutdown task——都被 InstrumentedExecutor 的 execute 包装——自动带上 tracing span。
适用场景:
- tracing/telemetry 集成:自动给所有任务加 span
- 资源限制:用自定义 executor 控制 spawn 的并发数
- 测试:在测试里 mock executor 不真跑 tokio
实战:配合 signal + cleanup 的优雅关闭
完整的生产配置:
rust
use axum::{Router, routing::get};
use std::sync::Arc;
use tokio::signal;
async fn shutdown_signal(db: Arc<Pool>, metrics: Arc<Metrics>) {
let ctrl_c = async {
signal::ctrl_c().await.expect("failed to install ctrl-c");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("signal received, starting graceful shutdown");
// 在这里做关闭前的准备(flush log、标记服务停机)
metrics.flush().await;
// 注意:别在这里关闭 db——正在处理的请求还需要它
// db 会在 serve 结束后 drop
}
#[tokio::main]
async fn main() {
let db = Arc::new(build_db().await);
let metrics = Arc::new(build_metrics());
let app = build_router(db.clone(), metrics.clone());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(db.clone(), metrics.clone()))
.await
.unwrap();
// serve 返回后,所有连接已处理完
tracing::info!("server shutdown complete, cleaning up");
drop(app); // Router 随 app 的 Drop 释放——state 里的 Arc 引用减一
// db 在这里是最后一份 Arc——真正 drop 时关数据库连接池
drop(db);
drop(metrics);
}几个生产要点:
- 同时监听 SIGINT 和 SIGTERM:SIGINT 是 ctrl-c、SIGTERM 是 kill 或 Docker stop。两者都应该触发 graceful shutdown
- 不要在 shutdown_signal 里关 db:graceful shutdown 期间的现有请求可能还需要 db。db 应该在 serve 返回后才 drop
- 记录 signal 接收日志:排查重启时有没有收到信号、收到哪种
- shutdown_signal future 只做"等信号 + 预处理":真正的关闭等待由 axum::serve 自己做
超时限制
默认的 graceful shutdown 会永远等——inflight 请求不结束就不退出。生产里通常要加超时保护:
rust
use tokio::time::{timeout, Duration};
let serve_fut = axum::serve(listener, app).with_graceful_shutdown(shutdown_signal());
match timeout(Duration::from_secs(30), serve_fut).await {
Ok(res) => res.unwrap(),
Err(_) => tracing::warn!("graceful shutdown timed out"),
}30 秒后如果还有连接卡住,直接放弃等待——任务被 tokio 取消、TCP 连接被强制断开。客户端会看到连接关闭——不理想但好于 server 永远不退出。
signal 监听的平台差异
生产环境的 graceful shutdown 应该响应多种系统信号,主要的两个:
Unix-like 系统(Linux / macOS):
- SIGINT(Ctrl-C、
kill -2):由用户交互发起 - SIGTERM(
kill/kill -15、Docker/Kubernetes 默认):由进程管理器发起 - SIGQUIT(Ctrl-\、
kill -3):请求 core dump 然后退出——通常想让 axum 正常关闭
Windows:
- 只有 Ctrl-C / Ctrl-Break 和
CTRL_CLOSE_EVENT(关闭控制台窗口) - 没有 POSIX 的 SIGTERM
跨平台写法:
rust
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("failed to install ctrl-c");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install terminate")
.recv().await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}tokio::signal::ctrl_c() 跨平台可用(Windows 下也能捕获 Ctrl-C);tokio::signal::unix::SignalKind::terminate 只在 Unix 下存在。用 #[cfg] 分叉保证 Windows 下编译过。
部署 Docker 时,Dockerfile 的 entrypoint 如果是 ["./server"] 而不是 sh -c "./server",SIGTERM 能正确传给进程;否则 shell 拦截信号,axum 收不到。生产踩坑常见。
数据流全景
把所有组件放一起画运行时全景:
这张图展示了 axum::serve 的完整生命周期——从 client TCP 到 accept-loop、到 handle_connection、到 hyper 解析、到 Router 处理、再回到 client。另一侧的 signal 路径协调优雅关闭。
HTTP/2 特殊配置
axum::serve 通过 hyper_util 支持 HTTP/1 和 HTTP/2——源码(mod.rs:606-611):
rust
#[cfg(feature = "http1")]
builder.http1().timer(TokioTimer::new());
#[cfg(feature = "http2")]
builder.http2().enable_connect_protocol();HTTP/1 打开 timer 启用请求头超时(默认 hyper 的 header_read_timeout——防止 slowloris 攻击);HTTP/2 启用 "extended CONNECT" 让 WebSocket 能走 HTTP/2(第 8 章讨论过)。
其他 hyper 选项(max_concurrent_streams、keep_alive_interval、max_frame_size)用默认值——axum::serve 本身不暴露这些。想定制需要绕过 serve:
rust
use hyper_util::server::conn::auto::Builder;
use hyper_util::rt::{TokioIo, TokioExecutor};
let listener = /* ... */;
loop {
let (stream, _) = listener.accept().await?;
let io = TokioIo::new(stream);
let service = app.clone();
tokio::spawn(async move {
let mut builder = Builder::new(TokioExecutor::new());
builder.http2().max_concurrent_streams(100); // 定制
builder.serve_connection(io, service).await
});
}这相当于自己手写 axum::serve 的 accept-loop——拿回对 hyper 配置的完全控制。代价是失去 axum 提供的 graceful shutdown 和 executor 抽象——要自己实现。
大多数生产场景用 axum::serve 默认配置够——hyper 的默认值经过调优。只有特殊需求(超高并发、特殊协议需求)才绕过。
实战:连接数限制
axum::serve 本身不限并发连接数——accept 有多少就处理多少。高并发下可能耗尽资源(文件描述符、内存、数据库连接池)。ConnLimiter 是 axum 0.8 提供的 ListenerExt 方法:
rust
// axum/src/serve/listener.rs 里提供
use axum::serve::ListenerExt;
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
let listener = listener.limited_connections(1000); // 最多 1000 并发
axum::serve(listener, app).await.unwrap();超出限制时 accept 会阻塞(不断连接进入)——新客户端会感到连接被拒绝或 reset。生产里需要配合 nginx 或 ALB 前置——前置层做 connection limiting 反馈给客户端更友好的 503 响应。
实战:连接级 metrics
另一个生产需求:知道活跃连接数。用自定义 MakeService 很容易:
rust
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tower::Service;
#[derive(Clone)]
struct CountingMakeService<M> {
inner: M,
active: Arc<AtomicUsize>,
}
// 实现 Service<IncomingStream>, call 时增 active, Service drop 时减
// 省略完整 impl...实际生产推荐用 tower-http 的 metrics layer——它按 request 维度打点、和 connection 维度配合完整的可观测性。
Drop 顺序和资源清理
serve 返回之后,drop 顺序决定资源清理的正确性。看这段代码:
rust
async fn main() {
let db = Arc::new(DbPool::new().await);
let app = Router::new().with_state(AppState { db: db.clone() });
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).with_graceful_shutdown(signal()).await.unwrap();
// 这里 serve 返回了 - 所有连接处理完
// 但 db 还是活的 - app 里 state 里的 Arc<DbPool> 还在
// drop 顺序取决于下面的代码
}serve 返回后,main 作用域里的变量按LIFO 顺序 drop:
- listener 已经 drop(
run里drop(listener)) - app(Router)drop——触发 Router 内部所有 layer 和 state 的 drop
- state 里
Arc<DbPool>引用计数减一 - db(原始
Arc<DbPool>)drop——引用计数降到 0、触发 DbPool::drop——关连接池
关键:db 应该在 serve 返回后才 drop。如果你在 shutdown_signal 里关 db(比如 db.close().await)、然后 serve 等 inflight 请求完成——那些请求会试图用已关闭的 db 然后失败。正确顺序是 serve 先等完 inflight、然后 drop 时才关 db。
这条细节容易被忽略——很多项目的 shutdown_signal 里写 cleanup 逻辑、实际上把事情做糟了。shutdown_signal 只应该做"发信号"——别做业务清理。业务清理交给 Rust 的 drop 自动处理。
热重启和 zero-downtime 部署
serve 的 graceful shutdown 只解决"正常退出"——还有更复杂的 deployment 模式:
零停机重启:新版本启动后老版本才退出——两者短暂共存。需要:
- 新版本 bind 新端口(或共享 SO_REUSEPORT)
- 负载均衡切流到新版本
- 老版本收 SIGTERM、graceful shutdown
- 老版本完全退出
axum 本身不提供这个——是部署工具的职责(Kubernetes rolling update、systemd socket activation)。axum 只保证收到信号后优雅关闭—— deployment 层保证流量切换。
socket inheritance:父进程持有 listener、fork 子进程继承 fd——子进程跑新版本、父进程退。需要 socket2 + fd 传递技巧——axum::serve 本身支持(因为 TcpListener::from_std),但具体流程需要自己写 shell/父进程管理。
listenfd 开发模式:开发时希望代码改了自动重启但保留连接监听。listenfd crate 让父进程(比如 cargo-watch)持有 listener、子进程(axum server)从 fd inherit。这样每次代码 reload,客户端的长连接不断。
rust
use listenfd::ListenFd;
let mut fd = ListenFd::from_env();
let listener = match fd.take_tcp_listener(0).unwrap() {
Some(std_listener) => {
std_listener.set_nonblocking(true).unwrap();
TcpListener::from_std(std_listener).unwrap()
}
None => TcpListener::bind("0.0.0.0:3000").await.unwrap(),
};
axum::serve(listener, app).await.unwrap();配合 systemfd --no-pid -s http::3000 -- cargo watch -x run 让开发时修改代码、cargo-watch 重建、listener fd 被 preserved——浏览器的连接在新版本启动后继续工作。轻量但实用的开发工作流。
AI 应用对 serve 的特殊考量
LLM 相关应用对 serve 有几个不同的要求:
一、长连接:SSE 流式响应可能持续分钟级(一个 prompt 可能产出几千 token)。graceful shutdown 的"等 inflight 完成"对这类连接等待时间很长。典型解法:
rust
axum::serve(listener, app)
.with_graceful_shutdown(signal())
.await;
// 配合外层 timeout
let serve = async {
axum::serve(listener, app)
.with_graceful_shutdown(signal())
.await
.unwrap()
};
tokio::select! {
_ = serve => {}
_ = tokio::time::sleep(Duration::from_secs(120)) => {
tracing::warn!("shutdown timed out, forcing");
}
}2 分钟上限——让 SSE 连接有机会完成、但也防止永远等。
二、连接数可能高:一个 LLM service 并发 1000+ SSE 连接是常见的。文件描述符、内存、tokio task 数都要配得起——生产需要监控。
三、heartbeat 失败的处理:第 10 章讨论过 SSE 的 keep-alive——如果 heartbeat 发失败(客户端断开),connection task 会 break loop 结束。不是 error 是正常场景,axum::serve 不会有任何告警——这是对的行为。
四、rolling deployment 的流量预热:AI 应用的 handler 可能第一次调用时慢(加载模型、JIT 编译)——Kubernetes 的 readiness probe 要等 warmup 完成才切流。axum 的 health endpoint 要反映"warmup 完成了没"——不是 "服务启动了"。
跨书关联:tokio 与 hyper 的粘合
axum::serve 本质上是 tokio + hyper 的粘合剂——它自己没做真正的 HTTP 解析或 TCP 监听,都委托给底层。
tokio 负责:TcpListener.accept、spawn 任务、watch channel、signal 监听。tokio::net::TcpListener 的实现和 epoll/kqueue/IOCP 等 OS 原生 I/O API 的集成是《Tokio 源码深度解析》第 8 章的内容。
hyper 负责:HTTP/1.1 和 HTTP/2 的字节流解析、keep-alive 连接复用、graceful shutdown 的 connection 端协议。hyper_util::server::conn::auto::Builder::serve_connection_with_upgrades 是《Hyper 与 Tower:工业级 HTTP 栈》第 15 章讨论的核心。
axum 负责:组合——让 listener 产出的 IO 流经 hyper 解析、让 hyper 解析出的 request 流向用户的 Router。
这种"粘合层"模式是 Rust 异步生态的典型——很多框架(tonic、warp)都是类似的"把 tokio 和 hyper 粘起来"。不同框架在粘合层上各自提供特定的 API 风格——axum 的风格是"handler-first",tonic 是"proto-first",warp 是"filter-first"。
对 axum 用户来说,只需要知道 serve 的行为和 API——底层的 tokio/hyper 细节不用操心。但写性能关键的应用时,知道底层能帮你调优——比如 hyper 有大量 Builder 选项(keep_alive_timeout、max_headers 等)——axum::serve 使用默认配置,想定制需要手写 hyper loop 或用其他 HTTP server crate。
性能剖析
serve 的开销分几层:
accept-loop 本身:每循环 listener.accept() + 一次 handle_connection 调用。accept 是 syscall(几百 ns 到几 µs),handle_connection 主要 cost 是 make_service.call(Router clone 大约 50 ns)和 spawn 任务(tokio::spawn 大约 100 ns)。整体单 accept 大约 几 µs——能做到每秒几万到十万新连接速率。
handle_connection 内部:TokioIo 包装是零成本(结构体 wrapper);make_service.call 是 Router 克隆(Arc::clone 级);TowerToHyperService::new 是零成本;spawn 的任务是 hyper 的 connection serve loop——这里才是主要工作。
每请求:hyper parse HTTP(几 µs 到几十 µs,取决于 header 数)、调 Router::call(走完整 layer stack)、hyper encode 响应(几 µs)。
graceful shutdown:watch channel 的 signal 传播是纳秒级。真正耗时在"等 inflight 请求完成"——取决于业务 handler 最长耗时。
几个性能优化要点:
- keep-alive 复用连接:HTTP/1.1 默认 keep-alive——同一连接多个请求共享一个 handle_connection 任务。比每请求新建连接快很多。不要在 reverse proxy 关 keep-alive
- HTTP/2 多路复用:一个连接上多路 stream 并发——更高吞吐
- tokio worker 数量:默认按 CPU 核数。CPU 密集型 handler 可能需要调整
- 调大 file descriptor limit:
ulimit -n——每个连接一个 fd,高并发需要调大
connection 错误怎么处理
hyper 的 serve_connection_with_upgrades 可能返回 Err——比如:
- 客户端中途断开:connection reset / EOF
- HTTP parse 错:客户端发了非 HTTP 的字节
- HTTP/2 协议错:违反 frame 规则
- 超时:header_read_timeout 触发
源码里 axum 这样处理(mod.rs:618-622):
rust
result = conn.as_mut() => {
if let Err(_err) = result {
trace!("failed to serve connection: {_err:#}");
}
break;
}只写 trace 日志、不做其他处理——因为这些错误大多是正常现象(客户端关浏览器、爬虫连了就走、网络抖动)。不应该当作应用 bug 报警。
生产里如果发现 connection error 率异常高(比如突然从 0.1% 涨到 10%),可能是:
- 某个客户端在攻击(大量半开连接、malformed request)——需要防御
- 负载均衡后端健康检查过于频繁——调整 health check 间隔
- upstream service 问题导致连接被对端关闭
这些需要指标监控——axum 自己的 trace 日志量级太大不适合告警,用 tower-http 的指标层或 reverse proxy 的日志做聚合分析。
serve vs 手写 hyper loop
axum::serve 和手写 hyper accept-loop 的对比:
| 维度 | axum::serve | 手写 hyper loop |
|---|---|---|
| 代码行数 | 1 行 | 20-50 行 |
| graceful shutdown | 内置 | 自己实现 |
| hyper 配置 | 默认 | 全部可自定义 |
| per-connection state | 支持 MakeService | 自己实现 |
| Executor 替换 | 支持 | 不直接支持 |
| 和 Router 集成 | 无缝 | 要自己写 adapter |
| 适用场景 | 99% 应用 | 需要特殊 hyper 配置 |
大多数项目用 serve——一行代码干所有事。只有少数场景(超高性能要求、特殊协议支持、需要 hyper 的未暴露功能)才需要绕过。
真实场景:配合 TLS
axum::serve 接受 tokio::net::TcpListener——纯 HTTP。生产 HTTPS 一般有两个选项:
- TLS terminator 前置(nginx / ALB):axum 跑纯 HTTP,前置层做 TLS。最常见、最简单
- axum 自己跑 TLS:用
axum-servercrate(不是 axum 官方,但官方推荐)加 rustls
axum-server 的 API 和 axum::serve 相似但支持 TLS:
rust
use axum_server::tls_rustls::RustlsConfig;
let config = RustlsConfig::from_pem_file("cert.pem", "key.pem").await.unwrap();
axum_server::bind_rustls("0.0.0.0:443".parse().unwrap(), config)
.serve(app.into_make_service())
.await
.unwrap();axum-server 内部也做 accept-loop + tokio::spawn——和 axum::serve 类似——只是处理 TLS 握手。两者 API 层相像——因为都遵循 tower 的 MakeService 模式。
实战:健康检查 endpoint
Kubernetes / ALB 等负载均衡需要 axum 暴露 health endpoint。典型设计:
rust
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Clone)]
struct HealthState {
ready: Arc<AtomicBool>,
alive: Arc<AtomicBool>,
}
async fn liveness(State(h): State<HealthState>) -> StatusCode {
if h.alive.load(Ordering::Relaxed) { StatusCode::OK }
else { StatusCode::SERVICE_UNAVAILABLE }
}
async fn readiness(State(h): State<HealthState>) -> StatusCode {
if h.ready.load(Ordering::Relaxed) { StatusCode::OK }
else { StatusCode::SERVICE_UNAVAILABLE }
}
// 在 shutdown_signal 里把 ready 先设 false、让 LB 停止发新请求
async fn shutdown_signal(h: HealthState) {
tokio::signal::ctrl_c().await.unwrap();
h.ready.store(false, Ordering::Relaxed);
// 给 LB 10 秒时间更新 endpoint 列表, 然后才真开始关闭
tokio::time::sleep(Duration::from_secs(10)).await;
}两个 endpoint 含义不同:
- liveness:进程活着吗?失败时 Kubernetes 重启 pod
- readiness:能接收新流量吗?失败时 Kubernetes 从 endpoint 摘除(但不重启)
shutdown 流程推荐:
- 收到 SIGTERM
- readiness 设 false——让 LB 停止发新请求
- sleep 几秒——等 LB endpoint 更新(通常几秒)
- 触发 axum 的 graceful shutdown——等现有请求完成
- 进程退出
这个流程让 "滚动更新" 不丢请求——新 pod 先 ready、老 pod 才离开 endpoint。
panic 在 connection task 里的隔离
handler 在 hyper 调用链里运行。handler panic 会让对应的 tokio task panic——但不会影响其他 task。axum::serve 的每个连接是独立 task——一个连接 panic 只影响那一个连接的客户端。
但这是裸 panic 的行为——客户端看到 connection reset。更友好的是用 CatchPanicLayer(第 12 章讲过)把 panic 捕获成 500 响应:
rust
let app = Router::new()
.route("/", get(handler))
.layer(tower_http::catch_panic::CatchPanicLayer::new());但 CatchPanicLayer 只捕获 handler + middleware 链里的 panic——不捕获 hyper 内部或 axum serve 层面的 panic。后两者通常是 bug(不该 panic),发生时 tokio task 会 abort——axum::serve 的主 task 会继续接受新连接,不会挂掉。
tokio::spawn 的 task panic 的默认行为:
- tokio runtime 继续:其他 task 不受影响
- task 的 JoinHandle await 时会返回 JoinError::Panicked——但 axum::serve 不 await connection tasks 的 handle(fire-and-forget)
- panic 信息去哪:tokio 会打到 stderr,带 task name
生产建议:
- 业务层用 CatchPanicLayer 防御业务 bug
- 监控 stderr 的 panic 消息(也可以设
tokio::runtime::Handle::unhandled_panic) - 有 panic 就去修 bug——panic 不是运行时错误恢复机制
启动性能
axum::serve 的启动路径:
tokio::net::TcpListener::bind—— socket + bind + listen syscall,~几十 µsaxum::serve(...)构造 Serve —— 零成本(只是字段赋值).await进入 run() —— 零成本- accept-loop 第一次 accept 前——零开销
整个启动 subscall 总 cost 几十 µs。从程序启动到 accept 第一个请求通常 1-10 ms——这部分 cost 主要在:
- tokio runtime 启动
- 异步 Router 构造(依赖 DB pool、配置加载等)
- 如果有
with_state——state 的构造时间
优化启动时间通常不是 axum 层面的事——是应用层的。axum 本身的启动开销可以忽略。
相关的一个场景:lambda / serverless。每次 invocation 启动一次进程——启动时间直接影响响应延迟(cold start)。用 axum 跑 lambda 要关心启动时间,aws-lambda-axum crate 帮你把 axum Router 适配到 lambda runtime——直接复用 handler 代码。
生产部署 checklist
用 axum::serve 部署到生产前的 checklist:
- [ ] graceful shutdown 配好:监听 SIGINT + SIGTERM
- [ ] shutdown timeout:用
tokio::time::timeout包 serve 防止永久挂 - [ ] signal 层:Dockerfile 用 exec 形式让信号到达进程
- [ ] file descriptor limit:
ulimit -n或 systemdLimitNOFILE调大 - [ ] connection limit:前置 nginx / ALB 做连接数限制
- [ ] keep-alive timeout:hyper 默认合理但重新 review 一下
- [ ] request body limit:
DefaultBodyLimit和 tower-http::RequestBodyLimitLayer - [ ] CatchPanicLayer:防止 handler panic 让整个进程挂
- [ ] TLS:前置或 axum-server
- [ ] HTTP/2:生产建议启用(大量 GET 请求的场景提升明显)
- [ ] tracing + metrics:监控 accept 速率、连接数、请求延迟、错误率
- [ ] log 输出到 stdout + 结构化 JSON:适配 Kubernetes / ECS 的日志收集
- [ ] 运行用户:别用 root 跑、系统用户 + 限权限
- [ ] readiness/liveness probe:Kubernetes 下需要的 health endpoint
这些都不是 axum::serve 一行 API 能搞定的——需要系统性的部署配置。但理解了 serve 的机制,这个清单里每项你都能判断需要不需要、为什么需要。
小结:serve 的三层抽象
最后总结 axum::serve 的三层抽象:
- 用户 API 层:
axum::serve(listener, app).await—— 一行代码跑 server - axum 粘合层:accept-loop + handle_connection + watch channel graceful shutdown —— axum 提供的运行时框架
- 底层运行时层:tokio(I/O、调度)+ hyper(HTTP 协议)—— 真正干活的
三层抽象各司其职——用户用简单 API、axum 做 glue work、tokio/hyper 做重活。每层都可以独立理解、测试、优化。
第 2 层的 axum 粘合代码极简——一共不到 200 行核心代码(不含类型定义和 Builder)。但这 200 行做了关键几件事:Listener 和 MakeService 的契约定义、两个 watch channel 的生命周期协调、hyper 连接任务的 spawn 和 graceful shutdown 传播、Executor 抽象给用户扩展点。每一件都是深思熟虑的设计——去掉任一项都让 serve 的能力少一截。
serve 层的设计可以说是 axum 整个框架审美的缩影——别什么都做、只做中间那些别人没做的事。hyper 做 HTTP 做得很好,axum 不重复;tokio 做 runtime 做得很好,axum 不重复。但"怎么把 Router 类型和 hyper service 类型匹配起来、怎么传 connect info、怎么支持 graceful shutdown"——这些是 axum 的领域。正是因为只做这些、不做其他,axum 能在几百行代码里完成事情。
这种"薄层胶水"设计是 axum 整体架构的特征——不重复造轮子(hyper 做 HTTP、tokio 做 runtime、tower 做中间件)、只在各层之间做必要粘合和 ergonomic 优化(handler 的提取器语法、middleware 的 from_fn 等)。结果是 axum 框架代码不大(全部加起来 几 MB 源码)、但功能完整——因为每一行都有高 leverage、做框架级别的抽象整合工作。
这也解释了 axum 为什么快——不是因为它特别优化了什么,而是因为它没有做多余工作。一个请求从 TCP 到 handler,axum 层面的代码路径只有 accept-loop + handle_connection + MakeService::call + TokioIo wrap——每一步都轻得像透明层。真正的重活(HTTP parse、Service 调用链)由底层负责——axum 不包装它们、不代理它们、不 proxy——只做简单的类型穿越。
本章回到开头的三行代码
回到本章开头的例子:
rust
let app = Router::new().route("/", get(handler));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();现在每一行背后的机制都清晰:
- 第一行:构造 Router——第 2-14 章讨论的类型驱动 handler 路由 + middleware 框架
- 第二行:tokio 在 OS 层打开一个监听 socket——
bind做 SO_REUSEADDR、listen 等基础配置 - 第三行:
serve(listener, app)构造Serve结构——暂时不真的跑.await触发 IntoFuture,进入 run()- run() 的 accept-loop:每次 accept 一个连接、handle_connection spawn 任务
- handle_connection:TokioIo 包装 → make_service.call → hyper builder.serve_connection_with_upgrades
- hyper 内部:HTTP/1.1 或 HTTP/2 解析 → Service::call → 响应编码
- loop 永远继续、直到进程被杀或外部信号触发 graceful shutdown
一句话总结:axum::serve 是 tokio TcpListener + hyper HTTP server 的 convention-over-configuration 胶水层——它不做 HTTP、不做 accept、不做调度——只是把三者按 axum 的约定粘起来,提供一个"一行启动"的 API。
这种约定的好处:用户代码极简——三行启动完整生产 server。坏处:想偏离约定(特殊 hyper 配置、自定义调度策略)就要绕过 serve 写更多代码。axum::serve 的存在是一个"常见场景优先"的 API 设计选择——和中间件章节讨论的"from_fn 相对原生 Layer"是同样的思路。
IntoFuture 的设计:为什么不直接是 Future
axum::serve 返回的 Serve 不是 Future——而是 IntoFuture。差别是:
rust
// Future: .await 直接可用
let future: impl Future<Output = T> = ...;
future.await;
// IntoFuture: 需要转换
let into: impl IntoFuture<Output = T> = ...;
into.await; // 自动调 into_future() 然后 awaitIntoFuture 允许类型在 await 前做方法链:
rust
axum::serve(listener, app)
.with_graceful_shutdown(signal()) // 返回 WithGracefulShutdown
.with_executor(MyExecutor) // 返回带新 executor 的版本
.await; // 最后一步触发 IntoFuture如果 Serve 直接是 Future,.with_graceful_shutdown(signal) 的链式调用就不 elegant——用户要写 axum::serve(...).with_graceful_shutdown(signal).into_future().await,多一层冗余。
Rust 2024 之前 IntoFuture 不稳定——很多旧框架用 "builder + build() + await" 两步走。Rust 2024 稳定后 IntoFuture 成为 builder API 的标准模式。axum 的 Serve / WithGracefulShutdown 就是模范用法。
常见问题
Q:我的 handler 很慢,graceful shutdown 会等它们吗?
会等。axum 的 graceful shutdown 是"无限等"——只等 inflight 请求完成。用外层 timeout 限制最大等待时间。
Q:能同时监听多个端口吗?
axum::serve 只接一个 listener。想多端口,多 spawn 几个 serve:
rust
tokio::try_join!(
axum::serve(listener1, app.clone()),
axum::serve(listener2, app),
);或者用 [tokio::io::join] 之类工具。
Q:serve 会不会阻塞 tokio runtime 的其他 task?
不会。accept-loop 是 async—— await 时让出给 runtime 调度其他任务。每次 accept 后 spawn 新 task——不阻塞 loop。loop 本身非常轻——不是 CPU 密集的。
Q:怎么知道某个请求被处理了?
用 tracing——middleware 层打 request span。axum::serve 本身不提供请求级事件(只有 trace log 级别的微弱信息)。
Q:ConnectInfo<SocketAddr> 为什么有时拿不到?
必须用 app.into_make_service_with_connect_info::<SocketAddr>() 而不是直接 app。serve 接受的是 make_service、默认的 IntoMakeService 不会塞 addr。
Q:能在运行中动态换 Router 吗?
不能——serve(listener, app) 后 app 被 move 进 run()。想动态改路由需要在 Router 前面加一层可变的 dispatch(比如 Arc<RwLock<Router>> + from_fn 做转发)——但这种方案不常见,通常 rolling restart 换版本就够。
Q:高并发下 accept-loop 会不会成为瓶颈?
通常不会。accept-loop 是单线程、单次 accept 是几百 ns 级——每秒百万级 accept 都能支持(实际瓶颈在 OS 的 backlog 和文件描述符限制)。真正的瓶颈在 handler 和中间件——不在 accept 本身。
这种极简也带来局限——想要某些 hyper 高级功能(比如自定义的 HTTP/2 flow control、专门的 HTTP/1 小优化)axum::serve 不暴露。这时候用户要么接受 axum 的默认值、要么绕过 serve 直接写 hyper loop。多数项目选前者;极少数有极端性能或协议需求的选后者——这是合理的 trade-off。
Q:backlog 满了客户端会怎样?
TCP 层 backlog(listen() 的第二参数、Linux 上实际取 min(backlog, /proc/sys/net/core/somaxconn)、默认 4096)满了之后新连接握手的 SYN 包不会被 ACK——客户端看到连接超时或 ECONNREFUSED。axum::serve 用 tokio 默认的 backlog——生产上高流量服务要显式调大:TcpSocket::new_v4()? → set_reuseaddr → bind → listen(65535)。观测 netstat -s | grep "listen" 的 overflow 计数——非零说明需要调大 backlog 或加速 accept。
下一章讲 Listener trait 和 Executor trait 的可插拔性——accept-loop 不变、传输和调度策略可换。