在併發程式設計中同步原語也就是我們通常說的鎖的主要作用是保證多個執行緒或者 goroutine
在訪問同一片記憶體時不會出現混亂的問題。Go
語言的sync
包提供了常見的併發程式設計同步原語,上一期轉載的文章《Golang 併發程式設計之同步原語》中也詳述了 Mutex
、RWMutex
、WaitGroup
、Once
和 Cond
這些同步原語的實現原理。今天的文章裡讓我們回到應用層,聚焦sync
包裡這些同步原語的應用場景,同時也會介紹sync
包中的Pool
和Map
的應用場景和使用方法。話不多說,讓我們開始吧。
sync.Mutex
sync.Mutex
可能是sync
包中使用最廣泛的原語。它允許在共享資源上互斥訪問(不能同時訪問):
mutex := &sync.Mutex{}
mutex.Lock()
// Update共享變數 (比如切片,結構體指標等)
mutex.Unlock()
必須指出的是,在第一次被使用後,不能再對sync.Mutex
進行復制。(sync
包的所有原語都一樣)。如果結構體具有同步原語欄位,則必須通過指標傳遞它。
sync.RWMutex
sync.RWMutex
是一個讀寫互斥鎖,它提供了我們上面的剛剛看到的sync.Mutex
的Lock
和UnLock
方法(因為這兩個結構都實現了sync.Locker
介面)。但是,它還允許使用RLock
和RUnlock
方法進行併發讀取:
mutex := &sync.RWMutex{}
mutex.Lock()
// Update 共享變數
mutex.Unlock()
mutex.RLock()
// Read 共享變數
mutex.RUnlock()
sync.RWMutex
允許至少一個讀鎖或一個寫鎖存在,而sync.Mutex
允許一個讀鎖或一個寫鎖存在。
通過基準測試來比較這幾個方法的效能:
BenchmarkMutexLock-4 83497579 17.7 ns/op
BenchmarkRWMutexLock-4 35286374 44.3 ns/op
BenchmarkRWMutexRLock-4 89403342 15.3 ns/op
可以看到鎖定/解鎖sync.RWMutex
讀鎖的速度比鎖定/解鎖sync.Mutex
更快,另一方面,在sync.RWMutex
上呼叫Lock()
/ Unlock()
是最慢的操作。
因此,只有在頻繁讀取和不頻繁寫入的場景裡,才應該使用sync.RWMutex
。
sync.WaitGroup
sync.WaitGroup
也是一個經常會用到的同步原語,它的使用場景是在一個goroutine
等待一組goroutine
執行完成。
sync.WaitGroup
擁有一個內部計數器。當計數器等於0
時,則Wait()
方法會立即返回。否則它將阻塞執行Wait()
方法的goroutine
直到計數器等於0
時為止。
要增加計數器,我們必須使用Add(int)
方法。要減少它,我們可以使用Done()
(將計數器減1
),也可以傳遞負數給Add
方法把計數器減少指定大小,Done()
方法底層就是通過Add(-1)
實現的。
在以下示例中,我們將啟動八個goroutine
,並等待他們完成:
wg := &sync.WaitGroup{}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
// Do something
wg.Done()
}()
}
wg.Wait()
// 繼續往下執行...
每次建立goroutine
時,我們都會使用wg.Add(1)
來增加wg
的內部計數器。我們也可以在for
迴圈之前呼叫wg.Add(8)
。
與此同時,每個goroutine
完成時,都會使用wg.Done()
減少wg
的內部計數器。
main goroutine
會在八個goroutine
都執行wg.Done()
將計數器變為0
後才能繼續執行。
sync.Map
sync.Map
是一個併發版本的Go
語言的map
,我們可以:
- 使用
Store(interface {},interface {})
新增元素。 - 使用
Load(interface {}) interface {}
檢索元素。 - 使用
Delete(interface {})
刪除元素。 - 使用
LoadOrStore(interface {},interface {}) (interface {},bool)
檢索或新增之前不存在的元素。如果鍵之前在map
中存在,則返回的布林值為true
。 - 使用
Range
遍歷元素。
m := &sync.Map{}
// 新增元素
m.Store(1, "one")
m.Store(2, "two")
// 獲取元素1
value, contains := m.Load(1)
if contains {
fmt.Printf("%s\n", value.(string))
}
// 返回已存value,否則把指定的鍵值儲存到map中
value, loaded := m.LoadOrStore(3, "three")
if !loaded {
fmt.Printf("%s\n", value.(string))
}
m.Delete(3)
// 迭代所有元素
m.Range(func(key, value interface{}) bool {
fmt.Printf("%d: %s\n", key.(int), value.(string))
return true
})
上面的程式會輸出:
one
three
1: one
2: two
如你所見,Range
方法接收一個型別為func(key,value interface {})bool
的函式引數。如果函式返回了false
,則停止迭代。有趣的事實是,即使我們在恆定時間後返回false
,最壞情況下的時間複雜度仍為O(n)
。
我們應該在什麼時候使用sync.Map
而不是在普通的map
上使用sync.Mutex
?
- 當我們對
map
有頻繁的讀取和不頻繁的寫入時。 - 當多個
goroutine
讀取,寫入和覆蓋不相交的鍵時。具體是什麼意思呢?例如,如果我們有一個分片實現,其中包含一組4個goroutine
,每個goroutine
負責25%的鍵(每個負責的鍵不衝突)。在這種情況下,sync.Map
是首選。
sync.Pool
sync.Pool
是一個併發池,負責安全地儲存一組物件。它有兩個匯出方法:
Get() interface{}
用來從併發池中取出元素。Put(interface{})
將一個物件加入併發池。
pool := &sync.Pool{}
pool.Put(NewConnection(1))
pool.Put(NewConnection(2))
pool.Put(NewConnection(3))
connection := pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)
connection = pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)
connection = pool.Get().(*Connection)
fmt.Printf("%d\n", connection.id)
輸出:
1
3
2
需要注意的是Get()
方法會從併發池中隨機取出物件,無法保證以固定的順序獲取併發池中儲存的物件。
還可以為sync.Pool
指定一個建立者方法:
pool := &sync.Pool{
New: func() interface{} {
return NewConnection()
},
}
connection := pool.Get().(*Connection)
這樣每次呼叫Get()
時,將返回由在pool.New
中指定的函式建立的物件(在本例中為指標)。
那麼什麼時候使用sync.Pool?有兩個用例:
第一個是當我們必須重用共享的和長期存在的物件(例如,資料庫連線)時。第二個是用於優化記憶體分配。
讓我們考慮一個寫入緩衝區並將結果持久儲存到檔案中的函式示例。使用sync.Pool
,我們可以通過在不同的函式呼叫之間重用同一物件來重用為緩衝區分配的空間。
第一步是檢索先前分配的緩衝區(如果是第一個呼叫,則建立一個緩衝區,但這是抽象的)。然後,defer
操作是將緩衝區放回sync.Pool
中。
func writeFile(pool *sync.Pool, filename string) error {
buf := pool.Get().(*bytes.Buffer)
defer pool.Put(buf)
// Reset 快取區,不然會連線上次呼叫時儲存在快取區裡的字串foo
// 程式設計foofoo 以此類推
buf.Reset()
buf.WriteString("foo")
return ioutil.WriteFile(filename, buf.Bytes(), 0644)
}
sync.Once
sync.Once
是一個簡單而強大的原語,可確保一個函式僅執行一次。在下面的示例中,只有一個goroutine
會顯示輸出訊息:
once := &sync.Once{}
for i := 0; i < 4; i++ {
i := i
go func() {
once.Do(func() {
fmt.Printf("first %d\n", i)
})
}()
}
我們使用了Do(func ())
方法來指定只能被呼叫一次的部分。
sync.Cond
sync.Cond
可能是sync
包提供的同步原語中最不常用的一個,它用於發出訊號(一對一)或廣播訊號(一對多)到goroutine
。讓我們考慮一個場景,我們必須向一個goroutine
指示共享切片的第一個元素已更新。建立sync.Cond
需要sync.Locker
物件(sync.Mutex
或sync.RWMutex
):
cond := sync.NewCond(&sync.Mutex{})
然後,讓我們編寫負責顯示切片的第一個元素的函式:
func printFirstElement(s []int, cond *sync.Cond) {
cond.L.Lock()
cond.Wait()
fmt.Printf("%d\n", s[0])
cond.L.Unlock()
}
我們可以使用cond.L
訪問內部的互斥鎖。一旦獲得了鎖,我們將呼叫cond.Wait()
,這會讓當前goroutine
在收到訊號前一直處於阻塞狀態。
讓我們回到main goroutine
。我們將通過傳遞共享切片和先前建立的sync.Cond
來建立printFirstElement
池。然後我們呼叫get()
函式,將結果儲存在s[0]
中併發出訊號:
s := make([]int, 1)
for i := 0; i < runtime.NumCPU(); i++ {
go printFirstElement(s, cond)
}
i := get()
cond.L.Lock()
s[0] = i
cond.Signal()
cond.L.Unlock()
這個訊號會解除一個goroutine
的阻塞狀態,解除阻塞的goroutine
將會顯示s[0]
中儲存的值。
但是,有的人可能會爭辯說我們的程式碼破壞了Go
的最基本原則之一:
不要通過共享記憶體進行通訊;而是通過通訊共享記憶體。
確實,在這個示例中,最好使用channel
來傳遞get()
返回的值。但是我們也提到了sync.Cond
也可以用於廣播訊號。我們修改一下上面的示例,把Signal()
呼叫改為呼叫Broadcast()
。
i := get()
cond.L.Lock()
s[0] = i
cond.Broadcast()
cond.L.Unlock()
在這種情況下,所有goroutine都將被觸發。
眾所周知,channel
裡的元素只會由一個goroutine
接收到。通過channel
模擬廣播的唯一方法是關閉channel
。
當一個channel被關閉後,channel中已經傳送的資料都被成功接收後,後續的接收操作將不再阻塞,它們會立即返回一個零值。
但是這種方式只能廣播一次。因此,儘管存在很大爭議,但這無疑是sync.Cond
的一個有趣的功能。
推薦閱讀
本作品採用《CC 協議》,轉載必須註明作者和本文連結