併發編程
併發是什麼?引用Rob Pike的經典描述:
併發是同一時間應對多件事情的能力
其實在我們身邊就有很多併發的事情,比如一邊上課,一邊發短信;一邊給小孩餵奶,一邊看電視,只要你細心留意,就會發現許多類似的事。相應地,在軟件的世界裡,我們也會發現這樣的事,比如一邊寫博客,一邊聽音樂;一邊看網頁,一邊下載軟件等等。顯而易見這樣會節約不少時間,幹更多的事。然而一開始計算機系統並不能同時處理兩件事,這明顯滿足不了我們的需要,後來慢慢提出了多進程,多線程的解決方案,再後來,硬件也發展到了多核多CPU的地步。在硬件和系統底層對併發的支持也來越多,相應地,各大編程語言也對併發處理提供了強力的支持,作為新興語言的Rust,自然也支持併發編程。那麼本章就將引領大家一覽Rust併發編程的相關知識,從線程開始,逐步嘗試進行數據交互,同步協作,最後進入到並行實現,一步一步揭開Rust併發編程的神祕面紗。由於本書主要介紹的是Rust語言的使用,所以本章不會對併發編程相關理論知識進行全面而深入地探討——要真那樣地話,一本書都不夠介紹的,而是更側重於介紹用Rust語言怎麼實現基本的併發。
首先我們會介紹線程的使用,線程是基本的執行單元,其重要性不言而喻,Rust程序就是由一堆線程組成的。在當今多核多CPU已經普及的情況下,各種大數據分析和並行計算又讓線程煥發出了更耀眼的光芒。如果對線程不甚瞭解,請先參閱操作系統相關的書籍,此處不過多介紹。然後介紹一些在解決併發問題時,需要處理的數據傳遞和協作的實現,比如消息傳遞,同步和共享內存。最後簡要介紹Rust中並行的實現。
24.1 線程創建與結束
相信線程對大家而言,一點也不陌生,在當今多CPU多核已經普及的情況下,大數據分析與並行計算都離不開它,幾乎所有的語言都支持它,所有的進程都是由一個或多個線程所組成的。既然如此重要,接下來我們就先來看一下在Rust中如何創建一個線程,然後線程又是如何結束的。
Rust對於線程的支持,和C++11
一樣,都是放在標準庫中來實現的,詳情請參見std::thread
,好在Rust從一開始就這樣做了,不用像C++那樣等呀等。在語言層面支持後,開發者就不用那麼苦兮兮地處理各平臺的移植問題。通過Rust的源碼可以看到,std::thread
其實就是對不同平臺的線程操作的封裝,相關API的實現都是調用操作系統的API來實現的,從而提供了線程操作的統一接口。對於我而言,能夠這樣簡單快捷地操作原生線程,身上的壓力一下輕了不少。
創建線程
首先,我們看一下在Rust中如何創建一個原生線程(native thread)。std::thread
提供了兩種創建方式,都非常簡單,第一種方式是通過spawn
函數來創建,參見下面的示例代碼:
use std::thread;
fn main() {
// 創建一個線程
let new_thread = thread::spawn(move || {
println!("I am a new thread.");
});
// 等待新建線程執行完成
new_thread.join().unwrap();
}
執行上面這段代碼,將會看到下面的輸出結果:
I am a new thread.
就5行代碼,少得不能再少,最關鍵的當然就是調用spawn
函數的那行代碼。使用這個函數,記得要先use std::thread
。注意spawn
函數需要一個函數作為參數,且是FnOnce
類型,如果已經忘了這種類型的函數,請學習或回顧一下函數和閉包章節。main
函數最後一行代碼即使不要,也能創建線程(關於join
函數的作用和使用在後續小節詳解,此處你只要知道它可以用來等待線程執行完成即可),可以去掉或者註釋該行代碼試試。這樣的話,運行結果可能沒有任何輸出,具體原因後面詳解。
接下來我們使用第二種方式創建線程,它比第一種方式稍微複雜一點,因為功能強大一點,可以在創建之前設置線程的名稱和堆棧大小,參見下面的代碼:
use std::thread;
fn main() {
// 創建一個線程,線程名稱為 thread1, 堆棧大小為4k
let new_thread_result = thread::Builder::new()
.name("thread1".to_string())
.stack_size(4*1024*1024).spawn(move || {
println!("I am thread1.");
});
// 等待新創建的線程執行完成
new_thread_result.unwrap().join().unwrap();
}
執行上面這段代碼,將會看到下面的輸出結果:
I am thread1.
通過和第一種方式的實現代碼比較可以發現,這種方式藉助了一個Builder
類來設置線程名稱和堆棧大小,除此之外,Builder
的spawn
函數的返回值是一個Result
,在正式的代碼編寫中,可不能像上面這樣直接unwrap.join
,應該判定一下。後面也會有很多類似的演示代碼,為了簡單說明不會做的很嚴謹。
以上就是Rust創建原生線程的兩種不同方式,示例代碼有點然並卵的意味,但是你可以稍加修改,就可以變得更加有用,試試吧。
線程結束
此時,我們已經知道如何創建一個新線程了,創建後,不管你見或者不見,它就在那裡,那麼它什麼時候才會消亡呢?自生自滅,亦或者被幹掉?如果接觸過一些系統編程,應該知道有些操作系統提供了粗暴地幹掉線程的接口,看它不爽,直接幹掉,完全可以不理會新建線程的感受。是否感覺很爽,但是Rust不會再讓這樣爽了,因為std::thread
並沒有提供這樣的接口,為什麼呢?如果深入接觸過併發編程或多線程編程,就會知道強制終止一個運行中的線程,會出現諸多問題。比如資源沒有釋放,引起狀態混亂,結果不可預期。強制幹掉那一刻,貌似很爽地解決問題了,然而可能後患無窮。Rust語言的一大特性就是安全,是絕對不允許這樣不負責任的做法的。即使在其他語言提供了類似的接口,也不應該濫用。
那麼在Rust中,新建的線程就只能讓它自身自滅了嗎?其實也有兩種方式,首先介紹大家都知道的自生自滅的方式,線程執行體執行完成,線程就結束了。比如上面創建線程的第一種方式,代碼執行完println!("I am a new thread.");
就結束了。 如果像下面這樣:
use std::thread;
fn main() {
// 創建一個線程
let new_thread = thread::spawn(move || {
loop {
println!("I am a new thread.");
}
});
// 等待新創建的線程執行完成
new_thread.join().unwrap();
}
線程就永遠都不會結束,如果你用的還是古董電腦,運行上面的代碼之前,請做好心理準備。在實際代碼中,要時刻警惕該情況的出現(單核情況下,CPU佔用率會飆升到100%),除非你是故意為之。
線程結束的另一種方式就是,線程所在進程結束了。我們把上面這個例子稍作修改:
use std::thread;
fn main() {
// 創建一個線程
thread::spawn(move || {
loop {
println!("I am a new thread.");
}
});
// 不等待新創建的線程執行完成
// new_thread.join().unwrap();
}
同上面的代碼相比,唯一的差別在於main
函數的最後一行代碼被註釋了,這樣主線程就不用等待新建線程了,在創建線程之後就執行完了,其所在進程也就結束了,從而新建的線程也就結束了。此處,你可能有疑問:為什麼一定是進程結束導致新建線程結束?也可能是創建新線程的主線程結束而導致的?事實到底如何,我們不妨驗證一下:
use std::thread;
fn main() {
// 創建一個線程
let new_thread = thread::spawn(move || {
// 再創建一個線程
thread::spawn(move || {
loop {
println!("I am a new thread.");
}
})
});
// 等待新創建的線程執行完成
new_thread.join().unwrap();
println!("Child thread is finish!");
// 睡眠一段時間,看子線程創建的子線程是否還在運行
thread::sleep_ms(100);
}
這次我們在新建線程中還創建了一個線程,從而第一個新建線程是父線程,主線程在等待該父線程結束後,主動睡眠一段時間。這樣做有兩個目的,一是確保整個程序不會馬上結束;二是如果子線程還存在,應該會獲得執行機會,以此來檢驗子線程是否還在運行,下面是輸出結果:
Child thread is finish!
I am a new thread.
I am a new thread.
......
結果表明,在父線程結束後,其創建的子線程還活著,這並不會因為父線程結束而結束。這個還是比較符合自然規律的,要不然真會斷子絕孫,人類滅絕。所以導致線程結束的第二種方式,是結束其所在進程。到此為止,我們已經把線程的創建和結束都介紹完了,那麼接下來我們會介紹一些更有趣的東西。但是在此之前,請先考慮一下下面的練習題。
練習題:
有一組學生的成績,我們需要對它們評分,90分及以上是A,80分及以上是B,70分及以上是C,60分及以上為D,60分以下為E。現在要求用Rust語言編寫一個程序來評分,且評分由新建的線程來做,最終輸出每個學生的學號,成績,評分。學生成績單隨機產生,學生人數100位,成績範圍為[0,100],學號依次從1開始,直到100。