Rust 學習之路─第十六章:並發與並行

有關於並發(concurrency)和並行(parallelism)的定義每個人可能有不一樣的解釋。筆者認為並行算是並發的子集合,所謂的「並發」,是指在某項工作結束之前,另一項工作就開始了,但這些項工作可以是同時執行,也可以是交替執行。而所謂的「並行」,是並發的一種設計方式,能將工作交給不同的處理器(或超執行緒技術的邏輯處理器)來執行,而達成同時執行兩項以上工作的目的。程式語言JavaScript,雖然只支援單執行緒,但是它也可以並發處理工作,例如它能在等待I/O處理的同時,去執行其它的函數。程式語言Java,可以建立出多個執行緒並發處理工作,當作業系統可以使用多個處理器(或邏輯處理器)時,執行緒就可以被分散到各個處理器(或邏輯處理器)來同時執行,且如果執行緒數量大於處理器(或邏輯處理器)數量的話,也會交替執行;如果作業系統沒有多個處理器(或邏輯處理器)的話,則只會讓其交替處理正在執行的執行緒

程式語言如Golang,可以利用goroutine(go函數)來並發處理工作,它利用多執行緒的方式去完成每個goroutine的工作,但並不是每個goroutine都會有一個獨立的執行緒執行緒的數量透過「GOMAXPROCS」環境變數來設定,預設值即為處理器(或邏輯處理器)的數量。假設「GOMAXPROCS」為8,而goroutine的數量有100的話,Golang程式只會使用8個執行緒去完成這些goroutine。

Rust並沒有什麼特別的方式來實作並發程式,也是和Java一樣要自行建立出多個執行緒。雖然並發程式會讓程式複雜度提升,增加潛在Bug出現的機率,而且也不易偵錯,不過,Rust本身的擁有權規則,可以避免掉並發程式大部份常見的潛在Bug。

使用多執行緒來開發程式可以增加程式的效能,因為在作業系統能使用的處理器(或邏輯處理器)數量足夠的情況下,被分配到不同執行緒的程式部份是可以被「同時」執行的。舉例來說,假設完成工作A所需的時間是5秒,完成工作B所需的時間是6秒。只使用單一執行緒來完成工作A和工作B需要5+6=11秒的時間,而如果使用多執行緒來同時完成工作A和工作B,只需要Max(5,6)=6秒的時間。

而多執行緒的程式可能會遭遇到以下幾個問題:

1. 競態修件(Race Condition):不同執行緒同時存取相同的資料所產生的不一致問題。
2. 死結(Deadlock):兩個執行緒彼此等待對方釋出資源的使用權,而造成這兩個執行緒永遠無法繼續執行。
3. 多執行緒的程式在每次執行時,程式的執行順序不一定會相同,因此當發生Bug時,會很難去重現出來。

要使用Rust程式語言建立新的執行緒,可以透過標準函式庫「std::thread」模組提供的「spawn」函數,搭配閉包來使用。舉例來說:

以上程式執行結果,可能如下:

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!

「std::thread」模組提供的「sleep」函數,可以讓其所在的執行緒進入睡眠狀態,讓其它執行緒能有更多機會被執行到。新的執行緒內的for迴圈計數範圍雖然為1到10(包含1,不包含10),但由於主執行緒的for迴圈計數範圍只有1到5(包含1,不包含5),因此主執行緒的for迴圈很有可能會在新執行緒內的for迴圈結束之前就結束了,一旦主執行緒的程式都執行完成,即便新執行緒還沒執行完,程式也會直接停止。因此在程式執行的輸出結果中,沒有看到新執行緒的for迴圈當i > 4時的結果是正常的。

「std::thread」模組的「spawn」函數會回傳「JoinHandle」結構實體,我們可以利用這個結構實體,將其對應的執行緒加入(join)至其他執行緒中來執行。可以在「JoinHandle」結構實體所對應的執行緒之外呼叫該「JoinHandle」結構實體提供「join」方法,使其它的執行緒能夠等待「JoinHandle」結構實體對應的執行緒執行完成之後再繼續執行。舉例來說:

以上程式執行結果,可能如下:

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!
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!

我們讓主執行緒在for迴圈結束執行之後,去等待新執行緒執行完,再結束程式,因此程式可以輸出新執行緒中for迴圈計數範圍為1到10(包含1,不包含10)的完整結果。

先前章節中學到的「借用」,在一般情況下無法跨執行緒使用,舉例來說:

以上程式會編譯錯誤,因為主程式中的變數a,無法借用給要在新執行緒執行的閉包。

然而我們可以替閉包加上「move」關鍵字,讓閉包主體內使用其所在的scope的資源去嘗試「複製」或是「移動」,而不是「借用」,程式就可以通過編譯了。程式如下:

在很多情況下,我們還是必須要讓相同的資料在不同的執行緒中被使用,此時可以透過「訊息傳遞」(message passing)來達成目的。

套用Golang文件中的一句話:

Do not communicate by sharing memory; instead, share memory by communicating.

意思為:不要使用共享記憶體來讓執行緒彼此溝通,而是要用溝通的方式去分享記憶體中的資料。

Rust程式語言的標準函式庫,提供了「std::sync」模組,提供了一些工具來協助我們處理執行緒間的同步。其中的子模組「mpsc」(multi-producer, single-consumer FIFO queue communication primitives),便是利用「訊息傳遞」的方式來實作的。

我們可以先使用「mpsc」模組提供的「channel」函數替要彼此同步的執行緒建立出「通道」。「channel」函數會回傳一個數組,第一個元素為Sender結構實體,第一個元素為Receiver結構實體。透過Sender結構實體發送出的訊息,可以被Receiver結構實體接收到,無論當時「Sender」結構實體或是「Receiver」結構實體被移動到哪個執行緒。

舉例來說:

以上程式,第5行我們建立出了一個新的通道,並在第9行新執行緒中,使用Sender結構實體的「send」方法將字串「hi」送出。程式第12行主執行緒中,使用Receiver結構實體的「recv」方法來接收從Sender結構實體送出的訊息,主執行緒執行時會先停在該「recv」方法,直到接收到來自同通道的Sender結構實體的訊息後,才會繼續執行。

程式執行結果如下:

Got: hi

如果不用Receiver結構實體的「recv」方法,而是使用其「try_recv」方法的話,主執行緒執行時就不會停住,會直接用回傳被Result列舉所包裹的結果。

通道所能傳送的資料型別並沒有限制,因此如果要傳遞多種資料的話,可以搭配Vec結構體使用。舉例來說:

Receiver結構體本身和其參考型別皆有實作IntoIterator特性,因此可以直接被使用在for迴圈。程式執行結果如下:

Got: hi
Got: from
Got: the
Got: thread

Sender結構實體可以被複製,也就是說,我們可以用多個Sender結構實體,透過同一個通道將訊息傳遞給同一個Receiver結構實體。舉例來說:

以上程式執行結果,可能如下:

Got: hi
Got: more
Got: from
Got: messages
Got: the
Got: for
Got: thread
Got: you

當我們的資料需要有多個擁有者時,訊息傳遞的執行緒溝通方式就不太適合了,此時我們還是得回歸共享記憶體的作法。在前面的章節中,我們學會了Rc結構體,可以讓資料的擁有者不只限於一個,然而,Rc結構體並不能用在多執行緒的場合,因為會很容易造成競態修件的問題。

舉例來說:

以上程式會編譯失敗,因為Rc結構體並不是「執行緒安全」(thread-safe)的設計,如果它可以被允許用在多個執行緒上,在參考的計數上將會出現問題。還好「std::sync」模組有提供一個「Atomic」版本的Rc結構體,稱為「Arc」。Arc結構體可以被用在多執行緒的場合,以上程式可以使用Arc結構體改寫如下:

以上程式依然會編譯失敗,因為RefCell結構體並不是「執行緒安全」(thread-safe)的設計,如果它可以被允許用在多個執行緒上,當同一個資料被多個執行緒同時存取時,就會發生競態修件的問題。還好「std::sync」模組也有提供一個好用的結構體來解決這個問題,稱為「Mutex」。Mutex結構體可以被用在多執行緒的場合,因為它會替資料建立出「互斥鎖」(mutex's lock),想要拿到該筆資料的擁有者必須要先拿到資料的互斥鎖才能存取該資料,且在結束資料的使用後,還要將資料的互斥鎖還回去,以供之後其它的擁有者來使用。以上程式可以使用Mutex結構體改寫如下:

Mutex結構實體並沒有提供「borrow」或是「borrow_mut」方法,取而代之的是「lock」方法。呼叫「lock」方法時會進行等待,直到能取得該Mutex結構實體所使用的互斥鎖後,才去取得互斥所,並且將資料本身放在「MutexGuard」結構實體中,以Result列舉包裹並回傳出來。MutexGuard結構體也是一種智慧型指標,其實體可以作為不可變參考和可變參考來使用。另外,Mutex結構實體還有一個「try_lock」方法,即為「lock」方法的不等待版本。

以上程式會編譯成功,並且輸出:

Result: 15

正確答案!

以效能來說,有「執行緒安全」的Arc結構體和Mutex結構體會比Rc結構體和RefCell結構體還要差一點,因此我們在單執行緒的場合下要使用Rc結構體和RefCell結構體,而在多執行緒的場合下才去使用Arc結構體和Mutex結構體會比較好。另外,在之前的章節中有提到,Rc結構體和RefCell結構體的組合可能會造成記憶體洩漏,如果改用Arc結構體和Mutex結構體的組合也是一樣會有!而且當Arc結構體和Mutex結構體用在多執行緒時,還可能會導致死結的發生,舉例來說:

以上程式,可能會因為主執行緒和新的執行緒發生死結而導致程式永遠無法執行結束。發生死結的契機為,新執行緒在程式第17行的變數i拿到x的互斥鎖的同時,主執行緒正好已經執行完程式第24行,變數u拿到y的互斥鎖。接著新執行緒在程式第18行想要讓變數j拿y的互斥鎖,就需要等主執行緒將互斥鎖釋出;而主執行緒此時想要讓程式第25行的變數v拿x的互斥鎖,就需要等新執行緒將互斥鎖釋出。像這種多個執行緒互相等待對方釋出資源的情況,就是死結啦!

除了Arc結構體和Mutex結構體可能會發生死結之外,「訊息傳遞」也是有可能會發生的。

舉例來說:

以上程式,在新執行緒發送訊息至第二個通道前,會先嘗試接收來自第一個通道的訊息;而主執行緒則是要在收到來自第二個通道的訊息後,才會發送訊息至第一個通道。因此,這兩個執行緒彼此都在等待對方的訊息,死結發生!程式執行結果如下:

main thread is waiting for channel 2
spawned thread is waiting for channel 1

結論

Rust程式語言嚴格的擁有權規則,使得開發併發程式的過程更有條理,更不易在執行階段出錯(尤其是競態修件的部份)。雖然還是有些典型的多執行緒問題可能會發生,但已經比其它程式語言要好多了!在下一章節,我們將會學習如何在Rust程式語言中應用物件導向概念來開發程式。

關於作者

Magic Len

Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook,並且請註明是從MagicLen來的。

相關文章