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

戚銀發表於2021-01-01

原文連結: 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.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 接收一個函式作為引數,該函式不接受任何引數,不返回任何引數。具體做什麼由使用方決定,錯誤處理也由使用方控制,對函式初始化的結果也由使用方進行儲存。

這給出了一種錯誤處理的例子 exec.closeOnceexec.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)
}

上訴程式碼中定義了 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協議 許可協議。轉載請註明出處!

相關文章