博客 / 詳情

返回

Go 併發控制:sync.Once 詳解

公眾號首發地址: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.OnceDo 方法執行 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-pathslow-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 相關函數: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 返回一個僅調用 f 一次的函數。這個返回的函數可以併發調用。
  • 如果函數 f 執行時出現 panic,則返回的函數將在每次調用時會產生同樣的 panic 值。

可以發現,其實 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 功能。

我們可以發現一些規律:

  • sync.Once.Dosync.Once 暴露的唯一接口,對於參數 f 函數確保僅執行一次。
  • sync.OnceFuncsync.OnceValuesync.OnceValues,三者是對 sync.Once 的封裝,都能實現 once 的功能,並且這三者對 f 函數產生 panic 的情況進行了處理,保證多次調用它們都能產生同樣的 panic
  • sync.Once.Dosync.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
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.