Rust 使用線程同時運行代碼

2023-03-22 15:14 更新
ch16-01-threads.md
commit 6b9eae8ce91dd0d94982795762d22077d372e90c

在大部分現(xiàn)代操作系統(tǒng)中,已執(zhí)行程序的代碼在一個 進(jìn)程process)中運行,操作系統(tǒng)則負(fù)責(zé)管理多個進(jìn)程。在程序內(nèi)部,也可以擁有多個同時運行的獨立部分。運行這些獨立部分的功能被稱為 線程threads)。

將程序中的計算拆分進(jìn)多個線程可以改善性能,因為程序可以同時進(jìn)行多個任務(wù),不過這也會增加復(fù)雜性。因為線程是同時運行的,所以無法預(yù)先保證不同線程中的代碼的執(zhí)行順序。這會導(dǎo)致諸如此類的問題:

  • 競態(tài)條件(Race conditions),多個線程以不一致的順序訪問數(shù)據(jù)或資源
  • 死鎖(Deadlocks),兩個線程相互等待對方停止使用其所擁有的資源,這會阻止它們繼續(xù)運行
  • 只會發(fā)生在特定情況且難以穩(wěn)定重現(xiàn)和修復(fù)的 bug

Rust 嘗試減輕使用線程的負(fù)面影響。不過在多線程上下文中編程仍需格外小心,同時其所要求的代碼結(jié)構(gòu)也不同于運行于單線程的程序。

編程語言有一些不同的方法來實現(xiàn)線程。很多操作系統(tǒng)提供了創(chuàng)建新線程的 API。這種由編程語言調(diào)用操作系統(tǒng) API 創(chuàng)建線程的模型有時被稱為 1:1,一個 OS 線程對應(yīng)一個語言線程。Rust 標(biāo)準(zhǔn)庫只提供了 1:1 線程實現(xiàn);有一些 crate 實現(xiàn)了其他有著不同取舍的線程模型。

使用 spawn 創(chuàng)建新線程

為了創(chuàng)建一個新線程,需要調(diào)用 thread::spawn 函數(shù)并傳遞一個閉包(第十三章學(xué)習(xí)了閉包),并在其中包含希望在新線程運行的代碼。示例 16-1 中的例子在主線程打印了一些文本而另一些文本則由新線程打?。?br>

文件名: src/main.rs

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));
    }
}

示例 16-1: 創(chuàng)建一個打印某些內(nèi)容的新線程,但是主線程打印其它內(nèi)容

注意這個函數(shù)編寫的方式,當(dāng)主線程結(jié)束時,新線程也會結(jié)束,而不管其是否執(zhí)行完畢。這個程序的輸出可能每次都略有不同,不過它大體上看起來像這樣:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

thread::sleep 調(diào)用強(qiáng)制線程停止執(zhí)行一小段時間,這會允許其他不同的線程運行。這些線程可能會輪流運行,不過并不保證如此:這依賴操作系統(tǒng)如何調(diào)度線程。在這里,主線程首先打印,即便新創(chuàng)建線程的打印語句位于程序的開頭,甚至即便我們告訴新建的線程打印直到 i 等于 9 ,它在主線程結(jié)束之前也只打印到了 5。

如果運行代碼只看到了主線程的輸出,或沒有出現(xiàn)重疊打印的現(xiàn)象,嘗試增大區(qū)間 (變量 i 的范圍) 來增加操作系統(tǒng)切換線程的機(jī)會。

使用 join 等待所有線程結(jié)束

由于主線程結(jié)束,示例 16-1 中的代碼大部分時候不光會提早結(jié)束新建線程,甚至不能實際保證新建線程會被執(zhí)行。其原因在于無法保證線程運行的順序!

可以通過將 thread::spawn 的返回值儲存在變量中來修復(fù)新建線程部分沒有執(zhí)行或者完全沒有執(zhí)行的問題。thread::spawn 的返回值類型是 JoinHandle。JoinHandle 是一個擁有所有權(quán)的值,當(dāng)對其調(diào)用 join 方法時,它會等待其線程結(jié)束。示例 16-2 展示了如何使用示例 16-1 中創(chuàng)建的線程的 JoinHandle 并調(diào)用 join 來確保新建線程在 main 退出前結(jié)束運行:

文件名: src/main.rs

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();
}

示例 16-2: 從 thread::spawn 保存一個 JoinHandle 以確保該線程能夠運行至結(jié)束

通過調(diào)用 handle 的 join 會阻塞當(dāng)前線程直到 handle 所代表的線程結(jié)束。阻塞Blocking) 線程意味著阻止該線程執(zhí)行工作或退出。因為我們將 join 調(diào)用放在了主線程的 for 循環(huán)之后,運行示例 16-2 應(yīng)該會產(chǎn)生類似這樣的輸出:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

這兩個線程仍然會交替執(zhí)行,不過主線程會由于 handle.join() 調(diào)用會等待直到新建線程執(zhí)行完畢。

不過讓我們看看將 handle.join() 移動到 main 中 for 循環(huán)之前會發(fā)生什么,如下:

文件名: src/main.rs

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í)行完畢之后才開始執(zhí)行 for 循環(huán),所以輸出將不會交替出現(xiàn),如下所示:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

諸如將 join 放置于何處這樣的小細(xì)節(jié),會影響線程是否同時運行。

線程與 move 閉包

move 關(guān)鍵字經(jīng)常用于傳遞給 thread::spawn 的閉包,因為閉包會獲取從環(huán)境中取得的值的所有權(quán),因此會將這些值的所有權(quán)從一個線程傳送到另一個線程。在第十三章 “閉包會捕獲其環(huán)境” 部分討論了閉包上下文中的 move。現(xiàn)在我們會更專注于 move 和 thread::spawn 之間的交互。

在第十三章中,我們講到可以在參數(shù)列表前使用 move 關(guān)鍵字強(qiáng)制閉包獲取其使用的環(huán)境值的所有權(quán)。這個技巧在創(chuàng)建新線程將值的所有權(quán)從一個線程移動到另一個線程時最為實用。

注意示例 16-1 中傳遞給 thread::spawn 的閉包并沒有任何參數(shù):并沒有在新建線程代碼中使用任何主線程的數(shù)據(jù)。為了在新建線程中使用來自于主線程的數(shù)據(jù),需要新建線程的閉包獲取它需要的值。示例 16-3 展示了一個嘗試在主線程中創(chuàng)建一個 vector 并用于新建線程的例子,不過這么寫還不能工作,如下所示:

文件名: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    handle.join().unwrap();
}

示例 16-3: 嘗試在另一個線程使用主線程創(chuàng)建的 vector

閉包使用了 v,所以閉包會捕獲 v 并使其成為閉包環(huán)境的一部分。因為 thread::spawn 在一個新線程中運行這個閉包,所以可以在新線程中訪問 v。然而當(dāng)編譯這個例子時,會得到如下錯誤:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {:?}", v);
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` due to previous error

Rust 會 推斷 如何捕獲 v,因為 println! 只需要 v 的引用,閉包嘗試借用 v。然而這有一個問題:Rust 不知道這個新建線程會執(zhí)行多久,所以無法知曉 v 的引用是否一直有效。

示例 16-4 展示了一個 v 的引用很有可能不再有效的場景:

文件名: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

示例 16-4: 一個具有閉包的線程,嘗試使用一個在主線程中被回收的引用 v

假如這段代碼能正常運行的話,則新建線程則可能會立刻被轉(zhuǎn)移到后臺并完全沒有機(jī)會運行。新建線程內(nèi)部有一個 v 的引用,不過主線程立刻就使用第十五章討論的 drop 丟棄了 v。接著當(dāng)新建線程開始執(zhí)行,v 已不再有效,所以其引用也是無效的。噢,這太糟了!

為了修復(fù)示例 16-3 的編譯錯誤,我們可以聽取錯誤信息的建議:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

通過在閉包之前增加 move 關(guān)鍵字,我們強(qiáng)制閉包獲取其使用的值的所有權(quán),而不是任由 Rust 推斷它應(yīng)該借用值。示例 16-5 中展示的對示例 16-3 代碼的修改,可以按照我們的預(yù)期編譯并運行:

文件名: src/main.rs

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();
}

示例 16-5: 使用 move 關(guān)鍵字強(qiáng)制獲取它使用的值的所有權(quán)

那么如果使用了 move 閉包,示例 16-4 中主線程調(diào)用了 drop 的代碼會發(fā)生什么呢?加了 move 就搞定了嗎?不幸的是,我們會得到一個不同的錯誤,因為示例 16-4 所嘗試的操作由于一個不同的原因而不被允許。如果為閉包增加 move,將會把 v 移動進(jìn)閉包的環(huán)境中,如此將不能在主線程中對其調(diào)用 drop 了。我們會得到如下不同的編譯錯誤:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  | 
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {:?}", v);
   |                                           - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` due to previous error

Rust 的所有權(quán)規(guī)則又一次幫助了我們!示例 16-3 中的錯誤是因為 Rust 是保守的并只會為線程借用 v,這意味著主線程理論上可能使新建線程的引用無效。通過告訴 Rust 將 v 的所有權(quán)移動到新建線程,我們向 Rust 保證主線程不會再使用 v。如果對示例 16-4 也做出如此修改,那么當(dāng)在主線程中使用 v 時就會違反所有權(quán)規(guī)則。 move 關(guān)鍵字覆蓋了 Rust 默認(rèn)保守的借用,但它不允許我們違反所有權(quán)規(guī)則。

現(xiàn)在我們對線程和線程 API 有了基本的了解,讓我們討論一下使用線程實際可以  什么吧。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號