在 Rust 編程中使用多線程
1. Rust線程實(shí)現(xiàn)理念
在大部分現(xiàn)代操作系統(tǒng)中,已執(zhí)行程序的代碼在一個(gè) 進(jìn)程(process)中運(yùn)行,操作系統(tǒng)則會(huì)負(fù)責(zé)管理多個(gè)進(jìn)程。在程序內(nèi)部,也可以擁有多個(gè)同時(shí)運(yùn)行的獨(dú)立部分。這些運(yùn)行這些獨(dú)立部分的功能被稱為 線程(threads)。例如,web 服務(wù)器可以有多個(gè)線程以便可以同時(shí)響應(yīng)多個(gè)請求。
將程序中的計(jì)算拆分進(jìn)多個(gè)線程可以改善性能,因?yàn)槌绦蚩梢酝瑫r(shí)進(jìn)行多個(gè)任務(wù),不過這也會(huì)增加復(fù)雜性。因?yàn)榫€程是同時(shí)運(yùn)行的,所以無法預(yù)先保證不同線程中的代碼的執(zhí)行順序。這會(huì)導(dǎo)致諸如此類的問題:
- 競態(tài)條件(Race conditions),多個(gè)線程以不一致的順序訪問數(shù)據(jù)或資源。
- 死鎖(Deadlocks),兩個(gè)線程相互等待對方,這會(huì)阻止兩者繼續(xù)運(yùn)行。
- 只會(huì)發(fā)生在特定情況且難以穩(wěn)定重現(xiàn)和修復(fù)的 bug。
Rust 嘗試減輕使用線程的負(fù)面影響。不過在多線程上下文中編程仍需格外小心,同時(shí)其所要求的代碼結(jié)構(gòu)也不同于運(yùn)行于單線程的程序。
編程語言有一些不同的方法來實(shí)現(xiàn)線程,而且很多操作系統(tǒng)提供了創(chuàng)建新線程的 API。Rust 標(biāo)準(zhǔn)庫使用 1:1 線程實(shí)現(xiàn),這代表程序的每一個(gè)語言級(jí)線程使用一個(gè)系統(tǒng)線程。
2.使用spawn創(chuàng)建新線程
為了創(chuàng)建一個(gè)新線程,需要調(diào)用 thread::spawn 函數(shù)并傳遞一個(gè)閉包, 并在其中包含希望在新線程運(yùn)行的代碼。看下面的例子:
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
注意當(dāng) Rust 程序的主線程結(jié)束時(shí),新線程也會(huì)結(jié)束,而不管其是否執(zhí)行完畢。這個(gè)程序的輸出可能每次都略有不同,不過它大體上看起來像這樣:
thread::sleep 調(diào)用強(qiáng)制線程停止執(zhí)行一小段時(shí)間,這會(huì)允許其他不同的線程運(yùn)行。這些線程可能會(huì)輪流運(yùn)行,不過并不保證如此:這依賴操作系統(tǒng)如何調(diào)度線程。在這里,主線程首先打印,即便新創(chuàng)建線程的打印語句位于程序的開頭,甚至即便我們告訴新建的線程打印直到 i 等于 9,它在主線程結(jié)束之前也只打印到了 5。
如果運(yùn)行代碼只看到了主線程的輸出,或沒有出現(xiàn)重疊打印的現(xiàn)象,嘗試增大區(qū)間 (變量 i 的范圍) 來增加操作系統(tǒng)切換線程的機(jī)會(huì)。
3.使用join等待所有線程結(jié)束
由于主線程結(jié)束,上面演示的代碼大部分時(shí)候不光會(huì)提早結(jié)束新建線程,因?yàn)闊o法保證線程運(yùn)行的順序,甚至不能實(shí)際保證新建線程會(huì)被執(zhí)行!
可以通過將 thread::spawn 的返回值儲(chǔ)存在變量中來修復(fù)新建線程部分沒有執(zhí)行或者完全沒有執(zhí)行的問題。thread::spawn 的返回值類型是 JoinHandle。JoinHandle 是一個(gè)擁有所有權(quán)的值,當(dāng)對其調(diào)用 join 方法時(shí),它會(huì)等待其線程結(jié)束。
看下面的示例代碼:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
通過調(diào)用 handle 的 join 會(huì)阻塞當(dāng)前線程直到 handle 所代表的線程結(jié)束。阻塞(Blocking)線程意味著阻止該線程執(zhí)行工作或退出。因?yàn)槲覀儗?join 調(diào)用放在了主線程的 for 循環(huán)之后,編譯這段代碼后運(yùn)行結(jié)果如下:
這兩個(gè)線程仍然會(huì)交替執(zhí)行,不過主線程會(huì)由于 handle.join() 調(diào)用會(huì)等待直到新建線程執(zhí)行完畢。
不過如果將 handle.join() 移動(dòng)到 main 中 for 循環(huán)之前會(huì)發(fā)生什么呢,看下面的代碼:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
代碼編譯執(zhí)行后結(jié)果如下:
主線程會(huì)等待直到新建線程執(zhí)行完畢之后才開始執(zhí)行 for 循環(huán),所以輸出將不會(huì)交替出現(xiàn)。
因此,將join放在代碼的不同地方, 將會(huì)影響線程是否同時(shí)執(zhí)行。
4.將move閉包與線程一起使用
move 關(guān)鍵字經(jīng)常用于傳遞給 thread::spawn 的閉包,因?yàn)殚]包會(huì)獲取從環(huán)境中取得的值的所有權(quán),因此會(huì)將這些值的所有權(quán)從一個(gè)線程傳送到另一個(gè)線程。
為了在新建線程中使用來自于主線程的數(shù)據(jù),需要新建線程的閉包獲取它需要的值, 下面的代碼展示了一個(gè)嘗試在主線程中創(chuàng)建一個(gè) vector 并用于新建線程的例子,不過這么寫還不能工作, 代碼如下:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
閉包使用了 v,所以閉包會(huì)捕獲 v 并使其成為閉包環(huán)境的一部分。因?yàn)?thread::spawn 在一個(gè)新線程中運(yùn)行這個(gè)閉包,所以可以在新線程中訪問 v。然而當(dāng)編譯這個(gè)例子時(shí),會(huì)得到如下錯(cuò)誤:
Rust 會(huì) 推斷 如何捕獲 v,因?yàn)?nbsp;println! 只需要 v 的引用,閉包嘗試借用 v。然而這有一個(gè)問題:Rust 不知道這個(gè)新建線程會(huì)執(zhí)行多久,所以無法知曉對 v 的引用是否一直有效。
看一段比較極端情況的代碼:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
drop(v); // 壞事兒了!
handle.join().unwrap();
}
如果 Rust 允許這段代碼運(yùn)行,則新建線程則可能會(huì)立刻被轉(zhuǎn)移到后臺(tái)并完全沒有機(jī)會(huì)運(yùn)行。新建線程內(nèi)部有一個(gè) v 的引用,不過主線程立刻就使用drop丟棄了v。接著當(dāng)新建線程開始執(zhí)行,v 已不再有效,所以其引用也是無效的。
為了修復(fù)上面的編譯錯(cuò)誤, 我們可以根據(jù)編譯器給予我們的help嘗試修正一下,如圖:
通過在閉包之前增加 move 關(guān)鍵字,強(qiáng)制閉包獲取其使用的值的所有權(quán),而不是任由 Rust 推斷它應(yīng)該借用值。
修正后的代碼如下:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
編譯運(yùn)行試一下:
看起來沒問題,那么以這個(gè)成功的經(jīng)驗(yàn), 修改那段極端情況的代碼如下:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
drop(v); // 壞事兒了!
handle.join().unwrap();
}
再次編譯一下看看結(jié)果如何:
Rust編譯器依然沒有放行, 這個(gè)修復(fù)行不通。
如果為閉包增加 move,將會(huì)把 v 移動(dòng)進(jìn)閉包的環(huán)境中, 因此將不能在主線程中對其調(diào)用 drop 了, Rust 的所有權(quán)規(guī)則又一次幫助我們杜絕了隱患。因?yàn)?Rust 是保守的并只會(huì)為線程借用 v,這意味著主線程理論上可能使新建線程的引用無效。通過告訴 Rust 將 v 的所有權(quán)移動(dòng)到新建線程,我們向 Rust 保證主線程不會(huì)再使用 v。如果對其作出同樣的move修改, 那么當(dāng)在主線程中使用 v 時(shí)就會(huì)違反所有權(quán)規(guī)則。move 關(guān)鍵字覆蓋了 Rust 默認(rèn)保守的借用,但它不允許我們違反所有權(quán)規(guī)則。