天天看點

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

我們在上篇文章中講到了sync.WaitGroup類型:一個可以幫我們實作一對多 goroutine 協作流程的同步工具。

在使用WaitGroup值的時候,我們最好用“先統一Add,再并發Done,最後Wait”的标準模式來建構協作流程。

如果在調用該值的Wait方法的同時,為了增大其計數器的值,而并發地調用該值的Add方法,那麼就很可能會引發 panic。

這就帶來了一個問題,如果我們不能在一開始就确定執行子任務的 goroutine 的數量,那麼使用WaitGroup值來協調它們和分發子任務的 goroutine,就是有一定風險的。一個解決方案是:分批地啟用執行子任務的 goroutine。

我們都知道,WaitGroup值是可以被複用的,但需要保證其計數周期的完整性。尤其是涉及對其Wait方法調用的時候,它的下一個計數周期必須要等到,與目前計數周期對應的那個Wait方法調用完成之後,才能夠開始。

我在前面提到的可能會引發 panic 的情況,就是由于沒有遵循這條規則而導緻的。

隻要我們在嚴格遵循上述規則的前提下,分批地啟用執行子任務的 goroutine,就肯定不會有問題。具體的實作方式有不少,其中最簡單的方式就是使用for循環來作為輔助。這裡的代碼如下:

這裡展示的coordinateWithWaitGroup函數,就是上一篇文章中同名函數的改造版本。而其中調用的addNum函數,則是上一篇文章中同名函數的簡化版本。這兩個函數都已被放置在了 demo67.go 檔案中。

我們可以看到,經過改造後的coordinateWithWaitGroup函數,循環地使用了由變量wg代表的WaitGroup值。它運用的依然是“先統一Add,再并發Done,最後Wait”的這種模式,隻不過它利用for語句,對此進行了複用。

好了,至此你應該已經對WaitGroup值的運用有所了解了。不過,我現在想讓你使用另一種工具來實作上面的協作流程。

更具體地說,我需要你編寫一個名為coordinateWithContext的函數。這個函數應該具有上面coordinateWithWaitGroup函數相同的功能。

顯然,你不能再使用sync.WaitGroup了,而要用context包中的函數和Context類型作為實作工具。這裡注意一點,是否分批啟用執行子任務的 goroutine 其實并不重要。

我在這裡給你一個參考答案。

在這個函數體中,我先後調用了context.Background函數和context.WithCancel函數,并得到了一個可撤銷的context.Context類型的值(由變量cxt代表),以及一個context.CancelFunc類型的撤銷函數(由變量cancelFunc代表)。

在後面那條唯一的for語句中,我在每次疊代中都通過一條go語句,異步地調用addNum函數,調用的總次數隻依據了total變量的值。

請注意我給予addNum函數的最後一個參數值。它是一個匿名函數,其中隻包含了一條if語句。這條if語句會“原子地”加載num變量的值,并判斷它是否等于total變量的值。

如果兩個值相等,那麼就調用cancelFunc函數。其含義是,如果所有的addNum函數都執行完畢,那麼就立即通知分發子任務的 goroutine。

這裡分發子任務的 goroutine,即為執行coordinateWithContext函數的 goroutine。它在執行完for語句後,會立即調用cxt變量的Done函數,并試圖針對該函數傳回的通道,進行接收操作。

由于一旦cancelFunc函數被調用,針對該通道的接收操作就會馬上結束,是以,這樣做就可以實作“等待所有的addNum函數都執行完畢”的功能。

context.Context類型(以下簡稱Context類型)是在 Go 1.7 釋出時才被加入到标準庫的。而後,标準庫中的很多其他代碼包都為了支援它而進行了擴充,包括:os/exec包、net包、database/sql包,以及runtime/pprof包和runtime/trace包,等等。

Context類型之是以受到了标準庫中衆多代碼包的積極支援,主要是因為它是一種非常通用的同步工具。它的值不但可以被任意地擴散,而且還可以被用來傳遞額外的資訊和信号。

更具體地說,Context類型可以提供一類代表上下文的值。此類值是并發安全的,也就是說它可以被傳播給多個 goroutine。

由于Context類型實際上是一個接口類型,而context包中實作該接口的所有私有類型,都是基于某個資料類型的指針類型,是以,如此傳播并不會影響該類型值的功能和安全。

Context類型的值(以下簡稱Context值)是可以繁衍的,這意味着我們可以通過一個Context值産生出任意個子值。這些子值可以攜帶其父值的屬性和資料,也可以響應我們通過其父值傳達的信号。

正因為如此,所有的Context值共同構成了一顆代表了上下文全貌的樹形結構。這棵樹的樹根(或者稱上下文根節點)是一個已經在context包中預定義好的Context值,它是全局唯一的。通過調用context.Background函數,我們就可以擷取到它(我在coordinateWithContext函數中就是這麼做的)。

這裡注意一下,這個上下文根節點僅僅是一個最基本的支點,它不提供任何額外的功能。也就是說,它既不可以被撤銷(cancel),也不能攜帶任何資料。

除此之外,context包中還包含了四個用于繁衍Context值的函數,即:WithCancel、WithDeadline、WithTimeout和WithValue。

這些函數的第一個參數的類型都是context.Context,而名稱都為parent。顧名思義,這個位置上的參數對應的都是它們将會産生的Context值的父值。

WithCancel函數用于産生一個可撤銷的parent的子值。在coordinateWithContext函數中,我通過調用該函數,獲得了一個衍生自上下文根節點的Context值,和一個用于觸發撤銷信号的函數。

而WithDeadline函數和WithTimeout函數則都可以被用來産生一個會定時撤銷的parent的子值。至于WithValue函數,我們可以通過調用它,産生一個會攜帶額外資料的parent的子值。

到這裡,我們已經對context包中的函數和Context類型有了一個基本的認識了。不過這還不夠,我們再來擴充一下。

我相信很多初識context包的 Go 程式開發者,都會有這樣的疑問。确實,“可撤銷的”(cancelable)這個詞在這裡是比較抽象的,很容易讓人迷惑。我這裡再來解釋一下。

這需要從Context類型的聲明講起。這個接口中有兩個方法與“撤銷”息息相關。Done方法會傳回一個元素類型為struct{}的接收通道。不過,這個接收通道的用途并不是傳遞元素值,而是讓調用方去感覺“撤銷”目前Context值的那個信号。

一旦目前的Context值被撤銷,這裡的接收通道就會被立即關閉。我們都知道,對于一個未包含任何元素值的通道來說,它的關閉會使任何針對它的接收操作立即結束。

正因為如此,在coordinateWithContext函數中,基于調用表達式cxt.Done()的接收操作,才能夠起到感覺撤銷信号的作用。

除了讓Context值的使用方感覺到撤銷信号,讓它們得到“撤銷”的具體原因,有時也是很有必要的。後者即是Context類型的Err方法的作用。該方法的結果是error類型的,并且其值隻可能等于context.Canceled變量的值,或者context.DeadlineExceeded變量的值。

前者用于表示手動撤銷,而後者則代表:由于我們給定的過期時間已到,而導緻的撤銷。

你可能已經感覺到了,對于Context值來說,“撤銷”這個詞如果當名詞講,指的其實就是被用來表達“撤銷”狀态的信号;如果當動詞講,指的就是對撤銷信号的傳達;而“可撤銷的”指的則是具有傳達這種撤銷信号的能力。

我在前面講過,當我們通過調用context.WithCancel函數産生一個可撤銷的Context值時,還會獲得一個用于觸發撤銷信号的函數。

通過調用這個函數,我們就可以觸發針對這個Context值的撤銷信号。一旦觸發,撤銷信号就會立即被傳達給這個Context值,并由它的Done方法的結果值(一個接收通道)表達出來。

撤銷函數隻負責觸發信号,而對應的可撤銷的Context值也隻負責傳達信号,它們都不會去管後邊具體的“撤銷”操作。實際上,我們的代碼可以在感覺到撤銷信号之後,進行任意的操作,Context值對此并沒有任何的限制。

最後,若再深究的話,這裡的“撤銷”最原始的含義其實就是,終止程式針對某種請求(比如 HTTP 請求)的響應,或者取消對某種指令(比如 SQL 指令)的處理。這也是 Go 語言團隊在建立context代碼包,和Context類型時的初衷。

如果我們去檢視net包和database/sql包的 API 和源碼的話,就可以了解它們在這方面的典型應用。

我在前面講了,context包中包含了四個用于繁衍Context值的函數。其中的WithCancel、WithDeadline和WithTimeout都是被用來基于給定的Context值産生可撤銷的子值的。

context包的WithCancel函數在被調用後會産生兩個結果值。第一個結果值就是那個可撤銷的Context值,而第二個結果值則是用于觸發撤銷信号的函數。

在撤銷函數被調用之後,對應的Context值會先關閉它内部的接收通道,也就是它的Done方法會傳回的那個通道。

然後,它會向它的所有子值(或者說子節點)傳達撤銷信号。這些子值會如法炮制,把撤銷信号繼續傳播下去。最後,這個Context值會斷開它與其父值之間的關聯。

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

(在上下文樹中傳播撤銷信号)

我們通過調用context包的WithDeadline函數或者WithTimeout函數生成的Context值也是可撤銷的。它們不但可以被手動撤銷,還會依據在生成時被給定的過期時間,自動地進行定時撤銷。這裡定時撤銷的功能是借助它們内部的計時器來實作的。

當過期時間到達時,這兩種Context值的行為與Context值被手動撤銷時的行為是幾乎一緻的,隻不過前者會在最後停止并釋放掉其内部的計時器。

最後要注意,通過調用context.WithValue函數得到的Context值是不可撤銷的。撤銷信号在被傳播時,若遇到它們則會直接跨過,并試圖将信号直接傳給它們的子值。

既然談到了context包的WithValue函數,我們就來說說Context值攜帶資料的方式。

WithValue函數在産生新的Context值(以下簡稱含資料的Context值)的時候需要三個參數,即:父值、鍵和值。與“字典對于鍵的限制”類似,這裡鍵的類型必須是可判等的。

原因很簡單,當我們從中擷取資料的時候,它需要根據給定的鍵來查找對應的值。不過,這種Context值并不是用字典來存儲鍵和值的,後兩者隻是被簡單地存儲在前者的相應字段中而已。

Context類型的Value方法就是被用來擷取資料的。在我們調用含資料的Context值的Value方法時,它會先判斷給定的鍵,是否與目前值中存儲的鍵相等,如果相等就把該值中存儲的值直接傳回,否則就到其父值中繼續查找。

如果其父值中仍然未存儲相等的鍵,那麼該方法就會沿着上下文根節點的方向一路查找下去。

注意,除了含資料的Context值以外,其他幾種Context值都是無法攜帶資料的。是以,Context值的Value方法在沿路查找的時候,會直接跨過那幾種值。

如果我們調用的Value方法的所屬值本身就是不含資料的,那麼實際調用的就将會是其父輩或祖輩的Value方法。這是由于這幾種Context值的實際類型,都屬于結構體類型,并且它們都是通過“将其父值嵌入到自身”,來表達父子關系的。

最後,提醒一下,Context接口并沒有提供改變資料的方法。是以,在通常情況下,我們隻能通過在上下文樹中添加含資料的Context值來存儲新的資料,或者通過撤銷此種值的父值丢棄掉相應的資料。如果你存儲在這裡的資料可以從外部改變,那麼必須自行保證安全。

我們今天主要讨論的是context包中的函數和Context類型。該包中的函數都是用于産生新的Context類型值的。Context類型是一個可以幫助我們實作多 goroutine 協作流程的同步工具。不但如此,我們還可以通過此類型的值傳達撤銷信号或傳遞資料。

Context類型的實際值大體上分為三種,即:根Context值、可撤銷的Context值和含資料的Context值。所有的Context值共同構成了一顆上下文樹。這棵樹的作用域是全局的,而根Context值就是這棵樹的根。它是全局唯一的,并且不提供任何額外的功能。

可撤銷的Context值又分為:隻可手動撤銷的Context值,和可以定時撤銷的Context值。

我們可以通過生成它們時得到的撤銷函數來對其進行手動的撤銷。對于後者,定時撤銷的時間必須在生成時就完全确定,并且不能更改。不過,我們可以在過期時間達到之前,對其進行手動的撤銷。

一旦撤銷函數被調用,撤銷信号就會立即被傳達給對應的Context值,并由該值的Done方法傳回的接收通道表達出來。

“撤銷”這個操作是Context值能夠協調多個 goroutine 的關鍵所在。撤銷信号總是會沿着上下文樹葉子節點的方向傳播開來。

含資料的Context值可以攜帶資料。每個值都可以存儲一對鍵和值。在我們調用它的Value方法的時候,它會沿着上下文樹的根節點的方向逐個值的進行查找。如果發現相等的鍵,它就會立即傳回對應的值,否則将在最後傳回nil。

含資料的Context值不能被撤銷,而可撤銷的Context值又無法攜帶資料。但是,由于它們共同組成了一個有機的整體(即上下文樹),是以在功能上要比sync.WaitGroup強大得多。

今天的思考題是:Context值在傳達撤銷信号的時候是廣度優先的,還是深度優先的?其優勢和劣勢都是什麼?

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

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

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

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