在專業的軟體開發過程中,邊緣案例錯誤常是最難發現的問題之一。這類別誤在大多數情況下不會出現,但一旦觸發,可能導致嚴重的後果,尤其是在安全性敏感的環境中。玄貓在多年的開發經驗中發現,這類別誤幾乎是所有開發者都曾寫過的,而與通常藏在不易發現的角落。
邊緣案例錯誤可能導致未定義行為(undefined behavior),在安全性上下文發嚴重問題。因此,除了基本的單元測試外,我們需要更全面的測試策略來捕捉這些問題。
模糊測試:尋找隱藏的臭蟲
模糊測試(fuzzing)是一種自動化測試技術,透過向程式提供隨機或異常輸入,嘗試發現程式中的錯誤。在 Rust 中,cargo-fuzz
是一個強大的模糊測試工具,可以幫助我們發現傳統測試難以捕捉的問題。
使用 cargo-fuzz 設定模糊測試
讓我們從建立一個簡單的模糊測試開始。首先,我們需要初始化 cargo-fuzz
的基本結構:
cargo fuzz init
執行這個命令後,cargo-fuzz
會在專案中建立以下結構:
.
├── Cargo.lock
├── Cargo.toml
├── fuzz
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── fuzz_targets
│ └── fuzz_target_1.rs
└── src
└── lib.rs
這個命令在我們的專案中建立了一個名為 fuzz
的子目錄,其中包含一個獨立的 Cargo 專案。fuzz_targets
目錄中的 fuzz_target_1.rs
是我們的第一個模糊測試目標檔案。我們可以使用 cargo fuzz list
命令列出所有模糊測試目標,執行後會顯示 fuzz_target_1
。
編寫模糊測試
假設我們有一個含有錯誤的字元串解析函式,這個函式檢查字元串是否僅包含數字,包括負數。此函式使用正規表示式比對 1-10 位數字,可選地以「-」為字首。
現在,我們需要編寫模糊測試來發現這個函式的潛在問題。模糊測試的關鍵在於提供隨機輸入,因此我們將使用 Arbitrary
特性來派生所需形式的資料。
#![no_main]
use arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
#[derive(Arbitrary, Debug)]
struct Input {
s: String,
}
fuzz_target!(|input: Input| {
use fuzzme::parse_integer;
parse_integer(&input.s);
});
這個模糊測試的結構非常簡單但功能強大:
#![no_main]
屬性表示這個程式沒有標準的main
函式入口點。- 我們使用
Arbitrary
特性自動為我們的Input
結構體生成隨機資料。 #[derive(Arbitrary, Debug)]
讓我們能夠自動實作這兩個特性,使得模糊測試引擎可以為我們的結構體生成隨機例項。Input
結構體只包含一個字元串,這個字元串將由模糊測試引擎隨機填充。fuzz_target!
巨集定義了我們的模糊測試入口點,它接收一個隨機生成的Input
例項。- 在模糊測試中,我們簡單地呼叫我們要測試的函式,傳入隨機字元串。
執行模糊測試
準備好測試後,我們可以使用以下命令執行模糊測試:
cargo fuzz run fuzz_target_1
執行模糊測試可能需要一些時間,特別是對於複雜的測試。對於無界資料(如長度無限制的字元串),模糊測試理論上可能需要無限時間。然而,對於我們的範例,通常在 60 秒內就能觸發錯誤。
當模糊測試發現問題時,輸出會類別於以下內容(已縮短以提高可讀性):
cargo fuzz run fuzz_target_1
Compiling fuzzme-fuzz v0.0.0
Finished release [optimized] target(s) in 1.07s
Finished release [optimized] target(s) in 0.01s
Running `fuzz/target/x86_64-apple-darwin/release/fuzz_target_1...
...
Failing input:
fuzz/artifacts/fuzz_target_1/crash-105eb7135ad863be4e095db6ffe64dc1b9a1a466
Output of `std::fmt::Debug`:
Input {
s: "8884844484",
}
Reproduce with:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-105eb7135ad863be4e095db6ffe64dc1b9a1a466
Minimize test case with:
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-105eb7135ad863be4e095db6ffe64dc1b9a1a466
這個輸出非常有用,它告訴我們:
- 模糊測試發現了一個導致程式當機的輸入:“8884844484”
cargo-fuzz
自動為我們建立了一個測試案例,我們可以使用它來確保這個錯誤將來不會再被觸發- 我們可以使用提供的命令再次執行這個特定的測試案例,無需重新從頭開始執行模糊測試
- 我們還可以使用
cargo fuzz tmin
命令最小化測試案例,找到能觸發錯誤的最小輸入
這種功能非常有價值,因為尋找能觸發當機的測試案例可能需要很長時間,而這種方式可以讓我們在修復錯誤時節省大量時間。
修復錯誤
發現錯誤後,下一步是修復它。在我們的範例中,問題可能出在字元串解析上。一個可能的解決方案是正確處理 parse()
方法回傳的 Result
。
parse()
方法已經為我們回傳了 Result
,我們應該適當地處理這個結果,而不是假設解析總是成功的。例如,可以這樣修改函式:
fn parse_integer(s: &str) -> Result<i32, ParseIntError> {
if DIGIT_RE.is_match(s) {
s.parse()
} else {
Err(s.parse::<i32>().unwrap_err())
}
}
這個修復確保了即使正規表示式比對成功,我們仍然處理 parse()
可能回傳的錯誤。這樣,當輸入超出 i32
範圍時(如我們的測試案例 “8884844484”,它超出了 i32 的最大值),函式會回傳一個錯誤而不是當機。
整合測試的特點與優勢
整合測試與單元測試互為補充,但它們有一個主要區別:整合測試只適用於公共介面。這使得整合測試成為測試 API 設計的絕佳方式,確保它對最終使用者說是適當與設計良好的。
Rust 的內建整合測試框架
Rust 的內建整合測試框架提供了最基本的功能,但對大多數目的來說已經足夠了。與單元測試一樣,Rust 的整合測試使用 libtest 函式庫是核心 Rust 的一部分。
整合測試通常放在專案根目錄的 tests
目錄中,每個測試檔案都是一個獨立的 crate,它們引入並測試主要函式庫共功能。
my_project/
├── src/
│ └── lib.rs
└── tests/
├── integration_test1.rs
└── integration_test2.rs
增強整合測試的工具
除了 Rust 內建的測試功能外,還有許多 crate 可以用來增強我們的整合測試:
- proptest - 提供根據屬性的測試,類別於我們上面使用的模糊測試,但更加結構化
- assert_cmd - 簡化命令列程式的測試
- assert_fs - 提供臨時檔案和目錄的輔助功能,適用於需要檔案系統互動的測試
- rexpect - 允許測試互動式命令列程式
這些工具組合使用,可以建立強大的整合測試套件,確保我們的程式在各種情況下都能正確執行。
同步與非同步測試
在談到整合測試時,不能不提及同步和非同步測試的差異。對於 I/O 密集型應用,非同步測試可能是必要的,但它們通常比同步測試更難編寫和理解。
在 Rust 中,我們可以使用 tokio
或 async-std
等函式庫寫非同步測試。例如,使用 tokio
的測試可能看起來像這樣:
#[tokio::test]
async fn test_async_function() {
let result = my_async_function().await;
assert_eq!(result, expected_value);
}
這個測試使用 #[tokio::test]
屬性而不是標準的 #[test]
,這告訴 Rust 這是一個需要在 tokio 執行時環境中執行的非同步測試。在測試函式內部,我們可以使用 .await
等待非同步操作完成。
測試策略最佳實踐
根據我的經驗,一個全面的測試策略應該包含以下元素:
- 單元測試 - 測試個別函式和方法的正確性
- 整合測試 - 測試元件如何協同工作
- 模糊測試 - 尋找邊緣案例和未預期的輸入問題
- 效能測試 - 確保程式在負載下仍然高效執行
- 檔案測試 - 確保檔案中的範例程式碼正確的
每一層測試都有其特定的目的,並且它們共同工作來確保我們的程式是穩健與可靠的。
非同步 Rust 的未來
在閱讀下一部分之前,我想簡單提一下非同步 Rust。非同步程式設計理並發的流行技術,尤其是當與平行性結合使用時。
非同步程式設計多方面只是語法糖,它讓我們可以編寫程式碼不必費太多腦細胞。我們將複雜性隱藏在抽象後面,從而為高層次問題提供思考空間。
要在系統程式設計中有效使用非同步 Rust,你需要理解抽象背後發生的事情。但別擔心,一旦你學會了非同步 Rust 中使用的定義、抽象和術語,你會發現它使用起來非常愉快。
自從 Rust 在 1.39 版本引入 async/await 語法以來,非同步 Rust 已經成熟了很多。現在有多個成熟的執行時(如 tokio 和 async-std)可供選擇,生態系統也在不斷增長。
在我們的實際專案中,非同步模式讓我們能夠處理大量並發連線,同時保持系統資源的高效使用。然而,它也帶來了一些挑戰,特別是在除錯和錯誤處理方面。
Rust 的整合測試和模糊測試工具為我們提供了強大的能力,使我們能夠發現和修復那些難以捉摸的錯誤。透過結合這些技術與良好的程式設計,我們可以構建出更加穩健和安全的系統。
記住,測試不僅是為了找出錯誤,更是為了確保我們的程式碼各種情況下都能按預期工作。投
Rust非同步程式設計:核心概念與實踐
在現代程式設計中,處理多工執行是提升應用程式效能的關鍵。Rust提供了強大的非同步程式設計系統,讓開發者能夠編寫高效能與安全的平行程式。這篇文章將探討Rust的非同步系統,從基礎概念到實際應用,幫助你掌握這項關鍵技術。
平行與平行處理:概念釐清
在討論非同步程式設計前,需要先明確兩個常被混淆的概念:平行(concurrency)和平行(parallelism)。
平行處理允許程式碼在多個CPU上同時執行,或透過作業系統層級的上下文一記憶體區域中執行。想象兩個執行緒在不同CPU上同時處理各自的任務,這就是平行處理的典型場景。
平行處理則是指單一執行緒內多個任務交錯執行的能力。當一個任務因等待I/O操作而閒置時,執行緒可以切換到另一個任務繼續工作,提高整體效率。
這兩個概念的區別可以用人類常活動來類別:
- 我們難以真正平行處理多項任務(例如,同時與兩個人進行完全不同的對話)
- 但我們善於平行處理事務,透過在不同任務間切換注意力來提高效率
Rust的非同步系統能夠根據需求提供平行或平行處理能力。雖然不使用async也能實作平行處理(透過執行緒),但要實作高效的平行處理就幾乎必須依賴async系統。
Rust非同步執行環境
Rust的非同步機制與其他語言相比有一個重要區別:語言本身僅提供基本元素(Future
trait、async
關鍵字和.await
表示式),而將執行細節交由第三方函式庫。這種設計提供了極大的彈性,但也意味著開發者需要選擇合適的執行環境。
目前Rust生態中有三個主要的非同步執行環境:
- Tokio:功能最完整的非同步執行環境,下載量最高(約1.44億次下載),生態系統最成熟
- async-std:以Rust標準函式庫實作的非同步執行環境,下載量約1,887萬次
- smol:輕量級執行環境,設計用來與Tokio競爭,下載量約360萬次
雖然async-std和smol都提供與Tokio的相容性,但在實際應用中,混合使用不同的執行環境通常會帶來問題。這是因為雖然各執行環境實作了相同的非同步API,但大多數實際應用都需要使用執行環境特定的功能。
我個人建議在大多數情況下使用Tokio,因為它是最成熟與應用最廣泛的執行環境。雖然未來可能會更容易交換或互換執行環境,但目前來說,堅持使用一種執行環境能避免不必要的麻煩。
非同步思維模式
非同步程式設計主要用於處理需要等待任務完成的控制流,特別是I/O操作。例如與檔案系統或網路通訊,或是計算雜湊值等耗時操作。
相比於同步I/O(大多數語言的預設模式),非同步程式設計有以下優勢:
- 更高的I/O效率:無需執行緒間的上下文支援平行處理,避免了互斥鎖和同步機制帶來的開銷
- 更易於推理:能夠避免許多種競爭條件,使程式邏輯更清晰
- 輕量級任務:可以同時處理數千或數百萬個非同步任務
特別是對於I/O操作,等待操作完成的時間通常遠大於處理結果所需的時間。使用非同步程式設計,我們可以在等待期間執行其他工作,而不是按順序執行每個任務。本質上,我們是將函式呼叫分解並穿插在I/O操作的等待間隙中。
阻塞式與非阻塞式I/O比較
非同步I/O本質上是非阻塞的,而同步I/O是阻塞的。如果處理I/O結果的時間遠少於等待I/O完成的時間,非同步I/O通常會更快。
考慮以下同步與非同步I/O的時間對比:
- 在同步模型中,各執行緒必須等待各自的I/O任務完成才能繼續
- 在非同步模型中,單一執行緒可以在等待一個I/O任務時切換到其他任務
值得注意的是,你也可以將多執行緒與非同步程式設計結合使用,但在許多情況下,單執行緒的非同步處理已經足夠高效。
當然,非同步程式設計並非萬能藥。如果處理I/O結果的時間超過等待I/O完成的時間,單執行緒的非同步處理可能會表現更差。好在Tokio提供了很大的靈活性,允許開發者選擇如何執行非同步任務,例如使用多少工作執行緒。
當你適應了非同步程式設計的思維模型後,你會發現它實際上比同步程式設計(尤其是多執行緒同步程式設計)簡單得多。
Future:處理非同步任務結果
大多數非同步函式庫言都根據Future設計模式,用於處理將來會回傳結果的任務(因此得名)。當執行非同步操作時,該操作的結果是一個Future,而不是直接回傳操作本身的值(如同步程式設計或普通函式呼叫中所見)。
雖然Future提供了便捷的抽象,但正確處理它們需要程式設計師做更多工作。為了更好地理解Future,讓我們考慮定時器是如何工作的:
- 建立(或啟動)一個非同步定時器,它回傳一個Future來表示定時器完成的訊號
- 僅建立定時器是不夠的,我們還需要告訴執行器(非同步執行環境的一部分)執行這個任務
在同步程式碼中,當我們想要暫停1秒時,只需呼叫sleep
函式,程式執行會立即暫停,然後在指定時間後繼續。但在非同步程式碼中,情況有所不同。
這部分解釋了Rust非同步程式設計的基本概念和優勢。核心點在於區分平行與平行處理:平行是關於任務交錯執行的能力,而平行是關於同時執行多個任務的能力。Rust的非同步系統能夠支援這兩種模式,但與其他語言不同的是,Rust只提供基本的非同步元素,而將執行環境的實作留給第三方函式庫 當前,Tokio是最受歡迎的非同步執行環境,也是大多數情況下的推薦選擇。非同步程式設計的主要優勢在於處理I/O密集型任務時能夠提高效率,因為它允許在等待I/O操作完成時執行其他工作。
Future是Rust非同步程式設計的核心概念,它代表未來將會完成並產生結果的操作。與同步程式設計不同,非同步操作不直接回傳結果,而是回傳一個Future,需要特定的方式來等待和處理結果。
Rust中的Future與非同步執行模型
在Rust中,Future是一個特質(trait),代表可能尚未完成的操作。與其他語言的Promise或Future類別,但Rust的Future有其獨特之處,特別是在執行模型上。
Future特質的核心概念
Rust的Future
特質定義如下:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
這個特質有兩個關鍵元素:
- 關聯類別
Output
:表示Future完成後產生的值的類別 poll
方法:檢查Future是否完成,並在完成時回傳結果
poll
方法回傳一個Poll
列舉,可以是以下兩種值之一:
Poll::Ready(value)
:表示Future已完成,value
是結果Poll::Pending
:表示Future尚未完成,需要稍後再檢查
這段程式碼義了Rust中Future的核心特質。Future
特質是Rust非同步程式設計的基礎,它有一個關聯類別Output
(表示最終結果的類別)和一個poll
方法(用於查詢Future的狀態)。當呼叫poll
時,如果操作已完成,它會回傳Poll::Ready(value)
;如果操作尚未完成,則回傳Poll::Pending
。
Pin<&mut Self>
引數確保Future在記憶體中的位置保持穩定,這對於包含自參照的Future很重要。Context
引數包含了一個Waker
,用於在Future準備好進一步處理時通知執行器。
非同步執行模型:提取式與推播式
Rust的非同步模型採用了「提取式」(pull-based)而非「推播式」(push-based):
- 提取式模型:執行器反覆呼叫
Future::poll
來檢查Future是否完成 - 推播式模型:當Future完成時,它會主動通知執行器(如JavaScript中的Promise)
Rust的模型看似效率較低,但實際上很高效,因為它使用了「喚醒器」(Waker)機制:當Future無法立即完成時,它會註冊一個喚醒器,在Future狀態變化時通知執行器再次呼叫poll
。
使用async/await簡化非同步程式設計
直接使用Future特質程式設計複雜,因此Rust提供了async
/.await
語法糖來簡化非同步程式設計:
async fn read_file(path: &str) -> Result<String, std::io::Error> {
// 這會自動轉換為回傳一個實作了Future特質的類別
std::fs::read_to_string(path).await
}
async
關鍵字將函式轉換為回傳實作Future
特質的類別的函式,而.await
表示式則用於等待Future完成並取得其結果。
這個例子展示瞭如何使用async
和.await
簡化非同步程式設計。async fn
宣告一個回傳Future的函式,而.await
則暫停執行直到Future完成。這種語法讓非同步程式碼看起來更像同步程式碼,大提高了可讀性和可維護性。
在底層,編譯器將async
函式轉換為一個狀態機,每次遇到.await
時,函式執行會暫停,並在Future完成時還原。這使得編寫非同步程式碼變得直觀,同時保持了Rust的高效能和安全性。
使用Tokio執行非同步程式碼
要執行非同步程式碼,我們需要一個執行環境。以下是使用Tokio執行非同步程式碼的基本模式:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
println!("開始");
// 建立一個非同步任務
let task = tokio::spawn(async {
println!("任務開始");
sleep(Duration::from_millis(100)).await;
println!("任務完成");
"任務結果"
});
// 等待任務完成並取得結果
let result = task.await.unwrap();
println!("獲得結果: {}", result);
println!("結束");
}
這段程式碼示瞭如何使用Tokio執行非同步程式碼。#[tokio::main]
屬性巨集main
函式轉換為一個非同步函式,並設定Tokio執行環境來執行它。
tokio::spawn
函式建立一個新的非同步任務,該任務在Tokio執行環境中獨立執行。這個任務是一個async
塊,包含了一系列操作,包括
非同步程式設計的核心原則與常見誤區
在 Rust 的非同步程式設計中,有一條黃金法則:絕不阻塞主執行緒。這條原則看似簡單,卻是許多開發者容易踩的坑。我在輔導團隊轉向非同步架構時,發現這個問題特別普遍。
同步與非同步的關鍵差異
雖然技術上可以在非同步程式碼中呼叫 sleep()
,但這是一個嚴重的反模式。思考以下情境:當你在非同步程式碼中插入 sleep()
時,程式不會當機,但會徹底違背非同步程式設計的初衷。
讓我們比較同步和非同步計時器的實作差異:
同步版本
fn main() {
use std::{thread, time};
let duration = time::Duration::from_secs(1);
thread::sleep(duration);
println!("Hello, world!");
}
這段程式碼相當直觀:引入必要的模組,設定一個 1 秒的持續時間,讓目前執行緒休眠 1 秒,然後印出訊息。整個過程是阻塞的,這意味著在這 1 秒內,程式完全靜止不動,無法處理任何其他工作。
非同步版本
fn main() {
use std::time;
let duration = time::Duration::from_secs(1);
tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
tokio::time::sleep(duration).await;
println!("Hello, world!");
});
}
非同步版本看起來複雜許多。這裡我們:
- 建立一個 Tokio 單執行緒執行時(runtime)
- 啟用時間功能(否則
sleep
將無法運作) - 建構執行時並處理可能的錯誤
- 使用
block_on
執行一個非同步區塊 - 在非同步區塊中,使用
tokio::time::sleep
非同步等待 1 秒 - 最後印出訊息
雖然這段程式碼看起來更複雜,但它的強大之處在於:在等待的 1 秒內,如果有其他非同步任務,執行時可以切換去執行它們,而不是像同步版本那樣完全閒置。
理解阻塞主執行緒的真正含義
當我們談論「不要阻塞主執行緒」時,實際上是在說:不應該阻止執行時長時間切換任務的能力。這一點對理解非同步程式設計至關重要。
什麼構成了阻塞操作?
通常,我們將 I/O 視為阻塞操作,因為它們完成所需的時間受到程式外部因素的影響。然而,即使是純 CPU 計算密集型任務,如果執行時間足夠長,也可能被視為阻塞操作。
介紹讓出點(yield point)的概念
避免過長時間阻塞主執行緒的關鍵在於引入「讓出點」。讓出點是任何將控制權交還給排程器的程式碼。在 Rust 中,使用 .await
通常會建立讓出點,讓執行時有機會切換到其他任務。
快速操作與慢速操作的量化比較
要理解什麼是「長時間」,可以比較典型的 CPU 操作和 I/O 操作所需時間:
- 典型函式呼叫:在 2 GHz CPU 上,一個需要約 50 條指令的函式可能需要約 25 納秒執行。
- 小型 I/O 操作:讀取 1KB 檔案可能需要約 260 微秒,比函式呼叫慢約 5,200 倍。
- 網路操作:可能比本地 I/O 再慢 1-2 個數量級。
這種時間差異正是為什麼我們需要非同步程式設計的主要原因。
處理長時間執行的非 I/O 操作
對於可能需要較長時間完成的非 I/O 操作,有兩種主要處理策略:
- 使用
tokio::task::spawn_blocking()
將其視為阻塞操作處理 - 透過在適當位置插入
.await
將其分解,給排程器切換任務的機會
如果不確定哪種方法更適合,應進行基準測試來決定。
使用 #[tokio::main] 簡化執行時定義
Tokio 提供了一個方便的巨集包裝 main()
函式,大幅簡化了非同步程式的入口點:
#[tokio::main]
async fn main() {
use std::time;
let duration = time::Duration::from_secs(1);
tokio::time::sleep(duration).await;
println!("Hello, world!");
}
這個版本看起來與同步版本幾乎一樣簡潔,但實際上 #[tokio::main]
巨集幕後處理了所有執行時設定的複雜細節。它將 main()
轉變為非同步函式,並自動處理啟動 Tokio 執行時和建立非同步上下文板程式碼
這種語法糖讓我們能夠用更直觀的方式編寫非同步程式,同時保持非同步程式設計的所有優勢。