前言

在多執行緒程式當中,共享變數可能會同時被多個執行緒存取,造成預期外的結果。


舉例來說:

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。

i++多執行緒安全

以上述狀況為例子就能知道為什麼在多執行緒時,我們會特別注重「執行緒安全」。

要解決 race condition 有很多種方式,以下將一一介紹 volatile、lock、atomic、channel。

暫存器就是 CPU 用來暫存指令的,空間小而快。

CPU除了有暫存器還有快取記憶體,快取記憶體還區分L1,L2,L3等級。

因此資源在記憶體CPU快取記憶體CPU暫存器之間的交互關係,都有可能造成多執行緒最終產生的預期外結果。

範例問題程式碼

下列的範例程式碼使用 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 來得好。


benchmark

(2) 易讀/可維護

雖然跑分上 atomic 的效能最快,也只有一行就可以完成,但是受限於 atomic 只有 29 個函式,可能很多時候無法滿足當下的情境


channel 的應用情境不單只是為了同步的實現,還可以用在多個 goroutine 溝通。


因此這3個機制是可以根據不同的情境使用。

參考連結

Does Go support volatile / non-volatile variables? - Stack Overflow Concurrency - Effective Go sync/atomic - Golang Documentation 使用 Go Channel 及 Goroutine 時機 - AppleBOY
FBLINETwitterLinkIn
回部落格