自从Rust 1.36在标准库中引入了std::future::Future
特征,异步就在Rust应用的各个领域遍地开花。
最先让我们接触到异步编程的,就是Tokio这个框架。基于现在版本的Rust标准库中的定义,已经有Tokio和async-std两个常用的异步框架供我们使用了,但是无论选择哪个框架,其核心都是基于标准库中提供的std::future::Future
特征。
但是在std::future
模块中,Rust还提供了一些用于快速实现异步函数的内容。
异步是当前许多编程语言中对于并发的一种新的实现形式。与传统的多线程编程不同,异步往往允许在一个线程上进行调度,从而使用更少的资源实现性能更高的任务调度,而且相比多线程编程,异步可以更好的利用现代多核心CPU。在Rust语言的初期是不支持异步的,然而在Tokio等异步库的推动下,Rust终于在标准库中提供了一套标准特征用来描述和供第三方库实现,但Rust标准库中并没有提供一套完整的异步实现。
Future
特征
Future
特征的目的就是用来描述一个在未来一定可以获取到的内容。类似于其他语言中的promise
、delay
、deferred
等,Future
就是对未来结果的一个包装代理。
在Rust中,Future
特征其实并不复杂,首先来看一下它在标准库中的定义。
|
|
在这个特征定义中出现了不少内容,现在来逐一研究一下。
Poll
枚举
Poll
枚举代表的是Future
当前的运行状态,其中提供的状态有两个。
Poll::Ready(T)
,用于表示Future
已经执行结束,将返回一个T
类型的返回值。Poll::Pending
,用于表示Future
当前还没有完成执行。如果Future
选择返回Poll::Pending
那么就需要确保自己被列入了唤醒队列,以使自己可以被再次唤醒。
Context
结构与Waker
结构
Context
位于标准库中的std::task
模块中,这个结构从它的名称上就可以看出来,它提供的是一个异步任务的运行环境上下文。Context
在目前只是提供了对于&Waker
结构类型的包装,可以允许便捷的访问公共的&Waker
。Context
主要提供了以下两个方法供使用。
fn from_waker(waker: &'a Waker) -> Context<'a>
,这个方法会创建一个Context
实例,并将一个&Waker
引用包装起来。这个方法在使用的时候需要注意,被包装的&Waker
引用,其生命期需要至少长于Context
实例的生命期。fn waker(&self) -> &'a Waker
,这个方法会返回Context
中包装的&Waker
引用。
在Context
中包装的Waker
结构类型的实例其实并不难理解它的用途。它的主要功能就是通知执行器唤醒一个被挂起的任务。Waker
实例一般都是由执行器创建并包装在Context
中的。一个Future
实例在执行的时候,如果选择返回Poll::Pending
进入等待,调用Waker.wake()
方法表示Future
已经产生了一些进展,需要执行器关注并处理。
Pin
特征
Pin
特征在之前的文章中已经提到过了,这个特征的主要功能就是将被包装的对象固定在内存中,使其不可被移动。那在Future
的poll
方法中为什么要使用Pin
来修饰&mut self
的引用呢?
首先要了解的一个情况就是有一种结构叫“自引用结构”,这种结构在其他的语言中并不少见,但是这个结构在Rust中,却不是安全的。例如有以下一个结构。
|
|
在这个结构中,name_ref
字段引用了name
字段,这样在Book
结构初始化的时候,一切都是正常的,name_ref
字段中保存的也是name
字段的地址。但是Rust会在所有权转移的时候在内存中移动值,所以当发生所有权转移的时候,name_ref
中保存的地址值,就不再是name
字段真正的地址值了,换句话说,谁也不知道此时的name_ref
究竟引用的是哪里的值。在这种情况下实际就引发了比较重大的安全隐患。为了解决这种自引用类型带来的安全隐患,Rust引入了Pin
、Unpin
这一系列特征。
Unpin
特征是非常好理解的,基本上所有Rust中的基本类型都是属于这一类的,它们在内存中的移动是安全的。
Unpin
是一个自动特征(auto trait),这表示你不需要担心它是怎么实现的,在哪里实现的。你所需要做的事情就是在不确定这个类型是否可以在内存中安全移动的时候,查阅文档看它是否实现了Unpin
特征。
Pin
则是用来包装一个指针,并且会阻止这个指针的移动。一个类型被Pin
包装以后,你就不能通过Pin<T>
获取被包装类型的所有权,所以也就无法移动被包装的类型T
。换一种更容易理解的说法就是Pin
是一个持有被包装类型T
的代理,你如果想访问T
,那么就必须通过代理来完成,而你虽然拥有代理的所有权,但是并不能取得被代理内容的所有权。
在绝大部分使用Future
特征的场景中,实现了Future
特征的异步运行结果实例基本上都是以匿名实例出现的,这就会让匿名Future
实例在内存中被移动,而且我们又不可能保证其中绝对不会存在自引用。这时就是Pin
发挥其作用的时候了。Pin
可以保证匿名Future
实例在移动过程中,其中的所有自引用都不会出现问题。
Pin
包装的内容是不能直接访问的,在访问的时候可以通过Pin::new()
再包裹一层或者使用.as_def()
来获取其中的内容。
其他可用特征与结构
以Future
特征为基础,std::future
模块还提供了其他若干比较实用的数据结构和功能。
IntoFuture
特征
IntoFuture
特征所表示的功能就跟其字面意思一样,表示实现了这个特征的数据结构可以被转换为一个Future
。这个特征常常用在.await
调用上,.await
会首先调用IntoFuture
中的.into_future()
方法将实例转换未一个Future
,然后再将其列入到异步处理中。
PollFn
结构
PollFn
结构跟FnOnce
、Fn
等结构一样,是用来描述函数类型的,只是PollFn
描述的是一个会返回Poll
结构类型的函数。std::future
里提供了一个poll_fn()
函数,可以用来快速创建一个PollFn
类型的函数。PollFn
实例可以直接使用.await
进行异步执行。
poll_fn()
函数的签名如下:
|
|
从poll_fn()
的签名可以看出来,这个函数可以将一个FnMut(&mut Context<'_>) -> Poll<T>
类型的胡转换为一个PollFn
的实现了Future
特征的结构。
Pending
结构
Pending
结构是一个特殊的实现了Future
的结构,它表示Future
将永远不会结束。跟PollFn
结构一样,Pending
结构可以实用std::future
中提供的pending()
函数来创建。
Ready
结构
Ready
结构的功能与Pending
结构的功能正好相反,Ready
结构将创建一个立刻结束的实现Future
的结构,同样的,它可以实用std::future
中提供的ready()
函数来创建。
编写异步程序
异步程序的编写有一个特点,如果程序中的一个函数或者语句块返回了Future
,那么所有调用这个函数或者语句块的位置也都将返回Future
。所以如果要编写实用了异步的程序,那么推荐整个程序都采用异步结构。
使用异步运行时
任何一个异步程序都需要一个异步运行时的支持,目前Rust中最为常用的异步运行时主要有Tokio和async-std两个。其中Tokio作为一款发展了很久的异步运行时,生态环境是非常好的,再实用过程中有大量的功能库可供使用。相比之下,async-std的特点则是足够新,完全基于std::future
模块实现,没有什么历史包袱,但缺点也是太新,生态环境有些不足,对于各个功能库的选择余地小。
异步运行时的启动都是采用宏标记在main()
函数上的,两个运行时在启动运行时的时候,书写方法基本上是相同的。
|
|
从上面这两个示例中可以看出,异步运行时的启动就是依靠#[tokio::main]
这样的宏标记,而且不同的运行时基本上都提供了相同的宏。
编写异步函数
其实编写一个异步函数并不需要使用到Future
特征,函数只需要使用async
关键字进行标记就可以了。例如编写一个访问Redis数据库中的指定键的函数。
|
|
async
关键字与fn
关键字结合的时候,其用途是定义一个异步函数。async
关键字还可以直接声明一个语句块,当async
修饰一个语句块的时候,会自动将语句块的返回结果转换为Future<Output = T>
。例如:
|
|
在编写异步函数或者异步代码块的时候,需要牢记的是async
标记的异步代码块是懒惰的,直到被执行器poll
或者使用.await
调用才会执行,并且使用.await
调用时,如果Future
没有完成,那么.await
就会让出当前线程的控制权,当Future
调用了Waker.wake()
的时候,执行器才会重新继续运行Future
,如此循环直到Future
完成运行。
手动启动异步任务
除了可以使用.await
启动异步任务以外,异步任务还可以直接在运行时中手动启动。手动启动启动异步任务一般都是通过运行时提供的.spawn()
函数来完成。
|
|
这种手动启动异步任务的方法很有用,可以跟多线程编程一样,创建仅会执行操作但不返回值的任务,例如定时任务等。
异步不适合处理什么
异步不是增强程序运行速度和并行容量的万灵药,异步也有一些场景是不合适合使用的。
- CPU密集型任务:CPU密集型任务会占用大量的CPU时间,无法通过出让时间片的方法使程序的并行处理能力得到加强。在处理这种类型的任务时一般只能通过开启多个线程来使每个CPU密集型任务充分利用CPU核心来加速。
- 读写大量文件的任务:虽然IO密集型任务非常适合使用异步来处理,但是大量文件读写的操作并不会因为程序采用了异步处理就会变得性能更好,这主要是受到了操作系统中文件系统的限制。
- 发送单个Web请求:异步运行时在响应Web请求的时候是可以通轮换活跃处理活动来使服务的并发能力提升的,但是在大宋单一Web请求的时候,程序能做的事情只有等待,所以在发送和处理单个Web请求的时候使用异步也不会有太大的性能提升效果。