select! 的取消安全:分支输了,数据丢了
主线位置:主线之外,用户层 API。
select!在当前任务里同时 poll 几个 future,一个完成,其余的被 drop。
这个问题没有报错。表现是:select! 循环里偶尔少一条消息、少半行数据;或者某个分支永远等不到结果。
flowchart TB
lp[循环开始] --> mk[创建各分支的 future]
mk --> sel[select! poll]
sel -->|分支 A 先完成| run[执行 A 的处理代码]
run --> drop[分支 B 的 future 被 drop]
drop --> lost{B 做到一半了吗}
lost -->|是| bad[已读的数据丢失]
lost -->|否| ok[无影响]
drop --> lp
宏怎么展开、分支什么时候被 drop、tokio 文档对 cancel safety 的定义和哪些方法是 cancel safe 的清单,见 select! 与 join!。那一页用 channel 演示过「取到消息后被取消」。这里看另外两种常见形态。
触发它的代码
丢半行:read_line
对端把一行分两次发出,中间隔 30ms。读端在 select! 里 read_line,另一个分支是每 10ms 一次的心跳:
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
use tokio::time::{interval, sleep};
// The peer sends one line in two pieces, 30ms apart, then a second line.
fn peer() -> DuplexStream {
let (client, mut server) = tokio::io::duplex(64);
tokio::spawn(async move {
server.write_all(b"hello ").await.unwrap();
sleep(Duration::from_millis(30)).await;
server.write_all(b"world\nsecond line\n").await.unwrap();
});
client
}
async fn buggy() {
let mut reader = BufReader::new(peer());
let mut tick = interval(Duration::from_millis(10));
let mut line = String::new();
loop {
tokio::select! {
n = reader.read_line(&mut line) => {
if n.unwrap() == 0 { break }
println!(" got {line:?}");
line.clear();
}
_ = tick.tick() => {} // heartbeat, metrics, shutdown check...
}
}
}
read_line inside select!:
got "world\n"
got "second line\n"
"hello " 没了。read_line 已经把它从 BufReader 里取走,还没等到换行符,心跳分支先完成,read_line 的 future 被 drop,读到的字节跟着一起销毁。下一轮新建的 read_line 从 "world\n" 开始读。
永远完成不了:每轮重建的长操作
一个要 50ms 才能完成的操作,和 10ms 的心跳一起放进 select!:
// Something that takes 50ms and must not be restarted (a handshake, a big download...)
async fn fetch_snapshot() -> &'static str {
sleep(Duration::from_millis(50)).await;
"snapshot"
}
let start = Instant::now();
let mut tick = interval(Duration::from_millis(10));
let mut ticks = 0;
loop {
tokio::select! {
s = fetch_snapshot() => { println!("in-loop future: got {s} at {:?}", start.elapsed()); break }
_ = tick.tick() => {
ticks += 1;
if ticks == 30 { println!("in-loop future: gave up after {ticks} ticks ({:?})", start.elapsed()); break }
}
}
}
in-loop future: gave up after 30 ticks (293.364083ms)
每 10ms 心跳赢一次,fetch_snapshot() 被 drop,下一轮从头开始,永远走不到 50ms。这里没有数据丢失,但如果这个操作有副作用(发了请求、扣了配额),每一轮都会重复一次。
源码里在哪
问题不在 select!,在被 drop 的 future 把状态存在了哪里。
read_line 创建 future 时,把调用方的 String 整个拿走,读到的字节存在 future 自己的 ReadLine 结构里:
pub(crate) fn read_line<'a, R>(reader: &'a mut R, string: &'a mut String) -> ReadLine<'a, R>
where
R: AsyncBufRead + ?Sized + Unpin,
{
ReadLine {
reader,
buf: mem::take(string).into_bytes(),
output: string,
read: 0,
_pin: PhantomPinned,
}
}
只有读到换行、future 完成时,才把结果写回 output。中途被 drop,buf 随 future 销毁,调用方的 String 是空的,BufReader 里那部分数据已经被消费。它的文档写明了这一点:「This method is not cancel safe. ... some data may have been partially read, and this data is lost.」
对照 Lines:读了一半的字节存在 Lines 结构体自己的 buf 里,不在 future 里。next_line 的 future 被 drop,Lines 还在,下次调用接着读。所以它的文档写的是「This method is cancel safe.」
Lines 的 buf 到 1.54.0 才是字节1.53 及更早,Lines 有三个字段 buf: String、bytes: Vec<u8>、read,通过 read_line 的内部函数往 String 里攒。进度一样存在 Lines 里,取消安全不受影响;出问题的是 IO 错误。#8400(1.54.0)的说明写道:一行读到一半遇到 IO 错误,如果这半行恰好断在一个多字节 UTF-8 字符中间,String 里放不下它;下一次 poll 会触发 debug_assert!(output.is_empty()),或者在 put_back_original_data 里 vector.len() - num_bytes_read 下溢,release 构建也会在后面的 .expect 上 panic。改法是 Lines 只留一个 buf: Vec<u8>,像 Split 那样直接调 read_until_internal,整行读完才转成 String。
规律是:future 被 drop 时,已经产生的进度存在哪里。存在 future 外面(Lines、channel、BufReader 的缓冲区)就安全;存在 future 里面(局部变量、ReadLine 的 buf、read_exact 已经填了多少字节的计数)就会丢。
read_until,第一行打印什么?把上面 buggy 里的 read_line 换成 read_until,其他不变(同一个 peer(),同样 10ms 的心跳,#[tokio::main]):
let mut reader = BufReader::new(peer());
let mut tick = interval(Duration::from_millis(10));
let mut buf = Vec::new();
loop {
tokio::select! {
n = reader.read_until(b'\n', &mut buf) => {
let n = n.unwrap();
if n == 0 { break }
println!("n = {n}, buf = {:?}", String::from_utf8_lossy(&buf));
buf.clear();
}
_ = tick.tick() => {}
}
}
"hello " 还会丢吗?第一行的 n 是几?答案在本页末尾。
怎么发现
在代码里找满足下面两条的 select!:
- 在循环里(
loop、while)。只执行一次的select!,输掉的分支本来就不要了。 - 分支里的 future 是在
select!里现场创建的,而且是下面之一:- 自己写的
async fn或async块,里面有不止一个.await。第一个.await完成后、最后一个完成前被 drop,前面的效果就丢了。 - tokio 文档里
# Cancel safety一节写着 not cancel safe 的方法:read_exact、read_to_end、read_to_string、read_line、write_all,以及各种锁和信号量的等待(丢的是排队位置)。完整清单在 select! 与 join! 的表里。
- 自己写的
查不到文档的第三方方法,按「中途被 drop 会不会丢进度」自己判断,判断不了就按不安全处理。
怎么修
换成 cancel safe 的方法
read_line 换成 lines().next_line(),进度存在 Lines 里:
async fn with_lines() {
let mut lines = BufReader::new(peer()).lines();
let mut tick = interval(Duration::from_millis(10));
loop {
tokio::select! {
l = lines.next_line() => match l.unwrap() {
Some(l) => println!(" got {l:?}"),
None => break,
},
_ = tick.tick() => {}
}
}
}
lines().next_line() inside select!:
got "hello world"
got "second line"
类似的替换:read_exact 换成 read/read_buf 加自己的缓冲区和计数;write_all 换成 write 加自己的偏移量;按帧读写用 tokio_util::codec 的 FramedRead,进度存在 Framed 结构里。
把 future 固定在循环外面
操作不能重来,就在循环外创建一次,用 tokio::pin! 固定,分支里写 &mut fut。select! drop 的只是这个引用,future 本身留着,下一轮接着 poll:
let start = Instant::now();
let mut tick = interval(Duration::from_millis(10));
let mut ticks = 0;
let fut = fetch_snapshot();
tokio::pin!(fut);
loop {
tokio::select! {
s = &mut fut => { println!("pinned future: got {s} at {:?} after {ticks} ticks", start.elapsed()); break }
_ = tick.tick() => ticks += 1,
}
}
pinned future: got snapshot at 53.590084ms after 6 ticks
它完成后不能再 poll。循环还要继续时,要么 break,要么换一个新的 future(fut.set(..)),要么给分支加 if !done 前置条件把它禁用。
这种写法要求 future 不借用循环里还要用的变量。read_line(&mut line) 借用了 reader 和 line,固定在外面以后,循环体里就不能再碰它们,所以 read_line 更适合上一种改法。
把工作挪进独立任务
读写放进一个单独的任务,它自己按顺序 .await,不参与 select!;结果通过 channel 送出来。主循环 select! 的是 rx.recv(),它是 cancel safe 的:
async fn with_task() {
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
let mut reader = BufReader::new(peer());
let mut line = String::new();
while reader.read_line(&mut line).await.unwrap() > 0 {
if tx.send(std::mem::take(&mut line)).await.is_err() { break }
}
});
let mut tick = interval(Duration::from_millis(10));
loop {
tokio::select! {
l = rx.recv() => match l {
Some(l) => println!(" got {l:?}"),
None => break,
},
_ = tick.tick() => {}
}
}
}
reader task + mpsc:
got "hello world\n"
got "second line\n"
三段 read_line 相关的输出来自同一次运行(buggy、with_lines、with_task 依次调用),两次运行结果一致。
选哪个
| 情况 | 改法 |
|---|---|
有现成的 cancel safe 版本(next_line、read、recv、FramedRead) | 直接换 |
| 一个长操作,必须完成且只做一次 | tokio::pin! 固定在循环外,&mut fut |
| 一串有顺序的异步步骤(读一帧、解析、回写) | 放进独立任务,用 channel 和主循环通信 |
| 程序正在关闭,丢掉半截数据无所谓 | 不用改,取消就是想要的效果 |
动脑筋答案
程序在 exp-hf-pitfalls/src/bin/read_until_select.rs,M2 Max、rustc 1.99.0,连跑 3 次,输出一致:
n = 6, buf = "hello world\n"
n = 12, buf = "second line\n"
"hello "没丢。read_until_internal每读到一段就buf.extend_from_slice(..),直接追加进调用方传进来的Vec<u8>。心跳赢了、future 被 drop,"hello "已经在buf里了。下一轮新建的read_until接着往同一个buf后面追加"world\n"。read_line做不到这一点,是因为它要保证String里永远是合法的 UTF-8,读了一半的字节只能先放在 future 自己的buf里。n = 6,不是 12。 返回值是 future 里的计数器read,它随第一个 future 一起被 drop 了,新 future 从 0 开始,只数到了"world\n"的 6 个字节。read_until的文档在# Cancel safety一节写了这一点:取消后再调用,计数器会重置。所以在select!里用它,要看buf.len(),不能看返回值。n = 12:第二行没有被打断,一次读完"second line\n"。
小结
select!输掉的分支的 future 会被 drop。future 里存着的进度随之消失,下一轮从头开始。- 典型后果有两种:读了一半的数据丢失(
read_line、read_exact、自己写的多步async fn),以及长操作每轮重来、永远完成不了。 - 判断一个操作是否 cancel safe,看它的进度存在 future 里面还是外面;tokio 的方法在文档
# Cancel safety一节写明。 - 改法:换 cancel safe 的方法;把 future 用
tokio::pin!固定在循环外;或者把有顺序的工作挪进独立任务,select!只等 channel。