Go語言核心36講(Go語言實戰與應用五)--學習筆記

MingsonZheng發表於2021-11-16

27 | 條件變數sync.Cond (上)

前導內容:條件變數與互斥鎖

我們常常會把條件變數這個同步工具拿來與互斥鎖一起討論。實際上,條件變數是基於互斥鎖的,它必須有互斥鎖的支撐才能發揮作用。

條件變數並不是被用來保護臨界區和共享資源的,它是用於協調想要訪問共享資源的那些執行緒的。當共享資源的狀態發生變化時,它可以被用來通知被互斥鎖阻塞的執行緒。

比如說,我們兩個人在共同執行一項祕密任務,這需要在不直接聯絡和見面的前提下進行。我需要向一個信箱裡放置情報,你需要從這個信箱中獲取情報。這個信箱就相當於一個共享資源,而我們就分別是進行寫操作的執行緒和進行讀操作的執行緒。

如果我在放置的時候發現信箱裡還有未被取走的情報,那就不再放置,而先返回。另一方面,如果你在獲取的時候發現信箱裡沒有情報,那也只能先回去了。這就相當於寫的執行緒或讀的執行緒阻塞的情況。

雖然我們倆都有信箱的鑰匙,但是同一時刻只能有一個人插入鑰匙並開啟信箱,這就是鎖的作用了。更何況我們們倆是不能直接見面的,所以這個信箱本身就可以被視為一個臨界區。

儘管沒有協調好,我們們倆仍然要想方設法的完成任務啊。所以,如果信箱裡有情報,而你卻遲遲未取走,那我就需要每過一段時間帶著新情報去檢查一次,若發現信箱空了,我就需要及時地把新情報放到裡面。

另一方面,如果信箱裡一直沒有情報,那你也要每過一段時間去開啟看看,一旦有了情報就及時地取走。這麼做是可以的,但就是太危險了,很容易被敵人發現。

後來,我們又想了一個計策,各自僱傭了一個不起眼的小孩兒。如果早上七點有一個戴紅色帽子的小孩兒從你家樓下路過,那麼就意味著信箱裡有了新情報。另一邊,如果上午九點有一個戴藍色帽子的小孩兒從我家樓下路過,那就說明你已經從信箱中取走了情報。

這樣一來,我們們執行任務的隱蔽性高多了,並且效率的提升非常顯著。這兩個戴不同顏色帽子的小孩兒就相當於條件變數,在共享資源的狀態產生變化的時候,起到了通知的作用。

當然了,我們是在用 Go 語言編寫程式,而不是在執行什麼祕密任務。因此,條件變數在這裡的最大優勢就是在效率方面的提升。當共享資源的狀態不滿足條件的時候,想操作它的執行緒再也不用迴圈往復地做檢查了,只要等待通知就好了。

說到這裡,想考考你知道怎麼使用條件變數嗎?所以,我們今天的問題就是:條件變數怎樣與互斥鎖配合使用?

這道題的典型回答是:條件變數的初始化離不開互斥鎖,並且它的方法有的也是基於互斥鎖的。

條件變數提供的方法有三個:等待通知(wait)、單發通知(signal)和廣播通知(broadcast)。

我們在利用條件變數等待通知的時候,需要在它基於的那個互斥鎖保護下進行。而在進行單發通知或廣播通知的時候,卻是恰恰相反的,也就是說,需要在對應的互斥鎖解鎖之後再做這兩種操作。

問題解析

問題解析這個問題看起來很簡單,但其實可以基於它, 延伸出很多其他的問題。比如,每個方法的使用時機是什麼?又比如,每個方法執行的內部流程是怎樣的?

下面,我們一邊用程式碼實現前面那個例子,一邊討論條件變數的使用。

首先,我們先來建立如下幾個變數。

var mailbox uint8
var lock sync.RWMutex
sendCond := sync.NewCond(&lock)
recvCond := sync.NewCond(lock.RLocker())

變數mailbox代表信箱,是uint8型別的。 若它的值為0則表示信箱中沒有情報,而當它的值為1時則說明信箱中有情報。lock是一個型別為sync.RWMutex的變數,是一個讀寫鎖,也可以被視為信箱上的
那把鎖。

另外,基於這把鎖,我還建立了兩個代表條件變數的變數,名字分別叫sendCond和recvCond。 它們都是*sync.Cond型別的,同時也都是由sync.NewCond函式來初始化的。

與sync.Mutex型別和sync.RWMutex型別不同,sync.Cond型別並不是開箱即用的。我們只能利用sync.NewCond函式建立它的指標值。這個函式需要一個sync.Locker型別的引數值。

還記得嗎?我在前面說過,條件變數是基於互斥鎖的,它必須有互斥鎖的支撐才能夠起作用。因此,這裡的引數值是不可或缺的,它會參與到條件變數的方法實現當中。

sync.Locker其實是一個介面,在它的宣告中只包含了兩個方法定義,即:Lock()和Unlock()。sync.Mutex型別和sync.RWMutex型別都擁有Lock方法和Unlock方法,只不過它們都是指標方法。因此,這兩個型別的指標型別才是sync.Locker介面的實現型別。

我在為sendCond變數做初始化的時候,把基於lock變數的指標值傳給了sync.NewCond函式。

原因是,lock變數的Lock方法和Unlock方法分別用於對其中寫鎖的鎖定和解鎖,它們與sendCond變數的含義是對應的。sendCond是專門為放置情報而準備的條件變數,向信箱裡放置情報,可以被視為對共享資源的寫操作。

相應的,recvCond變數代表的是專門為獲取情報而準備的條件變數。 雖然獲取情報也會涉及對信箱狀態的改變,但是好在做這件事的人只會有你一個,而且我們也需要藉此瞭解一下,條件變數與讀寫鎖中的讀鎖的聯用方式。所以,在這裡,我們暫且把獲取情報看做是對共享資源的讀操作。

因此,為了初始化recvCond這個條件變數,我們需要的是lock變數中的讀鎖,並且還需要是sync.Locker型別的。

可是,lock變數中用於對讀鎖進行鎖定和解鎖的方法卻是RLock和RUnlock,它們與sync.Locker介面中定義的方法並不匹配。

好在sync.RWMutex型別的RLocker方法可以實現這一需求。我們只要在呼叫sync.NewCond函式時,傳入呼叫表示式lock.RLocker()的結果值,就可以使該函式返回符合要求的條件變數了。

為什麼說通過lock.RLocker()得來的值就是lock變數中的讀鎖呢?實際上,這個值所擁有的Lock方法和Unlock方法,在其內部會分別呼叫lock變數的RLock方法和RUnlock方法。也就是說,前兩個方法僅僅是後兩個方法的代理而已。

好了,我們現在有四個變數。一個是代表信箱的mailbox,一個是代表信箱上的鎖的lock。還有兩個是,代表了藍帽子小孩兒的sendCond,以及代表了紅帽子小孩兒的recvCond。

image

(互斥鎖與條件變數)

我,現在是一個 goroutine(攜帶的go函式),想要適時地向信箱裡放置情報並通知你,應該怎麼做呢?

lock.Lock()
for mailbox == 1 {
 sendCond.Wait()
}
mailbox = 1
lock.Unlock()
recvCond.Signal()

我肯定需要先呼叫lock變數的Lock方法。注意,這個Lock方法在這裡意味的是:持有信箱上的鎖,並且有開啟信箱的權利,而不是鎖上這個鎖。

然後,我要檢查mailbox變數的值是否等於1,也就是說,要看看信箱裡是不是還存有情報。如果還有情報,那麼我就回家去等藍帽子小孩兒了。

這就是那條for語句以及其中的呼叫表示式sendCond.Wait()所表示的含義了。你可能會問,為什麼這裡是for語句而不是if語句呢?我在後面會對此進行解釋的。

我們再往後看,如果信箱裡沒有情報,那麼我就把新情報放進去,關上信箱、鎖上鎖,然後離開。用程式碼表達出來就是mailbox = 1和lock.Unlock()。

離開之後我還要做一件事,那就是讓紅帽子小孩兒準時去你家樓下路過。也就是說,我會及時地通知你“信箱裡已經有新情報了”,我們呼叫recvCond的Signal方法就可以實現這一步驟。

另一方面,你現在是另一個 goroutine,想要適時地從信箱中獲取情報,然後通知我。

lock.RLock()
for mailbox == 0 {
 recvCond.Wait()
}
mailbox = 0
lock.RUnlock()
sendCond.Signal()

你跟我做的事情在流程上其實基本一致,只不過每一步操作的物件是不同的。你需要呼叫的是lock變數的RLock方法。因為你要進行的是讀操作,並且會使用recvCond變數作為輔助。recvCond與lock變數的讀鎖是對應的。

在開啟信箱後,你要關注的是信箱裡是不是沒有情報,也就是檢查mailbox變數的值是否等於0。

如果它確實等於0,那麼你就需要回家去等紅帽子小孩兒,也就是呼叫recvCond的Wait方法。這裡使用的依然是for語句。如果信箱裡有情報,那麼你就應該取走情報,關上信箱、鎖上鎖,然後離開。對應的程式碼是mailbox = 0和lock.RUnlock()。之後,你還需要讓藍帽子小孩兒準時去我家樓下路過。這樣我就知道信箱中的情報已經被你獲取了。

以上這些,就是對我們們倆要執行祕密任務的程式碼實現。其中的條件變數的用法需要你特別注意。

再強調一下,只要條件不滿足,我就會通過呼叫sendCond變數的Wait方法,去等待你的通知,只有在收到通知之後我才會再次檢查信箱。

另外,當我需要通知你的時候,我會呼叫recvCond變數的Signal方法。你使用這兩個條件變數的方式正好與我相反。你可能也看出來了,利用條件變數可以實現單向的通知,而雙向的通知則需要兩個條件變數。這也是條件變數的基本使用規則。

看到上述例子的全部實現程式碼

package main

import (
	"log"
	"sync"
	"time"
)

func main() {
	// mailbox 代表信箱。
	// 0代表信箱是空的,1代表信箱是滿的。
	var mailbox uint8
	// lock 代表信箱上的鎖。
	var lock sync.RWMutex
	// sendCond 代表專用於發信的條件變數。
	sendCond := sync.NewCond(&lock)
	// recvCond 代表專用於收信的條件變數。
	recvCond := sync.NewCond(lock.RLocker())

	// sign 用於傳遞演示完成的訊號。
	sign := make(chan struct{}, 3)
	max := 5
	go func(max int) { // 用於發信。
		defer func() {
			sign <- struct{}{}
		}()
		for i := 1; i <= max; i++ {
			time.Sleep(time.Millisecond * 500)
			lock.Lock()
			for mailbox == 1 {
				sendCond.Wait()
			}
			log.Printf("sender [%d]: the mailbox is empty.", i)
			mailbox = 1
			log.Printf("sender [%d]: the letter has been sent.", i)
			lock.Unlock()
			recvCond.Signal()
		}
	}(max)
	go func(max int) { // 用於收信。
		defer func() {
			sign <- struct{}{}
		}()
		for j := 1; j <= max; j++ {
			time.Sleep(time.Millisecond * 500)
			lock.RLock()
			for mailbox == 0 {
				recvCond.Wait()
			}
			log.Printf("receiver [%d]: the mailbox is full.", j)
			mailbox = 0
			log.Printf("receiver [%d]: the letter has been received.", j)
			lock.RUnlock()
			sendCond.Signal()
		}
	}(max)

	<-sign
	<-sign
}

總結

我們這兩期的文章會圍繞條件變數的內容展開,條件變數是基於互斥鎖的一種同步工具,它必須有互斥鎖的支撐才能發揮作用。 條件變數可以協調那些想要訪問共享資源的執行緒。當共享資源的狀態發生變化時,它可以被用來通知被互斥鎖阻塞的執行緒。我在文章舉了一個兩人訪問信箱的例子,並用程式碼實現了這個過程。

思考題

*sync.Cond型別的值可以被傳遞嗎?那sync.Cond型別的值呢?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章