公眾號首發地址:https://mp.weixin.qq.com/s/ijAjiCdpb7BhRQwEa2BN3Q
在 Go 語言的併發編程中,常常會遇到需要確保某個操作僅執行一次的場景。sync.Once 是 Go 標準庫中的一個簡單而強大的工具,專門用於解決這種需求。本文將深入解析 sync.Once 的使用方法和原理,幫助你更好地理解 sync.Once 在併發控制中的用法。
sync.Once
sync.Once 是 Go 語言 sync 包中的一種同步原語。它可以確保一個操作(通常是一個函數)在程序的生命週期中只被執行一次,不論有多少 goroutine 同時調用該操作,這就保證了併發安全。
根據 sync.Once 的特點,很容易想到它的幾種常見使用場景:
- 單例模式:確保某個對象或配置僅初始化一次,例如使用單例模式初始化數據庫連接池、配置文件加載等。
- 懶加載:在需要時才加載某些資源,且保證它們只會加載一次。
- 併發安全的初始化:當初始化過程涉及多個 goroutine 時,使用
sync.Once保證初始化函數不會被重複調用。
快速上手
sync.Once 用法非常簡單,示例如下:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
首先使用 var once sync.Once 聲明瞭一個 sync.Once 類型的變量 once,但不必顯式初始化,這也是 sync 下很多包的慣用法。
然後定義了一個函數 onceBody,接着啓動 10 個 goroutine 併發調用 once.Do(onceBody),最終等待所有 goroutine 執行結束並退出。
執行示例代碼,得到如下輸出:
$ go run once/main.go
Only once
和預期一樣,once.Do 能夠保證傳遞給它的函數 onceBody 只被執行一次。
其實就算不啓用多個 goroutine,直接在主 goroutine 中調用多次 once.Do(onceBody),也能保證只執行一次:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
for i := 0; i < 10; i++ {
once.Do(onceBody)
}
}
執行示例代碼,得到如下輸出:
$ go run once/main.go
Only once
執行結果也是一樣的,僅會執行一次。
至此,我們就學會了如何使用 sync.Onec。以上就是 sync.Onec 提供的全部 API 了,沒錯它僅對外暴露了一個 Do 方法。
現在,我想你應該知道如何使用 sync.Onec 來實現單例模式了,我在另一篇文章《Go 常見設計模式之單例模式》中也有講解。
詳細介紹
我們快速上手了 sync.Onec 的使用方法,下面我們來看下 Go 官方是如何介紹 sync.Onec 的。
Go 官方文檔 對 sync.Once 的介紹只有簡單三句話:
<font style="color:rgb(32, 34, 36);">Once is an object that will perform exactly one action.</font>
<font style="color:rgb(32, 34, 36);">A Once must not be copied after first use.</font>
<font style="color:rgb(32, 34, 36);">In the terminology of </font><font style="color:rgb(32, 34, 36);">the Go memory model</font><font style="color:rgb(32, 34, 36);">, the return from f “synchronizes before” the return from any call of once.Do(f).</font>
<font style="color:rgb(32, 34, 36);">意思是説:</font>
Once 是一個對象,它會確保某個操作只執行一次。
在首次使用後,Once 對象不能被複制。
根據 Go 內存模型的術語,f 函數的返回 "<font style="color:rgb(32, 34, 36);">synchronizes before</font>" 於 once.Do(f) 的任何調用返回。
首先第一句話中所説的只執行一次的特性我們已經見識過了。
對於第二句話中的 Once 對象不能被複制,其實 sync 中很多對象都有這個特性,在我們稍後閲讀源碼時會有體現。
而第三句話不太好理解,實際上它想表達的是,在使用 sync.Once 的 Do 方法執行 f 函數後,f 的結果會對所有調用 once.Do(f) 的其他 goroutine 可見。這種“先行發生”(synchronizes before)的保證意味着,f 的執行結果會在所有調用 once.Do(f) 的 goroutine 中同步,因此所有 goroutine 都能獲得一致的結果。
具體來説:
- 當
f函數在一個 goroutine 中被once.Do(f)首次調用時,f會執行,並保證它的效果在內存中對其他 goroutine 可見。 - 之後的所有
once.Do(f)調用都不會重新執行f,但它們會“同步”f的結果,確保f的結果已經生效,並對調用它們的 goroutine 可見。
這樣,sync.Once 可以在多 goroutine 場景中安全地執行初始化等需要確保一次性操作的函數,而無需擔心數據不一致的問題。
此外我們還需要注意一點,once.Do(f) 接收的函數 f 是沒有返回值,所以所説 f 函數的執行的效果是指它執行的副作用。
如下示例就是利用 f 函數執行的副作用來修改變量 i 的值:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
var i = 10
onceBody := func() {
i *= 2
}
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
fmt.Println("i", i)
}
執行示例代碼,得到如下輸出:
$ go run once/main.go
i 20
f 函數對變量 i 的值影響僅有一次。
源碼解讀
接下來我們再來看下 sync.Onec 源碼,學習下它是如何實現的:
https://github.com/golang/go/blob/go1.23.0/src/sync/once.go
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sync
import (
"sync/atomic"
)
// Once is an object that will perform exactly one action.
//
// A Once must not be copied after first use.
//
// In the terminology of [the Go memory model],
// the return from f “synchronizes before”
// the return from any call of once.Do(f).
//
// [the Go memory model]: https://go.dev/ref/mem
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
done atomic.Uint32
m Mutex
}
// Do calls the function f if and only if Do is being called for the
// first time for this instance of [Once]. In other words, given
//
// var once Once
//
// if once.Do(f) is called multiple times, only the first call will invoke f,
// even if f has a different value in each invocation. A new instance of
// Once is required for each function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
//
// config.once.Do(func() { config.init(filename) })
//
// Because no call to Do returns until the one call to f returns, if f causes
// Do to be called, it will deadlock.
//
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
//
// Do guarantees that when it returns, f has finished.
// This implementation would not implement that guarantee:
// given two simultaneous calls, the winner of the cas would
// call f, and the second would return immediately, without
// waiting for the first's call to f to complete.
// This is why the slow path falls back to a mutex, and why
// the o.done.Store must be delayed until after f returns.
if o.done.Load() == 0 {
// Outlined slow-path to allow inlining of the fast-path.
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()
}
}
嗯,你沒看錯 sync.Onec 的源碼竟然如此簡單,算上全部的註釋和空行,也才僅有 78 行代碼,而註釋佔了一大半行數。
首先來看 Once 結構體的定義:
type Once struct {
// done 指示操作是否已執行。
// 它在結構體中位於首位(即第一個字段),因為它用於 hot path。
// hot path 在每個調用點都進行了內聯。
// 將 done 放在首位在某些架構(如 amd64 和 386)上允許生成更緊湊的指令,
// 而在其他架構上減少了指令數量(用於計算偏移量)。
done atomic.Uint32
m Mutex
}
Once 結構體有兩個屬性,done 屬性上的註釋告訴我們它是有意被放在結構體第一個字段的,在某些架構能夠減少 CPU 執行的指令數,以優化性能,作為 hot path。
關於為什麼放在結構體第一個字段就能優化性能,簡單一句話來解釋就是,第一個字段與結構體本身的指針地址是相同的,訪問 Once 結構體無需指針偏移操作,就可以直接操作 done 屬性。hot path 更多解釋的細節,我在另一篇文章《Go 語言中的結構體內存對齊你瞭解嗎?》中有所講解,你可以跳轉過去查看。
另外 done 屬性是 atomic.Uint32 類型,我們順便來看下 atomic.Uint32 是如何定義的:
// A Uint32 is an atomic uint32. The zero value is zero.
type Uint32 struct {
_ noCopy
v uint32
}
// noCopy may be added to structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
//
// Note that it must not be embedded, due to the Lock and Unlock methods.
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
這裏有一個特殊字段 _ noCopy,標識這個結構體不可複製,所以這也就是為什麼前文中提到 Once 對象不能被複制的原因了。
使用 noCopy 字段來標識結構體不可複製,是 Go 語言中的慣用法,我在另一篇文章《Go 中空結構體慣用法,我幫你總結全了!》中有講解。
Once 結構體的 n 屬性沒什麼好説的,就是一個互斥鎖 Mutex。
接下來看 Do 方法的實現:
func (o *Once) Do(f func()) {
// 注意:以下是一個錯誤的 Do 實現:
//
// if o.done.CompareAndSwap(0, 1) {
// f()
// }
//
// Do 保證當它返回時,f 已完成。
// 上述實現不滿足該保證:
// 給定有兩個同時調用的情況,誰取得了 CompareAndSwap 就會執行 f,
// 而第二個調用會立即返回,而不會等待第一個調用的 f 完成。
// 這就是為什麼慢路徑需要退回到使用互斥鎖 Mutex,
// 並且為什麼 o.done.Store 必須延遲到 f 返回之後。
if o.done.Load() == 0 {
// 慢路徑(slow-path)分離,以允許對快路徑(fast-path)進行內聯。
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()
}
}
可以看到,Do 方法內部還通過註釋貼心的解釋了為什麼不使用 o.done.CompareAndSwap(0, 1) 的實現,而是使用 o.done.Load() + Mutex 的實現。
Do 方法處理兩種 case,先是 if o.done.Load() == 0 的判斷,這是一個 fast-path,如果成立,則調用 o.doSlow(f) 進入 slow-path,否則 fast-path 執行結束直接返回了。
這裏簡單解釋下 fast-path 和 slow-path:
fast-path:一段針對常見操作或最佳情況進行優化的代碼路徑。在這條路徑上,通常執行步驟最少、效率最高。所以fast path通常在設計上避免了昂貴的操作(如加鎖、IO 操作等)以提高性能。slow-path:用於處理較為罕見或複雜的情況,通常執行步驟較多、性能較低。這類路徑通常在少數情況下才會被執行,比如當代碼需要處理邊緣情況或複雜的操作時。
所以説,Do 方法絕大多數情況下都會通過 fast-path 直接返回,只有第一次調用才會進入 o.doSlow(f) 邏輯。
在 doSlow 方法內部,先加鎖,然後再一次檢查了 if o.done.Load() == 0。很明顯這是一個 Double-Check Locking,保證極端情況下的併發安全。我在《Go 常見設計模式之單例模式》中有講解如何使用 Double-Check Locking 來實現單例模式,感興趣的讀者可以跳轉過去查看。
現在,sync.Once 的源碼就都解讀完成了。
當然,細心的讀者可能注意到,註釋中其實寫了為什麼要將 slow-path 分離出來,單獨定義一個函數,目的是為了對 fast-path 進行內聯優化。
將 slow-path 邏輯放在單獨的 doSlow 函數中可以使 Do 方法的快路徑更簡潔,這樣還有助於 Go 編譯器對 fast-path 進行內聯優化(即直接嵌入到調用處),從而減少函數調用的開銷,提高性能。
我們可以來驗證一下內聯是否生效,示例代碼如下:
package once
import "sync"
func main() {
var once sync.Once
once.Do(func() {
println("Only once")
})
}
執行 go build 時傳入 -gcflags='-m' 構建參數可以查看內聯情況:
$ go build -gcflags='-m' inlining/once/main.go
# command-line-arguments
inlining/once/main.go:7:10: can inline main.func1
inlining/once/main.go:7:9: inlining call to sync.(*Once).Do
inlining/once/main.go:7:9: inlining call to atomic.(*Uint32).Load
inlining/once/main.go:6:6: moved to heap: once
inlining/once/main.go:7:10: func literal does not escape
打印日誌中出現 inlining 關鍵字表示 main 中調用 sync.Once.Do 方法時確實存在內聯優化。
作為對比,我們再來實現一個沒有將 slow-path 分離出來的 Once 版本:
package sync
import (
"sync"
"sync/atomic"
)
type Once struct {
done atomic.Uint32
m sync.Mutex
}
func (o *Once) Do(f func()) {
if o.done.Load() == 0 {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
}
使用示例如下:
package once
import "github.com/jianghushinian/blog-go-example/sync/once/inlining/myonce/sync"
func main() {
var once sync.Once
once.Do(func() {
println("Only once")
})
}
執行 go build 查看內聯情況:
$ go build -gcflags='-m' inlining/myonce/main.go
# command-line-arguments
inlining/myonce/main.go:5:6: can inline main
inlining/myonce/main.go:7:10: can inline main.func1
inlining/myonce/main.go:6:6: moved to heap: once
inlining/myonce/main.go:7:10: func literal does not escape
這一次編譯器確實沒有進行內聯優化,可見 doSlow 函數的封裝還是起了作用的。
sync.Once 就這麼多功能,不過在 Go 1.21 中 Go 官方又增加了三個 sync.Once 相關函數:OnceFunc、OnceValue 和 OnceValues,來增強 sync.Once 功能,接下來我們就依次介紹下。
sync.OnceFunc
源碼解讀
首先我們來看一下 sync.OnceFunc 源碼實現:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L11
// OnceFunc returns a function that invokes f only once. The returned function
// may be called concurrently.
//
// If f panics, the returned function will panic with the same value on every call.
func OnceFunc(f func()) func() {
var (
once Once
valid bool
p any
)
// Construct the inner closure just once to reduce costs on the fast path.
g := func() {
defer func() {
p = recover()
if !valid {
// Re-panic immediately so on the first call the user gets a
// complete stack trace into f.
panic(p)
}
}()
f()
f = nil // Do not keep f alive after invoking it.
valid = true // Set only if f does not panic.
}
return func() {
once.Do(g)
if !valid {
panic(p)
}
}
}
根據 OnceFunc 上方的代碼註釋可知:
OnceFunc返回一個僅調用f一次的函數。這個返回的函數可以併發調用。- 如果函數
f執行時出現panic,則返回的函數將在每次調用時會產生同樣的panic值。
可以發現,其實 OnceFunc 函數就是對 once.Do 的封裝,不過顯然它考慮了更多情況,使用 defer + recover 對 panic 進行捕獲。用變量 p 暫存了 panic 信息,並且當多次調用 OnceFunc 返回函數時,都會重新 panic。
NOTE:
如果你不熟悉
defer、panic和recover,我在《Go 錯誤處理指北:Defer、Panic、Recover 三劍客》一文中對這三者進行了詳細講解,供你參考。
使用示例
sync.OnceFunc 使用示例如下:
package main
import (
"fmt"
"sync"
)
func main() {
onceBody := sync.OnceFunc(func() {
fmt.Println("Only once")
})
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
onceBody()
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
執行示例代碼,得到如下輸出:
$ go run oncefunc/main.go
Only once
如果發生 panic 會怎樣呢,我們可以嘗試一下:
package main
import (
"fmt"
"sync"
)
func main() {
onceBody := sync.OnceFunc(func() {
panic("Only once")
})
for i := 0; i < 5; i++ {
func() {
defer func() {
r := recover()
fmt.Println("recover", r)
}()
onceBody()
}()
}
}
執行示例代碼,得到如下輸出:
$ go run oncefunc/panic/main.go
recover Only once
recover Only once
recover Only once
recover Only once
recover Only once
作為對比,如果 sync.Once.Do 遇到 panic 又會怎樣呢?示例如下:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
onceBody := func() {
panic("Only once")
}
for i := 0; i < 5; i++ {
func() {
defer func() {
r := recover()
fmt.Println("recover", r)
}()
once.Do(onceBody)
}()
}
}
執行示例代碼,得到如下輸出:
$ go run once/panic/main.go
recover Only once
recover <nil>
recover <nil>
recover <nil>
recover <nil>
由此可見,可以認為 sync.OnceFunc 是比 sync.Once.Do 更好用的接口,它幫我們考慮了函數 f 發生 panic 情況,所以可以考慮優先使用這個實現。
sync.OnceValue
源碼解讀
我們再來看下 sync.OnceValue 源碼實現:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L43
// OnceValue returns a function that invokes f only once and returns the value
// returned by f. The returned function may be called concurrently.
//
// If f panics, the returned function will panic with the same value on every call.
func OnceValue[T any](f func() T) func() T {
var (
once Once
valid bool
p any
result T
)
g := func() {
defer func() {
p = recover()
if !valid {
panic(p)
}
}()
result = f()
f = nil
valid = true
}
return func() T {
once.Do(g)
if !valid {
panic(p)
}
return result
}
}
可以看到,與 OnceFunc 不同的是 OnceValue 使用了泛型,OnceValue 接收的函數 f 是帶有返回值的,並且它返回的函數也帶有返回值。
也就是説,相較於 OnceFunc,OnceValue 相當於是進化版,它接收的 f 函數簽名不同,可以支持返回一個值,而其他的地方與 OnceFunc 實現並無區別,內部也只是多了一個使用 result T 記錄返回值的邏輯。
使用示例
sync.OnceValue 使用示例如下:
package main
import (
"fmt"
"sync"
)
func main() {
once := sync.OnceValue(func() int {
sum := 0
for i := 0; i < 1000; i++ {
sum += i
}
fmt.Println("Computed once:", sum)
return sum
})
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
const want = 499500
got := once()
if got != want {
fmt.Println("want", want, "got", got)
}
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
執行示例代碼,得到如下輸出:
$ go run oncevalue/main.go
Computed once: 499500
sync.OnceValues
源碼解讀
最後,我們再來看下 sync.OnceValues 源碼實現:
https://github.com/golang/go/blob/go1.23.0/src/sync/oncefunc.go#L74
// OnceValues returns a function that invokes f only once and returns the values
// returned by f. The returned function may be called concurrently.
//
// If f panics, the returned function will panic with the same value on every call.
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2) {
var (
once Once
valid bool
p any
r1 T1
r2 T2
)
g := func() {
defer func() {
p = recover()
if !valid {
panic(p)
}
}()
r1, r2 = f()
f = nil
valid = true
}
return func() (T1, T2) {
once.Do(g)
if !valid {
panic(p)
}
return r1, r2
}
}
OnceValues 與 OnceValue 的唯一區別就是它支持返回兩個值,f 函數簽名也變了 f func() (T1, T2),所以我們可以想到最常見的使用方式,函數 f 返回一個 value 和一個 error,這也是 Go 函數慣用法。
使用示例
sync.OnceValues 使用示例如下:
package main
import (
"fmt"
"os"
"sync"
)
func main() {
once := sync.OnceValues(func() ([]byte, error) {
fmt.Println("Reading file once")
return os.ReadFile("oncevalues/example_test.go")
})
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
data, err := once()
if err != nil {
fmt.Println("error:", err)
}
_ = data // Ignore the data for this example
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
執行示例代碼,得到如下輸出:
$ go run oncevalues/main.go
Reading file once
現在,sync.Once 以及它的三個相關函數我們就都講解完成了。
總結
sync.Once 是一個非常實用的同步工具,它以簡潔高效的方式,確保操作只執行一次,避免了重複初始化的開銷。在多 goroutine 調用場景下,它能提供可靠的併發控制,是 Go 併發編程中不可或缺的工具。常用於單例模式、懶加載、併發安全的初始化等場景。
Go 1.21 還發布了幾個 sync.Once 相關的函數 sync.OnceFunc、sync.OnceValue 和 sync.OnceValues,來增強 sync.Once 功能。
我們可以發現一些規律:
sync.Once.Do是sync.Once暴露的唯一接口,對於參數f函數確保僅執行一次。- 而
sync.OnceFunc、sync.OnceValue和sync.OnceValues,三者是對sync.Once的封裝,都能實現once的功能,並且這三者對f函數產生panic的情況進行了處理,保證多次調用它們都能產生同樣的panic。 sync.Once.Do和sync.OnceFunc接收的參數f函數,無參數無返回值。sync.OnceValue接收的參數f函數有 1 個返回值。sync.OnceValues接收的參數f函數有 2 個返回值。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
延伸閲讀
- 本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/sync/once
聯繫我
- 公眾號:Go編程世界
- 微信:jianghushinian
- 郵箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn