跳到主要内容

翻译 | 如何理解异步取消?

提示

本文译自 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 fnasync {},而是来自于 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(或取消)另一个。

在这两个例子中,需要记住,由于这些都发生在异步函数的上下文中,parentparent2 都有可能本身被取消掉。也就是说,这个角度也继承了上一个角度的所有问题。

从异步运行时的角度

异步运行时是我们看异步取消的另一个角度。这和我上一个角度的内容有点像,因为运行时同时跟踪和运行许多不同的 Future。 但也有一些区别:运行时是同步和异步之间的界限--就像三明治的蛋黄酱。运行时提供同步接口来创建运行时,启动一些任务,并开始执行。 另一方面,运行时管理异步函数,通过调用 Future::poll 来推动其执行,并且一般会给 Future 提供同步原语。

因为运行时负责启动 poll 的调用链,对于一连串的取消中最初的那一个,应该从这里开始考察。在高层次上,运行时是这样做的:

  1. 从任务队列中获取下一个准备好的任务,或者如果没有准备好的任务,就等待。
  2. 调用准备好的任务的 poll 方法。
  3. 重复这些过程。

异步取消则是对第二步的一点小改动:

  1. 从任务队列中获取下一个准备好的任务,或者如果没有准备好的任务,就等待。
  2. 如果任务应该取消,就 drop 掉这个任务,否则就调用它的 poll 方法。
  3. 重复这些过程。

一个关键之处:运行时都是同步的代码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 WuytsNick Cameron 对这篇文章的早期版本的反馈。


  1. 也许你可以写一个异步的异步运行时,但我不知道你有什么理由要这样做……
  2. 手动编写的 Future,特别是像 racejoin 这种组合 Future 的 Future,有点像迷你运行时。可能这就是所谓的异步的异步运行时。
  3. 同样的惊喜也存在于 panic! 中,不过它略逊一筹,因为至少取消时中你可以在代码中看到 .await
  4. 这个取消 API 有点奇怪,因为它是一个同步函数,只是要求运行时取消任务,但并不等待取消的完成。对于像 async-std 这样的运行时来说,cancel的签名是 async fn cancel(self),它消耗任务并阻塞直到取消完成。如果我们想拥有这里例子中的请求取消但不等待完成的语义,我们更可能要做的是像 task.get_cancellation_handle() 这样的东西,它返回一个 Send 值,可以用来向另一个任务请求取消,即使如此,我们也希望 cancel是异步函数,因为实际请求取消可能需要在一些共享资源上进行同步。
Loading...