之前我们学完官方提供的入门文档
接下来我们来学习异步编程,不过文档还有些地方未完成。。
Getting Started - Asynchronous Programming in Rust (rust-lang.github.io)
Getting Started!
异步这玩意儿相信各位后端都能明白它的重要性,即使是在前端,那也是很有用的。
我们接下来将会了解到下面三部分:
rust
对它的特殊看法异步编程(下面直接叫”异步“了)是一种并发编程(concurrent programming
)模型,在很多编程语言中都有。
有了它,我们可以用少量的操作系统线程来处理一堆并发任务。
和js
的类似,也是async/await
语法。
并发编程相对于常规的顺序(同步)编程来说是比较不成熟的和不标准化的(standardlized
)。
所以这里先明确下接下来准备说的并发编程模型是基于rust
自身支持的并发编程模型。
下面是几种除异步之外最常见的并发模型:
OS threads
:不需要改动编程模型,这使得并发变得简单很多。但是多线程异步编程则变得困难许多,同时性能开销上也会变大,毕竟需要开很多线程。我们之前学的线程池可以缓和部分性能开销,但是对于大规模密集型工作来说还是不够的。Event-driven programming
:搭配callbacks
也就是回调,性价比高很多,但是代价则是冗长繁杂的写法、非线性(non-linear
)工作流(比如前端的回调地狱)。以及很难跟踪数据和错误产生的地点。(我作为前端切图仔,深有体会~)Coroutines
:和多线程类似,并不需要改动编程模型,这也就意味着用起来比较简单。同时它也和异步类似,可以支持大量的任务。而代价则是高度抽象,一些重要的底层细节都不会暴露出来。这也就意味着你想去自定义一些较底层的特性是很难实现的。The actor model
:actors
指的是将所有的并发计算切割成一小个一小个的单元(unit
),这些个单元通过易错信息(fallible message
)来沟通,比较像分布式系统(distributed systems
)。它可以有效的实施,但是它还有一些实用性理论比如控制流(control flow
)和重试逻辑(retry logic
)还没完善。虽然很多语言中都有异步,但是它们的底层实现细节是各种各样的。
rust
中异步和其他语言中的不同点主要有以下几点:
Futures are inert
:只有polled
之后才会继续执行。可以通过drop
掉futures
来停止执行。Async in zero-cost
:async
是无损耗的,损耗是来自于你写了啥,而不是异步这个行为。No built-in runtime
: 内部不提供runtime
的,如果有需要,就去社区找下。Both single- and multithreaded
:在rust
中单线程和多线程的runtime
都是有的,各有各的特长和问题。在rust
中如果不想使用异步,那么一般替代方案就是OS threads
了。
比如我们之前用过的std::thread
或者线程池。它俩互相重构往往需要大量的代码。
所以开发前确定好选择哪种并发模型能事半功倍。
如果你的项目并发量不大,也就是任务少,那么首选OS threads
,因为简单。
这些线程都是来自CPU
的,所以会有内存开销。创建/切换线程是非常昂贵的行为,即使是空闲线程也会吃资源。
之前也说过,线程池可以缓解开销昂贵这一问题,但也仅仅只是缓解。
但是它简单~不需要改变编程模型。在一些操作系统中,你还可以改变一个线程的优先级,这对于驱动器(drivers
)和其它延迟灵敏(latency sensitive
)的应用来说是很有用的。
而异步则可以明显的减少对于CPU
和内存的开销,尤其是在大规模密集型任务中,比如服务器、数据库。
其它和OS threads
差不多。
而代价则是大量的binary blob
,二进制斑点。它们来自async function
生成的状态机(state machines
)。因为每个可执行文件都被捆绑到一个异步runtime
上。
这里并不是说异步编程比多线程好,只是单纯的列举出不同点(?)。简单的场景直接用线程即可。
我们来看个例子,同时下载两个页面。
如果用常规的操作系统线程模型来实现,那么大概是这样
开俩子线程,然后让主线程等待它们下载完毕再释放。
但是这么做未免有些奢侈了,因为前面说过创建线程是一个昂贵的行为。对于大些的应用来说,这样很容易就会达到性能瓶颈。
而使用异步,我们就不用创建额外的线程了。
可以看到我们并没有创建子线程,一切都是静态分配的,所以不会有堆内存分配。
不过前提是你得声明这是个异步的,这我们之后会学到。
rust
并不会强迫你选择其中之一作为并发模型,你也可以两个都用。
甚至是你可以选择其它的并发模型作为编程模型。
用,都可以用~
rust
的异步现在还处于完善过程中,它里面有部分和同步一样的稳定,另一部分还在完善过程中。
使用异步,我们可以期望(expect
)以下的行为/东西:
runtime
性能pinning
compatibility constraint
),比如同步代码和异步代码之间、不同异步runtime
之间。higher maintenance burden
),因为还不成熟。。。后面有可能大改(不太可能,毕竟对于编程语言来说破坏性更新应该很少见,更多应该是兼容性更新)。。(这是我能期望的吗?)简单的说,异步可以用来提高runtime
性能(官方用best-in-class
,也就是同届中最好的~)。代价则是:难用以及高维护成本,不过随着时间的演进,async rust
会趋于完善成熟,维护成本会逐渐降低。(但是还是难用啊?)
虽然rust
自身对异步编程是支持的,但是大多数异步应用依赖的功能都是来自社区提供的crates
。
和这些应用同样,我们也需要依赖下面这些由库提供的功能和rust
自身提供的支持:
trait/type/function
,比如Future
这个trait
,它由标准库提供。async/await
语法,这玩意儿由rust
编译器直接支持。futures
这个crate
提供的工具类型/宏/函数。IO
和任务衍生(task spawning
),这些都由async runtime
提供。比如Tokio
何async-std
。绝大多数异步应用和一些异步crate
都依赖这个runtime
。具体可以看这一章:https://rust-lang.github.io/async-book/08_ecosystem/00_chapter.html注意:有些同步的特性当前并不能用于异步当中。这其中尤其需要注意的是:rust
并不允许给traits
声明异步method/function
。所以如果有这种需求,只能用别的方式替代,而这自然就会把代码变得有些冗杂。
对于大部分异步代码来说是和同步没啥差别的。
不过有几点不同需要注意:
compilation errors
异步编译错误也是遵守同步的高要求标准。
不过由于异步的场景一般都是比较复杂的,依赖的语言特性也杂,比如lifetimes
和pinning
,所以你可能更频繁的遇到(encounter
,还是邂逅浪漫点)错误。
Runtime errors
只要编译器遇到一个异步函数,它就会内部不可见的(under the hood
)创建一个状态机。
异步的堆栈跟踪信息(Stack traces
)中一般都包含来自这些状态机的信息,当异步函数在runtime
进入调用栈的时候。所以异步编程中这一块可以着重关注下。
New failure modes
一些新奇的错误模式在异步编程中是可能的,比如你调用了一块堵塞的(blocking
)异步上下文(context
)或者不正确地实现Future
这个trait
。这些错误会悄悄滴绕过编译器甚至是单元测试。所以我们需要了解异步编程的概念,这也是这文档的目标。
compatibility considerations
)同步和异步代码并不是所有时候都是可以自由组合的,比如你不能直接从同步函数中调用异步函数。
异步和同步的代码一般推荐使用不同的设计模式,这样不容易混合。
甚至对于异步代码来说,它自己也并不是所有时候都可以自由搭配异步代码的。有些crates
依赖于特定的async runtime
,这一般会在crate
的依赖列表中指明。
上面提到的这些兼容性问题会限制你的选择,所以在写代码之前,你需要明确这几个点,选择可用的async runtime
。
不过也不用太担心~
performance characteristics
)异步的性能问题取决于你的代码是基于哪个async runtime
来实现的。虽然异步在rust
中是比较新的东西,但是它在大部分地方性能都挺不错(你人也很不错.jpg)。
尽管如此,大部分异步生态系统(async ecosystem
)采用的是multi-threaded runtime
。这也就意味着相比于单线程异步应用来说,多线程异步很难完全的发挥异步理论上的(theoretical
)性能优势。
业界管这叫做cheaper synchronization
也就是廉价的同步。
另一个被忽略的例子就是latency sensitive tasks
也就是延迟敏感行任务,对于驱动器(drivers
)、GUI
应用等来说是非常重要的。这些任务依赖于runtime and/or OS
的支持,这样就能提前安排(scheduled appropriately
)。
primer
)async/.await
是一个内置的工具,它可以让我们把异步代码写的根同步的差不多。(大概可以参考前端用async/await
简写promise
,并且长得像同步代码,直接说成语法糖不就得了)。
async
把一块堵塞(block
)代码转换成实现了Future
这个trait
的状态机。
尽管同步方法中调用一块阻塞的(blocking
)函数会导致整个线程都堵塞,但是堵塞一块Future S
会让出当前线程的控制权,这样其它的Future S
就可以执行了。
让我们来写个demo
先加入依赖
然后我们来创建异步函数,关键字是async function
我们先把block_on
这个方法注释掉,然后cargo run
一下
可以看到什么都没有打印出来,因为async function
返回的是一个Future
,它需要excutor
来执行它。
然后我们把block_on
注释回来,发现正常打印了
我们使用block_on
方法阻塞当前线程,让它等待future
执行完毕再解开。
这个例子不太好体现异步代码同步样子
我们再来看个例子
我们声明了三个异步函数,然后分别用block_on
来包裹,其中learn_song
这个执行完毕的返回值传给sing_song
。
猜下这里执行顺序,理论上是按顺序输出,我们来看下结果
确实是按顺序来的。
这回应该就看的清楚了,就像是在写同步代码一样。
但是一直用这个block_on
挺烦的,有没有其它更优雅一点的呢?
这不还有个.await
嘛~
我们来改下代码。
这其实就是语法糖写法。
由于main
函数我们不能定义为async
,所以抽了一个run
方法作为入口。
然后把learn
和sing
这两个过程合并了,为什么呢?因为learn
返回的是一个struct
Song
,它没有实现Future
这个trait
,所以它不能被join!
这个宏接收,所以只能又套一个异步函数包裹,这个异步函数没有返回值。
你应该会想:那去掉这个learn_song
的.await
不就是Future
类型了?实际上确实是返回Future
类型,但是,你这个sing_song
的方法需要这个learn_song
的返回值作为参数,所以还是会报错。
那如果把sing_song
的参数类型改成Future
呢?
这样也不行,因为它要求这个Future
的T
是一个trait
。
应该还是可能有办法实现的,但是这样成本就变得较高了,所以不如直接用一个异步函数套一层完事。
今天是开头,基本都是理论~
发布于 2023-01-28 02:17・IP 属地广东