Go 併發程式設計

DogeDogeGo發表於2023-04-28

1、Mutex 幾種狀態

  • mutexLocked — 表示互斥鎖的鎖定狀態;

  • mutexWoken — 表示從正常模式被從喚醒;

  • mutexStarving — 當前的互斥鎖進入飢餓狀態;

  • waitersCount — 當前互斥鎖上等待的 Goroutine 個數;

2、Mutex 正常模式和飢餓模式

正常模式(非公平鎖)

正常模式下,所有等待鎖的 goroutine 按照 FIFO(先進先出)順序等待。喚醒 的 goroutine 不會直接擁有鎖,而是會和新請求 goroutine 競爭鎖。新請求的 goroutine 更容易搶佔:因為它正在 CPU 上執行,所以剛剛喚醒的 goroutine有很大可能在鎖競爭中失敗。在這種情況下,這個被喚醒的 goroutine 會加入 到等待佇列的前面。

飢餓模式(公平鎖)

為了解決了等待 goroutine 佇列的長尾問題 飢餓模式下,直接由 unlock 把鎖交給等待佇列中排在第一位的 goroutine (隊 頭),同時,飢餓模式下,新進來的 goroutine 不會參與搶鎖也不會進入自旋狀 態,會直接進入等待佇列的尾部。這樣很好的解決了老的 goroutine 一直搶不 到鎖的場景。

飢餓模式的觸發條件:當一個 goroutine 等待鎖時間超過 1 毫秒時,或者當前 佇列只剩下一個 goroutine 的時候,Mutex 切換到飢餓模式。

總結

對於兩種模式,正常模式下的效能是最好的,goroutine 可以連續多次獲取 鎖,飢餓模式解決了取鎖公平的問題,但是效能會下降,這其實是效能和公平 的一個平衡模式。

3、Mutex 允許自旋的條件

  • 鎖已被佔用,並且鎖不處於飢餓模式。

  • 積累的自旋次數小於最大自旋次數(active_spin=4)。

  • CPU 核數大於 1。

  • 有空閒的 P。

  • 當前 Goroutine 所掛載的 P 下,本地待執行佇列為空。

4、RWMutex 實現

透過記錄 readerCount 讀鎖的數量來進行控制,當有一個寫鎖的時候,會將讀 鎖數量設定為負數 1<<30。目的是讓新進入的讀鎖等待之前的寫鎖釋放通知讀鎖。同樣的當有寫鎖進行搶佔時,也會等待之前的讀鎖都釋放完畢,才會開始進行後續的操作。 而等寫鎖釋放完之後,會將值重新加上 1<<30, 並通知剛才 新進入的讀鎖(rw.readerSem),兩者互相限制。

5、RWMutex 注意事項

  • RWMutex 是單寫多讀鎖,該鎖可以加多個讀鎖或者一個寫鎖

  • 讀鎖佔用的情況下會阻止寫,不會阻止讀,多個 Goroutine 可以同時獲取讀鎖

  • 寫鎖會阻止其他 Goroutine(無論讀和寫)進來,整個鎖由該 Goroutine 獨佔

  • 適用於讀多寫少的場景

  • RWMutex 型別變數的零值是一個未鎖定狀態的互斥鎖

  • RWMutex 在首次被使用之後就不能再被複製

  • RWMutex 的讀鎖或寫鎖在未鎖定狀態,解鎖操作都會引發 panic

  • RWMutex 的一個寫鎖去鎖定臨界區的共享資源,如果臨界區的共享資源已 被(讀鎖或寫鎖)鎖定,這個寫鎖操作的 goroutine 將被阻塞直到解鎖

  • RWMutex 的讀鎖不要用於遞迴呼叫,比較容易產生死鎖

  • RWMutex 的鎖定狀態與特定的 goroutine 沒有關聯。一個 goroutine 可 以 RLock(Lock),另一個 goroutine 可以 RUnlock(Unlock)

  • 寫鎖被解鎖後,所有因操作鎖定讀鎖而被阻塞的 goroutine 會被喚醒,並 都可以成功鎖定讀鎖

  • 讀鎖被解鎖後,在沒有被其他讀鎖鎖定的前提下,所有因操作鎖定寫鎖而 被阻塞的 Goroutine,其中等待時間最長的一個 Goroutine 會被喚醒

6、Cond 是什麼

Cond 實現了一種條件變數,可以使用在多個 Reader 等待共享資源 ready 的場 景(如果只有一讀一寫,一個鎖或者 channel 就搞定了)每個 Cond 都會關聯一個 Lock(*sync.Mutex or *sync.RWMutex),當修改條 件或者呼叫 Wait 方法時,必須加鎖,保護 condition。

以下摘自ChatGPT:

在 Go 語言中,Cond(條件變數)是一種用於實現多個 goroutine 之間等待和通知的同步機制。Cond 可以用於解決多個 goroutine 之間協作的問題,例如生產者和消費者問題、讀寫鎖問題等。

Cond 通常與 Mutex 一起使用,它可以讓等待鎖的 goroutine 臨時釋放鎖,等待某個條件變數滿足之後再重新獲取鎖。

Cond 包含三個主要的方法:

  • Wait():等待條件變數滿足,同時釋放鎖,直到另外一個 goroutine 呼叫 Signal() 或 Broadcast() 來通知條件變數已經滿足,該方法才會返回,並重新獲取鎖。

  • Signal():通知等待條件變數的一個 goroutine,條件變數已經滿足。

  • Broadcast():通知所有等待條件變數的 goroutine,條件變數已經滿足。

當一個 goroutine 呼叫 Wait() 方法時,它會將當前的 goroutine 新增到條件變數的等待佇列中,並且臨時釋放鎖。當另外一個 goroutine 呼叫 Signal() 或 Broadcast() 方法時,它會通知條件變數中等待佇列中的 goroutine,條件變數已經滿足,這些 goroutine 會被喚醒並重新獲取鎖,然後繼續執行。

透過使用 Cond,多個 goroutine 可以協作地等待和通知條件變數的狀態變化,從而實現更加高效和靈活的併發程式設計。

例子

一個簡單的例子就是使用 Cond 實現生產者-消費者模式。

假設有一個佇列,多個生產者可以往佇列中新增資料,多個消費者可以從佇列中取出資料。由於多個 goroutine 同時對佇列進行操作,需要使用 Mutex 保證佇列的執行緒安全。同時,為了避免消費者不斷地進行無用的輪詢,可以使用 Cond 來實現消費者等待新的資料新增到佇列中的功能。

下面是使用 Cond 實現生產者-消費者模式的程式碼示例:

package main
​
import (
 "fmt"
 "sync"
)
​
type Queue struct {
 data []int
 mutex sync.Mutex
 cond *sync.Cond
}
​
func NewQueue() *Queue {
 q := &Queue{
 data: make([]int, 0),
 }
 q.cond = sync.NewCond(&q.mutex)
 return q
}func (q *Queue) Put(v int) {
 q.mutex.Lock()
 defer q.mutex.Unlock()
​
 q.data = append(q.data, v)
 q.cond.Signal() // 通知消費者
}func (q *Queue) Get() int {
 q.mutex.Lock()
 defer q.mutex.Unlock()for len(q.data) == 0 {
 q.cond.Wait() // 等待新的資料新增到佇列中
 }
​
 v := q.data[0]
 q.data = q.data[1:]
 return v
}
​
func main() {
 q := NewQueue()// 啟動多個生產者
 for i := 0; i < 3; i++ {
 go func(i int) {
 for j := 0; j < 5; j++ {
 v := i * 10 + j
 q.Put(v)
 fmt.Printf("producer %d put %d\n", i, v)
 }
 }(i)
 }// 啟動多個消費者
 for i := 0; i < 2; i++ {
 go func(i int) {
 for j := 0; j < 7; j++ {
 v := q.Get()
 fmt.Printf("consumer %d get %d\n", i, v)
 }
 }(i)
 }// 等待所有生產者和消費者結束
 var wg sync.WaitGroup
 wg.Add(5 + 7)
 wg.Wait()
}

在上面的程式碼中,生產者使用 Put() 方法往佇列中新增資料,消費者使用 Get() 方法從佇列中取出資料。當佇列中沒有資料時,消費者呼叫 Wait() 方法等待新的資料新增到佇列中,當生產者往佇列中新增資料時,會呼叫 Signal() 方法通知等待的消費者。

透過使用 Mutex 和 Cond,可以保證多個生產者和消費者能夠安全地協作地操作佇列,從而實現了生產者-消費者模式。

7、Broadcast 和 Signal 區別

func (c *Cond) Broadcast()

Broadcast 會喚醒所有等待 c 的 goroutine。 呼叫 Broadcast 的時候,可以加鎖,也可以不加鎖。

func (c *Cond) Signal()

Signal 只喚醒 1 個等待 c 的 goroutine。 呼叫 Signal 的時候,可以加鎖,也可以不加鎖。

8、Cond 中 Wait 使用

func (c *Cond) Wait()

Wait()會自動釋放 c.L 鎖,並掛起呼叫者的 goroutine。之後恢復執行,

Wait()會在返回時對 c.L加鎖。

除非被 Signal 或者 Broadcast 喚醒,否則 Wait()不會返回。

由於 Wait()第一次恢復時,C.L並沒有加鎖,所以當 Wait 返回時,呼叫者通常 並不能假設條件為真。如下程式碼:

取而代之的是, 呼叫者應該在迴圈中呼叫 Wait。(簡單來說,只要想使用 condition,就必須加鎖。)

c.L.Lock()
for !condition() {
 c.Wait()
}
... make use of condition ...
c.L.Unlock()

9、WaitGroup 用法

一個 WaitGroup 物件可以等待一組協程結束。使用方法是:

  1. main 協程透過呼叫 wg.Add(delta int) 設定 worker 協程的個數,然後創 建 worker 協程;

  2. worker 協程執行結束以後,都要呼叫 wg.Done();

  3. main 協程呼叫 wg.Wait() 且被 block,直到所有 worker 協程全部執行結束 後返回。

10、WaitGroup 實現原理

  • WaitGroup 主要維護了 2 個計數器,一個是請求計數器 v,一個是等待計數 器 w,二者組成一個 64bit 的值,請求計數器佔高 32bit,等待計數器佔低 32bit。

  • 每次 Add 執行,請求計數器 v 加 1,Done 方法執行,等待計數器減 1,v 為 0 時透過訊號量喚醒 Wait()。

11、什麼是 sync.Once

  • Once 可以用來執行且僅僅執行一次動作,常常用於單例物件的初始化場景

  • Once 常常用來初始化單例資源,或者併發訪問只需初始化一次的共享資 源,或者在測試的時候初始化一次測試資源。

  • sync.Once 只暴露了一個方法 Do,你可以多次呼叫 Do 方法,但是隻有第 一次呼叫 Do 方法時 f 引數才會執行,這裡的 f 是一個無引數無返回值 的函式。

以下摘自ChatGPT:

sync.Once 是 Go 語言中的一個同步原語,用於保證一個函式只被執行一次。

Once 型別有一個 Do 方法,該方法接受一個函式作為引數,只有在第一次呼叫 Do 方法時,才會執行該函式。後續呼叫 Do 方法會直接返回,不會再次執行傳入的函式。

使用 sync.Once 可以很方便地實現單例模式,例如在需要使用單例的場景下,我們可以將單例的初始化操作放在 sync.Once 的 Do 方法中,保證只有在第一次使用單例時進行初始化操作,後續使用直接返回已經初始化過的單例例項。

下面是一個使用 sync.Once 實現單例模式的簡單示例程式碼:

package main
​
import (
 "fmt"
 "sync"
)
​
type Singleton struct {
 name string
}var (
 instance *Singleton
 once     sync.Once
)
​
func GetInstance() *Singleton {
 once.Do(func() {
 instance = &Singleton{name: "singleton instance"}
 })
 return instance
}
​
func main() {
 // 第一次呼叫 GetInstance 會進行初始化操作
 singleton1 := GetInstance()
 fmt.Println(singleton1.name)// 再次呼叫 GetInstance 不會進行初始化操作
 singleton2 := GetInstance()
 fmt.Println(singleton2.name)// 兩次呼叫返回的是同一個例項
 fmt.Println(singleton1 == singleton2)
}

在上面的示例中,GetInstance() 函式使用 sync.Once 來保證 Singleton 型別的例項只被建立一次,後續的呼叫都直接返回已經建立的例項。這樣就可以方便地實現單例模式,避免了多次建立相同的物件,節省了系統資源。

12、什麼操作叫做原子操作

原子操作即是進行過程中不能被中斷的操作,針對某個值的原子操作在被進行 的過程中,CPU 絕不會再去進行其他的針對該值的操作。為了實現這樣的嚴謹 性,原子操作僅會由一個獨立的 CPU 指令代表和完成。原子操作是無鎖的,常 常直接透過 CPU 指令直接實現。 事實上,其它同步技術的實現常常依賴於原 子操作。

13、原子操作和鎖的區別

原子操作由底層硬體支援,而鎖則由作業系統的排程器實現。

鎖應當用來保護一段邏輯,對於一個變數更新的保護。

原子操作通常執行上會更有效率,並且更能利用計算機多核的優勢,如果要更新的是一個複合物件,則應當使用 atomic.Value封裝好的實現。

14、什麼是 CAS

CAS 的全稱為Compare And Swap,直譯就是比較交換。是一條 CPU 的原子指 令,其作用是讓 CPU 先進行比較兩個值是否相等,然後原子地更新某個位置的 值,其實現方式是給予硬體平臺的彙編指令,在 intel 的 CPU 中,使用的 cmpxchg 指令,就是說 CAS 是靠硬體實現的,從而在硬體層面提升效率。

簡述過程是這樣:

假設包含 3 個引數記憶體位置(V)、預期原值(A)和新值(B)。V 表示要更新變數的 值,E 表示預期值,N表示新值。僅當 V值等於E值時,才會將V的值設為N, 如果 V 值和E值不同,則說明已經有其他執行緒在做更新,則當前執行緒什麼都不 做,最後 CAS 返回當前 V的真實值。CAS 操作時抱著樂觀的態度進行的,它總 是認為自己可以成功完成操作。基於這樣的原理,CAS 操作即使沒有鎖,也可 以發現其他執行緒對於當前執行緒的干擾。

15、sync.Pool 有什麼用

對於很多需要重複分配、回收記憶體的地方,sync.Pool 是一個很好的選擇。頻 繁地分配、回收記憶體會給 GC 帶來一定的負擔,嚴重的時候會引起 CPU 的毛 刺。而 sync.Pool 可以將暫時將不用的物件快取起來,待下次需要的時候直 接使用,不用再次經過記憶體分配,複用物件的記憶體,減輕 GC 的壓力,提升系 統的效能。

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

相關文章