Skip to content

第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 启动后发生的事情:

  1. 绑定 Router 和 Listener:用户传给 serve 的两个参数,从此绑在一起
  2. 接受连接listener.accept() 从 OS 拿到新的 TCP stream
  3. 为每连接生成 Servicemake_service.call(IncomingStream) 产出一个独立的 Service
  4. spawn tokio 任务:每个连接一个独立任务跑 hyper 的连接 serve loop
  5. hyper 解析 HTTP:读字节、parse HTTP/1.1 或 HTTP/2、调 Service::call
  6. 处理响应:tower service 返回 Response、hyper 编码成字节写回 TCP
  7. 等待或关闭:无限 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 = InfallibleServeawait 永远不返回——它 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 来说:普通 Routermake_service 实现会忽略 IncomingStream、返回克隆的 Router。但对 IntoMakeServiceWithConnectInfo 来说,make_service.call 会把 remote_addr 提取出来塞进 Extension——这是第 8 章讨论过的 ConnectInfo<SocketAddr> 能工作的原因。

TokioIo:hyper 和 tokio 的 bridge

let io = TokioIo::new(io) —— TokioIohyper_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::Servicepoll_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 开始:

  1. 读字节流、parse HTTP/1.1 或 HTTP/2 帧
  2. 对每个 request,调 hyper_service.call(req) 拿 response
  3. 把 response 编码成 HTTP 字节流写回
  4. 处理 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 loop
  • signal_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 的引用 + 远端地址。主要用途:

  1. IntoMakeServiceWithConnectInforemote_addr,塞进 Extension 供 handler 的 ConnectInfo<SocketAddr> 提取器用
  2. 自定义 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 state
  • IntoMakeService<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 的特点:

  1. 只携带最新值:传统 mpsc 是 FIFO 队列,watch 只保留一个"最新快照"
  2. receiver 可以无限复制(clone)
  3. receiver count 可被 sender 查询receiver_countclosed / subscribe
  4. 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 receiver
  • drop(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 实现了三步协调:

  1. signal channel:外部信号 → 内部传播
  2. accept-loop 内部:select 上监听 signal,触发 break
  3. 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);
}

几个生产要点:

  1. 同时监听 SIGINT 和 SIGTERM:SIGINT 是 ctrl-c、SIGTERM 是 kill 或 Docker stop。两者都应该触发 graceful shutdown
  2. 不要在 shutdown_signal 里关 db:graceful shutdown 期间的现有请求可能还需要 db。db 应该在 serve 返回后才 drop
  3. 记录 signal 接收日志:排查重启时有没有收到信号、收到哪种
  4. 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):由用户交互发起
  • SIGTERMkill / 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_streamskeep_alive_intervalmax_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:

  1. listener 已经 drop(rundrop(listener)
  2. app(Router)drop——触发 Router 内部所有 layer 和 state 的 drop
  3. state 里 Arc<DbPool> 引用计数减一
  4. 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 模式:

零停机重启:新版本启动后老版本才退出——两者短暂共存。需要:

  1. 新版本 bind 新端口(或共享 SO_REUSEPORT)
  2. 负载均衡切流到新版本
  3. 老版本收 SIGTERM、graceful shutdown
  4. 老版本完全退出

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 最长耗时。

几个性能优化要点:

  1. keep-alive 复用连接:HTTP/1.1 默认 keep-alive——同一连接多个请求共享一个 handle_connection 任务。比每请求新建连接快很多。不要在 reverse proxy 关 keep-alive
  2. HTTP/2 多路复用:一个连接上多路 stream 并发——更高吞吐
  3. tokio worker 数量:默认按 CPU 核数。CPU 密集型 handler 可能需要调整
  4. 调大 file descriptor limitulimit -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 一般有两个选项:

  1. TLS terminator 前置(nginx / ALB):axum 跑纯 HTTP,前置层做 TLS。最常见、最简单
  2. axum 自己跑 TLS:用 axum-server crate(不是 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 流程推荐:

  1. 收到 SIGTERM
  2. readiness 设 false——让 LB 停止发新请求
  3. sleep 几秒——等 LB endpoint 更新(通常几秒)
  4. 触发 axum 的 graceful shutdown——等现有请求完成
  5. 进程退出

这个流程让 "滚动更新" 不丢请求——新 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 的启动路径:

  1. tokio::net::TcpListener::bind —— socket + bind + listen syscall,~几十 µs
  2. axum::serve(...) 构造 Serve —— 零成本(只是字段赋值)
  3. .await 进入 run() —— 零成本
  4. 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 limitulimit -n 或 systemd LimitNOFILE 调大
  • [ ] connection limit:前置 nginx / ALB 做连接数限制
  • [ ] keep-alive timeout:hyper 默认合理但重新 review 一下
  • [ ] request body limitDefaultBodyLimit 和 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 的三层抽象:

  1. 用户 API 层axum::serve(listener, app).await —— 一行代码跑 server
  2. axum 粘合层:accept-loop + handle_connection + watch channel graceful shutdown —— axum 提供的运行时框架
  3. 底层运行时层: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() 然后 await

IntoFuture 允许类型在 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 不变、传输和调度策略可换。

基于 VitePress 构建