Go 併發控制:sync-Once 詳解

在 Go 語言的併發編程中,常常會遇到需要確保某個操作僅執行一次的場景。sync.Once 是 Go 標準庫中的一個簡單而強大的工具,專門用於解決這種需求。本文將深入解析 sync.Once 的使用方法和原理,幫助你更好地理解 sync.Once 在併發控制中的用法。

sync.Once

sync.Once 是 Go 語言 sync 包中的一種同步原語。它可以確保一個操作(通常是一個函數)在程序的生命週期中只被執行一次,不論有多少 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.OnceDo 方法執行 f 函數後,f 的結果會對所有調用 once.Do(f) 的其他 goroutine 可見。這種 “先行發生”(synchronizes before)的保證意味着,f 的執行結果會在所有調用 once.Do(f) 的 goroutine 中同步,因此所有 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-pathslow-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 相關函數:OnceFuncOnceValueOnceValues,來增強 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 函數就是對 once.Do 的封裝,不過顯然它考慮了更多情況,使用 defer + recoverpanic 進行捕獲。用變量 p 暫存了 panic 信息,並且當多次調用 OnceFunc 返回函數時,都會重新 panic

NOTE:

如果你不熟悉 deferpanicrecover,我在《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 是帶有返回值的,並且它返回的函數也帶有返回值。

也就是說,相較於 OnceFuncOnceValue 相當於是進化版,它接收的 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
 }
}

OnceValuesOnceValue 的唯一區別就是它支持返回兩個值,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.OnceFuncsync.OnceValuesync.OnceValues,來增強 sync.Once 功能。

我們可以發現一些規律:

本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。

希望此文能對你有所啓發。

延伸閱讀

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ijAjiCdpb7BhRQwEa2BN3Q