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();或者让上游一次交付整帧。