你真的瞭解 sync.Once 嗎

Remember發表於2020-10-30

轉型做go大概一個多月了吧,工作中也是邊寫邊學,最近也是在極客時間學習一些go相關課程,現學現用,原始碼在我 github 上:github.com/wuqinqiang/Go_Concurren...

是什麼

引用官方描述的一段話,Once is a object that will perform exactly one action,即它是一個物件,它提供了保證某個動作只被執行一次的功能。最典型的場景當然就是單例物件的初始化操作。

咋麼做

Once 的程式碼很簡潔,從頭到尾加註釋不超過 70 行程式碼。對外暴露了一個唯一介面 Do(f func()) ,使用起來也是非常簡單。

package main

import (
  "fmt"
  "sync"
)

func main() {
  var once sync.Once
  fun1 := func() {
    fmt.Println("第一次列印")
  }
  once.Do(fun1)

  fun2 := func() {
    fmt.Println("第二次列印")
  }

  once.Do(fun2)
}

在執行上面這段程式碼之後,從結果中你會發現只執行了 fun1。這樣看好像沒什麼問題,但是這段程式碼並不是併發的呼叫 Do() ,那就稍微調整一下程式碼:

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var once sync.Once
  for i := 0; i < 5; i++ {
    go func(i int) {
      fun1 := func() {
        fmt.Printf("i:=%d\n", i)
      }
      once.Do(fun1)
    }(i)
  }
  // 為了防止主goroutine直接執行完了,啥都看不到
  time.Sleep(50 * time.Millisecond)
}

我們開啟了5個併發的 goroutine ,不管你咋麼執行,始終只列印一次,至於 i 是多少,就看先執行的是哪個 g 了。Once 保證只有第一次呼叫 Do() 方法時,傳遞的 f (無引數無返回值的函式) 才會執行,並且之後不管呼叫的引數是否改變了,也不再執行。

咋麼實現

在看一個功能的同時,其實我們本身也可以站在技術的角度上來思考,如果是你,你會咋麼實現這個 Once。我覺得這是件很有意思的事情。

第一時間想到的就是 go 中開箱即用的 sync.Mutex 的 Lock() 方法的第一段:

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
  // Fast path: grab unlocked mutex.
  if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
      ......
        return
  }
   ......
}

利用 atomic 的原子操作來實現這個需求。這確實可以保證只執行一次。但是也存在一個巨大的坑,我們來驗證下:

package main

import (
  "fmt"
  "net"
  "sync/atomic"
  "time"
)

type OnceA struct {
  done uint32
}

func (o *OnceA) Do(f func()) {
  if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    return
  }
  f()
}

func main() {
  var once OnceA 
  var conn net.Conn
  go func() {
    fun1 := func() {
      time.Sleep(5 * time.Second) //模擬初始化的速度很慢
      conn, _ = net.DialTimeout("tcp", "baidu.com:80", time.Second)
    }
    once.Do(fun1)
  }()
  time.Sleep(500 * time.Millisecond)
  fun2 := func() {
    fmt.Println("執行fun2")
    conn, _ = net.DialTimeout("tcp", "baidu.com:80", time.Second)
  }
  //再呼叫do已經檢查到done為1了
  once.Do(fun2)
  _, err := conn.Write([]byte("\"GET / HTTP/1.1\\r\\nHost: baidu.com\\r\\n Accept: */*\\r\\n\\r\\n\""))
  if err != nil {
    fmt.Println("err:", err)
  }
}

conn 是一個 net.Conn 的介面型別變數,這裡為了達到效果,通過 sleep 模擬了初始化資源的耗時 ,當 fun2() 想要進行初始化的時候,已然發現 done 的值是 1 了,但是 fun1 初始化速度很慢,導致接下來操作 conn.Write 的時候,因為此時 conn 還是一個空資源,最終執行時丟擲空指標的 panic 了。

這個問題的原因在於真正使用資源的時候,資源初始化還沒到位,真是尷尬?。

那麼 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.
type Once struct {
  done uint32
  m    Mutex
}
func (o *Once) Do(f func()) {
  // Note: Here is an incorrect implementation of Do:
  //
  //  if atomic.CompareAndSwapUint32(&o.done, 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 atomic.StoreUint32 must be delayed until after f returns.

  if atomic.LoadUint32(&o.done) == 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 == 0 {
    defer atomic.StoreUint32(&o.done, 1)
    f()
  }
}

你看大佬都直接註釋貼心的告訴你 if atomic.CompareAndSwapUint32(&o.done, 0, 1) 這個不是正確的實現。併發的情況下,勝者獲得呼叫 f ,但是第二個會直接返回,沒有等待第一個初始化結束。

所以 Once 實現使用了一個互斥鎖,互斥鎖保證了只有一個 g 初始化,同時採取的是雙檢查的機制,再次判斷 Once.done 是否為 0,如果為 0,代表第一次初始化,等到初始化結束之後,再釋放鎖。併發情況下,其他的 g 就會被阻塞在 o.m.Lock()

如何避坑

說是避坑,但是絕大多數的坑都是由於程式設計師自身程式碼問題所導致的,雖然有點尷尬,但確實如此。 Once 的“坑” 還算少的,不像 sync.MutexChannel 那樣,稍微姿勢不注意點就 panic 了。這一塊後續再寫文章介紹下。除了上面需要注意的使用資源的時候資源還未初始化完成的問題,在 Once 中還需要避免的是死鎖問題。

// 由於巢狀呼叫 Do 裡面的 lock導致死鎖
func ErrOne() {
  var o sync.Once
  o.Do(func() {
    o.Do(func() {
      fmt.Println("初始化")
    })
  })
}

這裡 Do 呼叫了 ff 裡面又呼叫了 Do,最終導致死鎖。我把上面的程式碼簡化成下面這樣

package main

import "sync"

func main() {
  var mu sync.Mutex
  mu.Lock()
  mu.Lock()
}

避免這種錯誤也很簡單,不要在 f 函式中再次呼叫當前的 Once 即可。

延伸

上面有提到過,Once.Do 由於某些原因導致初始化失敗,但是原生的問題在於,後續再也沒有機會執行同一個 Once.Do 了,發生這樣的情況,理想的處理是,只有真正初始化成功,才設定 Done 的值,並且如果初始化失敗,理應通知到上游服務,這樣上游服務可以做一些重試機制或者異常處理等操作。

package main
​
import (
  "fmt"
  "io"
  "net"
  "os"
  "sync"
  "sync/atomic"
  "time"
)type Once struct {
  done uint32
  m    sync.Mutex
}
// 傳入的f 有返回值,如果初始化失敗,返回對應error,
// Do方法再把這個err返回給上游服務
func (o *Once) Do(f func() error) error {
  if atomic.LoadUint32(&o.done) == 1 { //fast path
    return nil
  }
  return o.doSlow(f)
}func (o *Once) doSlow(f func() error) error {
  o.m.Lock()
  defer o.m.Unlock()
  var err error
  if o.done == 0 { //雙檢查,還沒有初始化
    err = f()
    if err == nil { // 只有真正初始化成功才把 done 的值改成1
      atomic.StoreUint32(&o.done, 1)
    }
  }
  return err
}

我們改變了 f 函式,增加了一個返回值,在初始化失敗之後返回給 Do 函式,由 Do 函式再把錯誤返回給上游的呼叫方,把控制權交還給呼叫方做失敗的處理。另外改動的一點是,只有真正初始化成功之後才把 Done 的值改成 1。那麼我們可以簡單的把上面的業務程式碼改造一下:


package main

import (
  "fmt"
  "io"
  "net"
  "os"
  "sync"
  "sync/atomic"
  "time"
)

type Once struct {
  done uint32
  m    sync.Mutex
}
// 傳入的f 有返回值,如果初始化失敗,返回對應error,
// Do方法再把這個err返回給上游服務
func (o *Once) Do(fn func() error) error {
  if atomic.LoadUint32(&o.done) == 1 {
    return nil
  }
  return o.doSlow(fn)
}

func (o *Once) doSlow(fn func() error) error {
  o.m.Lock()
  defer o.m.Unlock()
  var err error
  if o.done == 0 { /雙檢查,還沒有初始化
    err = fn()
    if err == nil { // 只有真正初始化成功才把 done 的值改成1
      atomic.StoreUint32(&o.done, 1)
    }
  }
  return err
}

func main() {
  urls := []string{
    "127.0.0.1:3453",
    "127.0.0.1:9002",
    "127.0.0.1:9003",
    "baidu.com:80",
  }
  var conn net.Conn
  var o Once
  count := 0
  var err error
  for _, url := range urls {
    err := o.Do(func() error {
      count++
      fmt.Printf("初始化%d次\n", count)
      conn, err = net.DialTimeout("tcp", url, time.Second)
      fmt.Println(err)
      return err
    })
    if err == nil {
      break
    }
    if count == 3 {
      fmt.Println("初始化失敗,不再重試")
      break
    }
  }

  if conn != nil {
    _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: google.com\r\n Accept: */*\r\n\r\n"))
    _, _ = io.Copy(os.Stdout, conn)
  }

}

當我們在使用一些開源工具時,只要業務需要,你可以改造各種你想要的東西。有時候,阻塞住你的,往往就是一身空想罷了。共勉.

本作品採用《CC 協議》,轉載必須註明作者和本文連結
吳親庫裡

相關文章