【Rust每週一知】Rust 異步入門
原創 洋芋 Rust語言中文社區 前天
這是一篇博文翻譯,略有刪減,整理代碼方便統一閱讀,Github鏈接:https://github.com/lesterli/rust-practice/tree/master/head-first/async-primer。
原文在2月11號的【Rust日報】中給大家推薦過, 原文鏈接: https://omarabid.com/async-rust
本文並不全面介紹Rust異步主題。如果對新的async/await關鍵字Futures感到疑惑,並且對Tokio的用途很感興趣,那麼到最後應該會不再毫無頭緒。
Rust異步技術是Rust領域的新熱點,它被譽爲Rust的重要里程碑,特別適合開發高性能網絡應用程序的人們。
讓我們從頭開始。
什麼是異步?
關於Async,我給一個簡短的版本:如果有一個處理器,想同時執行(類似)兩項任務,將如何做?解決方案是先運行第一個任務,然後切換並運行第二個任務,然後再切換回去,依此類推,直到完成兩個任務。
如果想給人以計算機同時運行兩個任務的感覺(即多任務處理),則此功能很有用。另一個用例是IO操作。當程序等待網絡響應時,CPU處於空閒狀態。這是切換到另一個任務的理想時間。
那麼我們如何編寫異步代碼?
首先,讓我們從一些同步代碼開始。
同步代碼
讓我們做一個簡單的程序,該程序讀取兩個文件:file1.txt和file2.txt。我們從file1.txt開始,然後移至file2.txt。
我們將程序分爲兩個文件:main.rs和file.rs。file.rs有一個函數:read_file,在main.rs中,用每個文件的路徑爲參數調用此函數。參見下面代碼:
// sync-example/src/file.rs
use std::fs::File;
use std::io::{self, Read};
pub fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path)?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
Ok(buffer)
}
// sync-example/src/main.rs
use std::io;
mod file;
fn main() -> io::Result<()> {
println!("program started");
let file1 = file::read_file("src/file1.txt")?;
println!("processed file 1");
let file2 = file::read_file("src/file2.txt")?;
println!("processed file 2");
dbg!(&file1);
dbg!(&file2);
Ok(())
}
使用cargo run編譯並運行程序。該程序應該毫無意外地運行,但是請確保已在src文件夾中放置了兩個文件(file1.txt和file2.txt)。
program started
processed file 1
processed file 2
[src/main.rs:14] &file1 = "file1"
[src/main.rs:15] &file2 = "file2"
到目前爲止,一切都很好。如果需要在處理file2.txt之前先處理file1.txt,那麼這是唯一的方法。但是有時不必關心每個文件的處理順序。理想情況下,希望儘快處理文件。
在這種情況下,我們可以利用多線程。
多線程方法
爲此,我們爲每個函數調用運行一個單獨的線程。由於我們使用的是多線程代碼,並且如果要訪問線程外部的文件內容,則必須使用Rust提供的同步原語之一。
這將如何影響代碼:file.rs將保持不變,因此這已經是一件好事了。在main.rs中,我們需要初始化兩個RwLock;這些將稍後在線程中用於存儲文件內容。
然後,我們運行一個無限循環,嘗試讀取這兩個變量的內容。如果這些變量不爲空,則我們知道文件處理(或讀取)已完成。 (這意味着文件不應爲空;否則,我們的程序將錯誤地保持等待狀態。另一種方法是使用Option並檢查Option是否爲None)。
此代碼需要crate lazy_static。
// multi-example/src/main.rs
use std::io;
use std::sync::RwLock;
use std::thread;
use lazy_static::lazy_static;
mod file;
// A sync primitive that allows to read/write from variables between threads.
// we declare the variables here, this requires the lazy_static crate
lazy_static! {
static ref FILE1: RwLock<String> = RwLock::new(String::from(""));
static ref FILE2: RwLock<String> = RwLock::new(String::from(""));
}
fn main() -> io::Result<()> {
println!("program started");
let thread_1 = thread::spawn(|| {
let mut w1 = FILE1.write().unwrap();
*w1 = file::read_file("src/file1.txt").unwrap();
println!("read file 1");
});
println!("Launched Thread 1");
let thread_2 = thread::spawn(|| {
let mut w2 = FILE2.write().unwrap();
*w2 = file::read_file("src/file2.txt").unwrap();
println!("read file 2");
});
println!("Launched Thread 2");
let mut rf1: bool = false;
let mut rf2: bool = false;
loop {
// read()
let r1 = FILE1.read().unwrap();
let r2 = FILE2.read().unwrap();
if *r1 != String::from("") && rf1 == false {
println!("completed file 1");
rf1 = true;
}
if *r2 != String::from("") && rf2 == false {
println!("completed file 2");
rf2 = true;
}
}
Ok(())
}
有趣的是,如果我們有一個非常大的file1.txt,我們將得到一個奇怪的輸出。首先處理第二個文件(讀取文件2);但在我們的循環內部,該程序似乎阻塞並等待第一個文件。
program started
Launched Thread 1
Launched Thread 2
read file 2
read file 1
completed file 1
completed file 2
多線程可能有點棘手,因爲我們必須考慮可能阻塞的原子操作。我們使用read函數來解鎖我們的變量,並且文檔對這種行爲發出警告。
使用共享的讀取訪問權限鎖定此rwlock,阻塞當前線程,直到可以獲取它爲止。
幸運的是,有一個try_read函數,如果無法獲取鎖,則返回Err。
嘗試使用共享的讀取訪問權限獲取此rwlock。
如果此時不能授予訪問權限,則返回Err。 否則,將返回RAII保護,當該保護被刪除時,該保護將釋放共享訪問。
在第二次嘗試中,我們使用try_read並忽略返回的Errs,因爲它們應該表示我們的鎖正忙。這有助於將程序移至下一個變量,並處理先準備好的變量。
// multi-example/src/main.rs
...
loop {
// try_read()
let r1 = FILE1.try_read();
let r2 = FILE2.try_read();
match r1 {
Ok(v) => {
if *v != String::from("") && rf1 == false {
println!("completed file 1");
rf1 = true;
}
}
// If rwlock can't be acquired, ignore the error
Err(_) => {}
}
match r2 {
Ok(v) => {
if *v != String::from("") && rf2 == false {
println!("completed file 2");
rf2 = true;
}
}
// If rwlock can't be acquired, ignore the error
Err(_) => {}
}
}
現在執行方式有所不同。如果file1.txt比file2.txt大得多,則應首先處理第二個文件。
program started
Launched Thread 1
Launched Thread 2
read file 2
completed file 2
read file 1
completed file 1
多線程的侷限性
如果我們已經有多線程,爲什麼我們需要異步?有兩個主要優點:性能和簡單性。產生線程很昂貴;從以上內容可以得出結論,編寫多線程代碼可能會變得非常複雜。
異步,關鍵字
Rust的重點是使編寫Async代碼儘可能簡單。只需要在函數聲明之前添加async/await關鍵字即可使代碼異步:函數聲明前async,解析異步函數await。
這聽起來很不錯。試一試吧。
use std::fs::File;
use std::io::{self, Read};
pub async fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path)?;
let mut buffer = String::new();
file.read_to_string(&mut buffer)?;
Ok(buffer)
}
use std::io;
mod file;
fn main() -> io::Result<()> {
let r1 = file::read_file("src/file1.txt");
let r2 = file::read_file("src/file2.txt");
let f1 = r1.await;
let f2 = r2.await;
dbg!(f1);
dbg!(f2);
Ok(())
}
但是這不能通過編譯,await僅在異步塊或函數中可用。如果我們嘗試運行此代碼,則編譯器將引發此錯誤。
error[E0728]: `await` is only allowed inside `async` functions and blocks
--> src/main.rs:9:14
|
5 | fn main() -> io::Result<()> {
| ---- this is not `async`
...
9 | let f1 = r1.await;
| ^^^^^^^^ only allowed inside `async` functions and blocks
我們可以使main函數異步嗎?不幸的是,事情並非如此簡單。我們得到另一個錯誤。
error[E0277]: `main` has invalid return type `impl std::future::Future`
--> src/main.rs:5:20
|
5 | async fn main() -> io::Result<()> {
| ^^^^^^^^^^^^^^ `main` can only return types that implement `std::process::Termination`
|
= help: consider using `()`, or a `Result`
但是,錯誤消息有點令人着迷。似乎async關鍵字使我們的函數返回Future而不是聲明的類型。
異步函數的返回類型是Future(確切地說是實現Future特性的閉包)。
那await呢?await在整個Future中循環直至完成。但是,還有另外一個謎團:Rust無法自解析Future。我們需要一個執行器來運行此異步代碼。
什麼是執行器?
如果回顧一下我們的多線程示例,會注意到我們使用循環來檢測何時處理文件。這很簡單:無限循環直到變量中包含某些內容,然後執行某些操作。如果讀取兩個文件,我們可以通過跳出循環來改善這一點。
一個異步執行器是循環。默認情況下,Rust沒有任何內置的執行程序。有許多異步運行時;async-std和Tokio是最受歡迎的。運行時的工作是輪詢異步函數(Future),直到它們最終返回一個值。
一個簡單的執行器
crate futures有一個非常基本的執行器,並且具有將兩個Future連接的函數。讓我們試一試。
以下代碼使用crate futures版本0.3.4。
// async-example/src/main.rs
use futures::executor::block_on;
use futures::join;
use std::io;
mod file;
fn main() -> io::Result<()> {
println!("Program started");
// Block on the final future
block_on(load_files());
Ok(())
}
async fn load_files() {
// Join the two futures together
join!(load_file_1(), load_file_2());
}
async fn load_file_1() {
let r1 = file::read_file("src/file1.txt").await;
println!("file 1 size: {}", r1.unwrap().len());
}
async fn load_file_2() {
let r2 = file::read_file("src/file2.txt").await;
println!("file 2 size: {}", r2.unwrap().len());
}
爲了驗證異步性,將一堆數據轉儲到file1.txt中。
Program started
file 1 size: 5399
file 2 size: 5
不幸的是,這看起來(確實)第一個文件函數再次阻塞了。
那麼異步到底是什麼?
與多線程類似,異步編程中也有一些陷阱和問題。事實是,async關鍵字不會神奇地使代碼異步;它只是使函數返回Future。仍然必須繁重地安排代碼執行時間。
這意味着函數必須迅速返回尚未準備就緒的狀態,而不是被困在進行計算的過程中。在我們的情況下,阻塞是特定在File::Open和file.read_to_string處發生的。這兩個函數不是異步的,因此會阻止執行。
我們需要創建這兩個函數的異步版本。幸運的是,一些使用async-std的人做了工作,將Rust中的std庫重寫爲異步版本。
使用async-std的文件IO
我們唯一要做的更改是將我們的std導入替換爲async_std。
對於以下示例,我們使用crate async-std版本1.5.0。
// async-example/src/file.rs
// We use async_std instead of std, it's that simple.
use async_std::io;
use async_std::fs::File;
use async_std::prelude::*;
pub async fn read_file(path: &str) -> io::Result<String> {
let mut file = File::open(path).await?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).await?;
Ok(buffer)
}
main.rs中的代碼保持不變;該程序仍使用crate futures中的block_on執行程序。
編譯並運行程序。(確保有一個大的file1.txt)
Program started
file 2 size: 5
file 1 size: 5399
最後!程序首先快速處理file2.txt,然後移至file1.txt。
讓我們回顧一下到目前爲止所學到的東西:
async使我們的函數返回Future。
運行我們的Future需要一個運行時。
運行時檢查Future是否準備就緒;並在就緒時返回其值。
總結
在這篇文章中,我們介紹了同步代碼,多線程代碼,Rust中的一些異步術語,async-std庫和簡單的Future實現。實際上,這是一個"輕量級"的介紹,爲簡潔起見,省略了許多細節。