原文: https://zhuanlan.zhihu.com/p/623090559
------------
簡介
在某些場景下,我們需要初始化一些資源,例如單例對象、配置等。實現資源的初始化有多種方法,如定義 package
級別的變量、在 init
函數中進行初始化,或者在 main
函數中進行初始化。這三種方式都能確保併發安全,並在程序啓動時完成資源的初始化。
然而,有時我們希望採用延遲初始化的方式,在我們真正需要資源的時候才進行初始化,這種需要確保併發安全,在這種情況下,Go
語言中的 sync.Once
提供一個優雅且併發安全的解決方案,本文將對其進行介紹。
sync.Once 基本概念
什麼是 sync.Once
sync.Once
是 Go
語言中的一種同步原語,用於確保某個操作或函數在併發環境下只被執行一次。它只有一個導出的方法,即 Do
,該方法接收一個函數參數。在 Do
方法被調用後,該函數將被執行,而且只會執行一次,即使在多個協程同時調用的情況下也是如此。
sync.Once 的應用場景
sync.Once
主要用於以下場景:
- 單例模式:確保全局只有一個實例對象,避免重複創建資源。
- 延遲初始化:在程序運行過程中需要用到某個資源時,通過
sync.Once
動態地初始化該資源。 - 只執行一次的操作:例如只需要執行一次的配置加載、數據清理等操作。
sync.Once 應用實例
單例模式
在單例模式中,我們需要確保一個結構體只被初始化一次。使用 sync.Once
可以輕鬆實現這一目標。
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
gofunc() {
defer wg.Done()
s := GetInstance()
fmt.Printf("Singleton instance address: %p\n", s)
}()
}
wg.Wait()
}
上述代碼中,GetInstance
函數通過 once.Do()
確保 instance
只會被初始化一次。在併發環境下,多個協程同時調用 GetInstance
時,只有一個協程會執行 instance = &Singleton{}
,所有協程得到的實例 s
都是同一個。
延遲初始化
有時候希望在需要時才初始化某些資源。使用 sync.Once
可以實現這一目標。
package main
import (
"fmt"
"sync"
)
type Config struct {
config map[string]string
}
var (
config *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
fmt.Println("init config...")
config = &Config{
config: map[string]string{
"c1": "v1",
"c2": "v2",
},
}
})
return config
}
func main() {
// 第一次需要獲取配置信息,初始化 config
cfg := GetConfig()
fmt.Println("c1: ", cfg.config["c1"])
// 第二次需要,此時 config 已經被初始化過,無需再次初始化
cfg2 := GetConfig()
fmt.Println("c2: ", cfg2.config["c2"])
}
在這個示例中,定義了一個 Config
結構體,它包含一些設置信息。使用 sync.Once
來實現 GetConfig
函數,該函數在第一次調用時初始化 Config
。這樣,我們可以在真正需要時才初始化 Config
,從而避免不必要的開銷。
sync.Once 實現原理
type Once struct {
// 表示是否執行了操作
done uint32
// 互斥鎖,確保多個協程訪問時,只能一個協程執行操作
m Mutex
}
func (o *Once) Do(f func()) {
// 判斷 done 的值,如果是 0,說明 f 還沒有被執行過
if atomic.LoadUint32(&o.done) == 0 {
// 構建慢路徑(slow-path),以允許對 Do 方法的快路徑(fast-path)進行內聯
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
// 加鎖
o.m.Lock()
defer o.m.Unlock()
// 雙重檢查,避免 f 已被執行過
if o.done == 0 {
// 修改 done 的值
defer atomic.StoreUint32(&o.done, 1)
// 執行函數
f()
}
}
sync.Once
結構體包含兩個字段:done
和 mu
。done
是一個 uint32
類型的變量,用於表示操作是否已經執行過;m
是一個互斥鎖,用於確保在多個協程訪問時,只有一個協程能執行操作。
sync.Once
結構體包含兩個方法:Do
和 doSlow
。Do
方法是其核心方法,它接收一個函數參數 f
。首先它會通過原子操作atomic.LoadUint32
(保證併發安全) 檢查 done
的值,如果爲 0,表示 f
函數沒有被執行過,然後執行 doSlow
方法。
在 doSlow
方法裏,首先對互斥鎖 m
進行加鎖,確保在多個協程訪問時,只有一個協程能執行 f
函數。接着再次檢查 done
變量的值,如果 done
的值仍爲 0,說明 f
函數沒有被執行過,此時執行 f
函數,最後通過原子操作 atomic.StoreUint32
將 done
變量的值設置爲 1。
爲什麼會封裝一個 doSlow 方法
doSlow
方法的存在主要是爲了性能優化。將慢路徑(slow-path
)代碼從 Do
方法中分離出來,使得 Do
方法的快路徑(fast-path
)能夠被內聯(inlined
),從而提高性能。
爲什麼會有雙重檢查(double check)的寫法
從源碼可知,存在兩次對 done
的值的判斷。
- 第一次檢查:在獲取鎖之前,先使用原子加載操作
atomic.LoadUint32
檢查done
變量的值,如果done
的值爲 1,表示操作已執行,此時直接返回,不再執行doSlow
方法。這一檢查可以避免不必要的鎖競爭。 - 第二次檢查:獲取鎖之後,再次檢查
done
變量的值,這一檢查是爲了確保在當前協程獲取鎖期間,其他協程沒有執行過f
函數。如果done
的值仍爲 0,表示f
函數沒有被執行過。
通過雙重檢查,可以在大多數情況下避免鎖競爭,提高性能。
加強的 sync.Once
sync.Once
提供的 Do
方法並沒有返回值,意味着如果我們傳入的函數如果發生 error
導致初始化失敗,後續調用 Do
方法也不會再初始化。爲了避免這個問題,我們可以實現一個 類似 sync.Once
的併發原語。
package main
import (
"sync"
"sync/atomic"
)
type Once struct {
done uint32
m sync.Mutex
}
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&o.done) == 0 {
return o.doSlow(f)
}
returnnil
}
func (o *Once) doSlow(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 {
err = f()
// 只有沒有 error 的時候,才修改 done 的值
if err == nil {
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
上述代碼實現了一個加強的 Once
結構體。與標準的 sync.Once
不同,這個實現允許 Do
方法的函數參數返回一個 error
。如果執行函數沒有返回 error
,則修改 done
的值以表示函數已執行。這樣,在後續的調用中,只有在沒有發生 error
的情況下,纔會跳過函數執行,避免初始化失敗。
sync.Once 的注意事項
死鎖
通過分析 sync.Once
的源碼,可以看到它包含一個名爲 m
的互斥鎖字段。當我們在 Do
方法內部重複調用 Do
方法時,將會多次嘗試獲取相同的鎖。但是 mutex
互斥鎖並不支持可重入操作,因此這將導致死鎖現象。
func main() {
once := sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("init...")
})
})
}
初始化失敗
這裏的初始化失敗指的是在調用 Do
方法之後,執行 f
函數的過程中發生 error
,導致執行失敗,現有的 sync.Once
設計我們是無法感知到初始化的失敗的,爲了解決這個問題,我們可以實現一個類似 sync.Once
的加強 once
,前面的內容已經提供了具體實現。
小結
本文詳細介紹了 Go
語言中的 sync.Once
,包括它的基本定義、使用場景和應用實例以及源碼分析等。在實際開發中,sync.Once
經常被用於實現單例模式和延遲初始化操作。
雖然 sync.Once
簡單而又高效,但是錯誤的使用可能會造成一些意外情況,需要格外小心。
總之,sync.Once
是 Go
中非常實用的一個併發原語,可以幫助開發者實現各種併發場景下的安全操作。如果遇到只需要初始化一次的場景,sync.Once
是一個非常好的選擇。