翻译 | 如何理解异步取消?
本文译自 Perspectives on Async Cancellation。 原文采用 CC BY-NC 4.0 协议授权。
最近,有很多关于 Rust 中异步取消的讨论。在这些讨论中,我注意到一件事:很多时候,在不同的人眼里,“取消”有完全不同的含义。 因此,一个人关心的问题可能并不被另一个人关心,甚至可能一个人都无法准确描述出另一个人关心的问题。 我想,也许原因在于,对异步取消的讨论,必须有某个角度为切入,但这些角度往往在讨论中是隐式的预设,而非经过明确表述或讨论的前提。 当从不同的角度看时,异步取消可以有不同的含义、能力和意义。
在这篇文章中,我将对讨论异步取消时可能涉及的各种观点与角度进行编目和分类。 我并不期望这能成为最后的结论,但我希望它能对澄清围绕取消的问题有所帮助,并以此启发更精确的分类。
什么是异步取消?
我不清楚是否有意为之,但在 Rust 中,Future 的运行必须通过调用 poll
方法进行外部驱动,异步取消在很大程度上是基于这一事实而存在的。
在大多数 async/await 异步的语言中,异步取消需要做一些工作,比如在任务上调用 .cancel()
;而在 Rust 中,只要不再调用 poll
,就可以取消一个 Future。
一旦一个 Future 被确定不可能再调用,例如被 drop 掉,或者它已经离开作用域,Rust 就会通过运行 Future 的析构器来进行清理——除非调用了 mem::forget
或用其他方式泄露掉它。
看待异步取消的角度
现在我们可以开始对看待取消的各种角度进行分类。我建议从以下四个角度开始:
- 从被取消的 Future 的角度
- 从父级 Future 的角度
- 从异步运行时的角度
- 从异步任务的角度
接下来,让我们深入这些角度的细节。
从被取消的 Future 的角度
“如果你是被取消的 Future 会发生什么?”这就是这个角度的含义。我们来看一个例子:
async fn cancel_me() {
println!("1");
some_other_async_fn().await;
println!("2");
}
很多时候,这是异步取消最典型的例子。取消可以发生在 .await
的位置,因为这里是异步函数的 poll
方法将返回的地方;
或者在执行开始之前,因为 Future 可能在第一次 poll
之前就被 drop 掉。
如果 some_other_async_fn
被中途取消,我们会发现 “1” 能打印出来,但 “2” 没有。cancel_me
函数不会继续执行 some_other_async_fn()
后面的部分。
还有其他的一些情况可以代替 some_other_async_fn().await;
实现取消。可能 some_other_async_fn
中发生 panic,
或者,可以用 Result
和 ?
(或者 .await?
)进行逻辑短路。这些例子的共同点是,在调用 some_other_async_fn
的那行代码之后,执行不再继续。
在编写 cancel_me
时,我们需要小心,对于可能的异步取消,要确保清理能正常进行,使你的程序保持健全。
Rust 的 API 一般都是以有助于此的方式编写的。例如,互斥锁不需要手动解锁,因为 MutexGuard
在其 Drop
实现中做了这个。
不过,并非所有的程序都会采用这种模式,也不是所有的不变量都可以这样子方便地编码为数据结构和类型。
手动编写的 Future
手动编写的 Future 是那些不来自于 async fn
或 async {}
,而是来自于 impl Future for T
的 Future。
基本上所有的异步性都源于那些手动实现的 Future,因为编译器自己不会生成 return Pending
。
在这种情况下的取消也值得考虑。让我们从一个非常简单的手动实现的 Future 的例子开始。
enum State {
A, B, C
}
impl Future for State {
type Output = ();
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context) -> Poll<Self::Output> {
match *self {
State::A => {
*self = State::B;
Poll::Pending
},
State::B => {
*self = State::C;
Poll::Pending
},
State::C => {
Poll::Ready(())
},
}
}
}
这是一个状态机,从 A 切换到 B,再到 C。它有几个问题,最明显的是,它在返回 Poll::Pending
之前没有在任何地方保存 Waker,这意味着没有办法告诉运行时在它返回之后再次继续运行这个 Future。
除此之外,从取消的角度,这段代码中没有 .await
。事实上,poll
是一个同步函数。因此,手动编写的 Future 中,取消只有在 poll
不再被调用时才会发生,或者根本不会发生。
从父级 Future 的角度
从这个角度来看,一个 Future 可能会选择 drop 一个子 Future,进而取消掉它。一个有点刻意的例子:
async fn parent() {
let cancel_future = cancel_me();
let do_not_cancel_future = do_not_cancel_me();
drop(cancel_future);
do_not_cancel_future.await;
}
这个例子可能称不上取消,因为 cancel_future
根本没有被 poll
过,但是 cancel_me
确实被调用了,并且这个函数确实没有执行,这看起来有点像取消。
一个更正常的例子:
async fn parent2() {
cancel_me()
.race(or_maybe_cancel_me())
.await;
}
这里的 .race
方法组合了两个 Future,同时运行它们(通过交替调用它们的 poll
方法),并返回先完成的那个 Future 的值,然后 drop(或取消)另一个。
在这两个例子中,需要记住,由于这些都发生在异步函数的上下文中,parent
和 parent2
都有可能本身被取消掉。也就是说,这个角度也继承了上一个角度的所有问题。
从异步运行时的角度
异步运行时是我们看异步取消的另一个角度。这和我上一个角度的内容有点像,因为运行时同时跟踪和运行许多不同的 Future。
但也有一些区别:运行时是同步和异步之间的界限--就像三明治的蛋黄酱。运行时提供同步接口来创建运行时,启动一些任务,并开始执行。
另一方面,运行时管理异步函数,通过调用 Future::poll
来推动其执行,并且一般会给 Future 提供同步原语。
因为运行时负责启动 poll
的调用链,对于一连串的取消中最初的那一个,应该从这里开始考察。在高层次上,运行时是这样做的:
- 从任务队列中获取下一个准备好的任务,或者如果没有准备好的任务,就等待。
- 调用准备好的任务的
poll
方法。 - 重复这些过程。
异步取消则是对第二步的一点小改动:
- 从任务队列中获取下一个准备好的任务,或者如果没有准备好的任务,就等待。
- 如果任务应该取消,就 drop 掉这个任务,否则就调用它的
poll
方法。 - 重复这些过程。
一个关键之处:运行时都是同步的代码1。很多与异步取消有关的复杂情况都不会影响到运行时,尽管运行时最终是某些取消的发源地。运行时能看到 Future 的很多元数据,但并不能洞察到 Future 本身。
运行时的角度和异步函数的角度之间还有另一个区别:在异步函数中,你不会直接调用 Future 的 poll
,而是调用 .await
,在这一过程的内部,不断调用 poll
直到 Future 完成。而运行时直接与 poll
方法交互,所以并不需要一直 poll
到完成。这也适用于之前所说的手动实现的 Future2。
取消和任务
其实在上一节,我们已经偷偷地在谈论异步任务了。异步任务带来了一些值得明确指出的问题。需要指出,Rust 中的任务和 Future 相关但不同。 与 Future 不同,在 Rust 中任务不是基础的概念,而是由运行时提供的概念,用于管理正在进行的各种 Future 的状态。
任务是异步性真正体现的地方。任务通常在多个线程上运行,即使没有,运行时也会在不同的任务之间进行切换,使多个任务协作。
这让取消在其他方面也具有更多的意义。比如说,一个任务可以请求取消另一个任务。再比如说,一个任务可以自外部取消。外部来源的取消可能会出人意料,因为与向上传播 Result::Err
不同,失败的发生无法从类型上体现3。
在迄今为止的所有案例中,要么是你在通过 drop 掉一个 Future 而直接取消它,要么你就是被取消的未来。在这种情况下,你并没有机会真正看到取消的发生。
任务是否可以取消是由运行时决定的,但是支持取消任务的运行时可能会提供类似于这样的 API:
async fn spawn_and_cancel() {
let task = spawn(async { ... });
task.cancel();
match task.join().await {
Completed(return_value) => ...,
Error(err) => ...,
Canceled(reason) => ...,
}
}
在这里,启动一个任务后,会拿到它的 handle,这可以用来与任务进行交互,比如取消它或等待它。
这个例子里,我们通过调用 task.cancel()
来请求取消任务4。然后我们调用 task.join().await
,它阻塞当前的 Future,直到任务完成。
join
的返回值里有更多关于任务完成完成的信息,包括如何完成、完成的原因。在这个假想的 API 中,有几个原因可以使任务完成,比如运行到完成(上面的 Completed
),panic(上面的 Error
),或者被取消(上面的 Cancelled
)。当然,区分这几种情况中实际上可能并不那么有用。
结论
我们刚刚完成了对 Rust 中异步取消的几个不同角度的研究。我们从被取消的 Future 的角度,从管理其他 Future 的 Future 的角度,从异步运行时的角度,以及在多个任务的背景下看取消的问题。在后续的文章里,我想用继续从这些角度来探讨异步 drop 意味着什么,以及捕捉异步取消意味着什么。
我(以及异步工作小组的其他成员,我想)在这里肯定没法获得所有答案,或者说没法把所有问题弄清楚。有可能,你对异步取消的看法与我不同。如果是这样,请让我知道!听听别人是如何使用异步的,以及他们是如何理解这些东西的,可以帮助我们建立一个健全的、用起来很愉快的东西。
感谢 Yosh Wuyts 和 Nick Cameron 对这篇文章的早期版本的反馈。
- 也许你可以写一个异步的异步运行时,但我不知道你有什么理由要这样做……↩
- 手动编写的 Future,特别是像
race
和join
这种组合 Future 的 Future,有点像迷你运行时。可能这就是所谓的异步的异步运行时。↩ - 同样的惊喜也存在于
panic!
中,不过它略逊一筹,因为至少取消时中你可以在代码中看到.await
。↩ - 这个取消 API 有点奇怪,因为它是一个同步函数,只是要求运行时取消任务,但并不等待取消的完成。对于像
async-std
这样的运行时来说,cancel
的签名是async fn cancel(self)
,它消耗任务并阻塞直到取消完成。如果我们想拥有这里例子中的请求取消但不等待完成的语义,我们更可能要做的是像task.get_cancellation_handle()
这样的东西,它返回一个Send
值,可以用来向另一个任务请求取消,即使如此,我们也希望cancel
是异步函数,因为实际请求取消可能需要在一些共享资源上进行同步。↩