用 tokio::time::timeout 包裹 select! 分支实现带截止时间的多路读取

在异步 Rust 里同时等待多个数据源,最直接的写法是 tokio::select!。但它有一个不太显眼的语义:select! 只负责「谁先就绪就返回谁」,不负责「等多久就不等了」。当某个分支既不返回数据也不报错——比如一个长时间静默的 WebSocket、一个对端迟迟不发心跳的 TCP 连接、一个被上游限流卡住的 HTTP body——整个 select!

在异步 Rust 里同时等待多个数据源,最直接的写法是 tokio::select!。但它有一个不太显眼的语义:select! 只负责「谁先就绪就返回谁」,不负责「等多久就不等了」。当某个分支既不返回数据也不报错——比如一个长时间静默的 WebSocket、一个对端迟迟不发心跳的 TCP 连接、一个被上游限流卡住的 HTTP body——整个 select! 就会一直挂着。上层传下来的 deadline 形同虚设。

常见的补救办法是给整个 select! 外面套一个大的 timeout,但这会带来两个新问题:一是超时后你无法区分「是哪一个分支卡住了」,二是某个分支已经读到一半的数据会被整体丢弃。本文给出另一种更细粒度的做法:把 timeout 下沉到 select! 的每个分支上,让每个数据源各自带自己的截止时间,同时保留「共享总 deadline」的能力。文中的代码全部基于 tokio = "1.40"(features 至少包含 macros、time、rt-multi-thread、io-util、sync、net),可直接 cargo run。

先看清 select! 的语义边界

写错这一点的代码通常长这样:

use tokio::time::{sleep, Duration};

async fn read_a() -> Option<u8> {
    sleep(Duration::from_secs(3600)).await;
    Some(1)
}

async fn read_b() -> Option<u8> {
    sleep(Duration::from_millis(50)).await;
    Some(2)
}

#[tokio::main]
async fn main() {
    // 期望 1 秒后放弃,实际上:read_b 50ms 就绪,select! 立即返回 Some(2)
    tokio::select! {
        v = read_a() => println!("a => {:?}", v),
        v = read_b() => println!("b => {:?}", v),
    }
}

这段代码能跑,输出是 b => Some(2)。它掩盖了真正的问题:一旦把 read_b 也换成一个长期静默的源,select! 会永久 pending。select! 本身没有时间维度。

另一个高频误解是 select! 加 else 分支能当超时用。不能。else 只在所有分支都被 disabled(即每个分支的 if 前置条件都为 false,或被 biased; 之前的手动禁用逻辑关掉)时执行,与时间无关。所以「加个 else 兜底」不是超时方案。

还有一点要记住:select! 的循环轮询是公平但仍可能饿死的。默认情况下 tokio 会在每次循环开始时随机选择分支的检查顺序,因此理论上存在某个分支连续多次被跳过的情况。如果你有明确优先级(比如「控制帧优先于数据帧」),用 biased;,但要清楚它会牺牲公平性。

核心模式:把 timeout 塞进每个分支

改造思路很直接:select! 的每个分支表达式本身就是一个 future,那就让这个 future 是 timeout(dur, fut) 的返回值,而不是 fut 本身。这样每个分支在「数据就绪」和「超时」两条路径上都必然完成,select! 不会再无限等待。

use std::time::{Duration, Instant};
use tokio::time::{sleep, timeout, Elapsed};

/// 模拟一个长期静默的数据源:只在 far_future 之后才产出
async fn silent_source() -> Result<u8, &'static str> {
    sleep(Duration::from_secs(3600)).await;
    Ok(0)
}

/// 模拟一个正常数据源
async fn fast_source() -> Result<u8, &'static str> {
    sleep(Duration::from_millis(80)).await;
    Ok(7)
}

#[tokio::main]
async fn main() {
    let started = Instant::now();

    tokio::select! {
        r = timeout(Duration::from_millis(500), silent_source()) => {
            match r {
                Ok(Ok(v)) => println!("silent => Ok({v})"),
                Ok(Err(e)) => println!("silent => 源内错误: {e}"),
                Err(Elapsed) => println!("silent => 超时,耗时 {:?}", started.elapsed()),
            }
        }
        r = timeout(Duration::from_millis(500), fast_source()) => {
            match r {
                Ok(Ok(v)) => println!("fast => Ok({v})"),
                Ok(Err(e)) => println!("fast => 源内错误: {e}"),
                Err(Elapsed) => println!("fast => 超时,耗时 {:?}", started.elapsed()),
            }
        }
    }
}

输出(时间会有毫秒级抖动):

fast => Ok(7)

把 fast_source 也换成 silent_source,则 500ms 后两个分支各有一个超时结果,但 select! 只会取最先完成的那个,输出形如:

silent => 超时,耗时 500.3ms

注意类型变化:分支的返回值从 Result<u8, E> 变成了 Result<Result<u8, E>, Elapsed>。内层是业务结果,外层是时间结果。这个双层 Result 是所有后续处理的核心,必须显式 match,不能 ? 一把梭,否则你会把超时和业务错误混为一谈。

共享总 deadline:剩下的时间才是每个分支的预算

上面每个分支各自写死 500ms 并不总是对的。更常见的需求是:上游给了本次调用 2 秒的总预算,四条数据源并行读,谁在 2 秒内没凑齐就整体失败。这时应该先算出 Deadline,再用 timeout_at(而不是 timeout)来约束分支,这样每个分支的绝对截止点一致,不会因为前一个分支已经消耗了时间而让后续分支多等。

use std::time::Duration;
use tokio::time::{timeout_at, Instant, sleep};

struct Reading {
    source: &'static str,
    value: Option<String>,
}

async fn source_slow() -> Result<String, &'static str> {
    sleep(Duration::from_millis(1200)).await;
    Ok("slow-payload".to_string())
}

async fn source_hang() -> Result<String, &'static str> {
    sleep(Duration::from_secs(60)).await;
    Ok("never".to_string())
}

async fn source_err() -> Result<String, &'static str> {
    sleep(Duration::from_millis(60)).await;
    Err("upstream 503")
}

#[tokio::main]
async fn main() {
    // 上游给的总预算 800ms
    let deadline = Instant::now() + Duration::from_millis(800);

    let mut out: Vec<Reading> = Vec::new();

    tokio::select! {
        r = timeout_at(deadline, source_slow()) => {
            out.push(Reading {
                source: "slow",
                value: r.ok().and_then(|x| x.ok()),
            });
        }
        r = timeout_at(deadline, source_hang()) => {
            out.push(Reading {
                source: "hang",
                value: r.ok().and_then(|x| x.ok()),
            });
        }
        r = timeout_at(deadline, source_err()) => {
            out.push(Reading {
                source: "err",
                value: r.ok().and_then(|x| x.ok()),
            });
        }
    }

    for r in &out {
        println!("{} => {:?}", r.source, r.value);
    }
}

输出:

err => None

source_err 在 60ms 就返回了 Err("upstream 503"),所以 select! 立刻结束,slow 和 hang 两个分支的 future 被 drop。这是 select! 的既有行为,与是否套 timeout 无关。如果你需要「尽量收齐、只放弃超时的那一路」,那就必须把 select! 放进循环里,用可变的状态记录每一路是否已完成,见下一节。

这里有个易错点:timeout_at 的 deadline 必须是 tokio::time::Instant,不是 std::time::Instant。两者不通用,混用会得到编译错误:

error[E0308]: mismatched types
   --> src/main.rs:31:26
    |
 31 |     tokio::time::timeout_at(std_deadline, fut)
    |     ------------------------- ^^^^^^^^^^^^ expected `tokio::time::Instant`, found `std::time::Instant`
    |
    = note: expected struct `tokio::time::Instant`
               found struct `std::time::Instant`

修法有两种:换成 tokio::time::Instant::now() + dur,或者用 tokio::time::Instant::from_std(std_instant) 做一次显式转换。不要用 as 强转,那是编译不过的。另外,开启 tokio 的 test-util feature 后,tokio::time::Instant 会跟随被拨快的虚拟时钟,而 std::time::Instant 不会——这也是测试里 mock 时间必须用 tokio 类型的原因。

循环多路读取:用状态位区分「已完成」与「超时」

真实场景里更常见的是:三个传感器要读,各自有独立超时,最后一并返回,超时的那路标记为缺失而不是让整批失败。做法是把每路包装成一个 Pin<Box<dyn Future>> 并保存在 Option 里,select! 分支用 if 守卫在 future 为 Some 时才参与,完成后置 None 并从循环条件里排除。

use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use tokio::time::{timeout_at, Instant, sleep};

type BoxFut = Pin<Box<dyn Future<Output = Result<String, &'static str>> + Send>>;

#[derive(Debug)]
struct Slot {
    name: &'static str,
    fut: Option<BoxFut>,
    value: Option<String>,
    timed_out: bool,
}

fn sensor_a() -> BoxFut {
    Box::pin(async {
        sleep(Duration::from_millis(40)).await;
        Ok("a=21.5".to_string())
    })
}

fn sensor_b() -> BoxFut {
    Box::pin(async {
        sleep(Duration::from_millis(5000)).await;
        Ok("b=99.9".to_string())
    })
}

fn sensor_c() -> BoxFut {
    Box::pin(async {
        sleep(Duration::from_millis(30)).await;
        Err("c: crc mismatch")
    })
}

#[tokio::main]
async fn main() {
    let deadline = Instant::now() + Duration::from_millis(300);

    let mut slots = vec![
        Slot { name: "a", fut: Some(sensor_a()), value: None, timed_out: false },
        Slot { name: "b", fut: Some(sensor_b()), value: None, timed_out: false },
        Slot { name: "c", fut: Some(sensor_c()), value: None, timed_out: false },
    ];

    while slots.iter().any(|s| s.fut.is_some()) {
        let (idx, outcome) = tokio::select! {
            biased;
            (i, r) = async {
                // 0 号槽位
                (0usize, timeout_at(deadline, slots[0].fut.as_mut().unwrap()).await)
            }, if slots[0].fut.is_some() => (i, r),

            (i, r) = async {
                (1usize, timeout_at(deadline, slots[1].fut.as_mut().unwrap()).await)
            }, if slots[1].fut.is_some() => (i, r),

            (i, r) = async {
                (2usize, timeout_at(deadline, slots[2].fut.as_mut().unwrap()).await)
            }, if slots[2].fut.is_some() => (i, r),
        };

        let slot = &mut slots[idx];
        slot.fut = None;
        match outcome {
            Ok(Ok(v)) => slot.value = Some(v),
            Ok(Err(e)) => {
                eprintln!("{} 读取失败: {}", slot.name, e);
            }
            Err(_) => slot.timed_out = true,
        }
    }

    for s in &slots {
        println!(
            "{} => value={:?} timed_out={}",
            s.name, s.value, s.timed_out
        );
    }
}

预期输出:

c 读取失败: c: crc mismatch
a => value=Some("a=21.5") timed_out=false
b => value=None timed_out=true
c => value=None timed_out=false

这个骨架里有三个必须讲清的点。

第一,as_mut().unwrap() 不会 panic。 因为每个分支都带 if slots[i].fut.is_some() 守卫,守卫在分支被选中之前求值;守卫为 false 的分支直接被禁用,不会进入表达式体。这是 select! 的语义保证。但守卫里的条件不能有副作用,它在每轮轮询中可能被多次求值(每次某个分支就绪时都会重新检查其他分支的守卫,取决于实现细节),写 slots.remove(0) 这种就会出问题。

第二,biased; 在这里是有意的。 因为它按书写顺序检查,a、c 这类快源会先被消费,b 这种慢源最后才被检查,避免了随机顺序下 b 先被选中、阻塞其他分支完成机会的低效。但要清楚 biased; 会让 0 号槽位长期占优;如果 a 是一个高频持续产出的源,b/c 会被饿死。本例中每个槽位完成一次就置 None,所以不存在饿死。

第三,如果用 timeout_at 共享同一个 deadline,那么「超时」是批量发生的。 上例中 b 在 300ms 被判定超时,但如果 a 和 c 都耗到 290ms,留给 b 的预算只有 10ms,这在业务上往往不合理。这时应该给每一路独立预算:timeout(slot.budget, fut),再用外层一个 timeout_at 做总兜底。两层并存的写法下面给出。

两层超时:单路预算 + 整体兜底

单路预算负责「这一路太慢就丢掉」,整体兜底负责「整个函数不能超过上游给的 SLA」。两者叠加时,注意外层一定用 timeout_at 且 deadline 只计算一次,否则每轮循环都重新 now() + dur,兜底就变成了滚动窗口,永远不会触发。

use std::time::Duration;
use tokio::time::{sleep, timeout, timeout_at, Instant};

async fn read_sensor(name: &'static str, cost: Duration) -> Result<String, &'static str> {
    sleep(cost).await;
    Ok(format!("{name}=ok"))
}

async fn collect_all() -> Vec<(&'static str, Option<String>)> {
    // 总 SLA:1 秒。只算一次
    let hard_deadline = Instant::now() + Duration::from_millis(1000);

    let mut results = Vec::new();

    let tasks: [(&'static str, Duration); 3] = [
        ("t1", Duration::from_millis(100)),
        ("t2", Duration::from_millis(400)),
        ("t3", Duration::from_millis(900)),
    ];

    for (name, cost) in tasks {
        // 单路预算 500ms,再被总 deadline 截断
        let per_call = timeout(Duration::from_millis(500), read_sensor(name, cost));
        let r = timeout_at(hard_deadline, per_call).await;

        let v = match r {
            Ok(Ok(Ok(s))) => Some(s),
            Ok(Ok(Err(e))) => {
                eprintln!("{name} 业务错误: {e}");
                None
            }
            Ok(Err(_)) => {
                eprintln!("{name} 单路超时(500ms)");
                None
            }
            Err(_) => {
                eprintln!("{name} 被总 deadline 截断");
                None
            }
        };
        results.push((name, v));
    }

    results
}

#[tokio::main]
async fn main() {
    let t0 = Instant::now();
    let out = collect_all().await;
    println!("{:?}", out);
    println!("总耗时 {:?}", t0.elapsed());
}

输出:

t1 => Some("t1=ok")
t2 => Some("t2=ok")
t3 单路超时(500ms)
t3 => None
总耗时 1.0002s

注意这里是串行 for 循环,所以 t3 的 500ms 单路预算在 100+400=500ms 之后才开始计时,到 1000ms 时刚好被单路预算卡掉,同时硬 deadline 也是 1000ms,两者几乎同时到达。当单路预算与总 deadline 撞在一起时,timeout_at 外层先返回还是内层先返回取决于哪个 timer 先触发,属于实现细节,业务逻辑上应把二者当作「都可能发生」来处理——所以上面三层 match 里的 Ok(Err(_)) 和 Err(_) 必须都写,不能只写一个。

顺带说一下内联等待的写法。很多人喜欢用 tokio::join! 让三路真正并行,这时每一路的 timeout 依然独立生效:

use tokio::time::{sleep, timeout, Duration};

async fn fn_a() -> &'static str {
    sleep(Duration::from_millis(120)).await;
    "a"
}
async fn fn_b() -> &'static str {
    sleep(Duration::from_secs(30)).await;
    "b"
}

#[tokio::main]
async fn main() {
    let (ra, rb) = tokio::join!(
        timeout(Duration::from_millis(300), fn_a()),
        timeout(Duration::from_millis(300), fn_b()),
    );
    println!("a={:?} b={:?}", ra.ok(), rb.ok());
}

输出 a=Some("a") b=None,总耗时约 300ms。join! 会等所有 future 完成,所以总耗时是两路中较慢的那个;select! 抢占则只要一路完成就返回。选哪个取决于业务:是要最快的那一路,还是要全部结果。

必须注意的三个坑

取消安全性(cancellation safety)

把 timeout 套到分支上,本质是在超时的瞬间 drop 掉内部 future。所以被包裹的 future 必须是取消安全的,否则丢数据。AsyncReadExt::read、AsyncWriteExt::write_all、tokio::sync::mpsc::Receiver::recv、tokio::sync::oneshot::Receiver 是取消安全的(drop 后再调用不会丢已读数据);AsyncBufReadExt::lines 的 next_line、BufReader 上的 read_line、以及自己手写的「先发请求再等响应再写状态」的复合 future 不是。对后者,超时 drop 后你已经发出的请求仍然在对端执行,重试会造成重复提交。

tokio 文档里有一条必须记住的结论:select! 在分支被 drop 时会丢弃该分支已经产生但尚未返回的部分结果。因此对 TcpStream 做带超时的读取时,更稳的写法是用 tokio::io::AsyncReadExt::read 读固定长度的 buffer 反复累积,而不是用 lines() 等整行。这类 bug 的现象是:偶发地少一行、少一个包,日志里却看不到任何错误。

timeout 不会中断阻塞任务

如果分支里跑的是 tokio::task::spawn_blocking 包住的同步代码,timeout 只是让等待方返回,blocking 线程池里的任务仍会跑完,且不能被取消。现象是超时日志打了,但 CPU 还在涨。std::thread::sleep 放进 async 分支同理——它会让整个 worker 线程卡住,超时回调根本没有机会执行。所有耗时操作必须用 tokio 的异步版本,或至少放在 spawn_blocking 里并接受「不可取消」。

Mock 时间要做对

用 tokio::time::pause() / advance() 做单测时,#[tokio::test] 需要开启 test-util feature,且只能用 tokio::time::sleep。若混入 std::thread::sleep 或 std::time::Instant,虚拟时钟不会推进,测试要么挂死,要么出现「1 毫秒的 timeout 测了 5 秒」的怪象。Cargo.toml 写法:

[dependencies]
tokio = { version = "1.40", features = ["macros", "rt-multi-thread", "time", "sync", "io-util", "net", "test-util"] }

另外 tokio::time::timeout 内部使用的 timer 精度约为 1ms,且受 runtime 当前负载影响,不要写断言 assert_eq!(elapsed, Duration::from_millis(500)),应写成 assert!(elapsed >= Duration::from_millis(500)),并给上界留足余量(例如 < 650ms),否则 CI 上偶发失败。

小结

把 timeout 与 select! 结合的关键不在语法,而在三件事:一是明确 select! 自身没有时间语义,超时必须由分支内的 future 承担;二是搞清 timeout 与 timeout_at 的区别,需要共享预算时先算一次 Instant 再 timeout_at,绝不能在循环里反复 now() + dur;三是接受超时即 drop 的事实,只对取消安全的 future 这么干,对非取消安全的操作用状态标注或 join! + 独立超时来替代抢占。返回值的 Result<Result<T, E>, Elapsed> 双层结构应当在类型上就区分开,而不是靠日志猜。

FAQ

为什么 timeout 包裹后返回类型变成了两层 Result,有没有更清爽的写法?

外层 Result 的 Err 类型固定是 tokio::time::error::Elapsed(零大小类型),内层是分支自己的错误。想清爽可以封装一个枚举:

use std::fmt;
use tokio::time::Elapsed;

#[derive(Debug)]
enum ReadError<E> {
    Timeout,
    Inner(E),
}

impl<E: fmt::Display> fmt::Display for ReadError<E> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ReadError::Timeout => write!(f, "read timed out"),
            ReadError::Inner(e) => write!(f, "inner error: {e}"),
        }
    }
}

fn flatten<T, E>(r: Result<Result<T, E>, Elapsed>) -> Result<T, ReadError<E>> {
    match r {
        Ok(Ok(v)) => Ok(v),
        Ok(Err(e)) => Err(ReadError::Inner(e)),
        Err(_) => Err(ReadError::Timeout),
    }
}

之后在分支里写 flatten(r) 即可,且这个枚举可以直接 impl std::error::Error 或交给 thiserror 生成。

select! 里同时用了 timeout 和 else,为什么 else 从没执行过?

因为只要还有一个分支的 if 守卫为 true,else 就不会执行。timeout 分支本身会在超时后正常完成并返回 Err(Elapsed),这被算作「分支完成」,不是「所有分支禁用」。else 只服务于全部守卫为 false 的场景,与时间无关。

报错 error[E0599]: no method named `as_mut` found for enum `Option<...> 怎么解?

因为 slots[i].fut 是 Option 而你写成了 slots[i].fut.as_mut() 之外的形式(例如对 &mut Option 调用了 Pin::new)。正确做法是先 let fut = slots[i].fut.as_mut().unwrap(); 拿到 &mut BoxFut,再解引用成 &mut (dyn Future):timeout_at(deadline, &mut **fut)。另一种常见原因是没有引入 use std::pin::Pin;。

超时后连接没有被真正关闭,对端还能收到后续请求?

timeout 只 drop future,不 drop TcpStream——如果你把 &mut stream 传进了分支,stream 的所有权还在外层,连接不受影响。这是设计上的优点(可以复用连接),但也意味着超时的语义是「我不等了」,而不是「我取消了」。若业务要求真取消,必须在超时分支里显式调用 stream.shutdown().await,或改用 spawn 出任务后 handle.abort(),后者仅对 abort 能打断的 await 点有效,同样不保证对端感知。

为什么我的 timeout(Duration::from_millis(100), fut) 实际等了三四秒才返回?

两个典型原因。其一是 fut 在执行过程中调用了阻塞函数(std::thread::sleep、同步 IO、block_on),把 worker 线程占死了,timer 驱动无法及时执行。其二是使用了 #[tokio::main] 的 current_thread 变体而某处又 spawn_blocking,导致调度队列积压。解决方向是一致的:让 await 链上不出现阻塞调用,必要时用 tokio-console 观察任务的 poll 耗时。开启方式是在 Cargo.toml 加 console-subscriber = "0.4"、运行时 feature 加 tokio_unstable,然后用 RUSTFLAGS="--cfg tokio_unstable" cargo run。