跳转至

16 用之则行,舍之则藏:异步编程的场景和陷阱

你好,我是鸟窝。

前面三节课我们已经全面了解了Rust的异步编程的知识了,这一节课我想扩展一下,给你介绍异步运行时的使用场景和陷阱。

异步运行时的反对意见

就像Go的泛型提案一样,中途给编程语言增加一个巨大的特性总是会引起争议,Rust的异步运行时也不意外,也一直有反对的意见。这些反对意见主要集中在以下几个方面:

图片

  1. 复杂性与学习曲线
  • 概念繁多:异步编程引入了 Future、Runtime、Poll、Waker、Pin 等诸多概念,对于初学者来说学习曲线陡峭。理解这些概念以及它们之间的关系需要花费一定的时间和精力。
  • 代码复杂:即使使用了 async/await 语法糖,异步代码在某些情况下仍然可能变得复杂,尤其是处理复杂的错误传播、取消和超时等情况时。调试异步代码也比同步代码更具挑战性。
  • trait bound 复杂:异步函数和 trait 经常需要复杂的 trait bound,例如 SendSync'static 等,这使得代码阅读和编写变得更加困难。
  • 调试困难:异步代码的执行流程不像同步代码那样直观,这使得调试变得更加困难。堆栈跟踪通常不够清晰,难以定位问题。
  1. 运行时选择与碎片化
  • 运行时选择:Rust 社区存在多个异步运行时,例如 tokioasync-stdsmol 等,每个运行时都有其特点和适用场景。选择合适的运行时可能需要仔细评估和权衡,这增加了开发者的选择成本。
  • 生态碎片化:由于存在多个运行时,一些库可能只支持特定的运行时,这导致了生态系统的碎片化,使得跨运行时使用库变得困难。虽然有像 async-compat 这样的库来做兼容,但并非所有库都支持。
  1. 对 Send trait 的限制
  • Send 的要求: 许多异步操作需要 future 实现 Send trait,这意味着 future 必须能够在线程之间安全地传递。这限制了一些非 Send 类型的使用,例如包含裸指针或线程局部存储的类型。虽然可以使用 LocalExecutor 来处理非 Send 的 future,但它只能在单个线程内使用,限制了并发性。
  • Pinning 的复杂性: 为了处理自引用结构体的异步操作,Rust 引入了 Pinning 机制。Pinning 增加了代码的复杂性,并且容易出错。正确地使用 Pinning 需要对 Rust 的内存模型有深入的理解。
  1. 性能开销
  • 调度开销: 异步运行时需要进行任务调度,这会带来一定的性能开销。虽然通常情况下开销很小,但在某些对性能要求极高的场景下,这仍然是一个需要考虑的因素。
  • 栈空间: 异步函数的状态机可能会占用一定的栈空间,尤其是在深度递归的异步调用中。虽然 Rust 编译器会对状态机进行优化,但在某些情况下仍然可能导致栈溢出。
  1. 与同步代码的交互
  • 阻塞操作: 在异步代码中执行阻塞操作可能会导致整个执行器阻塞,影响性能。需要使用 block_in_place 或其他方法将阻塞操作移到单独的线程中执行。
  • 同步到异步的转换: 将现有的同步代码迁移到异步代码可能需要进行大量的重构,这增加了迁移的成本。

以下是我搜集到讨论比较热烈的关于Rust异步编程的持有负面意见的文章:

  • 今天的异步编程经历真糟糕:这篇文章讨论了作者在使用异步 Rust 时遇到的困难。作者建议将上游异步客户端包装在同步通道接口中,以使其更易于使用。这种方法虽然效率较低,但实现起来更简单。作者还讨论了如何批量处理工作和处理错误。
  • 无论如何都要避免使用异步 Rust:貌似文章无法访问了,可以搜索看评论评论2
  • 异步 Rust 是一门糟糕的语言:作者认为,Rust 的 async/await 模型难以使用,并且可能导致复杂的代码。
  • async fn 是一个错误吗?:它讨论了 async fn 和 impl Future 之间的区别。作者认为,async fn 在某些情况下可能存在问题,例如在处理生命周期或在 trait 定义中使用时。作者提出了一种替代方法,该方法将使用 impl Trait 和 async 块而不是 async fn。作者认为这种方法更易于阅读和理解。
  • 异步 Rust:新的十亿美元错误?:Rust 的 async/await 特性,虽然旨在提高效率,但由于生态系统的不完善和语言本身的处理方式,反而成为了一个“十亿美元的错误”,严重损害了开发者生产力,并导致了大量 bug。

也有人认为虽然异步编程有诸多问题,但是我们不应该抵制它,而是优化它,比如:

  • 让异步 Rust 变得可靠:本文讨论了 Async Rust 的可靠性。它讨论了 Async Rust 的挑战以及如何使其更可靠。作者认为,可靠性是 Rust 最重要的特性。他认为 Async Rust 应该与 Rust 的其他部分处于平等地位。他还讨论了 Async Rust 的挑战,例如取消、饥饿和分离执行。他提出了解决这些挑战的几种解决方案,例如更好的原语、更好的契约和结构化并发。他得出结论,可靠性是 Async Rust 最重要的目标。

所以当前的建议是谨慎地使用异步编程,如果确信异步编程有助于提升代码的效率,那么就谨慎地使用它,避免踩坑。

那么,异步编程使用在哪些场景呢?又有哪些坑呢?

异步运行时的使用场景

Rust 的异步运行时在各种场景中都非常有用,尤其是在需要高并发和高性能的 I/O 密集型应用中。以下是一些 Rust 异步运行时常见的应用场景:

网络编程

Web 服务器和客户端: 处理大量的并发连接是 Web 服务器的关键需求。异步运行时允许服务器高效地处理成千上万的并发请求,而无需为每个连接创建一个单独的线程。像 Tokio 和 async-std 这样的运行时提供了构建高性能 Web 应用所需的工具,例如 TCP/UDP 套接字、HTTP 客户端和服务器等。

即时通讯应用: 聊天服务器、在线游戏服务器等需要处理大量的并发连接和低延迟的消息传递。异步运行时可以有效地管理这些连接,并提供高效的消息路由和处理。

代理服务器和负载均衡器: 这些应用需要处理大量的并发连接和数据转发。异步运行时可以提供高性能的连接管理和数据传输。

I/O 密集型应用

数据库驱动和客户端: 数据库操作通常涉及大量的 I/O 等待。异步运行时可以使应用程序在等待数据库响应时继续执行其他任务,从而提高整体性能。

文件 I/O 操作: 处理大型文件或需要同时访问多个文件的应用可以利用异步 I/O 提高效率。例如,图像处理、日志分析等。

微服务架构: 在微服务架构中,服务之间的通信通常通过网络进行。异步运行时可以提高服务之间的通信效率和并发处理能力。

并发和并行处理

并行计算: 虽然 Rust 的 std::thread 模块可以用于并行计算,但在 I/O 密集型场景下,异步运行时通常更有效。例如,可以利用异步任务并行地下载多个文件或处理多个网络请求。

任务队列和后台处理: 异步运行时可以用于实现任务队列,将耗时的任务放入队列中异步执行,从而避免阻塞主线程。

对于 CPU 密集型应用,使用异步运行时通常不是最佳选择。虽然 Rust 的异步运行时在 I/O 密集型应用中表现出色,但在 CPU 密集型场景下,它可能并不能带来显著的性能提升,甚至可能引入额外的开销。

CPU 密集型应用为何不建议使用异步运行时?

异步运行时的主要优势在于高效地处理 I/O 等待。 当程序需要等待 I/O 操作(例如网络请求、文件读写)完成时,异步运行时可以让 CPU 执行其他任务,从而提高 CPU 的利用率。然而,在 CPU 密集型应用中,CPU 始终处于忙碌状态,没有空闲时间去执行其他任务,因此异步运行时的优势无法发挥。

异步编程引入了额外的开销。 异步编程需要维护任务的状态、上下文切换等,这些操作都会带来一定的开销。在 CPU 密集型应用中,这些开销可能会抵消甚至超过异步编程带来的潜在收益。

线程仍然是 CPU 密集型任务的首选。 对于 CPU 密集型任务,使用多线程通常是更有效的选择。通过将任务分解成多个子任务,并在多个 CPU 核心上并行执行,可以充分利用多核处理器的性能。Rust 的 std::thread 模块提供了创建和管理线程的功能,可以方便地实现并行计算。可以考虑线程池优化资源占用。

什么时候可以考虑在 CPU 密集型应用中使用异步运行时?

虽然通常不建议在纯 CPU 密集型应用中使用异步运行时,但如果 CPU 密集型任务中包含一些 I/O 操作,例如读取配置文件、写入日志等,可以考虑使用异步运行时来提高这些 I/O 操作的效率。但需要注意的是,I/O 操作的占比不能太大,否则仍然可能得不偿失。

此外,如果 CPU 密集型任务需要与其他异步任务集成,例如网络请求处理,可以考虑使用异步运行时来统一编程模型,方便代码的组织和维护。

虽然 Tokio 主要针对 I/O 密集型任务进行了优化,但它也提供了机制来处理 CPU 密集型任务,以避免阻塞 Tokio 的运行时并影响其他任务的执行。以下是在 Tokio 中执行 CPU 密集型任务的几种方法:

  1. tokio::task::spawn_blocking

这是在 Tokio 中执行 CPU 密集型任务的首选方法。spawn_blocking 函数会将给定的闭包放到一个专门的线程池中执行,这个线程池与 Tokio 的运行时是分离的。这样,CPU 密集型任务就不会阻塞 Tokio 的运行时线程,从而保证 I/O 密集型任务的正常执行。

use tokio::task;

#[tokio::main]
async fn main() {
    // 执行一些异步 I/O 操作
    println!("开始 I/O 操作");
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    println!("I/O 操作完成");

    // 执行 CPU 密集型任务
    let result = task::spawn_blocking(move || {
        println!("开始 CPU 密集型任务");
        // 执行一些 CPU 密集型计算
        let mut sum = 0u64;
        for i in 0..100000000 {
            sum += i;
        }
        println!("CPU 密集型任务完成");
        sum
    }).await.unwrap();

    println!("计算结果:{}", result);
}

在这个例子中,CPU 密集型计算被放在 spawn_blocking 的闭包中执行。即使这个计算需要较长时间,也不会阻塞 Tokio 的运行时,I/O 操作仍然可以正常执行。

  1. 使用独立的线程池

另一种方法是使用独立的线程池来执行 CPU 密集型任务。你可以使用 std::thread 或像 rayon 这样的库来创建和管理线程池。然后,你可以使用通道(std::sync::mpsctokio::sync::mpsc)在 Tokio 运行时和线程池之间传递数据。

use std::thread;
use std::sync::mpsc;
use tokio::runtime::Runtime;

fn main() {
    let rt = Runtime::new().unwrap();
    let (tx, rx) = mpsc::channel();

    rt.block_on(async {
        // 执行一些异步 I/O 操作
        println!("开始 I/O 操作");
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        println!("I/O 操作完成");

        // 将 CPU 密集型任务发送到线程池
        let tx_clone = tx.clone();
        thread::spawn(move || {
            println!("开始 CPU 密集型任务");
            // 执行一些 CPU 密集型计算
            let mut sum = 0u64;
            for i in 0..100000000 {
                sum += i;
            }
            println!("CPU 密集型任务完成");
            tx_clone.send(sum).unwrap();
        });

        // 从通道接收结果
        let result = rx.recv().unwrap();
        println!("计算结果:{}", result);
    });
}

对于简单的 CPU 密集型任务,spawn_blocking 是最简单和方便的方法。对于复杂的 CPU 密集型任务或需要更精细的线程池控制的场景,使用独立的线程池可能更合适。

异步运行时的使用陷阱

图片

关于异步运行时常见的错误,这里有2个相关的文档,你可以点开看一下:Common Mistakes with Rust AsyncAsync Rust Important Mistakes

意外的同步阻塞

在异步代码中意外地执行同步阻塞操作是异步编程中一个重要的陷阱,会破坏异步编程的优势,导致性能瓶颈。以下是一些常见的场景:

  1. 在异步函数中使用阻塞的 I/O 操作:例如,在 async fn 中直接调用标准的 std::fs::File::openstd::net::TcpStream::connect 等阻塞函数。
  2. 在异步闭包中执行计算密集型任务:如果在异步闭包中执行大量的 CPU 计算,也会阻塞当前线程,影响其他异步任务的执行。
  3. 在异步代码中使用阻塞的库或函数:有些库或函数可能没有提供异步接口,只能以同步方式调用。如果在异步代码中使用了这些库或函数,就会造成阻塞。

你可以看一下下面这段代码,对比使用 std::thread::sleeptokio::time::sleep 的区别:

use tokio::task;
use tokio::time::Duration;

async fn handle_request() {
    println!("开始处理请求");
    // tokio::time::sleep(Duration::from_secs(1)).await; // 正确:使用 tokio::time::sleep
    std::thread::sleep(Duration::from_secs(1)); // 错误:使用 std::thread::sleep
    println!("请求处理完成");
}

#[tokio::main(flavor = "current_thread")] // 使用 tokio::main 宏,单线程模式
async fn main() {
    let start = std::time::Instant::now();

    // 启动多个并发任务
    let handles = (0..10).map(|_| {
        task::spawn(handle_request())
    }).collect::<Vec<_>>();

    // 等待所有任务完成(可选)
    for handle in handles {
        handle.await.unwrap();
    }

    println!("所有请求处理完成,耗时 {:?}", start.elapsed());
}

如何避免掉入同步阻塞的陷阱?

  1. 使用异步的库和函数:尽量使用提供异步接口的库和函数,例如 tokioasync-std 等运行时提供的异步 I/O、定时器、网络等功能。
  2. 将计算密集型任务放到单独的线程池中:如果需要在异步代码中执行计算密集型任务,可以使用 tokio::task::spawn_blockingasync-std::task::spawn_blocking 将任务放到单独的线程池中执行,避免阻塞主线程。
  3. 仔细检查依赖库:在使用第三方库时,要仔细检查其是否提供了异步接口,避免引入阻塞操作。
  4. 使用工具进行分析:可以使用性能分析工具来检测异步代码中是否存在阻塞操作,例如 tokio 提供了 console 工具。

忘记 .await

异步函数返回的是 Future,需要使用 .await 才能执行并获取结果。忘记 .await 会导致 Future 不会被执行。

如下面的代码:

async fn my_async_function() -> i32 { 42 }

#[tokio::main]
async fn main() {

    // 错误:忘记 .await,函数不会执行
    my_async_function();

    // 正确
    let result = my_async_function().await;
    println!("正确的异步操作的结果是:{}", result);
}

滥用 spawn

频繁地 spawn 轻量级任务会带来额外的开销,包括任务调度、上下文切换等,反而降低性能。

下面这个例子,我们为每一个数字都乘以2,然后放到一个vec中,最后打印出vec中元素的数量,实现了错误和正确两种方式:

use async_std::task;

async fn process_item(item: i32) -> i32 {
    // 非常简单的操作
    item * 2
}

async fn bad_use_of_spawn() {
    let mut results = Vec::new();
    for i in 0..10000 {
        // 错误:为每个简单的操作都 spawn 一个任务
        let handle = task::spawn(process_item(i));
        results.push(handle.await);
    }
    println!("{:?}",results.len());
}

async fn good_use_of_spawn() {
    let mut results = Vec::new();
    for i in 0..10000{
      results.push(process_item(i).await);
    }
    println!("{:?}",results.len());
}

fn main() {
    task::block_on(async {
        bad_use_of_spawn().await;
        good_use_of_spawn().await;
    });
}

在上面的错误示例中,为每个简单的乘法操作都 spawn 一个新的任务,导致大量的任务调度开销。正确的示例直接 await 异步函数,避免了额外的开销。

我们应该只在需要真正并发执行的任务中使用 spawn。对于计算密集型或 I/O 密集型但耗时较长的任务,spawn 是合适的。对于非常轻量级的任务,直接 await 通常更高效。可以使用 tokio::task::JoinSet 一次性管理多个任务。

总结

好了,在这一节课中,我们了解了异步编程的一些争议、应用场景和陷阱。

Rust 的异步编程是一把双刃剑。它在 I/O 密集型场景下可以提供显著的性能提升,但也增加了复杂性和学习曲线。开发者应该根据实际应用场景仔细评估是否使用异步编程,并注意避免常见的陷阱。对于 CPU 密集型任务,多线程通常是更好的选择。如果需要在异步环境中使用 CPU 密集型任务,应使用 spawn_blocking 或独立的线程池。

思考题

假设你需要编写一个异步函数,该函数需要读取一个文件的前 10 个字节。你发现 std::fs::File::openread 都是阻塞操作。请使用 Tokio 提供的工具编写一个正确的异步函数来实现此功能。

欢迎你在留言区分享你的代码,我们一起交流讨论,如果你觉得这节课的内容对你有帮助的话,也欢迎你分享给其他朋友,我们下节课再见!

精选留言(1)
  • soddygo 👍(0) 💬(0)

    同步转异步,或者异步转同步,我一般都用管道来做通信转换

    2025-04-28