解析 Golang sync-Once 用法和原理
Once is an object that will perform exactly one action.
sync.Once
是 Go 標準庫提供的使函數只執行一次的實現,常應用於單例模式,例如初始化配置、保持數據庫連接等。它可以在代碼的任意位置初始化和調用,因此可以延遲到使用時再執行,併發場景下是線程安全的。
- 對外接口 =======
Once 對外僅暴露了唯一的方法 Do(f func())
,f 爲需要執行的函數。
// Do calls the function f if and only if Do is being called for the
// first time for this instance of Once. In other words, given
// var once Once
// if once.Do(f) is called multiple times, only the first call will invoke f,
// even if f has a different value in each invocation. A new instance of
// Once is required for each function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
// config.once.Do(func() { config.init(filename) })
//
// Because no call to Do returns until the one call to f returns, if f causes
// Do to be called, it will deadlock.
//
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.
//
func (o *Once) Do(f func())
結合註釋,我們來看看 Do
方法有哪些需要注意的:
-
只有在當前的 Once 實例第一次調用
Do
方法時,纔會真正執行f
。哪怕在多次調用Do
中間f
的值有所變化,也只會被實際調用一次; -
Do
針對的是隻希望執行一次的初始化操作,由於f
是沒有參數的,如果需要傳參,可以採用包裝一層 func 的形式來實現:config.once.Do(func() { config.init(filename) })
-
在對
f
的調用返回之前,不會返回對Do
的調用,所以如果 f 方法中又調用來 Do 方法,將會死鎖。所以不要做這樣的操作:
func main() {
var once sync.Once
once.Do(func() {
once.Do(func() {
fmt.Println("hello kenmawr.")
})
})
}
-
如果
f
拋出了 panic,此時Do
會認爲f
已經返回,後續多次調用Do
也不會再觸發對f
的調用。 -
實戰用法 =======
sync.Once 的場景很多,但萬變不離其宗的落腳點在於:任何只希望執行一次的操作。
基於此,我們可以發現很多具體的應用場景落地,比如某個資源的清理,全局變量的初始化,單例模式等,它們本質都是一樣的。這裏簡單列幾個,大家可以直接參考代碼熟悉。
3.1 初始化
很多同學可能會有疑問,我直接在 init()
函數里面做初始化不就可以了嗎?效果上是一樣的,爲什麼還要用 sync.Once,這樣還需要多聲明一個 once 對象。
原因在於:init()
函數是在所在包首次被加載時執行,若未實際使用,既浪費了內存,又延緩了程序啓動時間。而 sync.Once
可以在任何位置調用,而且是併發安全的,我們可以在實際依賴某個變量時纔去初始化,這樣「延遲初始化」從功能上講並無差異,但可以有效地減少不必要的性能浪費。
我們來看 Golang 官方的 html 庫中的一個例子,我們經常使用的轉義字符串函數
func UnescapeString(s string) string
在進入函數的時候,首先就會依賴包裏內置的 populateMapsOnce
實例(本質是一個 sync.Once) 來執行初始化 entity
的操作。這裏的entity
是一個包含上千鍵值對的 map
,如果init()
時就去初始化,會浪費內存。
var populateMapsOnce sync.Once
var entity map[string]rune
func populateMaps() {
entity = map[string]rune{
"AElig;": '\U000000C6',
"AMP;": '\U00000026',
"Aacute;": '\U000000C1',
"Abreve;": '\U00000102',
"Acirc;": '\U000000C2',
// 省略後續鍵值對
}
}
func UnescapeString(s string) string {
populateMapsOnce.Do(populateMaps)
i := strings.IndexByte(s, '&')
if i < 0 {
return s
}
// 省略後續的實現
...
}
3.2 單例模式
開發中我們經常會實現 Getter
來暴露某個非導出的變量,這個時候就可以把 once.Do
放到 Getter
裏面,完成單例的創建。
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var singleton *Singleton
var once sync.Once
func GetSingletonObj() *Singleton {
once.Do(func() {
fmt.Println("Create Obj")
singleton = new(Singleton)
})
return singleton
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
obj := GetSingletonObj()
fmt.Printf("%p\n", obj)
}()
}
wg.Wait()
}
/*--------- 輸出 -----------
Create Obj
0x119f428
0x119f428
0x119f428
0x119f428
0x119f428
**/
3.3 關閉 channel
一個 channel 如果已經被關閉,再去關閉的話會 panic,此時就可以應用 sync.Once 來幫忙。
type T int
type MyChannel struct {
c chan T
once sync.Once
}
func (m *MyChannel) SafeClose() {
// 保證只關閉一次channel
m.once.Do(func() {
close(m.c)
})
}
- 原理 =====
在 sync 的源碼包中,Once 的定義是一個 struct,所有定義和實現去掉註釋後不過 30 行,我們直接上源碼來分析:
package sync
import (
"sync/atomic"
)
// 一個 Once 實例在使用之後不能被拷貝繼續使用
type Once struct {
done uint32 // done 表明了動作是否已經執行
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
這裏有兩個非常巧妙的設計值得學習,我們參照註釋來看一下:
- 結構體字段順序對速度的影響 我們來看一下帶註釋的 Once 結構定義
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/386),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}
sync.Once 絕大多數場景都會訪問o.done
,訪問 done
的機器指令是處於 hot path 上,hot path 表示程序非常頻繁執行的一系列指令。由於結構體第一個字段的地址和結構體的指針是相同的,如果是第一個字段,直接對結構體的指針解引用即可,如果是其他的字段,除了結構體指針外,還需要計算與第一個值的偏移,所以將 done 放在第一個字段,則 CPU 減少了一次偏移量的計算,訪問速度更快。
- 爲何不使用 CAS 來達到執行一次的效果
其實使用 atomic.CompareAndSwapUint32
是一個非常直觀的方案,這樣的話 Do
的實現就變成了
func (o *OnceA) Do(f func()) {
if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
return
}
f()
}
這樣的問題在於,一旦出現 CAS 失敗的情況,成功協程會繼續執行 f
,但其他失敗協程不會等待 f
執行結束。而Do
的 API 定位對此有着強要求,當一次 once.Do
返回時,執行的 f
一定是完成的狀態。
對此,sync.Once 官方給出的解法是:
Slow path falls back to a mutex, and the atomic.StoreUint32 must be delayed until after f returns.
我們再來結合 doSlow()
看一看這裏是怎麼解決這個併發問題的:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
atomic.LoadUint32
用於原子加載地址(也就是 &o.done),返回加載到的值;- o.done 爲 0 是代表尚未執行。若同時有兩個 goroutine 進來,發現 o.done 爲 0(此時
f
尚未執行),就會進入o.doSlow(f)
的慢路徑中(slow path); doSlow
使用sync.Mutex
來加鎖,一個協程進去,其他的被阻塞在鎖的地方(注意,此時是阻塞,不是直接返回,這是和 CAS 方案最大的差別);- 經過
o.m.Lock()
獲取到鎖以後,如果此時 o.done 還是 0,意味着依然沒有被執行,此時就可以放心的調用f
來執行了。否則,說明當前協程在被阻塞的過程中,已經失去了調用f
的機會,直接返回。 defer atomic.StoreUint32(&o.done, 1)
是這裏的精華,必須等到f()
返回,在 defer 裏才能夠去更新 o.done 的值爲 1。
- 避坑 =====
- 不要拷貝一個 sync.Once 使用或作爲參數傳遞,然後去執行
Do
,值傳遞時done
會歸 0,無法起到限制一次的效果。 - 不要在
Do
的f
中嵌套調用Do
。
參考資料
Golang atomic.LoadUint32() 用法及代碼示例
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/7088305487753510925