title: 【轉載】Go 語言中的單例模式
date: 2021-08-09 16:35:33
comment: false
toc: true
category:
- Golang
tags: - 轉載
- Go
- 設計
- 模式
- 單例
- 錯誤
本文轉載自:Go 語言中的單例模式 | 李文周的博客
在過去的幾年中,Go 語言的發展是驚人的,並且吸引了很多由其他語言(Python、PHP、Ruby)轉向 Go 語言的跨語言學習者。 Go 語言太容易實現並發了,以至於它在很多地方被不正確的使用了。
Go 語言中的單例模式#
在過去的幾年中,Go 語言的發展是驚人的,並且吸引了很多由其他語言(Python、PHP、Ruby)轉向 Go 語言的跨語言學習者。
在過去的很長時間裡,很多開發人員和初創公司都習慣使用 Python、PHP 或 Ruby 快速開發功能強大的系統,並且大多數情況下都不需要擔心內部事務如何工作,也不需要擔心線程安全性和並發性。直到最近幾年,多線程高並發的系統開始流行起來,我們現在不僅需要快速開發功能強大的系統,而且還要保證被開發的系統能夠足夠快速運行。(我們真是太難了☺️)
對於被 Go 語言天生支持並發的特性吸引來的跨語言學習者來說,我覺得掌握 Go 語言的語法並不是最難的,最難的是突破既有的思維定勢,真正理解並發和使用並發來解決實際問題。
Go 語言太容易實現並發了,以至於它在很多地方被不正確的使用了。
常見的錯誤#
有一些錯誤是很常見的,比如不考慮並發安全的單例模式。就像下面的示例代碼:
package singleton
type singleton struct {}
var instance *singleton
func GetInstance() *singleton {
if instance == nil {
instance = &singleton{} // 不是並發安全的
}
return instance
}
在上述情況下,多個 goroutine 可以執行第一個檢查,並且它們都將創建該singleton
類型的實例並相互覆蓋。無法保證它將在此處返回哪個實例,並且對該實例的其他進一步操作可能與開發人員的期望不一致。
不好的原因是,如果有代碼保留了對該單例實例的引用,則可能存在具有不同狀態的該類型的多個實例,從而產生潛在的不同代碼行為。這也成為調試過程中的一個噩夢,並且很難發現該錯誤,因為在調試時,由於運行時暫停而沒有出現任何錯誤,這使非並發安全執行的可能性降到了最低,並且很容易隱藏開發人員的問題。
激進的加鎖#
也有很多對這種並發安全問題的糟糕解決方案。使用下面的代碼確實能解決並發安全問題,但會帶來其他潛在的嚴重問題,通過加鎖把對該函數的並發調用變成了串行。
var mu Sync.Mutex
func GetInstance() *singleton {
mu.Lock() // 如果實例存在沒有必要加鎖
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
return instance
}
在上面的代碼中,我們可以看到在創建單例實例之前通過引入Sync.Mutex
和獲取 Lock 來解決並發安全問題。問題是我們在這裡執行了過多的鎖定,即使我們不需要這樣做,在實例已經創建的情況下,我們應該簡單地返回緩存的單例實例。在高度並發的代碼基礎上,這可能會產生瓶頸,因為一次只有一個 goroutine 可以獲得單例實例。
因此,這不是最佳方法。我們必須考慮其他解決方案。
Check-Lock-Check 模式#
在 C ++ 和其他語言中,確保最小程度的鎖定並且仍然是並發安全的最佳和最安全的方法是在獲取鎖定時利用眾所周知的Check-Lock-Check
模式。該模式的伪代碼表示如下。
if check() {
lock() {
if check() {
// 在這裡執行加鎖安全的代碼
}
}
}
該模式背後的思想是,你應該首先進行檢查,以最小化任何主動鎖定,因為 IF 語句的開銷要比加鎖小。其次,我們希望等待並獲取互斥鎖,這樣在同一時刻在那個塊中只有一個執行。但是,在第一次檢查和獲取互斥鎖之間,可能有其他 goroutine 獲取了鎖,因此,我們需要在鎖的內部再次進行檢查,以避免用另一個實例覆蓋了實例。
如果將這種模式應用於我們的GetInstance()
方法,我們會寫出類似下面的代碼:
func GetInstance() *singleton {
if instance == nil { // 不太完美 因為這裡不是完全原子的
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &singleton{}
}
}
return instance
}
通過使用sync/atomic
這個包,我們可以原子化加載並設置一個標誌,該標誌表明我們是否已初始化實例。
import "sync"
import "sync/atomic"
var initialized uint32
... // 此處省略
func GetInstance() *singleton {
if atomic.LoadUInt32(&initialized) == 1 { // 原子操作
return instance
}
mu.Lock()
defer mu.Unlock()
if initialized == 0 {
instance = &singleton{}
atomic.StoreUint32(&initialized, 1)
}
return instance
}
但是…… 這看起來有點繁瑣了,我們其實可以通過研究 Go 語言和標準庫如何實現 goroutine 同步來做得更好。
Go 語言慣用的單例模式#
我們希望利用 Go 慣用的方式來實現這個單例模式。我們在標準庫sync
中找到了Once
類型。它能保證某個操作僅且只執行一次。下面是來自 Go 標準庫的源碼(部分註釋有刪改)。
// Once is an object that will perform exactly one action.
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/x86),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 { // check
// Outlined slow-path to allow inlining of the fast-path.
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock() // lock
defer o.m.Unlock()
if o.done == 0 { // check
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
這說明我們可以借助這個實現只執行一次某個函數 / 方法,once.Do()
的用法如下:
once.Do(func() {
// 在這裡執行安全的初始化
})
下面就是單例實現的完整代碼,該實現利用sync.Once
類型去同步對GetInstance()
的訪問,並確保我們的類型僅被初始化一次。
package singleton
import (
"sync"
)
type singleton struct {}
var instance *singleton
var once sync.Once
func GetInstance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
因此,使用sync.Once
包是安全地實現此目標的首選方式,類似於 Objective-C 和 Swift(Cocoa)實現dispatch_once
方法來執行類似的初始化。
結論#
當涉及到並發和並行代碼時,需要對代碼進行更仔細的檢查。始終讓你的團隊成員執行代碼審查,因為這樣的事情很容易就會被發現。
所有剛轉到 Go 語言的新開發人員都必須真正了解並發安全性如何工作以更好地改進其代碼。即使 Go 語言本身通過允許你在對並發性知之甚少的情況下設計並發代碼,也完成了許多繁重的工作。在某些情況下,單純的依靠語言特性也無能為力,你仍然需要在開發代碼時應用最佳實踐。
翻譯自http://marcio.io/2015/07/singleton-pattern-in-go/,考慮到可讀性部分內容有修改。