Rust 异步编程:掌握 async/await 从入门到进阶

Rust 异步编程:掌握 async/await 从入门到进阶

Rust语言的异步编程提供了一种高效且性能优越的并发编程方式。本篇文章将介绍Rust的async/await机制,分析其优势和适用场景,并通过具体的代码示例来帮助读者理解如何在Rust中实现异步编程。我们将探索异步编程的背景,Rust中async的特性,以及如何通过简单的示例进行实际操作。

Rust的异步编程通过async和await语法为开发者提供了灵活的并发处理能力。在与传统的多线程编程模式相比,异步编程模型具有显著的优势,尤其在处理大量I/O任务时。本文概述了Rust中async的基本概念,分析了它与线程模型的区别,并介绍了如何使用async编程进行任务并发。同时,我们还讨论了Rust异步编程中的性能特性、运行时选择以及常见的兼容性问题。通过示例代码,读者可以轻松上手Rust异步编程,并掌握其核心概念。

Rust async 编程 Getting Started

1.1 为什么使用 async

为什么使用 async

  • Async 编程,是一种并发(concurrent)编程模型
  • 允许你在少数系统线程上运行大量的并发任务
  • 通过 async/await 语法,看起来和同步编程差不多

其它的并发模型

  • OS 线程
  • 无需改变任何编程模型,线程间同步困难,性能开销大
  • 线程池可以降低一些成本,但难以支撑大量 IO 绑定的工作
  • Event-driven 编程
  • 与回调函数一起用,可能高效
  • 非线性的控制流,数据流和错误传播难以追踪
  • 协程(Coroutines)
  • 类似线程,无需改变编程模型
  • 类似 async ,支持大量任务
  • 抽象掉了底层细节(这对系统编程、自定义运行时的实现很重要)
  • Actor 模型
  • 将所有并发计算划分为 actor , 消息通信易出错
  • 可以有效的实现 actor 模型,但许多实际问题没解决(例如流程控制、重试逻辑)

Rust 中的 async

  • Future 是惰性的
  • 只有poll时才能取得进展, 被丢弃的 future 就无法取得进展了
  • Async是零成本的
  • 使用async ,可以无需堆内存分配(heap allocation)和动态调度(dynamic dispatch),对性能大好,且允许在受限环境使用 async
  • 不提供内置运行时
  • 运行时由Rust 社区提供,例如 tokio
  • 单线程、多线程均支持
  • 这两者拥有各自的优缺点

Rust 中的 async 和线程(thread)

  • OS 线程:
  • 适用于少量任务,有内存和CPU开销,且线程生成和线程间切换非常昂贵
  • 线程池可以降低一些成本
  • 允许重用同步代码,代码无需大改,无需特定编程模型
  • 有些系统支持修改线程优先级
  • Async:
  • 显著降低内存和CPU开销
  • 同等条件下,支持比线程多几个数量级的任务(少数线程支撑大量任务)
  • 可执行文件大(需要生成状态机,每个可执行文件捆绑一个异步运行时)

Async 并不是比线程好,只是不同而已!

总结:

  • 有大量 IO 任务需要并发运行时,选 async 模型
  • 有部分 IO 任务需要并发运行时,选多线程,如果想要降低线程创建和销毁的开销,可以使用线程池
  • 有大量 CPU 密集任务需要并行运行时,例如并行计算,选多线程模型,且让线程数等于或者稍大于 CPU 核心数
  • 无所谓时,统一选多线程

例子

如果想并发的下载文件,你可以使用多线程如下实现:

fn get_two_sites() {
    // Spawn two threads to do work. 创建两个新线程执行任务
    let thread_one = thread::spawn(|| download("https://www.foo.com"));
    let thread_two = thread::spawn(|| download("https://www.bar.com"));

    // Wait for both threads to complete. 等待两个线程的完成
    thread_one.join().expect("thread one panicked");
    thread_two.join().expect("thread two panicked");
}

使用async的方式:

async fn get_two_sites_async() {
    // Create two different "futures" which, when run to completion, 创建两个不同的`future`,你可以把`future`理解为未来某个时刻会被执行的计划任务
    // will asynchronously download the webpages. 当两个`future`被同时执行后,它们将并发的去下载目标页面
    let future_one = download_async("https://www.foo.com");
    let future_two = download_async("https://www.bar.com");

    // Run both futures to completion at the same time. 同时运行两个`future`,直至完成
    join!(future_one, future_two);
}

自定义并发模型

  • 除了线程和async,还可以用其它的并发模型(例如 event-driven)

1.2 Rust Async 的目前状态

Async Rust 目前的状态

  • 部分稳定,部分仍在变化。
  • 特点:
  • 针对典型并发任务,性能出色
  • 与高级语言特性频繁交互(生命周期、pinning)
  • 同步和异步代码间、不同运行时的异步代码间存在兼容性约束
  • 由于不断进化,维护负担更重

语言和库的支持

  • 虽然Rust本身就支持Async编程,但很多应用依赖与社区的库:
  • 标准库提供了最基本的特性、类型和功能,例如 Future trait
  • async/await 语法直接被Rust编译器支持
  • futures crate 提供了许多实用类型、宏和函数。它们可以用于任何异步应用程序。
  • 异步代码、IO 和任务生成的执行由 "async runtimes" 提供,例如 Tokio 和 async-std。大多数async 应用程序和一些 async crate 都依赖于特定的运行时。

注意

  • Rust 不允许你在 trait 里声明 async 函数

编译和调试

  • 编译错误:
  • 由于 async 通常依赖于更复杂的语言功能,例如生命周期和Pinning,因此可能会更频繁地遇到这些类型的错误。
  • 运行时错误:
  • 每当运行时遇到异步函数,编译器会在后台生成一个状态机,Stack traces 里有其明细,以及运行时调用的函数。因此解释起来更复杂。
  • 新的失效模式:
  • 可能出现一些新的故障,它们可以通过编译,甚至单元测试。

兼容性考虑

  • async和同步代码不能总是自由组合
  • 例如,不能直接从同步函数来调用 async 异步函数

  • Async 代码间也不总是能自由组合

  • 一些crate依赖于特定的 async 运行时
  • 因此,尽早研究确定使用哪个 async 运行时

性能特征

  • async 的性能依赖于运行时的表现(通常较出色)

1.3 async/await 入门

async

  • async 把一段代码转化为一个实现了Future trait 的状态机
  • 虽然在同步方法中调用阻塞函数会阻塞整个线程,但阻塞的Future将放弃对线程的控制,从而允许其它Future来运行。
~/rust via 🅒 base
➜ cargo new async_demo
     Created binary (application) `async_demo` package

~/rust via 🅒 base
➜ cd async_demo

async_demo on  master [?] via 🦀 1.67.1 via 🅒 base
➜ c

async_demo on  master [?] via 🦀 1.67.1 via 🅒 base
➜

代码

#![allow(unused)]
fn main() {
    async fn do_something() {/* ... */}
}

Cargo .toml

[package]
name = "async_demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
futures = "0.3.28"

async fn

  • 异步函数语法:
  • async fn do_something() {/* ... */}
  • async fn 返回的是 Future,Future 需要由一个执行者来运行
  • futures::executor::block_on;
  • block_on 阻塞当前线程,直到提供的 Future 运行完成
  • 其它执行者提供更复杂的行为,例如将多个 Future 安排到同一个线程上
use futures::executor::block_on;

async fn hello_world() {
    println!("Hello world!");
}

fn main() {
    let future = hello_world(); // 什么都没打印出来
    block_on(future); // `future` 运行,并打印出 "Hello world!"
}

Await

  • 在 async fn 中,可以使用 .await 来等待另一个实现 Future trait 的完成
  • 与 block_on 不同,.await不会阻塞当前线程,而是异步的等待 Future 的完成(如果该 Future 目前无法取得进展,就允许其他任务执行)
use futures::executor::block_on;

struct Song {}

async fn learn_song() -> Song {
    Song {}
}
async fn sing_song(song: Song) { /* ... */
}

async fn dance() { /* ... */
}

fn main() {
    let song = block_on(learn_song());
    block_on(sing_song(song));
    block_on(dance());
}

修改之后

use futures::executor::block_on;

struct Song {}

async fn learn_song() -> Song {
    Song {}
}
async fn sing_song(song: Song) {}

async fn dance() {}

async fn learn_and_sing() {
    let song = learn_song().await;
    sing_song(song).await;
}

async fn async_main() {
    let f1 = learn_and_sing();
    let f2 = dance();
    futures::join!(f1, f2);
}

fn main() {
    block_on(async_main());
}

总结

Rust的异步编程是一种非常强大的并发模型,能够显著提高I/O密集型应用的性能。与传统的多线程模型相比,async编程通过共享少量的线程来高效地处理更多的任务,避免了线程切换和内存开销。虽然Rust的异步编程目前仍在不断发展,但它已成为开发者处理高并发任务的有力工具。通过本文的学习,读者应能理解异步编程的基本思想,掌握在Rust中应用async/await的方式,并能在实际项目中合理选择并发模型。

参考

全部评论(0)