Rust第七天—Async异步编程

117 阅读6分钟

之前在进阶部分讲到了多线程实现并发任务,今天就来看看另外一种并发模型Async.了解其他语言的话,基本上也会接触到async异步并发模型,但是Rust的异步与其他语言不同它的内部实现并不产生性能消耗,并且并没有内置异步所需的运行时.这些特性可以保证在不使用Async时写出的代码并不会产生任何额外的消耗,非常符合Rust追求高效性能的特点.

既然Rust已经有了多线程,那为什么还需要异步?这个问题还是取决于使用场景,对于多线程来说,线程创建以及上下文切换会导致资源消耗,特别是在线程数目较多时这种消耗是无法忽略的.总的来说,可以分为下面两种情况

  • CPU密集: 对于计算任务来说,往往需要线程持续运行并没有过多的上下文切换,因此适合使用多线程模型
  • IO密集: 对于IO任务,并不是时时刻刻都有任务,如果用多线程则会导致大量线程处于等待,并且上下文切换频繁会白白浪费资源.这种时候就适合异步模型,当某个任务处于等待时可以切换执行其他任务,异步任务切换的开销是远小于线程上下文切换的开销的.

但是异步也存在其他问题,相较于多线程编程,异步编写起来会更加复杂.并且由于没有包含运行时,因此编译时会将整个运行时打包进可执行文件,导致编译出的文件很大.

1. async与await

asyncawait是Rust内置的关键字,用来编写异步代码.不过在使用之前,需要在toml中添加一下依赖futures

async fn hello(){
    println!("hello wolrd!");
}
​
fn main() {
    let future=hello();
    futures::executor::block_on(future);
}

image-20230727170520958

不管学习什么新特性,以hello world为开始总是没错的.这里我利用async定义了一个异步函数,异步函数的返回值是一个Future,如果熟悉js可以理解为promise.如果直接调用异步函数是不会执行的,因此必须利用executor来执行这个Future.这里使用block_on表示会阻塞当前线程,直到内部的Future执行完毕.

但是如果处处都用block_on去阻塞当前线程,很明显不符合异步模型的思想.我们期望在等待某个Future执行的过程中还能执行其他Future,这个时候就可以使用await

use async_std::task::sleep;
​
async fn task1()->i32{
    println!("task1....");
    return 1;
}
​
​
async fn task2(x:i32){
    let y=x+1;
    println!("task2 calculate finish x+1= {}",y);
}
​
async fn task(){
    let input=task1().await;
    println!("preparing input...");
    sleep(Duration::from_secs(3)).await;
    task2(input).await;
}
​
​
async fn task3(){
    println!("task3...");
}
​
​
async fn async_main(){
    let f1= task();
    let f2=task3();
    futures::join!(f1,f2);
}
​
fn main() {
    futures::executor::block_on(async_main());
}

image-20230727173959416

这里,我们在task1结束后对异步函数休息了3秒,希望它能去执行task3.从结果来看,使用await并不会阻塞线程,达到了一开始的异步切换任务的目的.

2. Stream

Stream特征类似于Future,但是它可以在结束前生成多个值.更类似于包裹着一系列Future的“迭代器”.

不过在这里,我们不能使用for循环来遍历得到Stream的结果,而是需要使用while let或者loop语句,具体可以看下面这个例子

#[derive(Clone, Copy)]
struct Counter{
    count:usize,
}
​
impl Counter{
    fn new()->Counter{
        Counter{count:0}
    }
}
​
impl Stream for Counter {
    type Item = usize;
​
    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        self.count+=1;
        if self.count<10{
            Poll::Ready(Some(self.count))
        }else{
            Poll::Ready(None)
        }
    }
}
​
​
use futures::StreamExt;
use futures::pin_mut;
​
async fn sum1(stream: impl Stream<Item=usize>){
    pin_mut!(stream);
    let mut sum:usize=0;
    while let Some(item)=stream.next().await{
        sum+=item;
    }
    println!("sum1={}",sum);
}
​
​
async fn sum2(stream: impl Stream<Item=usize>){
    pin_mut!(stream);
    let mut sum:usize=0;
    loop{
        match stream.next().await {
            Some(item)=>{sum+=item}
            None=>break
        }
    }
    println!("sum2={}",sum);
}
​
async fn async_main(){
    let count_stream=Counter::new();
​
    let f1= sum1(count_stream);
    let f2=sum2(count_stream);
    futures::join!(f1,f2);
}
​
​
fn main() {
    block_on(async_main());
}

image-20230727192945247

我们实现了Stream特征,并且用两种循环来遍历计算Stream中值的和.需要注意的是,这里我们需要pin住传入的stream,防止它的地址发生变化.自己实现Stream是不是还是比较麻烦的,我们也可以直接使用futures::iter()来创建Stream

async fn async_main(){
    let count_stream=Counter::new();
    let stream2=stream::iter(1..20);
​
    let f1= sum1(count_stream);
    let f2=sum2(stream2);
    futures::join!(f1,f2);
}

image-20230727193601800

为了实现真正意义的并发,我们当然可以并发处理多个值,可以使用try_for_each_concurrent方法,注意传入Result就好.

3. 多个Future并发

其实在上面的demo里就涉及到了这个概念,我们使用futures::join!将两个task同时等待,可以并发执行这些Future.除此之外,如果希望某个Future执行失败就直接停止所有并发任务,那么可以使用try_join!.但是使用join!有一个问题,就是必须等待所有Future执行结束才能继续操作,如果希望有一个Future结束就可以操作,这个时候就需要使用select!.

async fn task1(){
    sleep(Duration::from_secs(3)).await;
    println!("task1...");
}
​
async fn task2(){
    sleep(Duration::from_secs(1)).await;
    println!("task2...");
}
​
async fn select_task(){
    let t1=task1().fuse();
    let t2=task2().fuse();
    pin_mut!(t1,t2);
    select! {
        ()=t1=>println!("task1 finish!"),
        ()=t2=>println!("task2 finish!"),
    }
}
​
​
fn main(){
    block_on(select_task());
}

image-20230727211925796

因为task2耗时短,因此先执行结束task2,得到的结果也是task2.

4. 异步服务器

对于服务器来说,我们并不需要等待处理完某个用户的请求才继续处理下一个用户请求,而是希望能并发处理大量请求,这个时候使用单线程的服务器就无法充分利用设备资源,因此往往需要异步甚至多线程来应对大量的请求.

async fn handle_connection(mut stream: TcpStream) {
    // 从连接中顺序读取 1024 字节数据
    let mut buffer = [0; 1024];
    stream.read(&mut buffer).await.unwrap();
​
    let get = b"GET / HTTP/1.1\r\n";
​
​
    // 处理HTTP协议头,若不符合则返回404和对应的 `html` 文件
    let (status_line, filename) = if buffer.starts_with(get) {
        ("HTTP/1.1 200 OK\r\n\r\n", "src/hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND\r\n\r\n", "src/404.html")
    };
    let contents = fs::read_to_string(filename).unwrap();
​
    // 将回复内容写入连接缓存中
    let response = format!("{status_line}{contents}");
    stream.write_all(response.as_bytes()).await.unwrap();
    // 使用 flush 将缓存中的内容发送到客户端
    stream.flush().await.unwrap();
}
​
​
#[async_std::main]
async fn main() {
    // 监听本地端口 7878 ,等待 TCP 连接的建立
    let listener = TcpListener::bind("127.0.0.1:7878").await.unwrap();
    listener.incoming().for_each_concurrent(10,|stream|async move{
        let stream=stream.unwrap();
        spawn(handle_connection(stream));
    }).await;
}

这样,我们就建立了一个简单的异步多线程服务器.异步显而易见,多线程主要是利用async_std::task::spawn去多线程执行haddle_connection()函数.那这样一个基础的服务器,到底性能怎么样呢?

我用go-wrk进行了测试,它的功能和ab类似,命令也差不多.利用go-wrk -t=10 -c=100 -n=1000000 "http://127.0.0.1:7878"对服务器进行100万次请求,其中最大连接数是100,使用10个线程.得到的结果如下

image-20230728084558309

平均响应时间1ms,RPS差不多99k左右同时保证了没有请求错误发生.这样来看,貌似这个简简单单的服务器性能还挺牛的.