前言
在多執行緒程式當中,共享變數可能會同時被多個執行緒存取,造成預期外的結果。
舉例來說:
Thread1 與 Thread2 同時存取變數 counter,並將其+1
counter++
以組合語言的角度檢視,程式實際運行時,counter++會被拆分成3個步驟,
(1) mov 將 counter 變數的數值載入暫存器
(2) add 將暫存器的數值+1
(3) mov 將暫存器的數值寫回 counter 變數
counter++ 被拆成3個指令之後,在多執行緒之間執行順序可能會造成預期外的結果。
舉例來說:
counter 一開始的值是0。
若 Thread1 先將 counter 讀入暫存器,Thread2 再將 counter 讀入暫存器,此時,Thread1 與 Thread2 暫存器的值都是0。(要注意,不同 thread 之間會存取不同的暫存器)
接著 Thread2 先將其暫存器的數值+1,Thread1 再將其暫存器的數值+1,然後2個thread分別將暫存器的值寫回變數。
最終,變數 counter 的值變成了1。
這種因為並行時先後順序產生衝突的狀況我們稱之為 race condition。
以上述狀況為例子就能知道為什麼在多執行緒時,我們會特別注重「執行緒安全」。
要解決 race condition 有很多種方式,以下將一一介紹 volatile、lock、atomic、channel。
範例問題程式碼
下列的範例程式碼使用 4個 goroutine 同時對一個變數個別 ++ 100次,這將會出現 race condition。
嘗試執行可以發現有時結果會是 400(正常),有時卻是小於 400。
而本文章將會透過 3 種方式進行改善與比較。
1. volatile
先說明 Golang 並沒有 volatile 關鍵字,詳情可以見下方參考連結「Does Go support volatile / non-volatile variables? - Stack Overflow」。
volatile 關鍵字會提醒編譯器這個變數可能隨時會被改變,讓編譯後的程式每次存取該變數時,都會從記憶體讀取,避免因為暫存至 CPU 暫存器時,在多執行緒運行的情況下,造成預期外的結果。
2. lock
在多個執行緒同時存取的情況下,必須讓資料在各個執行緒之間同步,取得同樣的資料。透過 lock 鎖定與解鎖機制可以完成這個需求,
在 Golang sync package 裡面提供了「Mutex」互斥鎖與「RWMutex」讀寫鎖。另外還有提供「Once」一次鎖。
(1) Mutex
Mutex 互斥鎖,當資源被第一個執行緒鎖定後,在解鎖之前,第二個執行緒無法存取,必須等待解鎖後才能執行。
也因此,會有等待的情形發生,造成效能上的影響。
我們也透過 Mutex 改寫有問題的範例程式碼,達成執行緒安全。
(2) RWMutex
在某些情境下,多個執行緒同時讀取一個資源是沒有問題的,只有當寫入時必須限制資源的同步,這讓多個執行緒同時讀取資源時不必消耗等待的時間,提升效能。
RWMutex 讀寫鎖,提供多讀單寫的功能,在特定情境時使用可以減少鎖定等待的狀況,提升效能。
在讀取資源的情境下,允許多個執行緒同時存取的鎖定方式,我們稱之為「Shared」共用鎖。
在寫入資源時,必須強制多個執行緒之間同步(與等待)的鎖定方式,我們稱之為「Exclusive」獨佔鎖。
(3) Once
Once 僅會執行一次鎖定。
這個機制很適合用在初始化的情境,例如 Singleton Pattern。
3. atomic
在 Golang sync/atomic package 裡提供了非常多的函式,以 go1.18.1 為例子,atomic 提供了29個函式。
這些函式保證功能執行時是有「原子性」的表現,也就是這個操作是不會被分割的(可參考前言),因此有多執行緒安全的特性。
由於 atomic 僅提供 29 個函式,因此撰寫程式時不見得每一段程式都能有相對應的函式套用。
我們也透過 atomic 改寫有問題的範例程式碼,達成執行緒安全。
4. channel
“
Do not communicate by sharing memory; instead, share memory by communicating.
”
在 Effective Go 的 Concurrency 章節,建議「透過溝通的方式來共享變數」而非「透過共享變數的方式來溝通」,也就是在並行的情境下,共用變數可能會出現預期外的結果,而 Effective Go 提倡使用 channel 溝通的方式來達成多執行緒之間資源同步。
但是也有提到並不見得所有的同步操作都是以 channel 溝通為最好的方式。
我們也透過 channel 改寫有問題的範例程式碼,達成執行緒安全。
這邊也必須說明一下,這段程式碼並不是一個很良好的範例,因為 data++ 的情境並不是很適合用 channel。這個範例的目的只是希望能達成透過 channel 來控制同步。
5. 比較 lock、atomic、channel
(1) 效能
透過 benchmark 可以看到各種解決方案的效能差異,連最初會造成 race condition 的寫法也一起跑分。若有時為了極致效能可以捨棄部分精準(誤
由於 channel 內部也是有 lock 的實現,因此在這個簡單的範例下,效能絕不會比單使用 lock 來得好。
(2) 易讀/可維護
雖然跑分上 atomic 的效能最快,也只有一行就可以完成,但是受限於 atomic 只有 29 個函式,可能很多時候無法滿足當下的情境
channel 的應用情境不單只是為了同步的實現,還可以用在多個 goroutine 溝通。
因此這3個機制是可以根據不同的情境使用。
暫存器就是 CPU 用來暫存指令的,空間小而快。
CPU除了有暫存器還有快取記憶體,快取記憶體還區分L1,L2,L3等級。
因此資源在記憶體、CPU快取記憶體、CPU暫存器之間的交互關係,都有可能造成多執行緒最終產生的預期外結果。