async vs 其它并发模型
- OS 线程 : Rust 就选择了原生支持线程级的并发编程。但是,这种模型也有缺点,例如线程间的同步将变得更加困难,线程间的上下文切换损耗较大。使用线程池在一定程度上可以提升性能,但是对于 IO 密集的场景来说,线程池还是不够
- 事件驱动(Event driven) : 事件驱动机制 , 这种模型性能相当的好,但最大的问题就是存在回调地狱的风险:非线性的控制流和结果处理导致了数据流向和错误传播变得难以掌控,还会导致代码可维护性和可读性的大幅降低,大名鼎鼎的
JavaScript
曾经就存在回调地狱 - 协程(Coroutines) : 协程跟线程类似,无需改变编程模型,同时,它也跟
async
类似,可以支持大量的任务并发运行。但协程抽象层次过高,导致用户无法接触到底层的细节,这对于系统编程语言和自定义异步运行时是难以接受的 - actor 模型:erlang 的杀手锏之一,它将所有并发计算分割成一个一个单元,这些单元被称为
actor
, 单元之间通过消息传递的方式进行通信和数据传递,跟分布式系统的设计理念非常相像。由于actor
模型跟现实很贴近,因此它相对来说更容易实现,但是一旦遇到流控制、失败重试等场景时,就会变得不太好用 - async-await: 该模型性能高,还能支持底层编程,同时又像线程和协程那样无需过多的改变编程模型,但有得必有失,
async
模型的问题就是内部实现机制过于复杂,对于用户来说,理解和使用起来也没有线程和协程简单,好在前者的复杂性开发者们已经帮我们封装好,而理解和使用起来不够简单
Rust 经过权衡取舍后,最终选择了同时提供多线程编程和 async 编程:
- 前者通过标准库实现,当你无需那么高的并发时,例如需要并行计算时,可以选择它,优点是线程内的代码执行效率更高、实现更直观更简单
- 后者通过语言特性 + 标准库 + 三方库的方式实现
async: Rust vs 其它语言
目前已经有诸多语言都通过 async
的方式提供了异步编程,例如 JavaScript
,但 Rust
在实现上有所区别:
- Future 在 Rust 中是惰性的,只有在被轮询(
poll
)时才会运行, 因此丢弃一个future
会阻止它未来再被运行, 你可以将Future
理解为一个在未来某个时间点被调度执行的任务。 - Async 在 Rust 中使用开销是零, 意味着只有你能看到的代码(自己的代码)才有性能损耗,你看不到的(
async
内部实现)都没有性能损耗,例如,你可以无需分配任何堆内存、也无需任何动态分发来使用async
,这对于热点路径的性能有非常大的好处,正是得益于此,Rust 的异步编程性能才会这么高。 - Rust 没有内置异步调用所必需的运行时,但是无需担心,Rust 社区生态中已经提供了非常优异的运行时实现,例如大明星
tokio
- 运行时同时支持单线程和多线程
async vs 多线程
对于长时间运行的 CPU 密集型任务,例如并行计算,使用线程将更有优势。 这种密集任务往往会让所在的线程持续运行,任何不必要的线程切换都会带来性能损耗,因此高并发反而在此时成为了一种多余。同时你所创建的线程数应该等于 CPU 核心数,充分利用 CPU 的并行能力,甚至还可以将线程绑定到 CPU 核心上,进一步减少线程上下文切换。
高并发更适合 IO
密集型任务,例如 web 服务器、数据库连接等等网络服务,因为这些任务绝大部分时间都处于等待状态,如果使用多线程,那线程大量时间会处于无所事事的状态,再加上线程上下文切换的高昂代价,让多线程做 IO
密集任务变成了一件非常奢侈的事
使用async
,既可以有效的降低 CPU
和内存的负担,又可以让大量的任务并发的运行,一个任务一旦处于IO
或者其他等待(阻塞)状态,就会被立刻切走并执行另一个任务,而这里的任务切换的性能开销要远远低于使用多线程时的线程上下文切换。
事实上, async
底层也是基于线程实现,但是它基于线程封装了一个运行时,可以将多个任务映射到少量线程上,然后将线程切换变成了任务切换,后者仅仅是内存中的访问,因此要高效的多。
不过async
也有其缺点,原因是编译器会为async
函数生成状态机,然后将整个运行时打包进来,这会造成我们编译出的二进制可执行文件体积显著增大。
如果想并发的下载文件,可以使用多线程如下实现:
fn get_two_sites() {
// 创建两个新线程执行任务
let thread_one = thread::spawn(|| download("https://course.rs"));
let thread_two = thread::spawn(|| download("https://fancy.rs"));
// 等待两个线程的完成
thread_one.join().expect("thread one panicked");
thread_two.join().expect("thread two panicked");
}
但是一旦下载文件的并发请求多起来,那一个下载任务占用一个线程的模式就太重了,会很容易成为程序的瓶颈
async fn get_two_sites_async() {
// 创建两个不同的`future`,你可以把`future`理解为未来某个时刻会被执行的计划任务
// 当两个`future`被同时执行后,它们将并发的去下载目标页面
let future_one = download_async("https://www.foo.com");
let future_two = download_async("https://www.bar.com");
// 同时运行两个`future`,直至完成
join!(future_one, future_two);
}
async/.await 简单入门
通过 async
标记的语法块会被转换成实现了Future
特征的状态机。 与同步调用阻塞当前线程不同,当Future
执行并遇到阻塞时,它会让出当前线程的控制权,这样其它的Future
就可以在该线程中运行,这种方式完全不会导致当前线程的阻塞
[dependencies]
futures = "0.3"
// `block_on`会阻塞当前线程直到指定的`Future`执行完成,这种阻塞当前线程以等待任务完成的方式较为简单、粗暴,
// 好在其它运行时的执行器(executor)会提供更加复杂的行为,例如将多个`future`调度到同一个线程上执行。
use futures::executor::block_on;
async fn hello_world() {
println!("hello, world!");
}
fn main() {
let future = hello_world(); // 返回一个Future, 因此不会打印任何输出
block_on(future); // 执行`Future`并等待其运行完成,此时"hello, world!"会被打印输出
}
如果你要在一个async fn
函数中去调用另一个async fn
并等待其完成后再执行后续的代码,该如何做?
use futures::executor::block_on;
async fn hello_world() {
hello_cat().await;
println!("hello, world!");
}
async fn hello_cat() {
println!("hello, kitty!");
}
fn main() {
let future = hello_world();
block_on(future);
}
hello, kitty!
hello, world!
输出的顺序跟代码定义的顺序完全符合,因此,我们在上面代码中使用同步的代码顺序实现了异步的执行效果,非常简单、高效,而且很好理解,未来也绝对不会有回调地狱的发生。
在async fn
函数中使用.await
可以等待另一个异步调用的完成。但是与block_on
不同,.await
并不会阻塞当前的线程,而是异步的等待Future A
的完成,在等待的过程中,该线程还可以继续执行其它的Future B
,最终实现了并发处理的效果。
use futures::executor::block_on;
struct Song {
author: String,
name: String,
}
async fn learn_song() -> Song {
Song {
author: "曲婉婷".to_string(),
name: String::from("《我的歌声里》"),
}
}
async fn sing_song(song: Song) {
println!(
"给大家献上一首{}的{} ~ {}",
song.author, song.name, "你存在我深深的脑海里~ ~"
);
}
async fn dance() {
println!("唱到情深处,身体不由自主的动了起来~ ~");
}
async fn learn_and_sing() {
// 这里使用`.await`来等待学歌的完成,但是并不会阻塞当前线程,该线程在学歌的任务`.await`后,完全可以去执行跳舞的任务
let song = learn_song().await;
// 唱歌必须要在学歌之后
sing_song(song).await;
}
async fn async_main() {
let f1 = learn_and_sing();
let f2 = dance();
// `join!`可以并发的处理和等待多个`Future`,若`learn_and_sing Future`被阻塞,那`dance Future`可以拿过线程的所有权继续执行。若`dance`也变成阻塞状态,那`learn_and_sing`又可以再次拿回线程所有权,继续执行。
// 若两个都被阻塞,那么`async main`会变成阻塞状态,然后让出线程所有权,并将其交给`main`函数中的`block_on`执行器
futures::join!(f1, f2);
}
fn main() {
block_on(async_main());
}
学歌和唱歌具有明显的先后顺序,但是这两者都可以跟跳舞一同存在,也就是你可以在跳舞的时候学歌,也可以在跳舞的时候唱歌。如果上面代码不使用.await
,而是使用block_on(learn_song())
, 那在学歌时,当前线程就会阻塞,不再可以做其它任何事,包括跳舞