Rust in Everything
基于 embassy-time 0.5.1

select / with_timeout 输了,读了一半的帧丢了

主线位置:跨任务主线的取消路径。Channel::receive 的实现见 Channel,select 怎么 poll、输家何时被 drop 见 embassy-futures,with_timeout 见 Timer、Ticker 与超时。

sequenceDiagram
  participant S as 发送方
  participant F as read_frame
  participant T as with_timeout
  S->>F: 字节 id
  Note over F: id 存在 future 里
  T->>T: 定时器先到
  T-->>F: drop 整个 future
  S->>F: 字节 value
  Note over F: 新 future 把 value 当 id

实验 crate exp-embassy/pitb-std,bin p17_select(release,5 次结果相同);M2 Max、macOS 27、rustc 1.99.0。

现象

一个两字节的帧 [id, value] 从串口接收缓冲区(这里用一个 Channel<_, u8, 16> 代替)读出来;主循环要求 200 ms 内没有帧就发一次心跳:

// a frame is [id, value]; not cancel-safe: `id` lives only inside this future
async fn read_frame() -> (u8, u8) {
  let id = RX.receive().await;
  let value = RX.receive().await;
  (id, value)
}

loop {
  match with_timeout(Duration::from_millis(200), read_frame()).await {
    Ok(f) => println!("{:>4} ms frame {:?}", Instant::now().as_millis(), f),
    Err(_) => println!("{:>4} ms timeout, send heartbeat", Instant::now().as_millis()),
  }
}

发送方发 4 帧 (1, 10)、(2, 20)、(3, 30)、(4, 40),第 2 帧的第二个字节晚到了 160 ms:

   0 ms   tx 1
  25 ms   tx 10
  25 ms frame (1, 10)
 110 ms   tx 2
 230 ms timeout, send heartbeat
 275 ms   tx 20
 356 ms   tx 3
 357 ms frame (20, 3)
 377 ms   tx 30
 462 ms   tx 4
 463 ms frame (30, 4)
 488 ms   tx 40
 668 ms timeout, send heartbeat
 872 ms timeout, send heartbeat

230 ms 的超时把已经读到的 2 扔掉了。之后每一帧都错了一位:(20, 3)、(30, 4),最后的 40 又被下一次超时扔掉。没有报错,数据就是错的。

出处

with_timeout 返回 TimeoutFuture,它的 poll 先 poll 内层,再 poll 定时器:

        if let Poll::Ready(x) = fut.poll(cx) {
            return Poll::Ready(Ok(x));
        }
        if let Poll::Ready(_) = timer.poll(cx) {
            return Poll::Ready(Err(TimeoutError));
        }
        Poll::Pending

返回 Err(TimeoutError) 之后,with_timeout(..).await 这个表达式结束,TimeoutFuture 连同里面的 read_frame() future 一起被 drop。read_frame 的状态机停在第二个 .await 上,局部变量 id 是状态机的一个字段,跟着没了。embassy_futures::select 也一样:Select 被 drop 时输家被 drop。

单个 RX.receive() 本身是取消安全的:ReceiveFuture 的 poll 只在返回 Ready 的那一次从队列取走消息,被 drop 时什么也没拿。丢数据的不是 receive,是把两次 receive 之间的状态放在了 future 里的 read_frame。tokio 的 select! 有同样的问题,见 tokio 卷的 select! 与 join!。

修法

把「读了一半」的状态挪到 future 外面,每次被取消的只剩一个 receive():

// cancel-safe version: the half frame lives in the reader, not in the future
struct FrameReader {
  id: Option<u8>,
}

impl FrameReader {
  async fn read_frame(&mut self) -> (u8, u8) {
    loop {
      let b = RX.receive().await; // a single receive() loses nothing when dropped
      match self.id.take() {
        None => self.id = Some(b),
        Some(id) => return (id, b),
      }
    }
  }
}

let mut reader = FrameReader { id: None };
loop {
  match with_timeout(Duration::from_millis(200), reader.read_frame()).await {
    // ...
  }
}

read_frame 在 receive().await 之后、下一个 .await 之前就把字节写进 self.id,这一步不会被打断;超时只会发生在 .await 上,那时没有任何只存在于 future 里的数据。同一个发送序列(p17_select fix):

   0 ms   tx 1
  25 ms   tx 10
  25 ms frame (1, 10)
 105 ms   tx 2
 228 ms timeout, send heartbeat
 266 ms   tx 20
 266 ms frame (2, 20)
 351 ms   tx 3
 376 ms   tx 30
 376 ms frame (3, 30)
 461 ms   tx 4
 486 ms   tx 40
 487 ms frame (4, 40)
 692 ms timeout, send heartbeat

心跳照发,4 帧全对。其他做法:

  • 超时只包第一个字节:with_timeout(d, RX.receive()) 等帧头,拿到帧头后不带超时(或者用另一个更长的帧间超时,超时后显式丢弃并重新同步)。
  • 让产生数据的一方(例如 UART 中断)组好整帧再放进 Channel<_, Frame, N>,接收方的 receive() 一次拿一整帧。

怎么确认没中招

  • 找出所有 with_timeout(、select(、select_array( 的参数。每个参数如果是一个 async fn / async 块,看它里面是否有两个以上的 .await,并且前一个 .await 的结果存在局部变量里。有,就是这个坑。
  • 嵌入式驱动的读方法也要看文档:例如串口的 read 是否保证「返回 Pending 时没有吃掉数据」。
  • 测试时制造一次「帧中间超时」(像上面那样让第二个字节晚到),看后面的帧是否还对。

小结

  • with_timeout 和 select 超时时 drop 整个内层 future,future 局部变量里的半帧随之丢失,后续帧错位。
  • 单个 Channel::receive() 取消安全;不安全的是把两次 .await 之间的状态放在 future 里的组合。
  • 修法:半帧状态放进 future 外面的结构体,被取消的只剩一个取消安全的 receive();或者让上游一次交付整帧。

💬 关联讨论

发起讨论
加载中...