1. 程式人生 > >【Go】我與sync.Once的愛恨糾纏

【Go】我與sync.Once的愛恨糾纏

**原文連結: [https://blog.thinkeridea.com/202101/go/exsync/once.html](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` 的結構定義: ```go // 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` 中進行賦值。 ```go // 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` 保證了重複關閉檔案,永遠只執行一次,並且總是返回首次關閉產生的錯誤資訊: ```go // 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](https://github.com/thinkeridea/go-extend/tree/v1.1.2/pool) 中的 [pool.BufferPool](https://github.com/thinkeridea/go-extend/blob/v1.1.2/pool/buffer.go#L33) 的朋友如果留意其原始碼的話會發現其中定義了一些 `sync.Once` 的例項,這相對上訴場景卻是相對少的,以下便是 [pool.BufferPool](https://github.com/thinkeridea/go-extend/blob/v1.1.2/pool/buffer.go#L33) 中的部分程式碼: ```go // 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](https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go#L34) 這是我的第一個實驗,它的實現非常簡潔: ```go // 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` 例子: ```go 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{}` 進行資料儲存, 一個簡單開啟檔案的例子: ```go 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](https://github.com/thinkeridea/go-extend/blob/efa13c9456cb4ce97c16824de2996c84fa285fc3/exsync/once.go#L66) 以期許它可以在效能上給我一個驚喜: ```go // 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` 儲存例項,讓其在編譯時確定型別,來提升其效能,使用示例如下: ```go 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](https://github.com/thinkeridea/go-extend/blob/main/exsync/once.go#L35) 及 [exsync.OncePointer](https://github.com/thinkeridea/go-extend/blob/main/exsync/once.go#L82) 實現中,這讓我得到與 `sync.Once` 接近的效能,[exsync.OncePointer](https://github.com/thinkeridea/go-extend/blob/main/exsync/once.go#L82) 的實現甚至總是好於 `sync.Once`。 以下是效能測試的結果,其程式碼位於 [exsync/benchmark/once_test.go](https://github.com/thinkeridea/go-extend/blob/main/exsync/benchmark/once_test.go): ```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](https://github.com/thinkeridea/go-extend/blob/main/pool/buffer.go#L24) 中的程式碼,這使 [pool.BufferPool](https://github.com/thinkeridea/go-extend/blob/main/pool/buffer.go#L24) 變得簡潔許多: ```go 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/))** **本文連結: [https://blog.thinkeridea.com/202101/go/exsync/once.html](https://blog.thinkeridea.com/202101/go/exsync/once.html)** **版權宣告: 本部落格所有文章除特別宣告外,均採用 [CC BY 4.0 CN協議](http://creativecommons.org/licenses/by/4.0/deed.zh) 許可協議。轉載請註明出