Go Singleton 模式的實現
背景
在日常開發(fā)中,我們經常會用到單例模式,比如啟動時創(chuàng)建一個訪問數據庫的客戶端,或者運行時創(chuàng)建一個訪問第三方服務的客戶端。啟動時的單例問題比較簡單,因為不存在并發(fā)問題,直接在 main 函數創(chuàng)建即可。
package main
func main() {
Init()
}
var client *Client
func Init() {
cilent, err = New(...)
if err != nil {
panic(err)
}
}
如果創(chuàng)建失敗則直接 panic 重新啟動服務。而運行時的單例問題情況有所不同,我們一般會使用 sync.Once 來實現。
var client *Client
var once sync.Once
func GetClient() Client {
once.Do(func() {
cilent, err = New(...)
if err != nil {
//
}
})
return client
}
雖然 sync.Once 可以保證并發(fā)情況下只執(zhí)行一次,但是這個只執(zhí)行一次也會帶來一個問題,那就是如果執(zhí)行失敗了再也不會再執(zhí)行了。下面是 go1.24.3 中 sync.Once 的實現。
type Once struct {
done atomic.Uint32
m Mutex
}
func (o *Once) Do(f func()) {
if o.done.Load() == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
可以看到不管 f 是否執(zhí)行成功,Go 都會設置 done 為 1,所以如果 f 創(chuàng)建客戶端失敗,那么后面也不會調用了。但是存在這樣的場景,我們在處理請求時會創(chuàng)建一個單例去做一些事情,但因為這不是關鍵路徑,所以執(zhí)行失敗時不能 panic,而是返回錯誤并希望下次還能重新走這個流程。所以我們需要實現一個單例模式,每次按需實時獲取,并且可以保證創(chuàng)建失敗時還可以重新執(zhí)行創(chuàng)建流程。
實現 1
type Singleton[T any] struct {
mu sync.Mutex
loaded bool
loader func() T
data T
}
func (in *Singleton[T]) Get() T {
in.mu.Lock()
if !in.loaded && in.loader != nil {
in.mu.Unlock()
in.Set(in.loader())
in.mu.Lock()
}
defer in.mu.Unlock()
return in.data
}
func (in *Singleton[T]) Set(data T) {
in.mu.Lock()
defer in.mu.Unlock()
in.loaded = true
in.data = data
}
這種方式實現的思路比較清晰簡單,但是性能相對來說不太好,因為第一個創(chuàng)建成功后后續(xù)每次獲取時都需要加鎖,如果并發(fā)量大的會引起一定時間的代碼阻塞。所以嘗試優(yōu)化這部分的邏輯。
實現 2
type F[T any] func() (*T, error)
type singleton[T any] struct {
factory F[T]
instance *T
mutex sync.Mutex
}
func (s *singleton[T]) Get() (*T, error) {
if s.instance != nil {
return s.instance, nil
}
s.mutex.Lock()
defer s.mutex.Unlock()
if s.instance != nil {
return s.instance, nil
}
result, err := s.factory()
if err != nil {
returnnil, err
}
s.instance = result
return result, nil
}
在 Get 的一開始先判斷是否已經創(chuàng)建過了,如果是則直接返回,避免了加鎖,這個看起來解決了問題,但是同時帶來了一個比較隱晦的問題,這種方式無法保證內存可見性,也就是說當讀者看到 s.instance 非空時,不代表 s.instance 指向的實例是完成的,即初始化完成的。看一個例子。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
type MyStruct struct {
Field int
}
for {
var flag atomic.Bool
var ptr *MyStruct
var wg sync.WaitGroup
wg.Add(2)
// Goroutine A
gofunc() {
defer wg.Done()
data := &MyStruct{Field: 42}
ptr = data // 原子操作,但不保證 data 內容立即對其他線程可見
}()
// Goroutine B
gofunc() {
defer wg.Done()
if ptr != nil {
field := ptr.Field
if field == 0 {
fmt.Println(field)
flag.Store(true)
}
}
}()
wg.Wait()
if flag.Load() {
println("flag is true")
break
}
}
}
執(zhí)行上面的代碼,最終會輸出 flag is true,說明 ptr 非空時,ptr.Field 卻是 0。回到單例實現的代碼中測試也是存在類似的問題。
package singleton
import (
"sync"
"testing"
)
type Dummy struct {
Ptr *string
}
func factory() (*Dummy, error) {
ptr := "test"
return &Dummy{
Ptr: &ptr,
}, nil
}
func TestConcurrent(t *testing.T) {
for {
singleton := New(factory)
var flag bool
var ptr *Dummy
var wg sync.WaitGroup
len := 10
wg.Add(len)
for i := 0; i < len; i++ {
gofunc() {
defer wg.Done()
ptr, _ = singleton.Get()
if ptr.Ptr == nil {
flag = true
}
}()
}
wg.Wait()
if flag {
t.Fatal("singleton should not be nil")
}
}
}
上面的代碼最終會輸出 singleton should not be nil。說明當 singleton.Get 觀察到 s.instance 非空時 s.instance 指向到單例對象并沒有完成構造。
實現 3
為了實現內存的可見性,我們需要使用 Go 提供的 API。
package singleton
import (
"sync"
"sync/atomic"
)
type F[T any] func() (*T, error)
type singleton[T any] struct {
factory F[T]
instance atomic.Pointer[T]
mutex sync.Mutex
}
func (s *singleton[T]) Get() (*T, error) {
if s.instance.Load() != nil {
return s.instance.Load(), nil
}
s.mutex.Lock()
defer s.mutex.Unlock()
if s.instance.Load() != nil {
return s.instance.Load(), nil
}
result, err := s.factory()
if err != nil {
returnnil, err
}
s.instance.Store(result)
return result, nil
}
上面代碼中,Load 會保證 Store 之前的寫入全部可見,也就是說當 Load 返回非空指針時,Store 寫入的指針以及 s.factory 構造的結構體已經全部同步完成。具體可以參考這里 https://github.com/theanarkh/singleton。