原文鏈接: 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協議 許可協議,轉載請注明出處!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/243453.html
標籤:Go
