浅显的原理第一篇: 状态的保存/变换
同系列传送门: rust-async
注: 参考了 <<Writing an OS in Rust>> 中的 async 篇,可以自己搜索下看看
为了更好地理解 Rust异步
背后的原理, 我们先了解下一些概念, 当然, 只是小补充而已:
现在,让我们开始吧!
抢占式 与 协作式, 是 多任务 的不同分类
抢占式多任务:
操作系统决定CPU的运行权
比如, 操作系统先让网易云音乐运行一会, 再让 VSCode 运行一会
各个程序中的任务们, 运行时间很短, 但切换速度也很快, 造成一种任务们同时运行的感觉
这是强迫/抢占的
协作式多任务:
任务们本身进行协调, 决定CPU的运行权
比如Rust中, 运行时调用某 Task(top-level Future) 的 poll 阻塞时, 返回 Poll::Pending
自己放弃继续执行, 并通知运行时执行其他Task
这是自愿/协作的, Task 们自愿放弃CPU的执行权
既然任务们能互相切换执行, 那么, 当再次轮到某任务执行时
该任务,应当从先前暂停的地方开始, 继续执行
因此我们应当备份某任务的先前状态, 以便于之后的继续执行, 这就是 状态保存
对于 抢占式 与 协作式, 处理 状态保存 的思路是不一样的:
反正, 你只需明白, 操作系统强制切换任务, 为每个任务分配相对公平的执行时间
但是, 代价也有, 比如不得不为每个任务保存它的所有状态, 内存开销大
yield
比如在Rust中, xxx.await
会执行一个Future
意味着当程序执行到这里时, 可能会yield
(poll返回Poll::Pending, 自愿放弃执行权)
xxx.await
就是一个stop/yield point
, 代表执行到此处时, 可能会发生暂停并yield
瞧, 所有可能yield的地方你都知道, 这就方便了保存状态(你可以舍弃已经不需要的中间变量)
因此, 我们可以准确分配Future执行所需要的最大空间
其实, 每个.await(也就是yield point)
, 就代表着一种状态
(之后会讲)
协作式的好处在于:
能自己掌握所有yield point
, 从而精打细算地选择最优的策略, 而非让操作系统为你暗地里处理
毕竟机器定义的分配策略, 总是没有比不过我们特意设计的
在任务暂停并转让执行权前, 准确保存下次继续所需要的状态
, 内存/性能优势很大
但坏处也有: 因为这是自愿/协作的, 当某个任务出现Bug, 永不放弃执行权, 其他任务便无法执行
在Rust中的异步, 我们之前也说过, 属于协作式多任务
而其状态保存
的实现, 就是利用状态机(state machine)
来实现
状态机(state machine)
一般指有限状态自动机
, 是一种数学模型
State_X=>State_Y
就叫变换
, 比如门的状态从Open=>Closed
稍微了解下状态机
的概念即可
那么现在, 就要看具体代码了
如果你想亲自运行一下, 先确保你的Cargo.toml
中的依赖如下:
[dependencies]
async-std = {version = "1", features = ["attributes", "unstable"]}
如下代码, 读取一个文件的行数
你可以通过 cargo run -- ./src/main.rs
运行
(通过target目录下的可执行文件, 加上参数后运行, 也可以哦)
use std::{
env::args,
process,
};
use async_std::{
prelude::*,
fs::File,
io::{self,BufReader},
};
#[async_std::main]
async fn main() -> io::Result<()> { // Start
let path = args().nth(1).unwrap_or_else(||{
eprintln!("Fuck you! No path for reading");
process::exit(1);
});
let file = File::open(path).await?; // Yield point
let lines = BufReader::new(file).lines();
let count = lines.count().await; // Yield point
println!("This file contains {} lines",count);
Ok(())
} // Done
mian()产生一个新实例, 异步运行open(path)
与count()
对于编译器, 每一个.await
其实都代表一种状态
Future实例, 实际上是个状态机
在这里共有四种状态/阶段(其中包括两个.await point(yield point)
):
这些状态的含义是: 表示当前Future执行到了哪一个阶段
执行器调用当前Future的poll推动进度时, 若在某一阶段
返回了Poll::Pending
, 则放弃执行权
当再次调用当前Future的poll推动进度时, 则从上次暂停的状态恢复(resume)
, 继续执行
官方目前似乎采用Generator(生成器)
为异步语法生成状态机, 每个状态都会存储一些数据, 便于进入下一个状态
Note 1:
Start状态, 会存储传入函数的参数(如果有参数)
Note 2:
Done状态下, 若进行resume, 则可能得到panic! 因为恢复已结束的计算是不可能, 也不应该的
Note 3:
怎么判断每个状态需要保存哪些变量? 很简单的一句话:
某个yield point之前定义, 且point之后还需要使用的变量
如上段代码中的 file
与 count
就需要被保存, 因为从暂停中恢复后还需要使用
其他的变量, 是一次性执行完的, 不会出现什么暂停一下, 等会继续的现象
所以不需要特殊的持久化操作
当每个状态存储数据时, 可能会导致发生自引用
, 比如:
async fn example() -> i32 { // Start
let arr = [1, 2, 3];
let element = &arr[2];
write_file("foo.txt", element.to_string()).await; // Yield1
*element // Done
}
这里有三个状态: Start
, Yield1
, Done
, 而在Yield1
这个状态下, 存储的数据如下:
// 状态的定义
struct Yield1State {
arr: [i32; 3],
element: *const i32, // 数组最后一个元素的地址
}
// 状态的实例
Yield1State {
arr: [1,2,3],
element: &arr[2] as *const i32,
}
等等, 你可能会对上面的代码有疑惑:
不是说, 状态只需要保存 在yield point之前定义, 并且该point之后还需要使用的变量
吗
上面的代码, 只有element这个引用需要被保存吧?
这是因为, 它是引用
嘛! 没了引用
背后的实际数据, 那它还有啥用, 不就是悬垂引用
了吗?
因此我们还得保存, 该引用
指向的背后数据: arr
, 并一起存储在同个struct(代表同一状态)
中
像这种 结构体内部的指针, 指向结构体本身
, 就叫 自引用结构
比如上面代码: 同一结构体下, 成员element指向了成员arr
如上所述, 这可能导致悬垂引用的产生, 若该struct实例的内存地址发生改变
, 如使用 std::mem
, 让struct实例的内存地址发生移动
以上面的那段代码为例, 如下:
值仍然是 [1, 2, 3]
先前arr的地址
, 注意, 是先前的 ! 而不是后来arr的新地址
这就产生了一个问题: 引用可能是失效/错误的(悬垂引用)
官方提出了std::pin::Pin(trait)
来解决自引用结构体方面的悬垂指针/引用问题
其实你听名字也很好理解, Pin(中文意思是钉子)
的作用是, 防止内存地址发生改变(给爷钉死吧!)
但是它又是怎么pin住的呢? 下一节再慢慢讲吧, 那又是一个值得探讨的话题了
欲知后事如何, 且听下回分解
(疯狂省略, 因为我累死了, 多么希望赶紧结束这一P啊 ! !)
上一篇: p3~> Future