Rust中async/await的目的是什么?

29
在像C#这样的语言中,给出此代码(我故意没有使用await关键字):
async Task Foo()
{
    var task = LongRunningOperationAsync();

    // Some other non-related operation
    AnotherOperation();

    result = task.Result;
}

在第一行中,长时间的操作在另一个线程中运行,并返回一个Task(即Future)。然后,您可以执行另一个操作,该操作将与第一个操作并行运行,最后,您可以等待操作完成。我认为这也是Python、JavaScript等中async/await的行为。

另一方面,在Rust中,我在RFC中读到:

Rust中的future与其他语言的future之间的根本区别在于,Rust中的future不会执行任何操作,除非被轮询。整个系统都基于此构建:例如,取消操作正是因为丢弃future而实现的。相比之下,在其他语言中,调用async fn会启动一个立即开始执行的future。

在这种情况下,Rust中async/await的目的是什么?看到其他语言中,这种表示法是一种方便的方法来运行并行操作,但如果调用async函数不运行任何内容,我无法看到它如何工作。


4
就此而言,Python 中的异步函数也会立即产生输出,只有在事件循环请求时才开始执行。这种设计与 Rust 非常相似。 - Sven Marnach
C++ 也有延迟期货! - asmmo
3个回答

50
你将几个概念混淆了。 并发不等于并行, 而asyncawait 是处理并发的工具,这意味着它们有时也是处理并行的工具。
此外,一个Future是否立即被轮询与所选择的语法无关。

async/await

asyncawait关键字的存在是为了使创建和交互异步代码更容易阅读,并且看起来更像"正常"同步代码。据我所知,在所有拥有这些关键字的语言中都是如此。

更简单的代码

下面是一个创建Future对象,在轮询时将两个数字相加的代码:

之前的代码

fn long_running_operation(a: u8, b: u8) -> impl Future<Output = u8> {
    struct Value(u8, u8);

    impl Future for Value {
        type Output = u8;

        fn poll(self: Pin<&mut Self>, _ctx: &mut Context) -> Poll<Self::Output> {
            Poll::Ready(self.0 + self.1)
        }
    }

    Value(a, b)
}

之后

async fn long_running_operation(a: u8, b: u8) -> u8 {
    a + b
}

请注意,“before”代码基本上是今天的poll_fn函数的实现
另请参见Peter Hall的答案,了解如何更好地跟踪多个变量。

参考资料

async/await可能会让人惊讶的一点是,它可以实现一种以前无法实现的特定模式:在futures中使用引用。以下是一些以异步方式填充缓冲区的代码:

之前

use std::io;

fn fill_up<'a>(buf: &'a mut [u8]) -> impl Future<Output = io::Result<usize>> + 'a {
    futures::future::lazy(move |_| {
        for b in buf.iter_mut() { *b = 42 }
        Ok(buf.len())
    })
}

fn foo() -> impl Future<Output = Vec<u8>> {
    let mut data = vec![0; 8];
    fill_up(&mut data).map(|_| data)
}

这段代码编译失败:

error[E0597]: `data` does not live long enough
  --> src/main.rs:33:17
   |
33 |     fill_up_old(&mut data).map(|_| data)
   |                 ^^^^^^^^^ borrowed value does not live long enough
34 | }
   | - `data` dropped here while still borrowed
   |
   = note: borrowed value must be valid for the static lifetime...

error[E0505]: cannot move out of `data` because it is borrowed
  --> src/main.rs:33:32
   |
33 |     fill_up_old(&mut data).map(|_| data)
   |                 ---------      ^^^ ---- move occurs due to use in closure
   |                 |              |
   |                 |              move out of `data` occurs here
   |                 borrow of `data` occurs here
   |
   = note: borrowed value must be valid for the static lifetime...

之后

use std::io;

async fn fill_up(buf: &mut [u8]) -> io::Result<usize> {
    for b in buf.iter_mut() { *b = 42 }
    Ok(buf.len())
}

async fn foo() -> Vec<u8> {
    let mut data = vec![0; 8];
    fill_up(&mut data).await.expect("IO failed");
    data
}

这很有效!

调用async函数不会运行任何内容

Future的实现和设计以及围绕着它的整个系统,与关键字asyncawait无关。事实上,在async/await关键字出现之前,Rust就拥有了一个蓬勃发展的异步生态系统(例如Tokio)。JavaScript也是如此。

为什么在创建时不立即轮询Future

对于最权威的答案,请查看withoutboats的此评论

Rust的futures与其他语言的futures的一个基本区别在于,除非被轮询,否则Rust的futures不会执行任何操作。整个系统都是围绕这一点构建的:例如,取消操作正是因为这个原因而放弃future。相比之下,在其他语言中,调用async fn会启动一个立即开始执行的future。
关于这一点的一个要点是,Rust中的async & await并不是本质上的并发构造。如果你的程序只使用async & await而没有并发原语,那么你程序中的代码将按照定义好的、静态已知的线性顺序执行。显然,大多数程序都会使用某种并发方式在事件循环上安排多个并发任务,但它们不必这样做。这意味着,即使在它们之间执行了一些你希望与某个更大的非本地事件集合异步执行的非阻塞IO(例如,在一个请求处理程序内部严格控制事件的顺序),你也可以轻松地本地保证某些事件的顺序。
这种特性赋予了Rust的async/await语法一种本地推理和低级控制的能力,这正是Rust成为它所是的原因。到达第一个await点并不会本质上违反这一点——你仍然会知道代码何时执行,只是它会在await前后两个不同的位置执行。然而,我认为其他语言开始立即执行的决定很大程度上源于它们的系统,在调用async fn时立即并发地安排一个任务(例如,这是我从Dart 2.0文档中获得的底层问题的印象)。

一些Dart 2.0的背景在这篇来自Munificent的讨论中有所涉及:

Hi, I'm on the Dart team. Dart's async/await was designed mainly by Erik Meijer, who also worked on async/await for C#. In C#, async/await is synchronous to the first await. For Dart, Erik and others felt that C#'s model was too confusing and instead specified that an async function always yields once before executing any code.

At the time, I and another on my team were tasked with being the guinea pigs to try out the new in-progress syntax and semantics in our package manager. Based on that experience, we felt async functions should run synchronously to the first await. Our arguments were mostly:

  1. Always yielding once incurs a performance penalty for no good reason. In most cases, this doesn't matter, but in some it really does. Even in cases where you can live with it, it's a drag to bleed a little perf everywhere.

  2. Always yielding means certain patterns cannot be implemented using async/await. In particular, it's really common to have code like (pseudo-code here):

    getThingFromNetwork():
      if (downloadAlreadyInProgress):
        return cachedFuture
    
      cachedFuture = startDownload()
      return cachedFuture
    

    In other words, you have an async operation that you can call multiple times before it completes. Later calls use the same previously-created pending future. You want to ensure you don't start the operation multiple times. That means you need to synchronously check the cache before starting the operation.

    If async functions are async from the start, the above function can't use async/await.

We pleaded our case, but ultimately the language designers stuck with async-from-the-top. This was several years ago.

That turned out to be the wrong call. The performance cost is real enough that many users developed a mindset that "async functions are slow" and started avoiding using it even in cases where the perf hit was affordable. Worse, we see nasty concurrency bugs where people think they can do some synchronous work at the top of a function and are dismayed to discover they've created race conditions. Overall, it seems users do not naturally assume an async function yields before executing any code.

So, for Dart 2, we are now taking the very painful breaking change to change async functions to be synchronous to the first await and migrating all of our existing code through that transition. I'm glad we're making the change, but I really wish we'd done the right thing on day one.

I don't know if Rust's ownership and performance model place different constraints on you where being async from the top really is better, but from our experience, sync-to-the-first-await is clearly the better trade-off for Dart.

克拉默特回复(请注意,现在有些语法已经过时):

If you need code to execute immediately when a function is called rather than later on when the future is polled, you can write your function like this:

fn foo() -> impl Future<Item=Thing> {
    println!("prints immediately");
    async_block! {
        println!("prints when the future is first polled");
        await!(bar());
        await!(baz())
    }
}

代码示例

这些示例使用 Rust 1.39 中的异步支持和 futures crate 0.3.1。

C# 代码的文字转录

use futures; // 0.3.1

async fn long_running_operation(a: u8, b: u8) -> u8 {
    println!("long_running_operation");

    a + b
}

fn another_operation(c: u8, d: u8) -> u8 {
    println!("another_operation");

    c * d
}

async fn foo() -> u8 {
    println!("foo");

    let sum = long_running_operation(1, 2);

    another_operation(3, 4);

    sum.await
}

fn main() {
    let task = foo();

    futures::executor::block_on(async {
        let v = task.await;
        println!("Result: {}", v);
    });
}

如果您调用foo,Rust 中的事件顺序将是:
1. 返回实现了 Future<Output = u8> 的内容。
就这样,还没有进行“实际”的工作。 如果您获取foo的结果并将其驱动至完成状态(通过轮询它,在这种情况下为futures :: executor :: block_on),那么下一步是:
2. 从调用long_running_operation返回实现了 Future<Output = u8> 的内容(尚未开始工作)。 3. another_operation作为同步操作运行。 4. .await语法使long_running_operation中的代码开始运行。foo future将继续返回“未准备就绪”,直到计算完成。
输出将是:
foo
another_operation
long_running_operation
Result: 3

请注意,这里没有线程池:所有操作都在单个线程上执行。

async

你也可以使用 async 块:
use futures::{future, FutureExt}; // 0.3.1

fn long_running_operation(a: u8, b: u8) -> u8 {
    println!("long_running_operation");

    a + b
}

fn another_operation(c: u8, d: u8) -> u8 {
    println!("another_operation");

    c * d
}

async fn foo() -> u8 {
    println!("foo");

    let sum = async { long_running_operation(1, 2) };
    let oth = async { another_operation(3, 4) };

    let both = future::join(sum, oth).map(|(sum, _)| sum);

    both.await
}

在这里,我们将同步代码包装在一个async块中,然后等待两个操作都完成后,该函数才会完成。

请注意,像这样包装同步代码对于实际上需要很长时间的任何事情来说都不是一个好主意;有关更多信息,请参见什么是最好的方法来封装future-rs中的阻塞I/O?

使用线程池

// Requires the `thread-pool` feature to be enabled 
use futures::{executor::ThreadPool, future, task::SpawnExt, FutureExt};

async fn foo(pool: &mut ThreadPool) -> u8 {
    println!("foo");

    let sum = pool
        .spawn_with_handle(async { long_running_operation(1, 2) })
        .unwrap();
    let oth = pool
        .spawn_with_handle(async { another_operation(3, 4) })
        .unwrap();

    let both = future::join(sum, oth).map(|(sum, _)| sum);

    both.await
}

3
抱歉,这仍然不太清楚。你有没有一个 Rust 代码示例可以执行与我写的 C# 代码相同的操作?我的意思是:同时使用 async/await 进行两个异步操作。 - Boiethios
@Boiethios 在单个异步函数中仍然可以生成多个“子”未来,并将它们join在一起。 - E net4
我认为开头的句子可以是:“你混淆了两个概念:并发和并行。” Async/Await 是一种语法,它使并发成为可能。例如,Python 生成器是并发的(生成器维护自己的堆栈,与调用者堆栈同时进行),但不会并行运行。并行需要并发,但并发在没有并行的情况下也很有用。 - Matthieu M.
你的第一个示例的函数体比必要的复杂得多。在稳定的 Rust 中,你可以简单地使用 poll_fn(|| a + b) 就完成了。在我看来,async/await 的主要优点是你可以跨越 yield 点进行借用,而这目前是不可能的。 - Sven Marnach
@SvenMarnach我同意关于引用的问题,我一直在努力更新以展示这一点。然而,我认为它并不复杂,因为我展示的基本上就是poll_fn的实现方式(https://github.com/rust-lang-nursery/futures-rs/blob/d040a57521ca09d7eb162840bfb084d90d72db03/futures-util/src/future/poll_fn.rs#L39-L54),只是稍微增加了一些可重用性。 - Shepmaster
当然,这基本上就是poll_fn()的实现方式,但如果问题仅仅如此,我们真的不需要任何新的语法,因为poll_fn()将是一个完全足够的解决方案。因此,这个例子并没有真正展示出为什么在Rust中使用asyncawait是有用的。 - Sven Marnach

9
在Rust中,async/await的目的是提供并发工具集,与C#和其他语言一样。
在C#和JavaScript中,async方法会立即开始运行,并且无论你是否等待结果,它们都会被调度。在Python和Rust中,当您调用async方法时,除非您等待它,否则什么也不会发生(甚至不会被调度)。但无论哪种方式,编程风格基本相同。
能够生成另一个任务(与当前任务并发且独立运行)的能力由库提供:请参见async_std::task::spawntokio::task::spawn
至于为什么Rust的async不完全像C#,那么考虑两种语言之间的差异:
  • Rust不鼓励全局可变状态。在C#和JS中,每个async方法调用都会隐式添加到全局可变队列中。这是某些隐含上下文的副作用。无论好坏,这不是Rust的风格。

  • Rust不是框架。 C#提供默认事件循环是有道理的。它还提供了一个伟大的垃圾收集器!在其他语言中标配的许多东西都是Rust可选的库。


1
谢谢您的回答。它增加了我们对async/await的原理的新认识。 - Boiethios
提供生成另一个任务(与当前任务并发且独立运行)的能力是由库提供的。这就是对 OP 问题的答复。 - a3y3

6
考虑这段简单的伪JavaScript代码,它获取一些数据,处理它,基于前一步获取更多数据,总结结果,然后打印输出:
getData(url)
   .then(response -> parseObjects(response.data))
   .then(data -> findAll(data, 'foo'))
   .then(foos -> getWikipediaPagesFor(foos))
   .then(sumPages)
   .then(sum -> console.log("sum is: ", sum));

在使用 async/await 语法时,代码应该类似这样:
async {
    let response = await getData(url);
    let objects = parseObjects(response.data);
    let foos = findAll(objects, 'foo');
    let pages = await getWikipediaPagesFor(foos);
    let sum = sumPages(pages);
    console.log("sum is: ", sum);
}

它引入了许多一次性变量,并且可以说比使用 Promises 的原始版本更糟糕。那么为什么要这样做呢?
考虑下面的更改,其中需要稍后在计算中使用变量 response 和 objects:
async {
    let response = await getData(url);
    let objects = parseObjects(response.data);
    let foos = findAll(objects, 'foo');
    let pages = await getWikipediaPagesFor(foos);
    let sum = sumPages(pages, objects.length);
    console.log("sum is: ", sum, " and status was: ", response.status);
}

请尝试使用Promise重写以下内容:


getData(url)
   .then(response -> Promise.resolve(parseObjects(response.data))
       .then(objects -> Promise.resolve(findAll(objects, 'foo'))
           .then(foos -> getWikipediaPagesFor(foos))
           .then(pages -> sumPages(pages, objects.length)))
       .then(sum -> console.log("sum is: ", sum, " and status was: ", response.status)));

每次需要引用先前的结果时,您需要将整个结构嵌套一个级别。这很快就会变得非常难以阅读和维护,但是使用async/await版本不会遇到这个问题。

在 Rust 中编写一些“累积”代码时,必须建立元组并从中进行选择,当函数变得越来越长时,这确实会变得非常烦人。 - Shepmaster

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接