异步 Rust 可以很愉快

文摘   2024-09-12 08:30   上海  

异步 Rust 很强大。但使用起来也可能很痛苦(学习起来也是)。如果你用过异步 Rust,你几乎肯定遇到过需要给函数添加 Send + Sync + 'static 约束,用 ArcMutex 包装变量,到处克隆这些 Arc,当你忘记 Arc::clone 时不可避免地遇到"future 不是 Send"错误。

然而,如果我们能够不使用 Send + Sync + 'static,异步 Rust 可以变得很愉快。怎么做到呢?通过结构化并发和每核一线程的异步运行时的组合。

无需 'static 的异步

为什么 future 需要 'static 呢?答案在于 Rust 中 future 通常的运行方式;具体来说,是通过 spawn 函数。这是  tokio::spawn[1]  的签名(注意那些 'static 约束):

pub fn spawn<T>(future: T) -> JoinHandle<T::Output>
where
    T: Future + Send + 'static,
    T::Output: Send + 'static,

spawn 需要 'static 约束,因为我们是在_后台_调度运行该 future。future 的生命周期超出了调用它的函数作用域,所以借用检查器无法静态地确定它包含的值何时可以被清理。'static 告诉借用检查器,就它而言,这些值将永远存在。

'static 约束是我们需要引用计数指针如 Arc 的原因。我们用运行时检查替代了编译时决定何时可以丢弃值。

我们今天在编写异步 Rust 时感受到的一些痛苦来自于我们绕过了 Rust 的一个核心部分(使用生命周期和 drop 检查器进行自动清理),违背了语言的自然"纹理"。这与 unsafe 不同,但使用 'static 我们关闭了语言的一个核心部分 -- 结果是痛苦的。

我们能否在不需要 'static 约束的情况下运行 future?可以!像 tokio 这样的异步运行时都带有  Runtime::block_on[2]  方法。这是 tokio 中该函数的签名(注意没有 'static 约束):

pub fn block_on<F: Future>(&self, future: F) -> F::Output

好吧,但那只运行单个 future,对吧?异步的全部意义在于同时处理多个任务。没错。这就是我们要稍微探讨一下结构化并发这个可以说被低估的概念的地方。

结构化并发

结构化并发[3] 是一个极其简单但影响深远的想法。简单来说,每个 future 都应该在一个作用域内创建,只有当它包含的每个 future 都完成时,该作用域才算完成。相比之下,非结构化并发指的是能够在后台生成任务,使其执行与创建它们的上下文脱节。

在  Tree-Structured Concurrency[4]  中,Yoshua Wuyts 这样描述它:

结构化并发是程序的一个属性。它不仅仅是任何结构,无论内部有多少并发,程序的结构保证是一棵树。一个很好的思考方式是,如果你能把程序的实时调用图绘制成一系列关系,它会整齐地形成一棵树。没有循环。没有悬空节点。只有一棵树。

感谢 Yoshua Wuyts 提供这张图。

正如出色而富有挑战性的文章  Notes on structured concurrency, or: Go statement considered harmful[5]  (我建议完整阅读)所概述的,结构化并发的一些主要好处是:

  • 保持函数抽象作为黑盒 - 当一个函数完成时,你知道它已经完成了。相比之下,如果函数能够生成后台任务,你需要实际阅读它们的源代码(打破黑盒抽象)才能弄清楚它们退出时是否真的_完成_了。
  • 自动传播错误 - 子任务中创建的错误自然地通过作用域向上传播。当任务在后台生成时,错误默认会被静默吞噬,除非你手动添加错误处理。
  • 启用自动资源清理 - 当一个作用域完成时,其中生成的任务使用的所有资源(如变量、文件或数据库连接)都可以自动清理。相比之下,当任务在后台运行时,何时可以清理资源就变得不那么明显了。

最后一点是我在思考异步 Rust 时触发_啊哈!_时刻的原因。**Rust 的生命周期和 drop 检查器都是关于自动资源清理的。**编译器静态分析程序以确定何时可以丢弃值。

非结构化并发使编译器无法为我们自动清理。我们需要 'static 来绕过正常的生命周期行为和 drop 检查器,然后我们需要引用计数指针来在运行时实现清理。相反,如果 future 绑定到一个作用域,它们的生命周期也自然绑定到该作用域的生命周期,借用检查器可以正确确定何时应该丢弃资源。

Rust 中的结构化并发

Rust 中_非结构化_并发的主要原语是 tokio::spawn(或其他运行时的 spawn 函数) -- 这正是我们想要避免使用的。

在 Rust 中有许多不同的方式使用结构化并发。我认为我们可以将它们分为两类:动态和静态。在"动态"或堆分配风格中,我们可以生成在编译时不知道确切数量的 future。在"静态"或栈分配风格中,我们必须在编译时知道 future 的确切数量 -- 因此我们期望获得更好的性能。(感谢 matklad 指出这两种不同的风格,基于他与 withoutboats 的对话。)

动态结构化并发

动态结构化并发非常适合我们无法在编译时知道 future 确切数量的情况。例如,当我们有处理传入连接的 future 时。

我所知道的提供动态结构化并发功能的 crate 有  moro[6]futures-concurrency[7]  (特别是  FutureGroup[8] )、 async_nursery[9]  和  FuturesUnordered[10]  (moro 在底层使用它)。

moro 中,你创建一个异步 Scope 并在该作用域中生成 future:

use moro::Scope;

async fn example() {
    let scope = Scope::new();

    for i in 0..10 {
        scope.spawn(async move {
            println!("Task {}", i);
        });
    }

    scope.wait().await;
}

上面的例子并没有直接说明能够在没有 'static 生命周期的情况下工作的能力,但下面的例子展示了如何使用 moro 生成可以访问超出作用域生命周期的值以及未包装在 Arc 中的值的 future。

use moro::Scope;

async fn example(data: &[u8]) {
    let mut result = Vec::new();
    let scope = Scope::new();

    for chunk in data.chunks(1024) {
        scope.spawn(async {
            // 这里我们可以捕获对 `chunk` 的引用,
            // 即使它不是 'static
            let processed = process_chunk(chunk);
            result.push(processed);
        });
    }

    scope.wait().await;
    println!("Result: {:?}", result);
}

通过在作用域内生成 future,我们将这些 future 的生命周期与作用域绑定,因此不需要 'static 约束。结果,我们可以使用异步而无需引用计数指针,我们可以避免所有那些 Arc::clone!

静态结构化并发

Rust future 的独特设计也使静态结构化并发成为可能。withoutboats 在描述 "任务内并发"[11] 时谈到了这一点:

你可以选择各种不同类型的 future 并等待其中任何一个首先完成,并且从单个任务内部,无需额外分配,这是异步 Rust 相比非异步 Rust 的独特属性和最强大的特性之一。

我所知道的一些主要静态结构化并发原语包括  futures::join[12]futures::select[13] ,以及  futures-concurrency[14]  中实现的其他原语。

例如,如果你正在对多个外部服务进行异步调用,你可以这样写:

use futures::join;

async fn fetch_data() -> Result<Vec<String>, Error> {
    let (users, posts, comments) = join!(
        fetch_users(),
        fetch_posts(),
        fetch_comments()
    );

    Ok(vec![users?, posts?, comments?])
}

这种方法不需要任何堆分配,并且在编译时知道 future 数量的任何情况下都会更高效。

动态和静态结构化并发方法在性能和理想用例方面会有所不同,但两者都使我们能够在没有 'static 生命周期的情况下使用 future。结构化并发使资源能够在异步作用域或合并的 future 完成后由 drop 检查器自动清理。这也意味着错误自然地冒泡上升,我们可以保持函数黑盒抽象,因为我们知道当一个异步函数完成时,它真的_完成_了。

无需 Send + Sync 的异步

现在我们已经看到可以使用结构化并发避免 'static 生命周期,让我们把注意力转向 SendSync

Send 意味着一个值可以安全地在线程之间移动。大多数类型自动实现 Send,除了引用、Rc 和包含这些的任何值。(这就是为什么如果你捕获一个引用,而不是将一个拥有的值移入 async move 块,你会遇到"future 不是 Send"错误。) Sync 是一个更严格的约束,意味着一个类型不仅可以在线程之间发送,而且可以安全地~从多个线程修改~在多个线程之间共享。~MutexRwLock 提供对那些不能安全地从多个线程并行修改的值的 Sync 访问。~

更正: Sync 是关于在线程之间共享值,不一定是关于修改它们。future 需要 Sync 是非常罕见的,但任何在 future 之间共享的状态都需要 SyncMutexRwLock 提供对线程间共享值的可变访问。

SendSync 与异步 Rust 有什么关系?实际上,它们只是因为 tokio(最流行的异步运行时)是一个_多线程、工作窃取_运行时才需要。如果你调用  tokio::Runtime::block_on[1] ,你不需要 future 是 Send,但一旦你开始 spawn future,你就需要。这是因为这些任务可能随时在线程之间移动。(换句话说,虽然 并发不是并行[15] ,但今天大多数异步 Rust 默认混合了两者。)

Send 约束是 对异步 Rust 最大抱怨之一[16] 的来源。这引发了一个问题:我们(总是)需要多线程、工作窃取的运行时吗?

每核一线程

工作窃取运行时的主要替代方案通常被称为"每核一线程"。(在  Local Async Executors and Why They Should be the Default[17]  中,Maciej Hirsz 认为这种模式应该是做异步 Rust 的默认方式。)与其有多个线程在空闲时自动从彼此那里窃取工作,每核一线程架构涉及将工作绑定到特定线程。

在 Rust 中,一些适合每核一线程架构的替代运行时是 DataDog 的  glommio[18]  和 ByteDance 的  monoio[19] ,以及实验性的  tokio-uring[20] 。这三个都使用  io_uring[21]  实现高性能,这是一个 Linux 内核 API,通过批处理系统调用和避免用户空间和内核空间之间的跳转来实现高吞吐量。不过,io_uring 要求单个线程拥有给予该特性名称的环形缓冲区,这适合与单线程异步运行时一起使用。除了使用 io_uring 的运行时外,像  Embassy[22]  这样的嵌入式运行时也是单线程的。

为什么单线程/每核一线程/无共享很重要?所有这些都假设 future 将由创建它们的线程处理,这反过来消除了对 Send 约束的需求。没有 Send 约束,我们可以编写异步 Rust 而无需 MutexRwLock,也无需不断克隆引用计数指针。(我们甚至可以避免使用 move 闭包和 async move 块,因为非 Send future 不需要拥有它们捕获的值。)

向线程每核心的架构转变不仅可以简化开发者体验,还可以用来实现极高的性能。一个值得注意的例子是 TigerBeetle[23] (声明:我曾与他们签约几个月)。它是一个为商业交易而设计的高性能数据库,虽然不是用Rust编写的,但它在设计上是单线程的 - 并且使用了io_uring。对于TigerBeetle的用例来说,单线程设计实际上提高了性能,因为它避免了交易中行锁的需要,这可能会使 热门账户[24] 的性能降至停滞。并非所有用例都会从单线程设计中获得性能提升,但论文 Scalability! But at what COST?[25] 表明,对于许多算法来说,单线程实现的性能可能比人们通常认为的并行实现更好。

withoutboats认为,将"线程每核心"架构称为 "无共享"[26] 架构会更准确,因为像Tokio这样的运行时确实为每个核心创建了一个线程,然后在这些线程之间共享工作。这是对术语的公平批评,我认为值得考虑从无共享到工作窃取的一个光谱。

无共享意味着工作线程之间不共享任何资源、文件、数据库连接、状态和任务。在光谱的另一端,工作窃取意味着任何资源都可以在任何线程之间共享 - 而且重要的是,这些资源可能会在任何时候在线程之间移动。

在工作窃取和无共享之间可能存在一个尚未充分探索的中间地带。

让我们想象一个多线程系统,每个线程都运行自己的异步运行时,如glommio或Tokio的 当前线程运行时[27] 。我们可能希望线程共享一些资源,比如数据库连接池或传入连接队列。我们能否在不使所有东西都Send的情况下,在线程之间共享这种状态?是的!

微妙的区别在于,在线程之间共享的任何资源肯定需要是Send的(如果是可变的,还需要是Sync的),但我们的futures本身不一定需要是Send的。如果每个Future都固定在单个线程上,我们仍然可以在不需要Send + Sync + 'static的情况下操作 - 同时在线程之间共享一些东西。

负载均衡的粒度

我们真正要讨论的是我们希望在线程之间共享或负载均衡的工作粒度。

工作窃取使得在future的每个await点进行负载均衡成为可能。

相比之下,我们可以在每个传入连接的级别进行负载均衡,而不需要我们的futures是Send的。一旦连接被分配给一个线程,它就会留在那里。如果该连接需要比其他连接更多的工作,我们应该能够将后续的传入连接分配给其他线程,但我们不会移动已经开始的任务。

负载均衡的正确粒度是什么?老实说,我不确定。

然而,对于像Web服务器这样的东西,我们可能已经在运行多个实例并在它们之间进行负载均衡(出于 除规模以外的原因[28] )。我们是否需要每个实例都是多线程的,并在内部进行负载均衡,或者在每个await点进行负载均衡?

为工作窃取辩护,withoutboats 写道[25] :

在实际系统中出现的一个问题是,不同的任务最终需要不同数量的工作。例如,一个HTTP请求可能需要比另一个HTTP请求多得多的工作来服务。因此,即使你试图在不同的线程之间预先平衡工作,由于任务之间不可预测的差异,它们最终可能会执行不同数量的工作。
在最大负载下,这意味着一些线程将被安排比它们能执行的更多的工作,而其他线程则会闲置。这个问题的程度取决于不同任务执行的工作量差异的程度。

这暗示了一个我们似乎需要更多数据的问题:在实践中或对特定系统而言,不同任务执行的工作量在多大程度上有所不同?请记住,这里的工作不包括仅仅等待数据库事务完成之类的时间。

我猜测,对于大多数处理HTTP请求或RPC命令的API服务器来说,每个请求所需的CPU时间相对相似。而且,我猜测每个请求所做的工作量是所有请求产生的工作量的一小部分。如果是这样的话,通过在线程之间分配请求或任务来预先平衡工作似乎会导致一个相对平衡的系统。(不过,Websockets或其他长期连接可能是另一回事,因为你在最初接收连接时不知道连接会持续多长时间。)

解释Tokio工作窃取调度器的 Tokio博客文章[29] 实际上说:

关于工作窃取用例要记住的一个关键点是,在负载下,队列上几乎没有争用,因为每个处理器只访问自己的队列。

如果是这样的话,这就会引发一个问题:在负载下的服务器中,工作实际上被窃取的频率有多高。

在 Tasks are the wrong abstraction[30] 中,Yoshua Wuyts写道:

工作窃取的前提是,它提供的性能增益超过了我们要求所有futures都是Send所带来的性能损失。 因为使futures成为Send不仅给语言带来了一定程度的复杂性,它还带来了固有的性能损失,因为它需要同步。你知道为什么你不能在async/.await中使用Rc - 这是工作窃取设计的直接产物。

工作窃取和线程每核心的基准测试

我不是基准测试专家 - 当然,基准测试总是需要声明,实际性能高度依赖于特定的工作负载。但我想尝试比较Tokio和像Glommio这样的线程每核心运行时的性能。

以下是我的结果(代码在 这里[31] )。这是在一台 Dedicated CPU Linode[32] 机器上运行的简单HTTP服务器,服务小型("Hello, world!")GET请求,该机器有16个CPU和32 GB的RAM。负载生成在同一服务器上使用 wrk[33] 运行2分钟,8个线程保持800个并发连接(每个线程100个)打开。

运行时 + HTTP框架 线程数 吞吐量(请求/秒) 50%延迟(毫秒) 99%延迟(毫秒) 最大延迟(毫秒)
Tokio + Hyper 1 92,543.69 8.40 16.6 26.16
Tokio工作窃取* + Hyper 8 597,472.31 1.29 2.93 52.97
Tokio轮询* + Hyper 8 588,788.43 1.30 2.84 66.26
Glommio + Hyper 1 92,700.52 8.41 10.55 46.96
Glommio + Hyper 8 678,234.99 1.13 4.12 40.49
NGINX 1 35,867.86 22.33 29.44 51.88
NGINX 8 187,840.20 4.12 10.31 29.82

  • "Tokio工作窃取"行显示了Tokio的正常多线程运行时。"Tokio轮询"将其与一个简单的包装器进行比较,该包装器使用一个线程接受传入的TCP连接,然后使用简单的轮询技术将它们传递给工作线程(比线程数少一个)。后一种方法仍然使用Tokio,但移除了工作窃取,因此可以与非Sendfutures一起工作。

你可以看到Glommio的吞吐量略高于Tokio(高13%),50%延迟略低(低14%),99%延迟更高(高40%)。我还包括了NGINX作为比较(Tokio和Glommio服务器都大大超过了它 - 这也意味着如果你把你的Rust服务器放在NGINX后面,所有这些额外的性能都是徒劳的)。

上面的基准测试使用了非常小且同质的任务,这意味着我们预计Tokio的工作窃取调度器不会显示其全部优势。

在下面的基准测试中,我修改了HTTP服务,使其等待0到10(包括10)个随机数量的futures,每个future同步睡眠10微秒(所以每个请求总共需要0到100微秒)。这里的想法是模拟请求处理程序等待额外的futures,每个future都需要一些CPU时间。

运行时 + HTTP框架 线程数 吞吐量(请求/秒) 50%延迟(毫秒) 99%延迟(毫秒) 最大延迟(毫秒)
Tokio + Hyper 1 2,909.87 266.71 515.79 566.40
Tokio工作窃取 + Hyper 8 21,986.46 34.67 86.81 148.90
Tokio轮询 + Hyper 8 19,329.83 40.16 81.97 835.27
Glommio + Hyper 1 2,873.93 278.00 295.52 309.80
Glommio + Hyper 8 21,674.20 36.33 43.03 62.34

在这个测试中,Tokio和Glommio的吞吐量和50%延迟相似。令人惊讶的是,Glommio在99%延迟(低51%)和最大延迟(低59%)方面实际上击败了Tokio。理论上,工作窃取的目的是减少具有不同工作量的任务的尾部延迟 - 所以可能有一些不同的基准测试会更好地近似这样的工作负载。

一些额外的注意事项:

  • 像Glommio这样的线程每核心运行时可能会在专注于零拷贝数据处理的不同HTTP框架下表现得更好。
  • io_uring可以在不同的轮询模式下操作。虽然 这篇论文[34] 主要关注磁盘I/O而不是网络I/O,但它表明轮询策略可能对性能产生重大影响。不幸的是,Glommio不允许你调整这个设置。

这篇博文的核心论点更多地集中在开发者体验而不是性能上,但我们想知道我们是否没有牺牲太多性能,或者理想情况下是否通过放弃工作窃取而获得了一些性能。基准测试很复杂,我会避免过度依赖它们。然而,我认为从这些测试中得出的结论是,至少对于这样的工作负载,似乎可以用Tokio和线程每核心运行时达到类似的性能。

没有Send + Sync + 'static的异步

现在我们已经讨论了如何在没有Send + Sync + 'static的情况下使用异步Rust,那么用它开发是什么样子的呢?

这里我们有一个(诚然是人为的)使用Tokio多线程运行时的异步代码示例。它包括从流中等待值,生成动态数量的后台任务,以及连接两个futures的结果。注意Context的每个部分是如何被包装在Arc中的,这样我们可以在将这些字段传递到async move块之前克隆它们(我们经常这样做)。

use std::sync::Arc;
use tokio::sync::Mutex;

struct Context {
    db: Arc<Mutex<Vec<String>>>,
    cache: Arc<Mutex<Vec<String>>>,
}

async fn process_stream(mut stream: impl Stream<Item = String> + Unpin, ctx: Arc<Context>) {
    while let Some(item) = stream.next().await {
        let db = ctx.db.clone();
        let cache = ctx.cache.clone();
        tokio::spawn(async move {
            let mut db = db.lock().await;
            db.push(item.clone());
            let mut cache = cache.lock().await;
            cache.push(item);
        });
    }
}

async fn do_work(ctx: Arc<Context>) -> String {
    let (result1, result2) = tokio::join!(
        async {
            let db = ctx.db.clone();
            let db = db.lock().await;
            db.len().to_string()
        },
        async {
            let cache = ctx.cache.clone();
            let cache = cache.lock().await;
            cache.len().to_string()
        }
    );
    format!("DB: {}, Cache: {}", result1, result2)
}

现在这里是使用Tokio当前线程运行时(模拟线程每核心)的等效代码。它使用来自 moro-local[35]async_scope宏,这是我对 moro[5] 的分支,可以在稳定的Rust上工作,并且是为非Sendfutures设计的。注意Context结构中的字段不需要被包装在Arc中,并且注意缺少async move块和我们在前一个版本中需要的所有clone

use moro_local::async_scope;

struct Context {
    db: Vec<String>,
    cache: Vec<String>,
}

async fn process_stream(mut stream: impl Stream<Item = String> + Unpin, ctx: &mut Context) {
    async_scope!(|s| {
        while let Some(item) = stream.next().await {
            s.spawn(async {
                ctx.db.push(item.clone());
                ctx.cache.push(item);
            });
        }
    });
}

async fn do_work(ctx: &Context) -> String {
    let (result1, result2) = tokio::join!(
        async { ctx.db.len().to_string() },
        async { ctx.cache.len().to_string() }
    );
    format!("DB: {}, Cache: {}", result1, result2)
}

显然,这段代码有点人为。然而,它旨在简洁地演示我在真实的异步Rust代码库中看到的多种模式。通过使用结构化并发和线程每核心运行时,我们可以使用普通引用,生命周期按预期工作,我们可以避免需要将所有东西包装在Arc中并在将值传递到async块之前克隆所有东西。

我们是否已经准备好线程每核心的Web了?

很遗憾不行。使用  hyper[36]  是可以不需要 Send + 'static 约束的(参见 单线程示例[37] )。然而,~所有~大多数流行的高级 web 框架,如  axum[38] 、 ~[39]poem[40]  和  tide[41]  都要求处理程序是 Send + 'static 的。( xitca-web[42]  是一个较新的 web 框架,专注于零拷贝请求处理,适合每核一线程架构和使用非 Send 的 Future。值得关注。)

更正: actix-web[38]  和  ntex[43]  支持返回非 Send + 'static future 的处理程序。

这种情况与 Niko Matsakis 在一系列 博客[44]  文章[45] 中描述的  Async trait send bounds[46]  密切相关。由于用户无法指定 trait 中的异步方法是否返回实现 Send 的 future,库的作者默认规定它们必须是 Send + 'static 的。如果 web 框架能同时支持 Send 和非 Send 的 HTTP 处理程序就好了,但目前这需要大量的代码重复。

所以,如果你愿意直接在异步运行时或像 hyper 这样的低级 HTTP 库之上编写异步代码,你现在就可以不用 Send + Sync + 'static 约束来编写。如果不愿意,你可能需要等待 web 框架支持非 Send + 'static 的 future。

作为一个社区,我认为我们应该投入更多精力来编写支持每核一线程运行时和非 Send + 'static future 的库和框架。目前异步 Rust 中所有东西都必须是 Send + 'static 的范式对新用户来说很难,对有经验的开发人员来说很繁琐,而且我认为有合理的理由质疑工作窃取一定意味着更好的性能这一假设。

结论

我最初打算编写一个宏,在将 Arc 化的值移入 async move 块和 move 闭包之前自动克隆和遮蔽它们(类似于  enclose[47] ,但它会自动确定要克隆哪些变量)。然而,我偶然看到了 结构化并发[4] 的博文,这让我开始思考使用 'static 生命周期和不断克隆 Arc 的烦恼是否实际上表明我们正在做一些"错误"的事情 - 或者至少是次优的。

Rust 社区长期以来一直在讨论"作用域任务"。两篇特别好的文章阐述了问题空间,分别是 withoutboats 的  The Scoped Task trilemma[48]  和 Tyler Mandry 的  A formulation for scoped tasks in Rust[49] 。withoutboats 写道:

任何健全的 API 最多只能提供以下三个理想属性中的两个:

  1. 并发性: 子任务与父任务并发进行。
  2. 可并行化: 子任务可以与父任务并行进行。
  3. 借用: 子任务可以在不同步的情况下从父任务借用数据。

这篇博文主张更多地关注三难困境的"借用 + 并发"解决方案,或者如 Tyler Mandry 所说的"放松并行性"。考虑到每核一线程运行时和 io_uring 的出现,值得重新审视我们是否真的需要在每个 await 点都能在线程之间移动我们的 future。

我认为 Parity 开发者  tomaka[50]  说得很好:

把所有东西都放在 Arc 中本身并没有错。问题是...这几乎让人感觉不像在写 Rust 了。
同一种鼓励你通过引用传递数据的语言,例如使用 &str 而不是 String,现在也鼓励你将代码拆分成小任务,这些任务必须克隆它们相互发送的每一块数据。同一种提供复杂生命周期系统以避免需要垃圾收集器的语言,现在也鼓励你把所有东西都放在 Arc 中。
我不认为这真的是一个问题,但我确实感觉现在一种语言中有两种语言:一种是用于纯 CPU 操作的低级语言,使用引用并精确跟踪所有内容的所有权;另一种是用于 I/O 的高级语言,通过克隆数据或将其放入 Arc 中来解决每个问题。

如果我们可以不需要 Send'static 要求,我们就可以编写感觉更像同步 Rust 的异步 Rust。我们可以通过使用每核一线程运行时来消除 Send 约束,我们可以通过用结构化并发原语(如  moro::async_scope[51]  或  futures-concurrency[52]  中的其他原语)替换对 spawn 的调用来避免 'static

今天的异步 Rust 比前异步/await 时代手动链接 future 的写法要愉快得多。消除 Send + 'static 约束将消除新开发人员的一个主要障碍,也会减少更有经验的 Rustacean 反复遇到的小麻烦。

未来工作

如果这篇文章中探讨的想法引起了你的共鸣,以下是一些可能值得深入研究的内容:

  • 人体工程学引用计数[53] 提案已被纳入  Rust 项目 2024 年下半年的目标[54] ,这意味着在未来 6 个月内应该会在 Nightly 中添加一些内容,以消除在将 Arc 移入闭包和 future 时显式克隆的需求。这将减轻使用 Send + 'static future 的一些痛苦,所以值得关注这项工作。

  • 我们需要更多关于工作窃取在实践中和不同工作负载下性能的基准测试和真实世界数据。

  • 我们还需要更多地探索使用 io_uring 的每核一线程运行时以及结构化并发和零拷贝设计的性能。

  • 如果像 web 框架这样的高级库不强制执行 Send + 'static 约束(除非它们直接调用 spawn)就好了。理想情况下,Send + 'static 约束应该来自服务器的运行方式(例如使用 tokio::spawn),而不是由框架自己的 trait 强制执行。

  • glommio 这样的每核一线程运行时可能希望更多地鼓励使用结构化并发而不是 spawn 风格的 API,这样用户就可以不需要 'static 生命周期。

  • 在使用每核一线程运行时时,可能会有一个有趣的选项,可以显式运行后台任务,使任何工作线程都可以拾取它。这可能是在共享无和工作窃取之间的另一个有趣的中间地带,用户可以有意将 Send + 'static 任务生成到全局工作队列中,而不是默认让所有 future 都是 Send + 'static 的。

  • What If We Pretended That a Task = Thread?[55]  和  Non-Send Futures When?[56]  这两篇文章探讨了一个有趣的问题,即是否可以通过将 Send 重新定义为指"执行上下文"而不是特定线程来消除对 Send 约束的需求(这样执行上下文就会随任务移动)。这将是很棒的,但我是从今天语言现状的用户角度而不是设计者角度来看待这个话题的。我也猜测这种改变现在做起来太困难了,很遗憾。

  • 异步闭包[57] (目前在 nightly 上可用)也将使异步 Rust 的某些方面变得更容易。异步闭包可以从其捕获中借用,我相信这个特性将消除在约束中指定它返回的 future 是否是 Send + 'static 的需求。


感谢 Aleksey Kladov、Benno van den Berg、Georgios Konstantopoulos、Jesse Hertz、Peter Malmgren、Senyo Simpson、Simon Rassmussen 和 Will Manning 对这篇博文草稿的反馈。⁩(当然这并不意味着他们认可或一定同意这里提出的论点。)


在  Reddit[58] 、 Lobste.rs[59]  或  Hacker News[60]  上讨论。

#rust[61] undefined

参考链接

1. tokio::spawn: https://docs.rs/tokio/latest/tokio/task/fn.spawn.html
2. Runtime::block_on: https://docs.rs/tokio/latest/tokio/runtime/struct.Runtime.html#method.block_on
3. 结构化并发: https://en.wikipedia.org/wiki/Structured_concurrency
4. Tree-Structured Concurrency: https://blog.yoshuawuyts.com/tree-structured-concurrency/
5. Notes on structured concurrency, or: Go statement considered harmful: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
6. moro: https://crates.io/crates/moro
7. futures-concurrency: https://crates.io/crates/futures-concurrency
8. FutureGroup: https://docs.rs/futures-concurrency/latest/futures_concurrency/future/struct.FutureGroup.html
9. async_nursery: https://crates.io/crates/async_nursery
10. FuturesUnordered: https://docs.rs/futures/latest/futures/stream/struct.FuturesUnordered.html
11. "任务内并发": https://without.boats/blog/let-futures-be-futures/#intra-task-concurrency
12. futures::join: https://docs.rs/futures/latest/futures/macro.join.html
13. futures::select: https://docs.rs/futures/latest/futures/macro.select.html
14. futures-concurrency: https://docs.rs/futures-concurrency/
15. 并发不是并行: https://go.dev/blog/waza-talk
16. 对异步 Rust 最大抱怨之一: https://dioxus.notion.site/Dioxus-Labs-High-level-Rust-5fe1f1c9c8334815ad488410d948f05e#5800e2a60cfa4dc2acc08f8c79d1dc39
17. Local Async Executors and Why They Should be the Default: https://maciej.codes/2022-06-09-local-async.html
18. glommio: https://crates.io/crates/glommio
19. monoio: https://crates.io/crates/monoio
20. tokio-uring: https://crates.io/crates/tokio-uring
21. io_uring: https://en.wikipedia.org/wiki/Io_uring
22. Embassy: https://embassy.dev/
23. TigerBeetle: https://tigerbeetle.com/
24. 热门账户: https://docs.tigerbeetle.com/about/oltp#business-transactions-dont-shard-well
25. Scalability! But at what COST?: https://www.usenix.org/system/files/conference/hotos15/hotos15-paper-mcsherry.pdf
26. "无共享": https://without.boats/blog/thread-per-core/
27. 当前线程运行时: https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.new_current_thread
28. 除规模以外的原因: https://brooker.co.za/blog/2024/06/04/scale.html
29. Tokio博客文章: https://tokio.rs/blog/2019-10-scheduler
30. Tasks are the wrong abstraction: https://blog.yoshuawuyts.com/tasks-are-the-wrong-abstraction/
31. 这里: https://github.com/emschwartz/rust-runtime-benchmark
32. Dedicated CPU Linode: https://www.linode.com/products/dedicated-cpu/
33. wrk: https://github.com/wg/wrk
34. 这篇论文: https://atlarge-research.com/pdfs/2022-systor-apis.pdf
35. moro-local: https://crates.io/crates/moro-local
36. hyper: https://crates.io/crates/hyper
37. 单线程示例: https://github.com/hyperium/hyper/blob/master/examples/single_threaded.rs
38. axum: https://crates.io/crates/axum
39. ~: https://crates.io/crates/actix-web
40. poem: https://crates.io/crates/poem
41. tide: https://crates.io/crates/tide
42. xitca-web: https://crates.io/crates/xitca-web
43. ntex: https://crates.io/crates/ntex
44. 博客: https://smallcultfollowing.com/babysteps/blog/2023/02/13/return-type-notation-send-bounds-part-2/
45. 文章: https://smallcultfollowing.com/babysteps/blog/2023/03/03/trait-transformers-send-bounds-part-3/
46. Async trait send bounds: https://smallcultfollowing.com/babysteps/blog/2023/02/01/async-trait-send-bounds-part-1-intro/
47. enclose: https://crates.io/crates/enclose
48. The Scoped Task trilemma: https://without.boats/blog/the-scoped-task-trilemma/
49. A formulation for scoped tasks in Rust: https://tmandry.gitlab.io/blog/posts/2023-03-01-scoped-tasks/
50. tomaka: https://tomaka.medium.com/a-look-back-at-asynchronous-rust-d54d63934a1c
51. moro::async_scope: https://docs.rs/moro/latest/moro/macro.async_scope.html
52. futures-concurrency: https://docs.rs/futures-concurrency
53. 人体工程学引用计数: https://rust-lang.github.io/rust-project-goals/2024h2/ergonomic-rc.html
54. Rust 项目 2024 年下半年的目标: https://blog.rust-lang.org/2024/08/12/Project-goals.html
55. What If We Pretended That a Task = Thread?: https://blaz.is/blog/post/lets-pretend-that-task-equals-thread/
56. Non-Send Futures When?: https://matklad.github.io/2023/12/10/nsfw.html
57. 异步闭包: https://rust-lang.github.io/rfcs/3668-async-closures.html
58. Reddit: https://www.reddit.com/r/rust/comments/1f920z8/async_rust_can_be_a_pleasure_to_work_with_without/
59. Lobste.rs: https://lobste.rs/s/ojm6cn/async_rust_can_be_pleasure_work_with
60. Hacker News: https://news.ycombinator.com/item?id=41448289
61. #rust: https://emschwartz.me/blog/?q=rust

幻想发生器
图解技术本质
 最新文章