原文連結: https://blog.thinkeridea.com/202101/go/exsync/once.html
官方描述 Once is an object that will perform exactly one action
, 即 Once
是一個物件,它提供了保證某個動作只被執行一次功能,最典型的場景就是單例模式,Once
可用於任何符合 "exactly once" 語義的場景。
sync.Once 的用法
在多數情況下,sync.Once
被用於控制變數的初始化,這個變數的讀寫通常遵循單例模式,滿足這三個條件:
- 當且僅當第一次讀某個變數時,進行初始化(寫操作)
- 變數被初始化過程中,所有讀都被阻塞(讀操作;當變數初始化完成後,讀操作繼續進行)
- 變數僅初始化一次,初始化完成後駐留在記憶體裡
在標準庫中不乏有大量 sync.Once
的使用案例,在 strings
包中 replace.go
裡實現字串批量替換功能時,需要預編譯生成替換規則,即採用不同的替換演算法並建立相關演算法例項,因 strings.Replacer
實現是執行緒安全且支援規則複用,在第一次解析替換規則並建立對應演算法例項後,可以併發的進行字串替換操作,避免多次解析替換規則浪費資源。
先看一下 strings.Replacer
的結構定義:
// source: strings/replace.go
type Replacer struct {
once sync.Once // guards buildOnce method
r replacer
oldnew []string
}
這裡定義了 once sync.Once
用來控制 r replacer
替換演算法初始化,當我們使用 strings.NewReplacer
建立 strings.Replacer
時,這裡採用惰性演算法,並沒有在這時進行 build
解析替換規則並建立對應演算法例項,而是在執行替換時( Replacer.Replace
和 Replacer.WriteString
)進行的, r.once.Do(r.buildOnce)
使用 sync.Once
的 Do
方法保證只有在首次執行時才會執行 buildOnce
方法,而在 buildOnce
中呼叫 build
解析替換規則並建立對應演算法例項,在 buildOnce
中進行賦值。
// source: strings/replace.go
func NewReplacer(oldnew ...string) *Replacer {
if len(oldnew)%2 == 1 {
panic("strings.NewReplacer: odd argument count")
}
return &Replacer{oldnew: append([]string(nil), oldnew...)}
}
func (r *Replacer) buildOnce() {
r.r = r.build()
r.oldnew = nil
}
func (b *Replacer) build() replacer {
....
}
func (r *Replacer) Replace(s string) string {
r.once.Do(r.buildOnce)
return r.r.Replace(s)
}
func (r *Replacer) WriteString(w io.Writer, s string) (n int, err error) {
r.once.Do(r.buildOnce)
return r.r.WriteString(w, s)
}
簡單來說,once.Do
中的函式只會執行一次,並保證 once.Do
返回時,傳入 Do
的函式已經執行完成。多個 goroutine
同時執行 once.Do
的時候,可以保證搶佔到 once.Do
執行權的 goroutine
執行完 once.Do
後,其他 goroutine
才能得到返回。
once.Do
接收一個函式作為引數,該函式不接受任何引數,不返回任何引數。具體做什麼由使用方決定,錯誤處理也由使用方控制,對函式初始化的結果也由使用方進行儲存。
這給出了一種錯誤處理的例子 exec.closeOnce
,exec.closeOnce
保證了重複關閉檔案,永遠只執行一次,並且總是返回首次關閉產生的錯誤資訊:
// source: os/exec/exec.go
type closeOnce struct {
*os.File
once sync.Once
err error
}
func (c *closeOnce) Close() error {
c.once.Do(c.close)
return c.err
}
func (c *closeOnce) close() {
c.err = c.File.Close()
}
對 sync.Once 的愛與恨
Once
的實現非常的靈活、簡潔、高效,排除註釋部分 Once
僅用 17 行實現,且單次執行時間在 0.3ns 左右。這讓我十分敬佩,對它可謂喜愛至極,但因為它的通用性,在使用 Once
時給我帶來了一些小小的負擔,這也成了我極少的使用它的原因。
Once
只保證呼叫安全性(即執行緒安全以及只執行一次動作函式),但是細心的朋友一定發現了我們往往需要配對定義 Once
和業務例項變數,極少使用的情況下(如上述兩個例子)看起來並沒有什麼負擔,但是如果我們專案中有大量例項進行管理時(一般是集中管理,便於解決依賴問題),這時就會變得有點醜陋。
一個實際的業務場景,我有一個 http
服務,它有數百個元件例項,我們建立了一個 APP
用來管理所有例項的初始化、依賴關係,從而保證各個元件依賴其介面,相互之間進行解耦,也使得每個元件的配置(初始化引數)、依賴易於管理,不過我們常常對單例例項在 http
服務啟動時進行初始化,這樣避免使用 Once
,且可以在 http
服務啟動時暴露外部依賴問題(資料庫、其它服務等)。
這個 http
服務需要很多輔助命令,每個命令負責極少的工作,如果我在命令啟動時使用 APP
初始化所有元件,這造成了大量的資源浪費。我單獨實現一個 Command
依賴管理元件,它大量使用 Once
保證各個元件只在第一次使用時進行初始化,這給我帶來了一些困擾,我大量定義 Once
的例項,且它和具體的元件例項沒有關聯,我在使用時需要非常的小心。
使用過 go-extend/pool 中的 pool.BufferPool 的朋友如果留意其原始碼的話會發現其中定義了一些 sync.Once
的例項,這相對上訴場景卻是相對少的,以下便是 pool.BufferPool 中的部分程式碼:
// source: https://github.com/thinkeridea/go-extend/blob/v1.1.2/pool/buffer.go
package pool
import (
"bytes"
"sync"
)
var (
buff64 *sync.Pool
buff128 *sync.Pool
buff512 *sync.Pool
buff1024 *sync.Pool
buff2048 *sync.Pool
buff4096 *sync.Pool
buff8192 *sync.Pool
buff64One sync.Once
buff128One sync.Once
buff512One sync.Once
buff1024One sync.Once
buff2048One sync.Once
buff4096One sync.Once
buff8192One sync.Once
)
type pool sync.Pool
// BufferPool bytes.Buffer 的 sync.Pool 介面
// 可以直接 Get *bytes.Buffer 並 Reset Buffer
type BufferPool interface {
// Get 從 Pool 中獲取一個 *bytes.Buffer 例項, 該例項已經被 Reset
Get() *bytes.Buffer
// Put 把 *bytes.Buffer 放回 Pool 中
Put(*bytes.Buffer)
}
func newBufferPool(size int) *sync.Pool {
return &sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, size))
},
}
}
// GetBuff64 獲取一個初始容量為 64 的 *bytes.Buffer Pool
func GetBuff64() BufferPool {
buff64One.Do(func() {
buff64 = newBufferPool(64)
})
return (*pool)(buff64)
}
上訴程式碼中定義了 buff64One
到 buff8192One
7個 Once
的例項,且對應的存在 buff64
到 buff8192
的業務例項,我在 GetBuff64
中必須小心使用 Once
例項,避免錯誤使用導致對應的例項未被初始化,而且上訴的程式碼看起來還有一些醜陋。
探尋緩和與 sync.Once 的尷尬
鑑於我對 sync.Once
靈活、簡潔、高效的喜愛,不能僅僅因為它的“吝嗇”(極簡的功能)便與之訣別,促使我開啟了探尋緩和與 sync.Once
關係之路。
首先我想到的是對 sync.Once
的二次包裝,使其可以儲存一個資料,這樣我就可以只定義 Once
的例項,由 Once
負責儲存初始化的結果。exsync.Once 這是我的第一個實驗,它的實現非常簡潔:
// source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go
type Once struct {
once sync.Once
v interface{}
}
func (o *Once) Do(f func() interface{}) interface{} {
o.once.Do(func() {
o.v = f()
})
return o.v
}
它巢狀一個 sync.Once
例項,並覆蓋其 Do
函式,使其接收一個 func() interface{}
函式,它要求初始化函式返回其結果,結果儲存在 Once.v
,每次呼叫 Do
它便返回自己儲存的結果,這使用起來就變得簡單許多,改造之前 exec.closeOnce
例子:
type closeOnce struct {
*os.File
once exsync.Once
}
func (c *closeOnce) Close() error {
return c.once.Do(c.close).(error)
}
func (c *closeOnce) close() interface{} {
return c.File.Close()
}
這減少了一個業務層的資料定義,如果包含多個資料,可以使用自定義 struct
或者 []interface{}
進行資料儲存, 一個簡單開啟檔案的例子:
type openOnce struct {
file exsync.Once
}
func (c *openOnce) Open(name string) (*os.File, error) {
f := c.file.Do(func() interface{} {
f, err := os.Open(name)
return []interface{}{f, err}
}).([]interface{})
return f[0].(*os.File), f[1].(error)
}
這看起來使初始化的程式碼變得複雜了一些,對多返回值的問題暫時沒有更好的實現,我會在後續逐漸考慮這類問題的處理方式,單個值時它使我得到一些驚喜和便捷。即使這樣我隨後發現它相對 sync.Once
的效能大幅度下降,達到10倍之多,起初我認為是 interface
的帶來的,我立刻實現了一個 exsync.OncePointer 以期許它可以在效能上給我一個驚喜:
// source: https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go
type OncePointer struct {
once sync.Once
v unsafe.Pointer
}
func (o *OncePointer) Do(f func() unsafe.Pointer) unsafe.Pointer {
o.once.Do(func() {
o.v = f()
})
return o.v
}
使用 unsafe.Pointer
儲存例項,讓其在編譯時確定型別,來提升其效能,使用示例如下:
type closeOnce struct {
*os.File
once exsync.OncePointer
}
func (c *closeOnce) Close() error {
return *(*error)(c.once.Do(c.close))
}
func (c *closeOnce) close() unsafe.Pointer {
err := c.File.Close()
return unsafe.Pointer(&err)
}
尷尬的是這並沒有使其效能有極大提升,僅僅只是稍微提升一些,難道我要和 sync.Once
就此訣別,還是湊合過……
轉機的到來
我本已放棄優化,即使其效能極大下降,但是它仍然可以在 3ns 內完成任務,這並不會形成瓶頸。但多少內心還是有些不甘,僅僅只是包裝使其儲存一個值不應該導致效能下降如此嚴重,究竟是什麼導致其效能如此嚴重下降的,仔細做了分析發現由於 sync.Once
非常的高效,且程式碼簡潔,我巢狀包裝使其多了一層呼叫,且可能導致其無法內聯,這對一些效能不高的元件影響極小,但是像 sync.Once
這樣高效任何小小的損耗表現都十分明顯。
我直接拷貝 sync.Once
中的程式碼到 exsync.Once 及 exsync.OncePointer 實現中,這讓我得到與 sync.Once
接近的效能,exsync.OncePointer 的實現甚至總是好於 sync.Once
。
以下是效能測試的結果,其程式碼位於 exsync/benchmark/once_test.go:
goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/go-extend/exsync/benchmark
BenchmarkSyncOnce-8 1000000000 0.391 ns/op 0 B/op 0 allocs/op
BenchmarkOnce-8 1000000000 0.407 ns/op 0 B/op 0 allocs/op
BenchmarkOncePointer-8 1000000000 0.389 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/thinkeridea/go-extend/exsync/benchmark 1.438s
得到這個結果後我毫不猶豫、馬不停蹄的改變了 pool.BufferPool 中的程式碼,這使 pool.BufferPool 變得簡潔許多:
package pool
import (
"bytes"
"sync"
"unsafe"
"github.com/thinkeridea/go-extend/exsync"
)
var (
buff64 exsync.OncePointer
buff128 exsync.OncePointer
buff512 exsync.OncePointer
buff1024 exsync.OncePointer
buff2048 exsync.OncePointer
buff4096 exsync.OncePointer
buff8192 exsync.OncePointer
)
type bufferPool struct {
sync.Pool
}
// BufferPool bytes.Buffer 的 sync.Pool 介面
// 可以直接 Get *bytes.Buffer 並 Reset Buffer
type BufferPool interface {
// Get 從 Pool 中獲取一個 *bytes.Buffer 例項, 該例項已經被 Reset
Get() *bytes.Buffer
// Put 把 *bytes.Buffer 放回 Pool 中
Put(*bytes.Buffer)
}
func newBufferPool(size int) unsafe.Pointer {
return unsafe.Pointer(&bufferPool{
Pool: sync.Pool{
New: func() interface{} {
return bytes.NewBuffer(make([]byte, size))
},
},
})
}
// GetBuff64 獲取一個初始容量為 64 的 *bytes.Buffer Pool
func GetBuff64() BufferPool {
return (*bufferPool)(buff64.Do(func() unsafe.Pointer {
return newBufferPool(64)
}))
}
總結
如此對 sync.Once
進行二次封裝,使其通用性有所下降,並一定是一個好的方案,我樂於公開它,因為它在大多數時刻可以減少使用者的負擔,使得程式碼變的簡練。
後續的思考:
Once
永遠只能執行一次,是否有安全快捷的方法可以使其重置。- 出現錯誤時,能否提供一種重試機制,否者程式會一直無法得到正確的結果,比如建立資料庫連線,某個時刻資料庫出現故障,而恰恰這時首次執行了
Do
函式。 - 對多個值的呼叫方式上是否能提供簡單的呼叫機制。
解決以上這些問題,可以使 sync.Once
應用在更多的場景中,但勢必導致其效能有所下降,這需要一些實驗和折中處理。
轉載:
本文作者: 戚銀(thinkeridea)
本文連結: https://blog.thinkeridea.com/202101/go/exsync/once.html
版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處!