Go 併發控制:sync-Once 詳解
在 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 常見設計模式之單例模式》(https://jianghushinian.cn/2022/03/04/Golang - 常見設計模式之單例模式 /)中也有講解。
詳細介紹
我們快速上手了 sync.Onec
的使用方法,下面我們來看下 Go 官方是如何介紹 sync.Onec
的。
Go 官方文檔 對 sync.Once
的介紹只有簡單三句話:
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).
意思是說:
Once 是一個對象,它會確保某個操作只執行一次。
在首次使用後,Once 對象不能被複制。
根據 Go 內存模型的術語,f 函數的返回 "synchronizes before" 於 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 中,歡迎點擊查看。
希望此文能對你有所啓發。
延伸閱讀
-
sync Documentation:https://pkg.go.dev/sync@go1.23.0
-
Go 1.21 Release Notes:https://go.dev/doc/go1.21#syncpkgsync
-
What does "hot path" mean in the context of sync.Once?:https://stackoverflow.com/questions/59174176/what-does-hot-path-mean-in-the-context-of-sync-once
-
Go 語言中的結構體內存對齊你瞭解嗎?:https://jianghushinian.cn/2024/07/07/do-you-understand-the-memory-alignment-of-structs-in-the-go/#hot-path
-
Go 中空結構體慣用法,我幫你總結全了!:https://jianghushinian.cn/2024/06/02/i-have-summarized-all-the-usages-of-empty-struct-in-go-for-you/
-
Go 錯誤處理指北:Defer、Panic、Recover 三劍客:https://jianghushinian.cn/2024/10/13/go-error-guidelines-defer-panic-recover/
-
Go 常見設計模式之單例模式:https://jianghushinian.cn/2022/03/04/Golang - 常見設計模式之單例模式 /
-
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/sync/once
聯繫我
-
公衆號:Go 編程世界
-
微信:jianghushinian
-
郵箱:jianghushinian007@outlook.com
-
博客:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ijAjiCdpb7BhRQwEa2BN3Q