rust-async-p4~> 状态的保存与变换
<~~ 发表日期:2021-09-12 | 本文词数:2203 | 预计阅读时间:12分钟 ~~>

浅显的原理第一篇: 状态的保存/变换

同系列传送门: rust-async

开篇

: 参考了 <<Writing an OS in Rust>> 中的 async 篇,可以自己搜索下看看
为了更好地理解 Rust异步背后的原理, 我们先了解下一些概念, 当然, 只是小补充而已:

  • 多任务: 抢占式与协作式
  • 状态机
  • 自引用结构体

现在,让我们开始吧!


两种多任务

介绍

抢占式 与 协作式, 是 多任务 的不同分类

  • 抢占式多任务:
    操作系统决定CPU的运行权
    比如, 操作系统先让网易云音乐运行一会, 再让 VSCode 运行一会
    各个程序中的任务们, 运行时间很短, 但切换速度也很快, 造成一种任务们同时运行的感觉
    这是强迫/抢占的

  • 协作式多任务:
    任务们本身进行协调, 决定CPU的运行权
    比如Rust中, 运行时调用某 Task(top-level Future) 的 poll 阻塞时, 返回 Poll::Pending
    自己放弃继续执行, 并通知运行时执行其他Task
    这是自愿/协作的, Task 们自愿放弃CPU的执行权

状态的恢复/保存

既然任务们能互相切换执行, 那么, 当再次轮到某任务执行时
该任务,应当从先前暂停的地方开始, 继续执行
因此我们应当备份某任务的先前状态, 以便于之后的继续执行, 这就是 状态保存

对于 抢占式 与 协作式, 处理 状态保存 的思路是不一样的:

  • 抢占式:
    因为是强迫切换执行的, 任务会在任意某个时刻被中断
    任务此时运行到了哪里?我们不知道啊!
    那么, 就只好将任务的所有状态全部保存, 包括调用栈(call stack)

反正, 你只需明白, 操作系统强制切换任务, 为每个任务分配相对公平的执行时间
但是, 代价也有, 比如不得不为每个任务保存它的所有状态, 内存开销大

  • 协作式:
    因为是自愿/协作地切换执行, 每个任务会在哪里放弃执行都是清楚的
    这种放弃执行权的操作, 我们称为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)一般指有限状态自动机, 是一种数学模型

  1. 状态(state):
    比如有一扇门, 它的状态就处于以下两种之一: Open or Closed
  2. 事件(event):
    某事件发生后, 会触发相应动作, 可能改变状态
    比如: 用钥匙锁门(event)=>关门(action)=>门被锁住(state)
  3. 动作 (action):
    事件发生后, 会触发动作
  4. 变换 (transition):
    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)):

  1. Start: 此Future刚开始执行
  2. Yield1: 第一个 yield point
  3. Yield2: 第二个 yield point
  4. Done: 此Future执行完毕

这些状态的含义是: 表示当前Future执行到了哪一个阶段
执行器调用当前Future的poll推动进度时, 若在某一阶段返回了Poll::Pending, 则放弃执行权
当再次调用当前Future的poll推动进度时, 则从上次暂停的状态恢复(resume), 继续执行

官方目前似乎采用Generator(生成器)为异步语法生成状态机, 每个状态都会存储一些数据, 便于进入下一个状态

Note 1:
Start状态, 会存储传入函数的参数(如果有参数)

Note 2:
Done状态下, 若进行resume, 则可能得到panic! 因为恢复已结束的计算是不可能, 也不应该的

Note 3:
怎么判断每个状态需要保存哪些变量? 很简单的一句话:
某个yield point之前定义, 且point之后还需要使用的变量
如上段代码中的 filecount就需要被保存, 因为从暂停中恢复后还需要使用
其他的变量, 是一次性执行完的, 不会出现什么暂停一下, 等会继续的现象
所以不需要特殊的持久化操作


自引用结构体

保存引用

当每个状态存储数据时, 可能会导致发生自引用, 比如:

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实例的内存地址发生移动
以上面的那段代码为例, 如下:

  • arr:
    内存地址会跟着结构体实例而改变, 但是, 值仍然是 [1, 2, 3]
  • element:
    内存地址会跟着结构体实例而改变, 但是, 值仍然是先前arr的地址, 注意, 是先前的 ! 而不是后来arr的新地址

这就产生了一个问题: 引用可能是失效/错误的(悬垂引用)
官方提出了std::pin::Pin(trait) 来解决自引用结构体方面的悬垂指针/引用问题

其实你听名字也很好理解, Pin(中文意思是钉子)的作用是, 防止内存地址发生改变(给爷钉死吧!)
但是它又是怎么pin住的呢? 下一节再慢慢讲吧, 那又是一个值得探讨的话题了

欲知后事如何, 且听下回分解
(疯狂省略, 因为我累死了, 多么希望赶紧结束这一P啊 ! !)