Rust随笔(七)
Rust随笔(七)
我们前面介绍了Arc<Mutex<T>>这个东西,本篇我们以此为引子,打开Rust编程中异步编程的门。
什么是异步编程
几乎每一个编程语言都能做到异步编程,那么什么是异步编程呢?这里举一个例子:
- A要去做n件事情
- 这些事情并不会立马出结果,并且需要等待,每件事情假设处理的时间为m,等待的时间为w
- 要在尽可能短的时间内做完并收集起来(假设每件事情收集完成的速度为t)
- 已知这些事情并没有关联 那么假设我们一件事情一件事情的做,那么所有事情做完就等于
n * (m + w + t),公式看起来不大,但是如果时间单位是秒,n是一个很大的数(比如说1百万),那么处理起来就很吓人了,从上面我们得到一个条件,就是每一件事情没有关联,那么我们就可以在处理这些事情的时候,在等待期间就可以做其他事情,那么按照这样的思路做,时间就变成了(n * m) + w + (n * t)(调度的时间在这里为了简化所以忽略不计),这样看时间是不是少了一个数量级,如果我们加多一些人手来做,假设添加了p个人,那么时间就变成了(n * (m + t)) / p + w,这样是不是又少了一个数量级。所以这个就是异步编程的核心思想: 协作式调度与非阻塞等待。任务在遇到需要等待的操作(如网络请求)时,会主动让出执行权,这样同一个工作线程就可以立刻去处理其他准备就绪的任务。这极大地提高了在 I/O 密集型场景下的资源利用率。 这里只是大致的描述一下异步编程,异步编程本身又非常多的概念,比如说并行,并发,同步,异步,资源调度等等。这些概念可以自行了解。接下来,我们介绍一下操作异步的基本单位:协程,与之相关的:线程和进程。
进程、线程、协程
什么是进程?
在计算机科学里面最核心的定义是:一个正在执行中的程序的实例,什么意思呢,就是当你在操作系统中打算打开一个程序时,操作系统会响应并且将程序自动加载到内存当中,然后程序根据自身的逻辑选择临时或者永久的停留在内存中,那么这个"运动"中的程序就叫进程。
什么是线程?
线程是被包含在进程之中,是进程中的一个实际执行流。简单来说,如果进程是程序运行的“容器”或“环境”,那么线程就是在这个环境中真正执行代码指令的“执行者”。我们在代码中创建出来的一个main函数,他就是运行在一个线程中的,我们称之为"主线程"。
什么是协程?
协程,有时也称作“微线程”或“纤程”,是一种比线程更加轻量级的程序组件。 与由操作系统强制调度(抢占式)的线程不同,协程是协作式的:它们会主动地在程序中预设的特定点(例如,等待网络数据时)暂停自己的执行,并将CPU的控制权让出给其他协程。 最关键的一点是,协程的调度和管理完全发生在用户空间 (user-space),而不是由操作系统内核来管理。
那么这三个有什么用呢,这个三个角色组成了整个现代异步编程的基础,我们处理事情几乎在一个主线程中完成的,当我们需要异步的处理一些事情时(上面那个例子),就需要创建一个线程或者协程去处理了,那么这个就叫多线程程序了,那么在rust中,这些需要如何实现呢?首先我们先介绍一下rust的异步模型。(这个模型就是负责描述异步过程中产生的资源调度,资源回收,等等的这些异步功能,这些资源指代的包括协程线程进程在内的所有在异步行为中用到的东西)
rust的异步模型
Rust 的异步模型是一个非常独特且强大的系统,其设计目标是实现内存安全、高性能且无运行时的并发。它与许多其他语言的异步模型在设计哲学上有所不同。 其核心可以概括为以下几个关键点:
Future Trait
- 在 Rust 中,一个异步操作被抽象为一个实现了
Futuretrait 的对象。 - 一个
Future代表了一个未来某个时刻才会完成的计算。你可以把它看作一张“提货单”,你拿着它,但货物(计算结果)还没准备好。 Future是惰性 (Lazy) 的:它本身什么也不做,直到你驱动它(轮询它)为止。
async / .await
async:一个用async关键字标记的函数或代码块,在被调用时并不会立即执行,而是会返回一个实现了Future的对象。
async fn my_async_function() -> u8 {
5
}
// 调用 my_async_function() 会立即返回一个 Future<Output = u8>
.await:这个关键字用于等待一个Future完成。当代码执行到.await时,如果Future还没有准备好,它会非阻塞地暂停当前任务的执行,并将CPU的控制权交还给调度器,让CPU可以去执行其他任务。当等待的操作完成后,调度器会唤醒这个任务,从.await的地方继续执行。
分离的执行器 (Executor) 和运行时 (Runtime)
- 这是 Rust 异步模型最独特的地方。Rust 的标准库只提供了
Futuretrait 和async/await语法,但不包含一个实际运行这些Future的执行器。执行器是一个库,它的职责是接收一堆Future任务,并持续地轮询(poll)它们,直到它们完成为止。 - 开发者需要自己选择一个异步运行时库,最流行的有
tokio和async-std。这种“自带运行时”的模式给了开发者极大的灵活性,可以根据应用场景(如嵌入式、Web服务器、桌面应用)选择最合适的运行时。(这也导致了rust异步生态的兼容性问题,两个不同的运行时不能兼容,目前来说并没有什么好的解决方案,所以目前一致采用tokio为默认运行时)
零成本抽象 (Zero-Cost Abstraction)
Rust 的 async/await 在编译时会被转换成一个非常高效的状态机。每次 .await 调用并不会像某些语言那样在堆上产生额外的内存分配。整个异步函数的上下文和状态都存储在一个单一的、在编译期就确定好大小的结构体中。这使得 Rust 的异步代码在性能上可以与手写的回调或状态机相媲美,同时享受着高级语法带来的便利。
安全保证
- Rust 的所有权和借用检查系统在异步代码中依然有效。
- 通过
Send和Synctrait,编译器可以在编译期就检查一个Future是否可以安全地在线程间移动,从而防止数据竞争。(这就是我在随笔6提到的Arc<T>和Mutex<T>) - 通过
Pin类型,Rust 解决了异步状态机中可能出现的自引用指针(self-referential pointers)问题,保证了内存安全。 以上就是Rust的异步模型,接下来我们写一个例子来理解这些东西
异步示例
我们要编写一个程序,它可以并发地抓取一个 URL 列表,提取每个网页的标题,并将结果(URL -> 标题)安全地存入一个共享的集合中。
- 所有并发任务都需要向同一个“结果集合”中写入数据。
- 每个 URL 的抓取任务都将作为一个独立的协程来运行。
tokio运行时会管理一个线程池来执行这些协程。 首先创建项目,并添加tokio和reqwest:
cargo new url-title
cargo add tokio -F full
cargo add reqwest
其次,在main函数中编写代码:
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex; //使用 tokio 提供的异步 Mutex
// 这个异步函数负责抓取单个 URL 并提取标题
async fn fetch_title(url: &str) -> Result<String, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
// 使用 split 来查找和截取
let title = body
.split_once("<title>") // 从 <title> 处将字符串分割成两部分
.and_then(|(_, after_title_tag)| { // 只关心 <title> 之后的部分
after_title_tag.split_once("</title>") // 在这部分里,再从 </title> 处分割
})
.map(|(title_content, _)| title_content.to_string()) // 只关心 </title> 之前的部分,即标题内容
.unwrap_or_else(|| "No title found".to_string()); // 5. 如果任何一步分割失败,返回默认值
Ok(title.trim().to_string()) // 最后 trim 一下,去掉可能的空白字符
}
// #[tokio::main] 宏会自动设置并启动 Tokio 异步运行时
#[tokio::main]
async fn main() {
// 2. 设定场景:要抓取的 URL 列表和一个用于存储结果的共享状态
// 我们使用 Arc<Mutex<...>> 来让多个协程安全地共享和修改 HashMap let results = Arc::new(Mutex::new(HashMap::new()));
let urls_to_fetch = vec![
"https://www.rust-lang.org/",
"https://tokio.rs/",
"https://docs.rs/",
"https://crates.io/",
"https://www.rust-lang.org/this-is-a-404", // 一个无效地址用于演示错误处理
];
let mut handles = vec![];
println!("开始并发抓取 {} 个 URL...", urls_to_fetch.len());
for url in urls_to_fetch {
// 为每个 URL 创建一个协程(异步任务)
// 克隆 Arc 指针,以便将其所有权移动到新的协程中
let results_clone = Arc::clone(&results);
// tokio::spawn 启动一个新的协程,它会在 Tokio 的线程池上并发执行
let handle = tokio::spawn(async move {
println!("开始抓取: {}", url);
match fetch_title(url).await {
Ok(title) => {
// 安全地修改共享状态
// .lock().await 会异步地等待获取锁
let mut results_map = results_clone.lock().await;
results_map.insert(url.to_string(), title.clone());
println!("成功抓取 '{}': {}", url, title);
}
Err(e) => {
eprintln!("抓取 '{}' 失败: {}", url, e);
}
} // MutexGuard在这里离开作用域,锁会自动释放
});
handles.push(handle);
}
// 等待所有协程执行完毕
for handle in handles {
handle.await.unwrap();
}
// 打印最终的聚合结果
println!("\n所有任务完成");
println!("抓取结果:");
let final_results = results.lock().await;
for (url, title) in &*final_results {
println!("- {}: {}", url, title);
}
}
执行结果:
开始并发抓取 5 个 URL...
开始抓取: https://tokio.rs/
开始抓取: https://www.rust-lang.org/this-is-a-404
开始抓取: https://docs.rs/
开始抓取: https://www.rust-lang.org/
开始抓取: https://crates.io/
成功抓取 'https://docs.rs/': Docs.rs
成功抓取 'https://www.rust-lang.org/this-is-a-404': 404 - Rust Programming Language
成功抓取 'https://www.rust-lang.org/': Rust Programming Language
成功抓取 'https://crates.io/': No title found
成功抓取 'https://tokio.rs/': No title found
所有任务完成
抓取结果:
- https://crates.io/: No title found
- https://docs.rs/: Docs.rs
- https://www.rust-lang.org/this-is-a-404: 404 - Rust Programming Language
- https://www.rust-lang.org/: Rust Programming Language
- https://tokio.rs/: No title found
本文探讨了 Rust 独特的异步模型:它将语言特性(async/await)与社区运行时(如 tokio)分离,以非阻塞的方式高效处理 I/O 密集型任务。在并发网页抓取的示例中,我们组合了 tokio::spawn 创建协程,并利用 Arc<Mutex<T>> 实现了跨任务的安全状态共享与修改。这体现了 Rust 将其所有权、类型安全和零成本抽象等核心哲学延伸至并发领域的思想。