轉型做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.Mutex
和 Channel
那樣,稍微姿勢不注意點就 panic
了。這一塊後續再寫文章介紹下。除了上面需要注意的使用資源的時候資源還未初始化完成的問題,在 Once
中還需要避免的是死鎖問題。
// 由於巢狀呼叫 Do 裡面的 lock導致死鎖
func ErrOne() {
var o sync.Once
o.Do(func() {
o.Do(func() {
fmt.Println("初始化")
})
})
}
這裡 Do
呼叫了 f
,f
裡面又呼叫了 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 協議》,轉載必須註明作者和本文連結