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

原文連接: https://blog.thinkeridea.com/202101/go/exsync/once.htmlhtml

官方描述 Once is an object that will perform exactly one action, 即 Once 是一個對象,它提供了保證某個動做只被執行一次功能,最典型的場景就是單例模式,Once 可用於任何符合 "exactly once" 語義的場景。git

sync.Once 的用法

在多數狀況下,sync.Once 被用於控制變量的初始化,這個變量的讀寫一般遵循單例模式,知足這三個條件:github

  • 當且僅當第一次讀某個變量時,進行初始化(寫操做)
  • 變量被初始化過程當中,全部讀都被阻塞(讀操做;當變量初始化完成後,讀操做繼續進行)
  • 變量僅初始化一次,初始化完成後駐留在內存裏

在標準庫中不乏有大量 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.ReplaceReplacer.WriteString)進行的, r.once.Do(r.buildOnce) 使用 sync.OnceDo 方法保證只有在首次執行時纔會執行 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 接收一個函數做爲參數,該函數不接受任何參數,不返回任何參數。具體作什麼由使用方決定,錯誤處理也由使用方控制,對函數初始化的結果也由使用方進行保存。app

這給出了一種錯誤處理的例子 exec.closeOnceexec.closeOnce 保證了重複關閉文件,永遠只執行一次,而且老是返回首次關閉產生的錯誤信息:ide

// 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)
}

上訴代碼中定義了 buff64Onebuff8192One 7個 Once 的實例,且對應的存在 buff64buff8192 的業務實例,我在 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.Onceexsync.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協議 許可協議。轉載請註明出處!

相關文章
相關標籤/搜索