Rust异步Asyn的特点

22-09-27 banq

经常听到有人把Rust和其他语言描述为 "穿风衣的N种语言"。在Rust中,我们有Rust的控制流结构,我们有decl-macro元语言,我们有trait系统(它是图灵完备的),我们有cfg注释语言--这个名单还在继续。但是,如果我们把Rust看作是开箱即用的 "基础Rust",那么它就有一些明显的修改因素。
  • unsafe Rust:使用原始指针和FFI
  • const Rust:在编译时计算数值
  • async Rust:启用非阻塞式计算。

所有这些Rust语言的 "修饰关键字 "都提供了 "基础Rust "中所没有的新功能。但它们有时也会夺走一些能力。我开始思考和谈论语言特性的方式是用 "语言的子集 "或 "语言的超集"。有了这种分类,我们可以再看看修饰语的关键词,并做出以下分类。
  • 不安全的Rust:超集
  • const Rust:子集
  • 异步Rust:超集

unsafe Rust只增加了使用原始指针的能力。 async只增加了.await值的能力。但是const增加了在编译过程中计算值的能力,但删除了使用静态和访问诸如网络或文件系统的能力。

如果语言特性只增加了基础Rust,那么它们被认为是超集。但如果他们增加的功能要求他们也限制其他功能,那么他们就被认为是子集。在const的情况下,所有const函数都可以在运行时执行。但不是所有可以在运行时执行的代码都可以被标记为const。

将语言特性设计成 "基础 "Rust的子/超集是至关重要的:它可以确保语言保持内聚感。而比起大小或范围,统一性是导致简单的感觉的原因。

引擎盖下的异步
Rust的核心async/.await提供了一种标准化的方法来返回类型,并在它们上使用返回另一种类型的方法。函数不是直接返回一个类型,而是async首先返回一个中间类型。

/// 这个函数返回一个字符串
fn read_to_string(path: Path) -> String { . }

/// 这个函数返回一个类型,最终返回一个字符串
async fn read_to_string(path: Path) -> String { . }

/// 不使用`async fn`,我们也可以这样写。
/// `impl Future`在这里是一个类型转换的结构
fn read_to_string(path: Path) -> impl Future<Output = String> { . }


Future只是一个带有方法的类型(fn poll)。如果我们在正确的时间以正确的方式调用该方法,那么最终它会给我们等价于Option<T>whereT是我们想要的值。

当我们谈论“在一个类型中表示一个计算”时,我们实际上是在谈论将async fn其及其所有.await点编译成一个状态机,该状态机知道如何从各个.await 点暂停和恢复。这些状态机只是其中包含一些字段的结构,并且具有自动生成的Future::poll实现,它知道如何在各种状态之间正确转换。要了解有关这些状态机如何工作的更多信息,我建议观看tmandry 的“异步 fn 的生命”

.await语法提供了一种方法来确保没有任何底层poll 细节出现在用户语法中。大多数用法async/.await看起来就像非异步 Rust,但async/.await在顶部添加了注释。

RUST 的异步特性
async/.awaitRust 提供的核心特性是对执行的控制:
从:

>“函数调用”->“输出”

变成:

>“函数调用”->“计算”->“输出”


计算不再只是对我们隐藏的东西。async/.await我们有权操纵计算本身。 这导致了几个关键功能:
  • 暂停/取消/暂停/恢复计算的能力(临时取消)
  • 并发执行计算的能力(临时并发)
  • 结合对执行、取消和并发的控制的能力



临时取消
暂停/取消/暂停/恢复计算的能力是非常有用的。在这三种能力中,能够取消执行可能是最有用的一种。在同步和异步代码中,在执行完成之前停止执行都是可取的。但异步Rust的独特之处在于,任何计算都可以以一种统一的方式停止。每个未来都可以被取消,而所有的未来都需要考虑到这一点 4.。

特设并发
并发执行计算的能力是async Rust的另一个标志能力。任何数量的异步fns都可以并发运行,并且.一起等待。
在非async Rust中,并发性通常是与并行性联系在一起的:许多计算可以通过使用thread::spoon并以这种方式拆分来安排并发。但async Rust将并发性与并行性分开,提供了更多的控制。

结合取消和并发性
现在,最后:当你把取消和并发结合起来时会发生什么?它允许我们做一些有趣的事情: 在我的博文 " "Async Time III: Cancellation and Signals" "中,我深入探讨了一些你可以用它做的事情。但这里的典型例子是:超时。
超时是一些future 和一个定时器未来的并发执行,映射到一个结果。

  • 如果future在定时器之前完成,我们取消定时器并返回Ok。
  • 如果定时器在future 之前完成,我们就取消未来并返回Err。

这就是取消+并发的组合,提供了一种新的第三种操作类型。
要想了解为什么能够让任何计算超时是一个有用的属性,我强烈推荐阅读Crash-Only Software by Candea and Fox。但它并不仅仅停留在超时上:如果我们将任何暂停/取消/暂停/恢复功能与并发性结合起来,我们就会释放出无数新的可能操作。

这些都是async Rust实现的功能。
在非async Rust中,并发、取消和暂停往往需要调用底层操作系统--而这并不总是被支持。比如说。Rust没有内置的方法来取消线程。做到这一点的方法通常是给线程传递一个通道,并定期检查它,看是否有一些 "取消 "信息被传递。

相反,在async Rust中,任何计算都可以被暂停、取消或并发运行。这并不意味着所有的计算都应该并发运行,或者所有的东西都应该有一个超时。但这些决定可以在我们实现的基础上做出,而不是受到系统调用可用性等外部因素的限制。

性能:工作负载
当某样东西被说成比其他东西性能更好时,总是值得一问。"在什么情况下?" 性能总是取决于工作负载。在显卡基准测试中,你经常会看到显卡之间的差异是基于运行哪些游戏。在CPU基准测试中,一个工作负载主要是单线程还是多线程非常重要。而当我们谈论软件功能时,"性能 "也不是二进制的,而是高度依赖于工作负载的。在谈论并行处理时,我们可以区分两类一般的工作负载。

  • 面向吞吐量的工作负载
  • 面向延时的工作负载

面向吞吐量的工作负载通常关心的是在最短的时间内处理最大数量的事情。而面向延迟的工作负载关心的是尽可能快地处理每件事情。听起来令人困惑?让我们把它说得更清楚。

一个考虑到吞吐量的软件的例子是Hadoop。它是为 "离线 "批量处理工作负载而建立的;其中最重要的设计目标是尽量减少处理数据所花费的总的CPU时间。当数据被放入系统时,它可能经常需要几分钟甚至几小时才能被处理。而这很好。我们并不关心何时得到结果(当然是在合理范围内),我们主要关心的是使用尽可能少的资源来得到结果。

与一个面向公众的HTTP服务器相比。网络通常是面向延迟的。我们通常不关心我们能处理多少个请求,而是关心我们能多快地对它们作出反应。当一个请求进来时,我们不希望花几分钟或几小时来生成一个响应。我们希望请求-响应的往返时间最多只能以毫秒计算。而像p99尾巴延迟这样的东西经常被用来作为关键的性能指标。

一般来说,异步Rust被认为是更注重延迟而不是吞吐量。async-std和tokio等运行时主要关注的是保持低的整体延迟,并防止突然的延迟峰值。

了解正在讨论的工作负载的类型,往往是讨论性能的第一步。Async Rust的一个关键好处是,大多数使用它的系统都经过了大量的调整,以便为面向延迟的工作负载提供良好的性能,

如果你想处理更多面向吞吐量的工作负载,像rayon这样的非async crates通常更适合。

性能:优化
Async Rust将并发性和并行性相互分离。有时这两者会相互混淆,但事实上它们是不同的。

并行性是一种资源,并发性是一种计算调度方式

最好把 "并行性 "看成是一个最大值。例如,如果你的计算机有两个核心,那么你拥有的最大平行度可能是两个8。 但平行度与并发性不同:计算可以在单个核心上交错进行,所以当我们在等待网络执行工作时,我们可以运行一些其他的计算,直到我们有一个回应。即使在单线程机器上,计算也可以是交错的和并发的。反之亦然:我们把事情安排成并行的并不意味着计算是交错的。无论我们在多少个核心上运行,如果线程通过等待一个单一的、共享的锁来轮流运行,那么逻辑执行实际上可能仍然是按顺序进行的。

让我们来看看在async和非async Rust中的并发工作负载的例子。在非async Rust中,最常见的是使用线程来实现并发执行9。但由于线程也是实现并行执行的抽象,这意味着在非async Rust中,并发性和并行性往往是紧密相连的。

在async Rust中,我们可以把并发性和并行性分开。如果一个工作负载是并发的,这并不意味着它也是可并行的。这为执行提供了更精细的控制,这也是async Rust的关键优势。让我们比较一下非同步和同步Rust的并发性。

// jthread-based concurrent computation
let x = thread::spawn(|| 1 + 1);
let y = thread::spawn(|| 2 + 2);
let (x, y) = (x.join(), y.join()); // wait for both threads to return

// async-based concurrent computation
let x = async { 1 + 1 };
let y = async { 2 + 2 };
let (x, y) = (x, y).await;  // resolve both futures concurrently


这似乎是一个非常愚蠢的例子:计算是同步的,所以两者都做同样的事情,但非异步变体有需要产生实际线程的开销。它并不止于此:因为第二个示例不需要线程,编译器的内联会激活启动,并且可能能够将其优化为以下10

// 编译器优化的基于异步的并发计算
 let (x, y) = ( 2 , 4 );


相比之下,编译器可能对基于线程的变体执行的最佳优化是:

// 基于线程的并发计算
let x = thread::spawn(|| 2 );
让y = thread::spawn(|| 4 );
让(x,y)=(x.join (),y.join ());// 等待两个线程返回


将并发性与并行性分开可以对计算进行更多优化。async在 Rust 中,基本上是一种创建状态机的奇特方式,嵌套async/.await调用允许将类型编译成单个状态机。
有时我们可能想要分离状态机,但这是异步 Rust 为我们提供的那种控制,使用非异步 Rust 更难实现。

生态系统
在结束之前,我们应该指出人们可能选择异步 Rust 的最后一个原因:生态系统的规模。如果没有关键字泛型,库作者可能需要大量工作来发布和维护在异步和非异步 Rust 中都可以工作的库。通常只发布异步或非异步库是最简单的,而不考虑其他用例。但是 crates.io 上的许多与网络相关的库都使用 async Rust,这意味着在此之上构建的库也将使用 async Rust。反过来,那些希望在不从头开始重写所有内容的情况下构建网站的人在使用异步 Rust 时通常会有更大的生态系统可供选择。
网络效应是真实的,需要在这种情况下得到承认。不是每个想要建立网站的人都会考虑语言功能,而可能只是考虑他们在生态系统方面的选择。这也是使用 async Rust 的完全正当理由。

总结
Async Rust赋予我们控制执行的能力,这在非async Rust中是不可能的。坦率地说,在许多其他具有async/.await的编程语言中也不可能实现。事实上,一个async fn编译成一个懒惰的状态机,而不是一个急切的管理任务,这是一个关键的区别。这意味着我们可以完全在库代码中编写并发原语,而不需要在编译器或运行时中构建它。

在Async Rust中,它所实现的功能是相互关联的。下面是对它们之间关系的简要总结:

Yosh 的
        Rust 异步能力层次结构
    ┌──────────────────────────────┐ 
3. │ 超时、定时器、信号 │ …h然后可以组合成… 
    ├─────────────┬────────────────┤ 
2. │ 取消 │ 并发 │ …依次enable... 
    ├───────────────┴────────────────┤ 
1. │ 控制执行 │ 核心future启用... 
    └── ──────────────────────────────┘


一般来说,当你做异步IO时,它的性能会比非异步Rust高。但这主要是在底层系统API为之准备的情况下,这通常包括网络API,最近也开始包括磁盘IO。
 

1