2. async-std中的异步概念
Rust中的Futures有一个很不好使用的名声。
虽然我们并不这样认为。而且是最简单的并发概念之一,并且有一个很直观的表达。
当然,这种看法有充分的理由。Futures有三个基本概念,似乎是让人困惑的来源:延迟计算,异步性和独立执行策略。
这些概念并不难,但很多人并不习惯。许多面向细节的实现放大了这种基本混淆。对这些大多数的实现解释也针对高级用户,对初学者来说可能很难。
我们试图提供易于理解相近的原语和概念。
Futures是一种如何运行代码的抽象概念。单独的Futures是无法运行的。这在命令式语言中,是一个奇怪的概念,因为通常一件事发生在另一件事之后。
那么,Futures如何运行?是由你决定!如果没有执行它们的代码,Futures就无法执行。运行Futures的叫做执行者。执行者决定何时以及如何执行你的futures。
async-std::task模块为您提供了一个执行器的接口。
Let's start with a little bit of motivation, though.
不过,让我们先从一点动力开始。
2.1 Futures
Rust有一个引人注意的特点是无所畏惧的并发性。这是一个概念,你可以在不放弃并发安全的前提下,更好的做好并行的事情。
另外,Rust是一种低级语言,它没有选择特定策略来实现无畏并发。
这意味着,如果我们想在不同策略的用户之间共享代码,就必须对策略进行抽象,以便以后进行选择。
Futures抽象于计算。它们描述的是“什么”,独立于“地点”和“时间”。为此,他们的目标是将代码分解成小的、可组合的操作,
然后由程序的执行器来执行。让我们来了解一下业务计算的意义,以便找到可以抽象的地方。
2.1.1 Send and Sync
幸运的是,Rust在并发领域已经有两个著名而有效的概念,它们抽象了程序并发部分之间的共享:Send和Sync。
值得注意的是,Send和Sync特性都是对并发工作策略的抽象,组合得很整洁,并且没有具体实现。
简单总结一下:
Send 通过将计算中的数据传递给另一个并发计算(我们称之为接收方)来抽象发送行为,从而在发送方失去对它的访问行为。
在许多编程语言中,这种策略通常被实现,但是缺少语言方面的支持(而rust中天然存在这种机制),希望发送方自己强制执行“失去访问”行为。
这是一个常见的bug源:发送者保存发送的东西的句柄,甚至可能在发送后使用它们。
通过使这种Send行为为人所知,rust减轻了这个问题。
可以发送或不发送类型(通过实现适当的标记特征),允许或不允许发送它们,并且所有权和借用规则阻止后续访问。
Sync 是指在程序的两个并发线程中之间共享数据。
这是另一种常见的模式:由于在另一方写入数据时写入内存位置或读取数据本身是不安全的,因此需要通过同步手段来调节这种访问。
双方有许多共同的方法来同意在不同时使用内存中的同一部分,例如使用互斥锁和自旋锁。
同样,Rust给了你选择不在关心并发引发的安全问题。
Rust让你有能力表达出某些东西需要同步,虽然并没有具体地方式。
注意我们如何避免使用“thread”这样的词,而是选择“computation”。
Send和Sync的全部作用能减轻您的心智负担。在实现时,您只需要知道哪种共享方法适合自己的业务类型。
这使得推理保持局部性,并且不受该类型用户以后使用的任何实现的影响。
Send和Sync可以用有趣的方式编写程序,但这超出了本文的范围。你可以在其它关于rust的书里找到例子。
综上所述:Rust给了我们能够安全地抽象并发程序的重要属性,即它们的数据共享。
它是以一种非常轻量级的方式实现的;语言本身只知道Send和Sync这两个标记,并在可能的情况下通过实现它们来帮助我们。
剩下的是async-std库的问题。
2.1.2 简单的计算观
虽然计算是一个需要写一整本书的主题,但一个非常简单的了解就足够了:一系列可组合的操作,可以根据决策判断进行分支运算,连续运行并产生一个结果或一个错误
2.1.3 延迟计算
如上所述,Send和Sync是关于数据的。但是程序不仅仅是关于数据的,他们还要关于数据的计算。Futures就是用来干这样的事的。
我们将在下一章中仔细研究它是如何工作的。让我们看看Futures能让我们用英语表达什么。
Futures来自这个计划:
Do X 做X业务
If X succeeded, do Y 如果X成功了,做Y业务
towards:
朝着:
Start doing X 开始做X业务
Once X succeeds, start doing Y 一旦X成功,就开始做Y业务
还记得介绍中关于“延迟计算”的讨论吗?就这些。与其告诉计算机现在要执行和决定什么,不如告诉它开始做什么,以及未来如何的响应。
2.1.4 Orienting towards the beginning
面向开始
让我们看看一个简单的函数,注意返回值:
fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
你可以随时调用它,所以你可以完全控制何时调用它。
但问题是:当你调用它的时候,你失去了控制权,而只能等调用的函数最终它返回一个值。
注意这个返回值是关于已计算出的。
这样有一个缺点:就是所有的决定都已作出。
但它也有一个优势:结果是显而易见的。我们可以使用已计算出的结果,然后决定如何处理它。
That's fundamentally incompatible with looking at the results of previous computation all the time.
So,
let's find a type that describes a computation without running it. Let's look at the function again:
但我们想计算抽象出来,可让别人来选择如何运行它。
这从根本上讲与一直查看先前计算的结果是不兼容的。所以,让我们找一个不运行就能描述计算的类型。让我们再看看函数:
fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
Speaking in terms of time, we can only take action before calling the function or after the function returned.
This is not desirable, as it takes from us the ability to do something while it runs.
When working with parallel code,
this would take from us the ability to start a parallel task while the first runs (because we gave away control).
就目前而言,我们只能在调用函数之前或函数返回之后采取行动。这是不可取的,因为我们需要这个抽象在运行期间有做某事的能力。
当使用并行代码时,这将剥夺我们在第一次运行时启动并行任务的能力(因为我们放弃了控制权)。
This is the moment where we could reach for threads. But threads are a very specific concurrency primitive and we said that we are searching for an abstraction.
这是我们可以抓住线索的时刻。但是线程是一个非常特殊的并发原语,我们说我们正在寻找一个抽象。
What we are searching for is something that represents ongoing work towards a result in the future.
Whenever we say "something" in Rust, we almost always mean a trait.
Let's start with an incomplete definition of the Future trait:
我们正在寻找的是一种能够代表未来不断取得成果的东西。每当我们在Rust中说“某物”时,我们几乎总是指一种trait。
让我们开始对Future trait的进行不完全定义:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
仔细观察,我们可以发现:
它定义了一个Output类型。
它提供了一个名为poll的函数,允许我们检查当前计算的状态。(暂时忽略Pin和Context,先高层次理解暂时不需要关注它们。)
每次调用poll()都可能出现以下两种情况之一:
1. 计算完成,poll将返回Poll::Ready
2. 计算尚未完成执行,它将返回Poll::Pending
这使得我们可以从外部检查未来是否还有未完成的工作,或者是否最终完成,并能给我们一个值。
最简单(但不是最有效)的方法就是不断地在一个循环中对utures进行poll()调用。
一个好的运行时可以为给你做出更好的优化,这就是。
请注意,在出现结果1后,也就是计算完成,poll返回了Poll::Ready,再次调用poll可能会导致混淆行为,具体可以参考futures-docs。
2.1.5 Async
虽然Future trait在Rust中已经存在了一段时间,但是构造和描述它们是不方便的。
为此,Rust现在有一个特殊的语法:async。上面的示例是用async-std实现的,如下所示:
async fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
Ok(contents)
}
与前面的例子对比发现是差别很小的,对吧?我们所做的仅是用async标记函数并插入2个特殊命令:.await。
此异步函数设置延迟计算。
调用此函数时,这个函数将生成一个实现的Future<Output = io::Result<String>>的类型,而不是立即返回io::Result<String>。
(或者,更准确地说,为您生成实现Future<Output=io::Result<String>>的类型。)
2.2.6 .await 能做什么?
.await后缀的作用与它在tin上所说的完全一样:
当您使用它时,代码将一直等到请求的操作(例如打开文件或读取其中的所有数据)完成。
.await?不是特别的,只是用?操作.await等到的Rusult类型结果。 那么,在最初的代码示例中获得了什么?我们得到了futures,然后马上等他们?
.await作为标记。在这里,代码将等待Future产生其值。
Future将如何结束?你不用关心!负责执行这段代标记码的组件(通常称为“运行时”),会在计算完成时做它所需要做的处理。
当您在后台计算操作完成时,它将返回到.await处。
这就是为什么这种编程风格也称为事件编程。
我们在等待事情发生(例如,打开一个文件),然后做出响应(开始进行读操作)。
当同时执行这些函数中的2个或更多时,我们的运行时系统就可以在等待时间内处理当前发生的所有其他事件。
2.1.7 结论
从值开始工作,我们搜索一些表示朝着稍后可用的值工作的内容。在那里,我们讨论了polling的概念。
Future不是表示任何数据类型的值,而是表示在将来某个时间点产生值的能力。
Implementations of this are very varied and detailed depending on use-case, but the interface is simple.
根据用例的不同,这方面的实现非常多样和详细,但是接口很简单。 (也许业务多变,但接口实现是很简单的)
接下来,我们将向您介绍任务,这些任务将用于实际运行Futures。
双方在读操作的同时保证没有人在写操作,并发访问总是安全的。