Rust 语言从入门到实战 唐刚--读书笔记13

Rust 语言从入门到实战 唐刚

进阶篇 (2讲)

13|独立王国:初步了解Rust异步并发编程

系统学习 Rust 异步并发编程。

异步 Rust(async Rust),有一定的独立性,有突出的特点。

async rust

Rust v1.39 版本,引入了 async 关键字,用于支持异步编程的工程学体验,使程序员可以用已经习惯了的同步代码书写方式来编写异步代码。

如果你了解过早期的 JavaScript 语言,你可能会对回调模式以及“回调地狱”有所了解。搜索“回调地狱”关键词,看看它是如何产生的,以及可以用什么方式去解决。

JavaScript 在 ECMAScript 2017 版本中引入了 async/await 关键字组合,用于改进 JavaScript 中异步编程体验,从此以后程序员可以用顺序的逻辑书写方式来写出异步执行的代码,而不是那种用回调方式把一段连续的逻辑切割成一小块一小块的。

Rust 其实也差不多,它用类似的方式引入了 async/.await 关键字对。Mozilla 是互联网标准组织的重要成员,JavaScript 之父就在 Mozilla 公司,参与了 JavaScript 标准制定的全过程。同时,Mozilla 还推出了 Rust 语言以及 WebAssembly 字节码规范。

async 函数和块(代码片段)

Rust 中,用上 async 的函数:

async fn foo() {
  
}

在原来的 fn 前加上 async 修饰。

async 块,在 async 后面加花括号。

fn foo() {
    async {
      // 这就是async块
    };
}

fn foo() {
    async move { 
      // 加move,类似闭包,明确标识把用到的环境变量移动进来
    };
}

可以编译通过,但会有一些问题。提示:

futures do nothing unless you `.await` or poll them

futures 不做任何事情,除非用 .await 或轮询它们。

Rust 中,async 函数或块会被视作一个 Future 对象,类似 JS 的 Promise,async 关键字只是用来定义这个 Future 对象,定义好的这片异步代码并不会自动执行,需要和 async 配对的 .await 去驱动它才会执行。

比如:

fn foo() {
    let a = async {};
    a.await;            // 用.await驱动异步块
}
// 或者更紧凑的写法
fn foo() {
    async {}.await;
}

// 报错

error[E0728]: `await` is only allowed inside `async` functions and blocks

提示,await 关键字只能在 async 块或函数里使用。

于是得改成:

async fn foo() {
    let a = async {};
    a.await;
}

两条规则。

  1. 用 async 定义异步代码,用 .await 驱动执行。
  2.  .await 只能在 async 块中调用。

鸡和蛋,第一个最外层的 async 代码块或函数如何被调用呢?

Rust 程序都是从 main 函数开始执行的。即使是异步代码,也不能破坏这个规则。

fn main() {
}

试着这样写:

async fn main() {      // 在main函数前加一个async修饰
    let a = async {};
    a.await;
}

// 报错

error[E0752]: `main` function is not allowed to be `async`

Rust 明确规定了,main 函数前不能加 async 修饰。也就是说,只能写成这种形式。

fn main() {
    let a = async {};
    a.await;
}

前面说过了,.await 只能写在 async 代码块或函数里。两难的境地。

必然要引入一种外部驱动机制。如,有一个辅助函数,可以接收 Future,并驱动它,而不需要使用 .await。像这样:

fn main() {
    let a = async {};
    block_on(a);  // 辅助驱动函数 block_on
}

block_on() 不是一个普通的函数,它必须是一个运行时(Runtime)的入口。在它下面,蕴藏着一整套运行时机制。

到目前知道,仅仅利用我们之前学到的 Rust 知识,还驱动不了异步代码,必须要借助于一种新的叫做运行时(Runtime)的机制才能处理。

目前 Rust 标准库中还没有内置一个官方的异步 Runtime,Rust 生态中有很多第三方的 Runtime 实现库,比如 tokio、async-std 等。tokio 应用最为广泛,成了 Rust 生态中异步运行时事实上的标准。

异步运行时是什么?

异步运行时是一个库,包含:一个响应器(reactor)、一个或多个执行器(executor)。

需要处理:

  1. 执行异步代码。
  2. 遇到 .await 时,判断能不能获取到结果。若不能,(CPU 不会一直阻塞等)缓存当前任务的状态,将当前任务挂起,放到内部一个任务池中,同时向 OS 注册要监听等待的外部事件。
  3. 询问或执行其他任务。若所有任务都暂时没有进展,就会进入一个空闲(idle)状态,不会使 CPU 忙等待。
  4. 只要某个任务对应所监听到的信号来了(有结果返回),把对应的任务从缓存中恢复暂停前的状态,继续执行。从代码上看,就是从上一个 .await 后面的代码继续往下执行。
  5. 遇到下一个 .await,重复第 1 步~第 4 步。
  6. 直到这个异步代码(函数)执行完毕,完成操作或返回结果。

总结, 6 项任务。

  1. 异步代码的执行;
  2. 任务的暂停;
  3. 状态的缓存;
  4. 外部事件的监听注册;
  5. 外部信号来了后,唤醒对应的任务,恢复任务状态;
  6. 多个任务间的调度。

Rust 异步运行时要干的事情还不少。要设计一个高效的异步运行时是一件相当有技术挑战的工作。

以 tokio 为例,介绍 Rust 中的异步编程。

tokio 异步编程

基于 tokio runtime 的代码范例。

引入依赖

在 Cargo.toml 中引入 tokio 依赖。

tokio = { version = "1", features = ["full"] }

main 函数

把 tokio 提供的属性宏标注在 main 函数上,main 函数前就可以加 async 修饰了。

#[tokio::main]      // 这个是tokio库里面提供的一个属性宏标注
async fn main() {   // 注意 main 函数前面有 async 
    println!("Hello world");
}

#[tokio::main] 做的,把用 async 修饰的 main 函数展开,展开会类似:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {              // 注意这里block_on,里面是异步代码
            println!("Hello world");
        })
}

在 main 函数里构建一个 Runtime 实例,

  • 用 tokio 库下 Runtime 模块的 Builder 类型里的 new_multi_thread() 函数,路径用 :: 号连接(:: ,路径符)。创建的多线程版本的 Runtime 实例
  • .enable_all() 打开默认所有配置,
  • .build() 用于真正创建实例,返回用 Result 包起来的结果,
  • .unwrap() 把 Result 解开,把 Runtime 实例拿出来,
  • 然后在这个实例上调用 .block_on() 函数。

        整个过程用的是链式调用风格,要遵循前一个函数调用返回自身或者新的对象即可。

block_on() 会执行异步代码,这样就把异步代码给加载到这个 Runtime 实例上并驱动起来。

tokio 还可以基于当前系统线程创建单线程的 Runtime,示例:

#[tokio::main(flavor = "current_thread")]  // 属性标注里面配置参数
async fn main() {
    println!("Hello world");
}

展开后,是这样:

fn main() {
    tokio::runtime::Builder::new_current_thread()  // 注意这一句
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {
            println!("Hello world");
        })
}

单线程的 Runtime 由 Builder::new_current_thread()  函数创建,代码的其他部分和多线程 Runtime 都一样。

代码示例

了解基于 tokio 的代码。

文件写

基于 tokio 做文件的写操作。

use tokio::fs::File;
use tokio::io::AsyncWriteExt;     // 引入AsyncWriteExt trait

async fn doit() -> std::io::Result<()> {             
    let mut file = File::create("foo.txt").await.unwrap();  // 创建文件
    file.write_all(b"hello, world!").await.unwrap();        // 写入内容
    Ok(())
}

#[tokio::main]
async fn main() {
    let result = doit().await;   // 注意这里的.await
}

文件读

基于 tokio 做文件的读操作。

use tokio::fs::File;
use tokio::io::AsyncReadExt;   // 引入AsyncReadExt trait

async fn doit() -> std::io::Result<()> {
    let mut file = File::open("foo.txt").await.unwrap();  // 打开文件
    let mut contents = vec![];
    // 将文件内容读到contents动态数组里面,注意传入的是可变引用
    file.read_to_end(&mut contents).await.unwrap();  
    println!("len = {}", contents.len());
    Ok(())
}

#[tokio::main]
async fn main() {
    let result = doit().await;  // 注意这里的.await
    // process
}

Rust 的异步代码和 JavaScript 的异步代码非常类似,JavaScript 的 await 关键字放在语句前面。

定时器操作

基于 tokio 做定时器操作。

use tokio::time;
use std::time::Duration;

#[tokio::main]
async fn main() {
    // 创建Interval实例
    let mut interval = time::interval(Duration::from_millis(10));
    // 滴答,立即执行
    interval.tick().await;
    // 滴答,这个滴答完成后,10ms过去了
    interval.tick().await;
    // 滴答,这个滴答完成后,20ms过去了
    interval.tick().await;
}

Duration::from_millis(10) 创建一个 10ms 的时间段,

其他语言中直接传入一个数字,如 10000 ,默认单位 us。

Rust 中会尽可能地类型化,这里定义了 Duration 类型,接收 s、ms、us 等单位的数值来构造时间段。这点上,Java 和 Rust 比较像。

tokio 组件

tokio 是一个功能丰富、机制完善的 Runtime 框架。它针对异步场景把 Rust 标准库里对应的类型和设施都重新实现了一遍。 6 个部分。

  • Runtime 设施组件:可自由地配置创建基于系统单线程的 Runtime 和多线程的 Runtime。
  • 轻量级任务 task:类似 Go 语言中的 Goroutine 这种轻量级线程,不是操作系统层面的线程。
  • 异步输入输出(I/O):网络模块 net、文件操作模块 fs、signal 模块、process 模块等。
  • 时间模块:定时器 Interval 等。
  • 异步场景下的同步原语:channel、Mutex 锁等等。
  • 在异步环境下执行计算密集型任务的方案spawn_blocking等。

基础设施的重新实现,tokio 为 Rust 异步编程的生态打下了坚实的基础,上层建筑蓬勃发展起来了。如:

  • Hyper:HTTP 协议 Server 和 Client 的实现
  • Axum:Web 开发框架
  • async-graphql:GraphQL 开发框架
  • tonic:gRPC 框架的 Rust 实现
  • ……

tokio 底层机制

tokio 的底层魔法是什么?

最底层是硬件、CPU 等。

其上是操作系统,Linux、Windows、macOS 等。

不同的操作系统会提供不同的异步抽象机制,如 Linux 有 epoll,macOS 有 kqueue。

Tokio 的异步 Runtime 能力是建立在操作系统的异步机制上的。

Tokio 的 reactor 用来接收从操作系统的异步框架中传回的消息事件,然后通知 tokio waker 把对应的任务唤醒,放回 tokio executor 中执行。每个任务会被抽象成一个 Future 来独立处理,而每个 Future 在 Rust 中会被处理成一个结构体,用状态机的方式来管理。Tokio 中还实现了对这些任务的安排调度机制。

注:官方的 async book 有对这个专题更深入的讲解:不过这本异步书写得偏难,并不适合新手。

task:轻量级线程

tokio 提供合作式(而非抢占式)的任务模型:每个任务 task 是一个轻量级的线程,与操作系统线程相对。操作系统默认的线程机制要消耗较多资源,普通服务器最多几千个。tokio 的轻量级线程可以上百万个。

M:N 模型

M 轻量级线程的数量,N 操作系统线程的数量。

将所有的轻量级线程映射到具体的 N 个操作系统线程上来执行,在操作系统线程之上抽象了一层,这层抽象是否高效正是衡量一个 Runtime 好坏的核心标准。

操作系统线程数量 N 是可以由开发者自行配置的,常用默认配置:N 等于 CPU 逻辑处理器核数量。

合作式

同一个 CPU 核上的任务配合着执行(不同 CPU 核上的任务并行执行)。

若 A 和 B 两个任务被分配到了同一个 CPU 核上,A 先执行,只有在 A 异步代码中碰到 .await 而且不能立即得到返回值的时候,才会触发挂起,进而切换到任务 B 执行。

当任务 B 碰到 .await 时,又会回去检查一下任务 A 所 await 的那个值回来没有,如果回来了就唤醒任务 A,从之前那个 .await 后面的语句继续执行;如果没回来就继续等待,或者看看能不能从其他核上拿点任务过来执行,因为此时任务 A 和任务 B 都在等待 await 的值回来。任何一个 task 里 await 的值回来后(会由操作系统向 tokio 通知一个事件),tokio 就会唤醒对应的 task 继续往下执行。

一个 task 没遇到 .await ,不会主动交出这个 CPU 核,其他 task 也不能主动来抢占这个 CPU 核。

tokio 实现的模型叫合作式的。Go 语言 Runtime 实现的 Goroutine 是抢占式的轻量级线程。

非阻塞

程序员视角看,一个 task(一段异步代码)遇到 .await ,被阻塞了,等待请求结果的返回。

tokio 底层的运行和调度机制看,是非阻塞的。一个轻量级线程 task 的“卡住”,OS 线程被调度了新的任务执行。CPU 资源没有被浪费。

task 的调度工作在 tokio 内部自动完成,程序员不可见。写异步并发代码,(跟写同步代码基本一样)顺着将逻辑写下去就行了。不会为适应异步回调而把代码逻辑打碎分散到文件的各个地方。tokio 的 task 真正执行的时候是非阻塞的,不会浪费系统资源。

创建 tokio task,用 task::spawn() 函数。

use tokio::task;

#[tokio::main]
async fn main() {
    task::spawn(async {
        // 在这里执行异步任务
    });
}

main 函数里创建一个新的 task,用来执行具体的任务。要知道,tokio 管理下的 async fn main() {} 本身就是一个 task,相当于在 main task 中,创建了一个新的 task 来执行。main task 是父 task,新创建 task 是子 task。

这两个 task 之间的生存关系是怎样的呢?其实是没有关系的。在 tokio 中,子 task 的生存期有可能超过父 task 的生存期,也就是父 task 执行结束了,但子 task 还在执行。如果在父 task 里要等待子 task 执行完,再结束自己,保险的做法是用 JoinHandler。

注:在 main 函数中有更多细节,如果 main 函数所在的 task 先结束了,会导致整个程序进程退出,有可能会强制杀掉那些新创建的子 task。

use tokio::task;

#[tokio::main]
async fn main() {
    // 在这里执行异步任务
    let task_a = task::spawn(async {    
        "hello world!"
    });
    // ...
    // 等待子任务结束,返回结果
    let result = task_a.await.unwrap();
    assert_eq!(result, "hello world!");
}

JoinHandler 概念跟 task 的管理相关。在 main task 中里创建一个新 task 后,task::spawn() 函数返回一个 handler,这个 handler 指代这个新的 task, task 的名字。

把新的任务命名为 task_a,类型是 JoinHandler。在用 spawn() 创建 task_a 后,这个新任务就立即执行

task_a.await 会返回一个 Result,要 unwrap() 把 task_a 真正的返回内容解包出来。 task 的 .await 为什么会返回一个 Result,而不是直接返回异步任务的返回值本身,是因为 task 里有可能会发生 panic。例子。

use tokio::task;

#[tokio::main]
async fn main() {
    let task_a = task::spawn(async {
        panic!("something bad happened!")
    });
    // 当task_a里面panic时,对task handler进行.await,会得到Err
    assert!(task_a.await.is_err());
}

task 可能会 panic,对 task 的返回值用 Result 包一层,方便在上层的 task 里处理这种错误。在 Rust 中,只要过程中有可能返回错误,那就用 Result 包一层作为返回值,典型做法。

有了 JoinHandler,可以方便地创建一批新任务,并等待它们的返回值。示例。

use tokio::task;

async fn my_background_op(id: i32) -> String {
    let s = format!("Starting background task {}.", id);
    println!("{}", s);
    s
}

#[tokio::main]
async main() {
    let ops = vec![1, 2, 3];
    let mut tasks = Vec::with_capacity(ops.len());
    for op in ops {
        // 任务创建后,立即开始运行,我们用一个Vec来持有各个任务的handler
        tasks.push(tokio::spawn(my_background_op(op)));
    }
    let mut outputs = Vec::with_capacity(tasks.len());
    for task in tasks {
        outputs.push(task.await.unwrap());
    }
    println!("{:?}", outputs);
}
// 输出
Starting background task 1.
Starting background task 2.
Starting background task 3.

用 tasks 动态数组持有 3 个异步任务的 handler,它们是并发执行的。然后对 tasks 进行迭代,等待每个 task 执行完成,并且搜集任务的结果放到 outputs 动态数组里。最后打印出来。

在 tokio 中创建一批任务并发执行非常简单,循环调用 task::spawn() 就行了,并且还能对创建的任务进行管理。

哪些操作要加 .await?

在写异步代码的时候,哪些地方该加 .await,哪些地方不该加呢?

总体的原则涉及到 I/O 操作的,都加,因为 tokio 已经帮我们实现了一份异步的对应于 Rust 标准库的 I/O 实现。最常见的 I/O 操作就是网络 I/O、磁盘 I/O 等。有几大模块。

  • net 模块:网络操作;
  • fs 模块:文件操作;
  • 定时器操作:Interval、sleep 等函数;
  • channel:四种管道 oneshot、mpsc、watch、broadcast;
  • signal 模块:系统信号处理;
  • process 模块:调用系统命令等。

具体查看 tokio API。查看 API 文档,只要接口前有 async 关键字修饰,用时就要加 .await。

如,tokio::fs::read() 的定义:

pub async fn read(path: impl AsRef<Path>) -> Result<Vec<u8>>

一些数据结构的基本操作,如 Vec<T>、HashMap<K, V> 的操作等,都是在内存里执行,其接口前面也没有 async 关键字修饰,不需要也不能加 .await。

小结

Async Rust 和 tokio 相关的基本概念。Async Rust 在整个 Rust 的体系中,相对于 std Rust 来讲是一片新的领地。

Rust 中的 async 代码具有传染性,一个函数要调用一个 async 函数,它本身也要是 async 函数。Rust 在语言层面提供了 async/.await 语法支持,但没有官方的异步运行时,来对异步代码的执行进行驱动。tokio 是异步运行时的胜出者,有强大的功能、丰富的特性和广泛的使用度。

tokio 轻量级线程模型,大规模并发程序开发,高性能 Web 服务器领域,一般的异步业务。

思考题

为什么我们要把 async Rust 叫做“独立王国”呢?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值