Golang 併發程式設計中條件變數的理解與使用

Pyvago發表於2019-12-15
  (本文提到的channel和讀寫鎖混用導致的隱性死鎖,和應用條件變數的程式碼見文章末尾。)
   現在的討論情形是一個擁有多個生產者同時還有多個消費者的“生產者-消費者”模型。此時為了解決多個go程同時訪問公共區造成的資料混亂,可以加入互斥鎖。
   與之前隱性死鎖的例子不同的是,當時是將無緩衝的channel和讀寫鎖一起使用,並將這個無緩衝channel作為了公共區,而無緩衝的特性是:負責讀和寫的兩個go程必須同時處於執行態(非阻塞),否則這個channel的兩端都會一直阻塞,可是一旦向公共區加了鎖,它就不會允許你的讀go程和寫go程同時訪問公共區,由此而產生隱性死鎖。但是這個例子不太一樣了,它將作為公共區的無緩衝channel改成了有緩衝channel,這樣的話對公共區的讀寫就可以在不同的時刻進行,也就不會產生那種隱性死鎖。
   那麼從現在開始,所有的go程在訪問公共區之前,必須要搶到這把鎖,當某一個go程拿到鎖以後就可以對公共區進行訪問,訪問結束後解鎖,相當於把鎖扔掉,然後所有go程又開始搶這把鎖。
  有了這把互斥鎖,就能夠讓這多個go程在訪問公共區時,由並行變成序列,從而保證了執行緒(go程)安全。但是當寫入公共區的go程遠遠多於讀go程時,就會產生這樣一種情形:公共區的空間已經被寫go程給寫滿了,還沒有來得及被讀go程給讀走,此時很可能又有一個寫go程搶到了鎖,但是它在嘗試向公共區寫資料時,由於公共區已滿,當前go程就會在此阻塞。也就是說這個寫go程不是因為拿不到鎖而阻塞,它雖然已經拿到鎖了,但是在嘗試向公共區寫資料的時候阻塞住了。而與此同時,其他所有的go程在訪問公共區時都因為拿不到鎖而阻塞住了,所以導致了所有的go程都陷入阻塞,就會產生死鎖。
   同樣地,當讀go程遠遠多於寫go程時,公共區的資料全部被讀走,寫go程還沒有來得及向公共區寫入資料,此時會有讀go程搶到鎖並嘗試從公共區讀取資料,但由於公共區為空,所以當前讀go程進入阻塞,而其他go程與此同時由於拿不到鎖也會阻塞,這樣也會發生和前一種同樣的死鎖。
  為了解決這個問題,引入了條件變數的概念。
   先來看一下什麼是條件變數,以及條件變數中包含哪些方法。GO標準庫中的sys.Cond型別代表了條件變數。條件變數要與鎖(互斥鎖,或者讀寫鎖)一起使用。成員變數L代表與條件變數搭配使用的鎖。

type Cond struct {
noCopy noCopy
// L is held while observing or changing the condition
L Locker
notify notifyList
checker copyChecker
}
對應的有3個常用方法,Wait,Signal,Broadcast。
1)func (c Cond) Wait()
該函式的作用可歸納為如下三點:
a)阻塞等待條件變數滿足
b)釋放已掌握的互斥鎖相當於cond.L.Unlock()。 注意:兩步為一個原子操作。
c)當被喚醒,Wait()函式返回時,解除阻塞並重新獲取互斥鎖。相當於cond.L.Lock()
2)func (c
Cond) Signal()
單發通知,給一個正等待(阻塞)在該條件變數上的goroutine(執行緒)傳送通知。
3)func (c *Cond) Broadcast()
廣播通知,給正在等待(阻塞)在該條件變數上的所有goroutine(執行緒)傳送通知。
再來看一下條件變數的使用流程。
使用流程:

1.  建立 條件變數: var cond    sync.Cond

2.  指定條件變數用的 鎖:  cond.L = new(sync.Mutex)

3.  cond.L.Lock()   給公共區加鎖(互斥量)

4.  判斷是否到達 阻塞條件(緩衝區滿/空) —— for 迴圈判斷

    for  len(ch) == cap(ch) {   cond.Wait() —— 1) 阻塞 2) 解鎖 3) 加鎖

5.  訪問公共區 —— 讀、寫資料、列印 

6.  解鎖條件變數用的 鎖  cond.L.Unlock()

7.  喚醒阻塞在條件變數上的 對端。 signal()  Broadcast()

      我把這個過程比喻成了公司的員工流動,公共區就是這個公司固定數量的崗位,對公共區的寫操作就是員工入職,讀操作就是員工離職。互斥鎖就是一間辦公室,想入職的必須來這間辦公室面試,想離職的也必須來這間辦公室辦離職手續。並且這間辦公室只能給一個人面試或者給一個人辦離職手續(執行緒間互斥)。員工要想入職(go程向公共區寫資料)就一定要進入這間辦公室來面試(加鎖),接下來判斷是否滿足條件(條件變數),如果公司崗位已經滿了(公共區被寫滿),就讓他先不必面試,並離開這間辦公室(wait函式完成的解鎖),但是公司還不能讓你走,一直在門外等待(解了鎖但沒有解除阻塞),防止他走了以後又來面試(佔著鎖不放)。這樣就能讓出辦公室(多個go程重新搶鎖),給其他辦離職手續的人讓位置,當有人離職後會發出通知(signal方法),讓門口等待的人再進去面試(重新獲得鎖),該員工入職後就會離開辦公室(解鎖),上崗幹活(向公共區寫入資料),然後通知準備離職的員工可以去那個辦公室辦理離職手續(signal方法喚醒阻塞在條件變數上的對端)。
   至於說為什麼公共區每次寫進來或讀出資料以後都要執行signal方法來喚醒對端上阻塞的go程,因為每次我有資料寫進去就說明讀端現在一定有資料讀,一定可以喚醒一個讀端的阻塞go程,同樣,一旦有資料讀出去,就說明寫端至少有一個go程可以被喚醒並寫入資料。通常signal方法比broadcast更常用一些。

程式碼:
(1)channel和讀寫鎖混用導致的隱性死鎖:
package main

import (
"math/rand"
"time"
"fmt"
"sync"
)

var rwMutex sync.RWMutex // 鎖只有一把, 2 個屬性 r w

func readGo(in <-chan int, idx int) {
for {
rwMutex.RLock() // 以讀模式加鎖
num := <-in
fmt.Printf("----%dth 讀 go程,讀出:%d\n", idx, num)
rwMutex.RUnlock() // 以讀模式解鎖
}
}

func writeGo(out chan<- int, idx int) {
for {
// 生成隨機數
num := rand.Intn(1000)
rwMutex.Lock() // 以寫模式加鎖
out <- num
fmt.Printf("%dth 寫go程,寫入:%d\n", idx, num)
time.Sleep(time.Millisecond * 300) // 放大實驗現象
rwMutex.Unlock()
}
}

func main() {
// 播種隨機數種子
rand.Seed(time.Now().UnixNano())

quit := make(chan bool)         // 用於 關閉主go程的channel
ch := make(chan int)            // 用於 資料傳遞的 channel

for i:=0; i<5; i++ {
    go readGo(ch, i+1)
}
for i:=0; i<5; i++ {
    go writeGo(ch,i+1)
}
for{
    ;
}

}

(2)應用條件變數的程式碼:
package main

import (
"fmt"
"time"
"math/rand"
"sync"
)
var cond sync.Cond // 定義全域性條件變數

func producer08(out chan<- int, idx int) {
for {
// 先加鎖
cond.L.Lock()
// 判斷緩衝區是否滿
for len(out) == 5 {
cond.Wait() // 1. 2. 3.
}
num := rand.Intn(800)
out <- num
fmt.Printf("生產者%dth,生產:%d\n", idx, num)
// 訪問公共區結束,並且列印結束,解鎖
cond.L.Unlock()
// 喚醒阻塞在條件變數上的 消費者
cond.Signal()
time.Sleep(time.Millisecond * 200)
}
}

func consumer08(in <-chan int, idx int) {
for {
// 先加鎖
cond.L.Lock()
// 判斷 緩衝區是否為空
for len(in) == 0 {
cond.Wait()
}
num := <-in
fmt.Printf("-----消費者%dth,消費:%d\n",idx, num)
// 訪問公共區結束後,解鎖
cond.L.Unlock()
// 喚醒 阻塞在條件變數上的 生產者
cond.Signal()
time.Sleep(time.Millisecond * 200)
}
}

func main() {
product := make(chan int, 5)
rand.Seed(time.Now().UnixNano())

quit := make(chan bool)

// 指定條件變數 使用的鎖
cond.L = new(sync.Mutex)                // 互斥鎖 初值 0 , 未加鎖狀態

for i:=0; i<5; i++ {
    go producer08(product, i+1)         // 1 生產者
}
for i:=0; i<5; i++ {
    go consumer08(product, i+1)         // 3 個消費者
}

/ for {
runtime.GC()
}
/
<-quit //主go程在此一直阻塞,相當於死迴圈,程式必須手動退出

}

相關文章