在开始之前:不,在Rust中不存在所谓的异步异常。我很快会解释我的意思。注意标题中的逗号:)。
GHC Haskell支持一种叫做异步(或异步)异常的功能。正常情况下,同步异常是由当前运行的代码产生的,比如试图读取一个不存在的文件。异步异常是由不同的执行线程产生的,可以是另一个Haskell绿色线程,也可以是运行时系统本身。
也许使用异步异常的最好例子是timeout 函数。这个函数将需要一定的微秒数和一个动作来运行。如果该动作在该时间内完成,则一切正常。如果动作没有在这段时间内完成,那么运行该动作的线程就会收到一个异步异常。
Rust根本就没有异常,更没有异步异常。(是的,panic的行为与同步异常相当类似,但在这里我们将忽略这些。它们并不相关)。Rust也没有像Haskell那样有一个绿色的基于线程的运行时间。基本上没有直接的方法可以将Haskell中的异步异常概念对比到Rust中。
或者说,至少是没有的。有了Tokio、async/.await 、executor、tasks和futures,故事就完全不同了。一个Haskell的绿色线程看起来很像一个Rust的任务。突然间,Tokio中出现了一个timeout 函数。这篇文章将比较Haskell的异步异常机制和Tokio的timeout 。它将关注这两种不同方法的各种权衡。最后,我将以我个人的分析结束。
Haskell中的异步异常
GHC的Haskell运行时提供了一个绿色线程系统。这意味着有一个调度器将不同的绿色线程分配给实际的操作系统线程来运行。这些线程继续运行,直到它们遇到屈服点。一个常见的屈服点的例子是套接字I/O。以下面的伪代码为例:
socket <- openConnection address
send socket "Hello world!" -- yields
msg <- recv socket -- yields
putStrLn ("Received message: " ++ show msg)
每次我们执行在Haskell中看起来像阻塞的I/O时,实际上我们是:
- 当套接字完成其发送或接收时,向调度器注册一个唤醒电话
- 让当前的绿色线程进入睡眠状态
- 当调度器有一个空闲的操作系统线程并且套接字上有数据时,我们会被再次唤醒。
然而,屈服点的发生远比异步I/O更频繁。每次我们执行任何分配时,GHC都会自动插入一个屈服点。由于Haskell(不幸的是)倾向于做大量的堆分配,这意味着我们的代码中隐含着大量的屈服点。以至于我们基本上可以假设,在我们执行的任何时候,我们都可能遇到一个屈服点。
这就给我们带来了异步异常。每个绿色线程都有自己的传入异步异常队列。在每个屈服点,运行时系统会检查队列中是否有异常在等待。如果有,它将从队列中跳出一个,并将其扔到当前的绿色线程中,在那里它可以被捕获,或者最终导致整个线程的崩溃。
我的最佳实践建议是,永远不要从一个异步异常中恢复。相反,你应该只在异步异常发生时清理你的资源。换句话说,如果你捕捉到一个异步异常,你可以做一些清理工作,但随后你必须立即重新抛出该异常。
由于异步异常可以在任何地方发生,所以当我们在Haskell中编写资源安全的代码时,必须要有高度的偏执性。例如,考虑这个伪代码:
h <- openFile fp WriteMode
setPerms 0o600 h `onException` closeFile h
useFile h `finally` closeFile h
在一个没有异步异常的世界里,这是个安全的异常。我们首先打开文件。如果打开抛出一个异常,那么openFile 调用本身负责释放它所获得的任何资源。接下来,如果setPerms 抛出一个异常,我们的onException 调用确保closeFile 将关闭文件句柄。最后,当我们调用useFile ,我们使用finally ,以确保无论发生什么异步异常,closeFile 都会被调用。
然而,在一个有异步异常的世界里,还有很多事情会出错:
- 在调用
openFile和setPerms之间可能会产生一个异常,因为那里没有异常处理程序。 - 在调用
setPerms和 之间可能会产生一个异常。useFile
相反,在Haskell中,我们必须对异步异常进行屏蔽,从而暂时阻止它们被传递。上面的代码可以写成:
mask $ \restore -> do
h <- openFile fp WriteMode
setPerms 0o600 h `onException` closeFile h
restore (useFile h) `finally` closeFile h
然而,一般来说,处理屏蔽状态真的很复杂。因此,我们喜欢使用像bracket 这样的辅助函数来代替:
bracket (openFile fp WriteMode) closeFile $ \h -> do
setPerms 0o600 h
useFile h
围绕着Haskell中异步异常的实现和使用,还有很多细节,但目前这对我们的比较来说已经足够了。
Rust中取消的期货
Rust中的Future 特质为任何可以被await的东西定义了一个抽象。其核心功能是poll ,其工作原理是这样的:
- 告诉我你是否准备好了
- 如果你准备好了,很好!告诉我完成的值
- 如果你还没有准备好,我想注册一个
Waker
Future 然后,Waker 可以与执行器进行交互,以确保当awaiting的任务准备好时被唤醒。
在Rust的一个简单的异步应用中,你会有一个任务,每次等待一个Future 。例如,再以伪代码为例:
async {
let socket = open_connection(&address);
socket::send("Hello world!").await;
let msg = socket::recv().await;
println!("Received message: {}", msg);
}
每一个await,都是一个屈服点。执行器可以允许另一个任务运行,并在I/O完成后唤醒当前任务。这与我上面给出的Haskell例子非常相似。
然而,与Haskell不同的是:
- 没有一个异步异常队列坐在那里,等待着杀死我们的任务
- 没有由分配产生的隐性屈服点
如果没有异步异常,那么Rust中的timeout 到底是如何工作的呢?好吧,一个任务不是在等待单个 Future ,而是在等待两个Future中的一个完成。你可以自己检查一下代码,但基本的想法是:
- 创建两个
Future- 你想尝试运行的行动
- 一个将在超时后完成的定时器
- 每当我们
poll,看看事情是否准备好了。- 检查该动作是否准备好了。如果是的话:好的!将其结果作为
Ok - 检查定时器是否准备好了。如果是:我们的超时已经过期,我们应该返回一个
Err,说明已经过了多少时间。 - 如果都没有准备好,就说我们也没有准备好,等待再次被唤醒
- 检查该动作是否准备好了。如果是的话:好的!将其结果作为
我个人认为,这是一个相当优雅的问题解决方案。和Haskell的解决方案一样,它意味着动作只能在一个屈服点停止。然而,与Haskell的解决方案不同的是,屈服点在Rust程序中会少得多,因为我们没有分配造成的隐性撒网式的屈服。
但是现在,让我们来谈谈资源管理。我已经说得很清楚了,在Haskell中正确处理存在异步异常的资源是很棘手的。但在Rust中却不是这样。处理资源的标准方法是使用RAII:你定义一个数据类型并在上面贴上一个Drop 。在可取消的Futures的世界里,这一切都很完美。
Future本身拥有它所使用的任何资源- 如果
timeout在动作完成之前触发,相关的Future就会被放弃。 - 当
Future被放弃时,它所拥有的资源也被放弃。
下面的例子比上面的Haskell等价物更加冗长,但这是因为我们定义了一个合成的Resource 结构。在现实生活的代码中,这样的结构可能已经存在。
注意:你至少需要Rust 1.39才能运行下面的代码,并在Tokio上添加一个依赖关系,比如说一行。tokio = { version = "0.2", features = ["macros", "time"] }:
use tokio::time::{delay_for, timeout};
use std::time::Duration;
struct Resource;
impl Resource {
fn new() -> Self {
println!("acquire");
Resource
}
}
impl Drop for Resource {
fn drop(&mut self) {
println!("release");
}
}
async fn worker() {
let _resource = Resource::new();
for i in 1..=10 {
delay_for(Duration::from_millis(100)).await;
println!("i == {}", i);
}
}
#[tokio::main]
async fn main() {
println!("Round 1");
let res = timeout(Duration::from_millis(2000), worker()).await;
println!("{:?}", res);
println!("\n\nRound 2");
let res = timeout(Duration::from_millis(1000), worker()).await;
println!("{:?}", res);
println!("\n\nRound 3");
let res = timeout(Duration::from_millis(500), worker()).await;
println!("{:?}", res);
}
我的分析
Haskell在这一切中最有利的一点是它在计算中抢占的能力。而Rust的模型可以让你抢占大多数I/O动作,在其他代码中不会有很多收益点。这可能会导致很多意外的阻塞。最近有一些关于在执行器层面上可能缓解这个问题的讨论。
Haskell在这里的优势被削弱了,因为如果你的代码没有分配任何内存,你就不会得到任何收益点。然而,在实践中,这几乎从未发生。这种情况最近确实影响了我的一些同事,所以它不是没有发生过。但这是比较少见的,你可以将屈服点重新插入到一个优化的应用程序中,用 -fno-omit-yields.你可以争辩说,这有时会出现惊人的失败,这更糟糕。
我喜欢这样的事实:在Rust中,你可以清楚地知道你的程序在哪里可能会简单地停止执行。每次你看到.await ,你就知道 "嗯,完全有可能在我回来之前执行器就把我丢掉了。"而且,在异步和同步的Rust代码中,所有权、RAII和丢弃对资源管理的解决是完全一样的,这一点很好。
Haskell为用异步异常杀死线程的能力付出了很多。每位管理资源的代码都需要付出认知开销的代价。在实践中,这确实导致了大量的bug。弄清楚如何以及何时屏蔽异常,以及是否有可中断或不可中断的屏蔽(这一点我并没有真正讨论过),是另一个主要的曲线球。我认为适当的API设计可以减轻这里的很多痛苦。但是基础库并不包含这样的API设计,而且坏的做法比比皆是。
最后,一个问题:可取消的任务/可杀死的线程在实践中有多重要?在某些情况下,能够给事情计时当然是很强大的。那么,让两个行动竞赛,看哪一个先完成呢?在我看来价值不大。我在给Haskell培训时当然会教它,但通常有更优雅的方法来解决同样的问题。
既然我被异步异常困住了,我会在Haskell中使用timeout 和race ,因为使用它们并不是危险的部分,而是首先拥有它们。如果我从头开始为Haskell设计一个运行时系统,我不确定我是否会引入这个概念。它确实解决了一些非常棘手的问题,比如中断长期运行的纯代码。但我不相信这个功能真的能发挥它的作用。
另一方面,在Rust中,这个特性基本上是免费的。Future 特质的设计是为了解决一堆一般性的问题,然后在库的层面上,可以引入取消任务的解决方案。相当巧妙。
最后,这两种语言的相同之处。它们都能优雅而轻松地解决一般的异步I/O问题。你可以编写没有阻塞的阻塞式代码。这两种语言在表面下都有相当复杂的细节(Haskell:屏蔽,Rust:poll 方法),我们通常可以,也很幸运地,忽略这些细节,让别人去搞。