【译】异步迭代语义(一)

426 阅读6分钟

原文链接:https://blog.yoshuawuyts.com/async-iteration/

原文标题:ASYNC ITERATION SEMANTICS

公众号:Rust 碎碎念

在最近的一次语言团队会议[1]上,我们讨论了即将到来的Steeam[2] RFC。我们讨论了将其落实所需的步骤,以及之后想要采取的步骤。对于 stream,我们最终想要的其中一个东西就是:“异步迭代语法(async iteration syntax)”。就像for x in y适用于Iterator一样,我们想要适用于Stream的类似的东西。例如,Javascript 支持通过for..of循环的syncasync迭代:

// sync
for (let num of iter) {
  console.log(i);
}

// async
for await (let event of emitter) {
    console.log(event);
}

但是当讨论 Rust 中的异步迭代语法时,一个问题反复出现:

异步迭代默认应该是并行(parallel)的还是串行(sequential)的?

在本文中,我将讨论我所见到的并行迭代的挑战,并说明默认为串行迭代语义的理由。

但是在我们继续之前,按照惯例进行免责声明:我不是语言团队的一员。我不拥有任何关于 Rust 语言的直接决策权。我是 Async Foundations WG 的成员,但除了我自己,我不代表团队中的任何人。 本文仅来自我的个人观点。

问题空间(THE PROBLEM SPACE)

我们试图要回答的核心问题是:“异步迭代默认应该是并行还是串行?” 把这个问题回归到代码中,它就是某个假定将来的”异步迭代“ 语法应该编译成什么,以及提供哪种语义的问题:

// This code...
for event.await in channel {
    dbg!(event);
}

// ... should it lower into this? (sequential)
while let Some(event) = channel.next().await {
    dbg!(event);
}

// ... or should it lower into this? (parallel)
channel.into_par_stream().for_each(async |event| dbg!(event));

这里我们描述的”lowering“不是我们希望编译器输出的具体的代码。但是希望它能讲清楚要点。在”串行“情况下,我们希望异步迭代循环和现在的异步的while let Some()行为一致。在并行情况下,我们希望能够适用于类似这些parallel-stream[3]的语义。我听到的支持异步迭代语义的主要论点是,大多数情况下,异步迭代语义都是人们想要的。例如下面的代码:

use async_std::io;
use async_std::net::TcpListener;
use async_std::prelude::*;

let listener = TcpListener::bind("127.0.0.1:8080").await?;

for stream.await? in listener.incoming() {
    io::copy(&stream, &stream).await?;
}

在串行迭代语义下,这个循环一次最多处理一个 TCP 链接,无论系统可用资源情况如何。但是,在并行迭代语义下,它将能够访问所有的可用资源。其中的思想就是,并行迭代语义为这个问题提供了一个语言层面的解决方案。

控制流操作(CONTROL FLOW OPERATORS)

并行异步迭代的第一个问题是,breakcontinue控制流操作将会有不同的语义,或者需要完全禁用。考虑下面的代码:

use async_std::io;
use async_std::net::TcpListener;
use async_std::prelude::*;

let listener = TcpListener::bind("127.0.0.1:8080").await?;

// Let's pretend this is valid syntax..
for (i, stream?).await in listener.incoming().enumerate() {
    if i === 1000 {
        break;
    }
    io::copy(&stream, &stream).await?;
}

假定当我们执行break时,有几十个处于打开状态的并行请求。我们应该停止接收新请求。对于仍然处于打开状态的连接应该发生什么呢?

没有正确的答案。我们可能想丢弃他们。我们可能想让它们保持运行。更有可能的是,期望的行为可能取决于你正在做的事情。但是因为并行的缘故,语义总是不同于常规的循环。

在一个已经比较成熟的概念中添加新的语义是比较棘手的,而且在将现有的算法转换为异步的 Rust 时,可能会出现微妙的转换问题。最安全的选择是在这个上下文环境中将这些关键字全部禁用。

非异步 Rust 的一致性(CONSISTENCY WITH NON-ASYNC RUST)

为了让任务能够并行化,它们需要以某种方式在一个线程池上生成(spawn)。这意味着在语言层面实现并行迭代的一个关键步骤是把 executor 的概念作为语言的一部分。

C++ 正在将这个概念添加[4]到语言中(预计是在 C++23 中),所以 Rust 做同样的事情也并非不可想象的。事实上,Boats 之前已经写了关于添加一个#[gobal_exector]内容[5]

// The lowered code from the previous example...
channel.into_par_stream().for_each(async |event| dbg!(event));

// ... would in turn lower into this
while let Some(event) = channel.next() {
    task::spawn(async move {
        dbg!(event);
    });
}

正如你所见,我们正在对每个事件调用task::spawn。这个功能需要来自某个地方,并且因为异步迭代是一个语言层面的问题,这意味着这个功能需要成为语言的一部分。

我认为能够在语言层面有一种可以执行并行迭代的方式是个很好的想法。并且拥有一个带有默认实现的#[global_executor]钩子(hook)将会解决生态系统中现有的很多问题。

但是,历史证明,这需要很长时间才能改变。并且,我认为让异步迭代语法依赖于 executor 的落实(即使是 unstable)意味着这会需要更长的时间。

并行!!SEND STREAMS(PARALLELIZING !SEND STREAMS)

不是所有的 streams 都是Send(译注:指 Send 这个 trait),并且异步迭代应该也能够处理这个问题。我们不能像对异步块(async block)那样传播!Send;全局的 executor 也要支持产生!Send future。这并不是一个主要障碍,因为像 async-std 就提供了spawn_local函数。但是这是设计上需要考虑的一部分。

背压和并发控制(BACKPRESSURE & CONCURENCY CONTROL)

Node.js stream 的一些缺点是正确的,那就是背压(backpressure)。例如,如果向文件写入数据的速度比从文件读取数据的速度慢,如果写入者不可用,管道就不会继续读取数据。这确保了你的 RAM 不会被等待写入的字节填满。

const fs = require('fs');

const reader = fs.createReadStream('file.txt');
const compress = zlib.createGzip();
const writer = fs.createWriteStream('file.txt.gz');

reader.pipe(compress).pipe(writer);

在 Rust 中,串行异步迭代默认采用背压。对上面 Node.js 代码的一个翻译可能看起来像下面这样:

let reader = fs::open("file.txt").await?;
let writer = fs::open("file.txt.gz").await?;

for buf in GzipEncoder::new(reader) {
    writer.write(buf).await?;
}

async-compression's Stream[6] 类型已经像这样工作了。因为我们这里正在执行串行异步迭代,从 stream 中读取数据受限于我们可以写入数据的速度。但是反过来,如果没有新的能够读取的数据,这个 stream 也会暂停。

如果异步迭代语法默认是并行的,我们将会失去默认的背压。这意味着,如果没有指定限制,即使没有准备去处理数据的消费者,默认的异步迭代也将会保持读取数据。相应的,限制总是需要手动来设置:

for buf in GzipEncoder::new(reader).limit(5) {
    writer.write(buf).await?;
}

还有第三种方式解决背压问题,我们还没有讨论:默认设置某些限制。与其让异步迭代默认拥有无边界的并行,不如让我们来默认启用 1000 项。这个限制几乎总是错误的,我们应该在了解工作负载的情况下进行设置。

名称(name) 最大吞吐量(max throughput) 背压(backpressure)?
串行(sequential) 1 是(yes)
有边界(bounded) n 是(yes)
无边界(unbounded) 无限制(unlimited) 否(no)

本文首发于个人公众号: Rust碎碎念 。非授权禁止转载,欢迎扫码关注获取最新文章信息。

参考资料

[1]

语言团队会议: https://youtu.be/rIQHws2_lQg

[2]

Steeam: https://docs.rs/futures-core/0.3.5/futures_core/stream/trait.Stream.html

[3]

parallel-stream: https://docs.rs/parallel-stream/2.1.1/parallel_stream/

[4]

将这个概念添加: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0761r2.pdf

[5]

gobal_exector]`的[内容: https://without.boats/blog/global-executors/

[6]

async-compression's Stream: https://docs.rs/async-compression/0.3.5/async_compression/stream/struct.GzipEncoder.html#impl-Stream