Ruby 與 Thread

Posted by Noel on 2018-07-04

前言

執行緒是什麼?你的程式有使用到執行緒嗎?如果有的話,那它有保證是 Thread-Safe 嗎?身為一個軟體工程師應該多少都聽過這些問題,又發現或著曾經在學校的課堂上、或是作業系統的考卷上看過這個名詞。但我們真的能回答的出這些問題嗎?至少我曾經也是對它一無所知的,雖然不懂執行緒也依然可以開發產品,但如果能深入地去理解、認識,那將能幫我們打開一扇新的窗,給予我們更多了能力與選擇去拓展我們的眼界。

Thread 基本介紹

Thread (執行緒/線程) 是 CPU 使用時的基本單位,它是由程式計數器、暫存器、堆疊所組成的,藉由執行緒我們可以同時執行多項事務而達到多工的目的。單看字面的介紹,我想許多人還是對 Thread 不太了解,因此改用更簡單直白的方式介紹 Thread 的由來。

要介紹 Thread ,就不得不提到 Process(程序/進程) ,在 Unix 系統下幾乎萬物都是程序,無論是系統背景或是我們自行啟動的程式都算,例如:打開瀏覽器、開啟文書處理器、或著在終端機執行一段 Ruby Script 都是在開啟並執行一項程序;而每個程序下,又可能需要在 同時 進行許多任務,例如觀看影片,就需要有畫面與聲音播放、甚至字幕顯示等三件事,因此需要三個執行緒來運作,不然只靠一個執行緒的話,那就會變成先看完影片、聽到聲音、最後出現字幕,這顯然無法接受;而如何讓這三個執行緒可以在單核心作業系統上也能同時進行呢?!那就是必須借助作業系統來排程、調度這些執行緒,並且是很快速的切換,快速到讓我們感受這三個執行緒像是在同時進行一般(如果是多核心系統那多個執行緒將有機會被同時執行)。

從上可推論出,每個程序至少有一或多個執行緒,即便我們的程序裡沒有明確地呼叫到任何執行緒的 code。

1
2
Thread.main
# => #<Thread:0x007fd75c880a50 run>

Thread 與 Process 的關係

從上我們可以得知 Process 與 Thread 有某種從屬的關係,這是因為 Process 是系統資源(例如:CPU)分配的最小單位,而 Thread 又是 CPU 執行的最小單位。

若我們把 Process 視為一個持有資源的工廠,而工人就是負責執行工作的單位,也就是 Thread 扮演的角色,而一個工廠則至少要有一個工人才可以運作,當然也可多個工人一起工作,這樣可以更有效率地去處理事情,而不會讓多餘的資源閒置在那,這也就是 Thread 的由來與目的。

Thread 的基本使用

Ruby 使用 Thread 非常容易,只需要傳一個 codeblock 給 Thread.new 即可:

1
2
3
Thread.new do 
puts "Hello World"
end

但其實這樣看不出什麼效果,所以我們可以建立 5 個 Thread 來執行這段 code:

1
2
3
4
5
6
7
8
9
10
11
5.times do
Thread.new do
puts "Hello World"
end
end
puts "Here comes the Happy Ending"
### 以下是輸出結果
Hello World
Hello World
Hello World
Here comes the Happy Ending

Oops ,只顯示三次 Hello World,而且多重複跑幾次會發現出現的次數也不太一樣。但我們可以發現最後總是顯示 Here comes the Happy Ending

這是因為 Thread 彼此是會互相影響的,而且當主 Thread 結束的時候,其他還未執行的 Thread 也就跟著被捨棄。

所以此時我們要使用 join 方法來通知當前的主 Thread 等到該 Thread 執行完畢後才可以繼續執行下去。

1
2
3
4
5
6
7
8
9
10
11
12
13
5.times.map do
Thread.new do
puts "Hello World"
end
end.each(&:join)
puts "Here comes the Happy Ending"
### 以下是輸出結果
Hello World
Hello World
Hello WorldHello World

Hello World
Here comes the Happy Ending

雖然顯示的順序似乎有一點怪,但我們確保一定會顯示五次 Hello World 後才顯示 Here comes the Happy Ending,以確保主 Thread 總是在其他的 Thread 執行完後才會執行。

而剛剛說提到的 Thread 彼此會相互影響,所以其實也意味著,當某個 Thread 執行失敗時,也會連動影響到其他運行的 Thread 而造成 crash ,這是 Thread 與 Process 一個主要的不同之處。

剛剛運行結果另外一個奇怪的地方是,顯示的順序似乎有點奇怪,如果我們多加上一行這種奇特順序會更為明顯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
5.times.map do
Thread.new do
puts "Hello World"
puts "Wonderful World"
end
end.each(&:join)
puts "Here comes the Happy Ending"
### 以下是輸出結果
Hello World
Wonderful World
Hello World
Hello World
Hello World
Wonderful World
Wonderful World
Hello World
Wonderful World
Wonderful World
Here comes the Happy Ending

會造成這樣怪異的順序的原因是 Thread 的排程與調用所致,而負責此功能是屬於系統內核的部分,我們無法透過程式去自行安排或預期。所以如果多試幾次會發現顯示(執行)的結果都不盡相同。

Thread 的排程與調用更白話一點,就是當系統在執行 Thread 裡面的程式時(任一行 code),可能隨時會被叫停,並且把執行權讓給其他的 Thread,再不定時的跳換回來繼續執行,這種機制是為了不讓某一 Thread 執行太久而佔住資源,而這種排程與調用又是完全無法預期的。

而 Thread 的這種特性也就導致了另一個重要的議題 Thread-Safe

我們通常 Thread 排程、調用的動作稱作 Context Switch

Thread-Safe

Thread-Safe 顧名思義就是在 多執行緒的使用 下,造成無法預期的結果,而這種結果並不是說會讓程式當機或是失敗,而是跑出我們沒想到的結果,像是執行順序、次數的不同造成的前後依賴、或是對共同資源存取的爭搶問題。

但我們要記住一件事,使用多執行緒並不代表就一定是非 Thread-Safe。

Thread-Safe 的原因

常見會引發 Thread-Safe 的原因通常有兩個:

1. Thread 排程與調用導致的非原子性操作

雖然我們無法預期 Thread 何時會被調離,以及執行順序的問題,但如果我們的程式本來就不會因為 Thread 彼此執行順序所影響的話 (不同的 Thread 彼此不會有功能相依性),那是不會有任何影響的。

不過常犯的錯誤是,一行 Ruby Code 並不代表就一個最小的執行單位,例如:
i += 1 實際上等價於 i = i + 1
而以系統的角度來看,他可以再被視為 i=i + 1兩個部分,而非我們認為的一行為單位,所以 Thread 切換的時間並不是以一行的程式碼來看。

但儘管如此,因為 Thread 擁有自己的 Stack 所以也就擁有自己的區域變數,所以即使被排程影響,也還不會有什麼問題,但如果配上第二個原因,那問題可就大了。

2. Thread 對共享資源的 Race Condition

Race Condition 是什麼可以自行查略維基百科,但簡單來說就是 多個男生搶著把一個妹,但妹可能同時腳踏多條船,這當然是不允許的 多個東西想同時搶著修改一件事物,而這個事物此時的參考可能產生存取上的錯亂。

1
2
3
4
5
6
7
8
9
10
11
i = 1 # 這個被共享的變數,因為不被 Thread 所包含
5.times.map do
Thread.new do
5.times do
i += 1
end
end
end.each(&:join)
puts "count is #{i}"
### 以下是輸出結果
count is 25

糟糕,居然又是 25 ,即使多試幾次也一樣,那豈不是被打臉了嗎?這是因為我使用的 Ruby 是 MRI ,而 MRI 持有 GIL 所以造成這個問題 很難 被測試出來。如果使用的是 jRuby 這種沒有 GIL 機制的引擎,那很輕鬆的就可以被測試出來;不過即使有 GIL 我們還是有辦法重現問題的發生,只需要再修改一點 code 增加了 IO 的操作即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
j = 0
5.times.map do
Thread.new do
5.times do
i = j
i = i + 1
puts 'lala'
j = i
end
end
end.each(&:join)
puts "count is #{j}"
### 以下是輸出結果
lala
lala
..(省略一堆 lala)
count is 6

最後輸出的結果與本來預期的 25 相差很多,如果把那行 puts (IO 模擬) 移除的話,結果會有極高的機率再度變回 25 ,造成這個的原因是因為當 Thread Scheduler 碰到等待外部 IO 或是睡眠狀態時,Scheduler 會把 Thread 的執行權讓給其他的 Thread ,從而產生了 Context Switch 再搭配上述的原因一造成的錯亂,從而產生非預期的結果。

更白話的說明以上的問題發生的狀況就是,例如 A Thread 拿到當前的 i 值為 5 ,並進行了 + 1 等於 6 ,但此時還未寫入給 i 就被 Schedule 暫停跑去執行 B Thread ,而 B Thread 得到的 i 值也為 5 (因為 A 還未寫回去),所以 B 就進行了 + 1 也得到了結果 6 並寫回 i ,而 A Thread 後來又得到了執行權,再把它當時得到的結果 6 寫回 i 而覆蓋掉了 B Thread 的結果。

而共享資源在實際業務的例子,可能是變數、DB 操作、File 等等 。

而如果我們的程式有使用到多執行緒,又存在著對共有資源存取的可能,那我們的程式就隱含有 Thread-Safe 的問題。

如何確保 Thread-Safe

實際上有許多方法可以防止以上的問題發生,只要能達到目的就是好方法,而最常見也最簡單的方式就是用 互斥鎖 (Mutex; Mutually Exclusive),這個鎖的作用就是告訴系統內核當 Thread 持有這個鎖的期間,不要做 Context Switch;也從而達到把關鍵操作原子化的目的。像 DB 的 Transaction 也是必須把裡面的東西全部執行完才行(作為一個交易的不可分割單位),而且只有執行成功,或失敗全部退回,沒有執行一半的狀況。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
mutex = Mutex.new # 鎖是要被 Thread 共享的,所以基本上對應一個關鍵操作只該有一個鎖
j = 0
5.times.map do
Thread.new do
5.times do
mutex.synchronize do # 裡面包覆著的操作皆為不可分割的原子操作
i = j
i = i + 1
puts 'lala'
j = i
end
end
end
end.each(&:join)
puts "count is #{j}"
### 以下是輸出結果
lala
lala
...
...
count is 25

僅對關鍵操作使用互斥鎖

有了 Mutex 我們可以選擇鎖住任何範圍的操作,但僅要找出關鍵的地方,也就是對共享資源存取的部分,去使用鎖,而不要涵蓋過多個地方,等同於變回循序執行了,而讓多執行緒帶給我們的效益降到最低。

注意死鎖的問題

若同時有存在 1 個以上的鎖,而且鎖裡面又會去嘗試得到其他的鎖來使用,那就可能存在著進入死結的問題,想像一下有兩個 Thread 並且搭配兩種鎖,雙方都先用其中一個鎖確保自己得操作不可分割,但不可分割的動作中又包含要去拿到對方的鎖,則會進入死循環,因為雙方都在等對方釋放鎖。

何時該使用 Thread

一般來說,我們可以把任務分成兩種類型:

1. IO 密集任務

就是任務中主要的運算時間都浪費在等待 IO 的讀取或寫入上,包然對外部 HTTP 的請求、檔案的讀寫、資料庫的讀寫等等;此時程式通常都在等待對方回應,而不是處理資料或邏輯運算,這樣種等待通常是種浪費,就像去便利商店買御便當,而店員拿便當去微波就在那邊呆呆地等到微波好了才繼續服務下一個人的事情,但實際上他應該先回來繼續服務下一個人,等到便當微波好了再去拿。

2. CPU 密集任務

顧名思義就是主要的運算時間都真的拿去做大量的數學運算、複雜的邏輯處理等等,繪圖等等,這時候每個 Process 分到的資源及時間真的都拿下去運算了分身乏術。如果今天每個人去便利商店都是要繳一堆單據的費用等等,店員一次幫一個人處理單據,或是同時處理所有單據的時間基本是差不多的。

3. 實務上有混合 CPU 與 IO 密集任務

從上面的兩種任務來看,假使今天只有一個店員(單核心 CPU) ,那麼多執行緒只有在 IO 密集任務上處理有所幫助,CPU 密集的任務則愛莫能助。

而如果有多個店員(多核心 CPU),那麼無論是 IO 密集還是 CPU 密集都能有所幫助,但 IO 密集的任務幫助明顯還是好很多的。

該用多少 Thread 才能獲取效益最大

Thread 當然不是越多越好,如果一次使用 10000 個以上的 Thread 系統應該會爆給你看或是禁止;除了系統資源與硬體限制以外,Contex Switch 所造成的成本也是與 Thread 的數量有線性正相關的。

所以答案是必須依情境實際去測試跟推算看看:

1. 估算

假使一個 Process 一次分到 1000ms 的執行時間(資源)其中等待 IO 佔用的時間為 800ms,而實際執行的時間僅為 200ms ,其他的時間都在空轉等待,所以理想而言我們應該開 5 個 Thread 才能達到效益及大化,而超過 5 個以上就勢必有 thread 必須等待,而且還會有 Context Switch 的消耗而讓效益持續降低。

2. 測試

實際寫一段 code 搭配 benchmark 或是畫圖便可找出峰值,而那峰值正是我們的最佳數量,例如:30 個任務要跑,可以分別建立 2, 6, 10, 15, 30 隻 Thread 去均分這些任務然後執行,觀察其結果。

結論:

長長的一大篇其實也只介紹了 Thread 的基本使用方式跟基礎知識而已,還有許多的議題或特色沒有提到,諸如 Condition Variable 的使用、Queue 的使用與解耦、經典的消費者與生產者問題。

未來有機會我會再補上以這些為議題的文章或是範例,但仍希望這篇文章能給許多想理解 Thread 的使用者們能有些良好的概念,幫助對平行處理等領域有更好的認識。

補充:

全局鎖 (GIL; Global Interpreter Lock)

剛剛上面的例子有提到 GIL,是被作為一種防範以及降低錯誤可能的機制而被加上的全局鎖,MRI 實作了這種鎖,他主要的功能是強迫在任一時間只能有一個 Thread 被執行,即使多核 CPU 也是一樣,也迫使了只要使用 MRI 就永遠無法達到 Parallel Computing 的效果(Python 也有實作 GIL),所以如果想要充分有效利用多核去實作多執行緒,可考慮用 jRuby 或其他。

而需要注意的地方是,GIL 沒辦法保證 Thread-Safe ,上述提到的例子已經有清楚的證明這件事了,所以不能同時運算並不代表就沒有共發性 (Concurrency) 的問題。