文章目錄
- sync.Mutex 互斥鎖
- sync.RWMutex 讀寫鎖
- sync.Once 惰性初始化
- Goroutine 與線程
- 動態棧
- Goroutine 調度
- GOMAXPROCS
- Goroutine 沒有 ID 號
上一篇文章當中我們已經系統性地回顧了在 Go 當中基于 Goroutine 和 Channel 進行并發控制的方法,Goroutine 指的是 Golang 的應用級線程,相比于系統線程,goroutine 是輕量級的,完全在用戶態進行調度;Channel 可以被理解為 goroutine 之間進行通信的信道,它是 Golang 當中的引用類型,底層引用的數據結構是一個數組。基于 channel,在 Golang 當中我們可以直接進行 Goroutine 之間的通信,而無需基于共享內存進行通信。
這一節的內容是對 Golang 并發機制的深入,介紹了 Go 當中的鎖機制,基于鎖機制我們可以在 Go 當中基于共享變量來進行線程之間的通信。基于這一節的內容,我們將完整地回顧完與 Golang 并發機制以及 Goroutine 調度有關的知識,在這一節的最后,將會詳細地回顧 goroutine 與操作系統線程的區別。
sync.Mutex 互斥鎖
基于 channel,我們實際上可以實現在同一時刻只有一個并發執行的 goroutine 訪問存儲關鍵資源的共享變量。下例模擬了一個銀行的存取邏輯,基于緩沖區大小為 1 的 channel 來確保不同 goroutine 對關鍵資源的訪問互斥:
var (sema = make(chan struct{}, 1) // 二元信號量balance int
)func Deposit(amount int) {sema <- struct{}{} // 如果緩沖區為空, 就不會被阻塞balance = balance + amount<- sema // 釋放鎖信號
}func Balance() int {sema <- struct{}{} // 換言之, 如果緩沖區被填滿, 這條寫入操作就會被阻塞b := balance<- semareturn b
}
Golang 的sync
包中有一個名為Mutex
的類型同樣能夠實現上述邏輯,它具有兩個方法,分別是Lock
和Unlock
,前者會加鎖而后者會釋放鎖:
import "sync"var (mu sync.Mutex // guards balancebalance int
)func Deposit(amount int) {mu.Lock()balance = balance + amountmu.Unlock()
}func Balance() int {mu.Lock()b := balancemu.Unlock()return b
}
在Lock
與Unlock
之間代碼段的內容可以被當前持有鎖的 goroutine 隨意讀取或修改,這個代碼段叫做臨界區。鎖的持有者在其他 goroutine 獲取鎖之前需要調用Unlock
,這也就意味著持有鎖的 goroutine 在結束之前必須將鎖釋放。
可以將Unlock
行為與defer
關鍵字組合,來確保鎖最終被釋放:
func Balance() int {mu.Lock()defer mu.Unlock()// ... ... ...return balance
}
接下來我們研究一個更加復雜的案例,考慮下面的Withdraw
函數,在成功時,它會調用Deposit
扣減余額并返回 true,如果銀行資金不足,那么就恢復余額并返回 false:
func Withdraw(amount int) bool {Deposit(-amount)if Balance() < 0 {Deposit(amount)return false}return true
}
函數可以給出正確的結果,但這個函數有一個副作用,那就是過多的取款操作并發時,balance 可能會瞬間被減到 0,這可能會導致并發取款與支付被不合理的拒絕。產生上述問題的原因是,在當前的取款$ \rightarrow $支付邏輯不是一個原子性的操作,每一步都需要去單獨地獲取互斥鎖,任何一次上鎖都不會鎖住整個流程。
理想情況下,取款$ \rightarrow $支付邏輯應該在開始時獲取互斥鎖,結束時釋放,但由于我們還沒有修改Deposit/Balance
的邏輯,這就意味著下述代碼會產生錯誤:
// ? INCORRECT
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()Deposit(-amount)if Balance() < 0 {Deposit(amount)return false // insufficient funds}return true
}
在Withdraw
開始時,我們去獲取鎖,但是在Deposit
中我們會再次嘗試獲取鎖,由于 Golang 的 Mutex 不可重入,無法對一個已經上鎖的 Mutex 再次加鎖,這就會導致程序死鎖,沒有辦法繼續執行下去(因為Deposit
等待的鎖永遠不會釋放)。
基于上述原因,我們能夠做的就是對Deposit
進行修改,新建一個它的非導出版本,在這個非導出版本當中,不需要基于鎖進行并發控制,因為它將會被Withdraw
調用:
func Withdraw(amount int) bool {mu.Lock()defer mu.Unlock()deposit(-amount)if balance < 0 {deposit(amount)return false // insufficient funds}return true
}func Deposit(amount int) {mu.Lock()defer mu.Unlock()deposit(amount)
}func Balance() int {mu.Lock()defer mu.Unlock()return balance
}// This function requires that the lock be held.
func deposit(amount int) { balance += amount }
sync.RWMutex 讀寫鎖
上例當中的Balance
函數實際的行為就是讀取變量的狀態,而不會對變量進行狀態改變,所以實際上我們并發調用多個Balance
是安全的。
sync.RWMutex
是一種特殊類型的鎖,它允許多操作并發執行,但是寫操作互斥,這種鎖叫做“多讀單寫”鎖:
var mu sync.RWMutex
var balance int
func Balance() int {mu.RLock()defer mu.RUnlock()return balance
}
sync.Once 惰性初始化
在單例模式當中,我們已經見到了「惰性初始化」的基本用法。一個線程安全的單例模式的模版是:
package mainimport ("fmt""sync"
)var lock sync.Mutextype singleton struct{}var instance *singletonfunc GetInstance() *singleton {lock.Lock()defer lock.Unlock()if instance == nil {return new(singleton)} else {return instance}
}func (s *singleton) SomeThing() {fmt.Println("SomeThing is called")
}func main() {s := GetInstance()s.SomeThing()
}
在“懶漢式”的單例模式下,為了確保單例類實例只有在需要的時候才被初始化,我們引入了一個 Mutex 鎖,來在調用GetInstance
函數的時候,首先加鎖判斷單例類實例是否被創建,如果沒有被創建,則新建這個單例類實例。
我們進行進一步的細化,考慮下面這樣的一個 icons 變量:
var icons map[string]image.Image
我們嘗試使用“懶漢式”的做法來對 icons 進行初始化:
var mu sync.Mutex
var icons map[string]image.Imagefunc loadIcon(path string) image.Image {// ... ... ...return /* ... */
}func loadIcons() {icons = map[string]image.Image{"spades.png": loadIcon("spades.png"),"hearts.png": loadIcon("hearts.png"),"diamonds.png": loadIcon("diamonds.png"),"clubs.png": loadIcon("clubs.png"),}
}// NOTE: not concurrency-safe!
func Icon(name string) image.Image {mu.Lock()defer mu.Unlock()if icons == nil {loadIcons() // one-time initialization}return icons[name]
}
上述做法下,icons 的初始化當然是安全的。如果有 goroutine 并發地調用Icon
,由于鎖機制的存在,如果當前有其他 goroutine 正在調用Icon
,那么當前Icon
將會被阻塞。
一個問題在于,如果Icon
已經被初始化完成,那么并發的 goroutine 無法并發地讀 icons 這個變量,這會導致性能的下降,我們可以進一步使用RLock
來對上述Icon
函數的邏輯進行修改:
var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {mu.RLock()if icons != nil {icon := icons[name]mu.RUnlock()return icon}mu.RUnlock()// acquire an exclusive lockmu.Lock()if icons == nil { // NOTE: must recheck for nilloadIcons()}icon := icons[name]mu.Unlock()return icon
}
修改后的Icon
既在初始化時線程安全,又支持并發讀,但是代碼較為復雜,我們可以使用 Golang sync
包內置的sync.Once
來專門解決這種“懶漢式”的一次性初始化的并發問題。
下例使用sync.Once
繼續優化Icon
的邏輯:
var loadIconsOnce sync.Once
var icons map[string]image.Image
func Icon(name string) image.Image{loadIconsOnce.Do(loadIcons)return icons[name]
}
每一次Do(loadIcons)
的調用都會鎖定mutex
,并檢查記錄初始化是否完成的 bool 變量。只有 bool 為 false 的時候才鎖定 mutex 來完成初始化操作。
總結一下,對于“懶漢式”初始化這種典型場景,為了確保并發調用該函數訪問單例類實例時,不會產生:
- 重復初始化;
- 讀等待;
可以使用sync.Once
當中的Do
來優化初始化邏輯。
Goroutine 與線程
在這一小節當中,我們具體地來區分一下 goroutine 這個用戶態線程與操作系統線程之間的區別。這些區別可以說是在面試 Golang 開發崗位時必知必會的細節。
動態棧
操作系統線程的棧有一個固定的內存塊(一般為 2MB),這個內存塊將會用作棧,這個棧用于存儲當前正在被調用的函數或掛起的函數(指的是為了調用當前這個函數而被暫時掛起的其他函數)的內部變量。
2MB 對于一個線程的棧而言,很大又很小。與 goroutine 相比,初始的 goroutine 的棧內存大小僅為 2KB,2MB 是它的一千倍。
對于 Go 程序而言,同時創建成百上千個 goroutine 是很普遍的,如果每一個 goroutine 都需要這么大的內存用作棧的話,那么讓成百上千這個數量級的 goroutine 同時運行是不可能的。一個 goroutine 的棧是動態的,在其生命周期開始時,棧的大小只有 2KB。goroutine 的棧的作用與操作系統線程的棧類似,都是用于保存當前活躍以及掛起的函數調用的本地變量。goroutine 棧的大小可以動態伸縮,最大值可以達到 1TB,比傳統的固定大小的棧大得多得多,但實際上大多數情況下棧的內存空間不會達到這個數量級。
Goroutine 調度
操作系統線程會基于操作系統的內核進行調度。每幾毫秒,一個硬件的計時器會中斷處理器,調用一個名為 scheduler 的內核函數。這個函數會掛起當前執行的線程,并將它的寄存器內容保存到內存當中,檢查線程列表并決定下一次調度哪一個線程到 CPU 上執行,scheduler 會從內存中恢復該線程的寄存器信息,然后恢復該線程的現場并開始執行線程。
顯然,由于操作系統線程是被內核當中的 scheduler 函數調度的,所以一個線程向另一個線程移動需要進行完整的上下文切換(首先保存當前線程的上下文狀態到內存,之后檢查線程列表根據調度策略選擇下一個要調度的線程,從內存當中將它的狀態轉移到寄存器,恢復線程的現場,開始執行線程)。上下文切換的操作很慢,需要經過若干次的內存訪問,會增加 CPU 的運行周期。
Go 的運行時包含了自己的調度器(GMP 線程調度模型,Golang 開發面試時的考察熱點),比如m:n
調度,它會讓n
個操作系統線程多工調度m
個 goroutine。Go 調度器的工作原理與內核當中的 scheduler 函數非常的相似,但是 Go 的調度發生在用戶態,而非內核態。由于不需要進入內核進行上下文切換,所以 Go 調度 goroutine 的成本比操作系統線程的調度成本要低很多。
GOMAXPROCS
Go 的調度器會使用一個名為 GOMAXPROCS 的變量來決定會有多少個 OS 線程同時執行 Go 代碼,其默認值是運行機器上的 CPU 的核數。比如對于一個 8 核的機器,調度器一次會在 8 個 OS 線程上調度 Go 代碼。
GOMAXPROCS 可以設置為比 CPU 核數更多的值,但是這樣做通常不會帶來性能的提升,甚至可能會由于過多的線程切換而導致性能下降。
GOMAXPROCS 對應的是 GMP 調度模型當中的 P,即具體的“調度器”,負責協調 G 和 M 的執行,GOMAXPROCS 的值就是 P 的數量。
有關 Golang 的 GMP 調度模型,可以詳見我之前的文章:https://blog.csdn.net/Coffeemaker88/article/details/146607091
Goroutine 沒有 ID 號
大多數支持多線程的操作系統或程序設計語言,都會為線程分配一個獨特的身份(ID),并且這個身份可以以一個普通值的形式(比如一個整型數值)被輕易地獲取到。基于線程的 ID,我們可以對線程進行本地存儲(Thread-Local Storage,TLS),只需要使用一個 map 將線程 ID 與實際的線程對應起來即可。
TLS 可能會被濫用,因為線程本身的身份信息可能會改變,這就會使得承載在這個線程上的函數的行為變得不可預測。因此直接在程序當中基于 TLS 與線程進行交互是不安全的。
Goroutine 沒有身份信息的概念,避免了 TLS 的濫用。