簡介
當我第一次使用 Go 的 channels 工作的時候,我犯了一個錯誤,把 channels 考慮為一個資料結構。我把 channels 看作為 goroutines 之間提供自動同步訪問的佇列。這種結構上的理解導致我寫了很多不好且結構複雜的併發程式碼。
隨著時間的推移,我認識到最好的方式是忘記 channels 是資料結構,轉而關注它的行為。所以現在談論到 channels,我只考慮一件事情:signaling(訊號)。一個 channel 允許一個 goroutine 給另外一個發特定事件的訊號。訊號是使用 channel 做一切事情的核心。將 channel 看作是一種訊號機制,可以讓你寫出明確定義和精確行為的更好程式碼。
為了理解訊號怎樣工作,我們必須理解以下三個特性:
- 交付保證
- 狀態
- 有資料或無資料
這三個特性共同構成了圍繞訊號的設計哲學,在討論這些特性之後,我將提供一系列程式碼示例,這些示例將演示使用這些屬性的訊號。
交付保證
交付保證基於一個問題:“我是否需要保證由特定的 goroutine 傳送的訊號已經被接收?”
換句話說,我們可以給出清單1的示例:
清單1
01 go func() {
02 p := <-ch // Receive
03 }()
04
05 ch <- "paper" // Send
複製程式碼
傳送的 goroutine 是否需要保證在第五行中傳送給 channel 的 paper,在繼續執行前, 會被第二行的 goroutine 接收。
基於這個問題的答案,你將知道使用兩種型別的 channels 中的哪種:無緩衝或有緩衝。每個channel圍繞交付保證提供不同的行為。
圖1
保證很重要,並且如果你不這樣認為,我有很多東西兜售給你。當然,我想開個玩笑,當你的生活沒有保障的時候你不會害怕嗎?在編寫併發程式碼時,對是否需要一項保證有很強的理解是至關重要的。隨著繼續,你將學會如何做決策。
狀態
一個 channel 的行為直接被它當前的狀態所影響。一個channel 的狀態是:nil,open 或 closed。
下面的清單2展示了怎樣宣告或把一個 channel放進這三個狀態。
清單2
// ** nil channel
// A channel is in a nil state when it is declared to its zero value
var ch chan string
// A channel can be placed in a nil state by explicitly setting it to nil.
ch = nil
// ** open channel
// A channel is in a open state when it’s made using the built-in function make.
ch := make(chan string)
// ** closed channel
// A channel is in a closed state when it’s closed using the built-in function close.
close(ch)
複製程式碼
狀態決定了怎樣send(傳送)和receive(接收)操作行為。
訊號通過一個 channel 傳送和接收。不要說讀和寫,因為 channels 不執行 I/O。
圖2
當一個 channel 是 nil 狀態,任何試圖在 channel 的傳送或接收都將會被阻塞。當一個 channel 是在 open 狀態,訊號可以被髮送和接收。當一個 channel 被置為 closed 狀態,訊號將不在被髮送,但是依然可以接收訊號。
這些狀態將在你遭遇不同的情況的時候可以提供不同的行為。當結合狀態和交付保證,作為你設計選擇的結果,你可以分析你承擔的成本/收益。你也可以僅僅通過讀程式碼快速發現錯誤,因為你懂得 channel 將表現出什麼行為。
有資料和無資料
最後的訊號特性需要考慮你是否需要訊號有資料或者無資料。
在一個 channel 中有資料的訊號被執行一個傳送。
清單3
01 ch <- "paper"
複製程式碼
當你的訊號有資料,它通常是因為:
- 一個 goroutine 被要求啟動一個新的 task。
- 一個 goroutine 傳達一個結果。
無資料訊號通過關閉一個 channel。
清單4
01 close(ch)
複製程式碼
當訊號沒有資料的時候,它通常是因為:
- 一個 goroutine 被告知停止它正在做的事情。
- 一個 goroutine 報告它們已經完成,沒有結果。
- 一個 goroutine 報告它已經完成處理並且關閉。
這些規則也有例外,但這些都是主要的用例,並且我們將在本文中重點討論這些問題。我認為這些規則例外的情況是最初的程式碼味道。
無資料訊號的一個好處是一個單獨的 goroutine 可以立刻給很多 goroutines 訊號。有資料的訊號通常是在 goroutines 之間一對一的交換資料。
有資料訊號
當你使用有資料訊號的時候,依賴於你需要保證的型別,有三個channel配置選項可以選擇。
圖3:有資料訊號
這三個 channel 選項是:Unbuffered, Buffered >1 或 Buffered =1。
-
有保證
-
一個無緩衝的channel給你保證被髮送的訊號已經被接收。
- 因為訊號接收發生在訊號傳送完成之前。
-
無保證
-
一個
size > 1
的有緩衝的 channel 不會保證傳送的訊號已經被接收。 -
因為訊號傳送發生在訊號接送完成之前。
-
延遲保證
-
一個
size = 1
的有緩衝 channel 提供延遲保證。它可以保證先前傳送的訊號已經被接收。 -
因為第一個接收訊號,發生在第二個完成的傳送訊號之前。
緩衝大小絕對不能是一個隨機數字,它必須是為一些定義好的約束而計算出來的。在計算中沒有無窮大,無論是空間還是時間,所有的東西都必須要有良好的定義約束。
無資料訊號
無資料訊號主要用於取消,它允許一個 goroutine 傳送訊號給另外一個來取消它們正在做的事情。取消可以被有緩衝和無緩衝的channels實現,但是在沒有資料傳送的情況下使用緩衝 channel 會更好。
圖4:無資料訊號
內建的函式 close
被用於無資料訊號。正如上面狀態章節所解釋的那樣,你依然可以在channel關閉的時候接收訊號。實際上,在一個關閉的channel上的任何接收都不會被阻塞,並且接收操作將一直返回。
在大多數情況下,你想使用標準的庫 context
包來實現無資料訊號。context
包使用一個無緩衝channel傳遞訊號以及內建函式close
傳送無資料訊號。
如果你選擇使用你自己的 channel 而不是 context
包來取消,你的channel 應該是chan struct{}
型別,這是一種零空間的慣用方式,用來表示一個訊號僅僅用於訊號傳遞。
場景
有了這些特性,更進一步理解它們在實踐中怎樣工作的最好方式就是執行一系列的程式碼場景。當我在讀寫 channel 基礎程式碼的時候,我喜歡把goroutines想像成人。這個形象對我非常有幫助,我將把它用作下面的輔助工具。
有資料訊號 – 保證 – 無緩衝 Channels
當你需要知道一個被髮送的訊號已經被接收的時候,有兩種情況需要考慮。它們是 等待任務和等待結果。
場景1 – 等待任務
考慮一下作為一名經理,需要僱傭一名新員工。在本場景中,你想你的新員工執行一個任務,但是他們需要等待直到你準備好。這是因為在他們開始前你需要遞給他們一份報告。
清單5
01 func waitForTask() {
02 ch := make(chan string)
03
04 go func() {
05 p := <-ch
06
07 // Employee performs work here.
08
09 // Employee is done and free to go.
10 }()
11
12 time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
13
14 ch <- "paper"
15 }
複製程式碼
在清單5的第2行,一個帶有屬性的無緩衝channel被建立,string
資料將與訊號一起被髮送。在第4行,一名員工被僱傭並在開始工作前,被告訴等待你的訊號【在第5行】。第5行是一個 channel 接收,引起員工阻塞直到等到你傳送的報告。一旦報告被員工接收,員工將執行工作並在完成的時候可以離開。
你作為經理正在併發的與你的員工工作。因此在第4行你僱傭員工之後,你發現你自己需要做什麼來解鎖並且發訊號給員工(第12行)。值得注意的是,不知道要花費多長的時間來準備這份報告(paper)。
最終你準備好給員工發訊號,在第14行,你執行一個有資料訊號,資料就是那份報告。由於一個無緩衝的channel被使用,你得到一個保證就是一旦你操作完成,員工就已經接收到了這份報告。接收發生在傳送之前。
技術上你所知道的一切就是在你的channel傳送操作完成的同時員工接收到了這份報告。在兩個channel操作之後,排程器可以選擇執行它想要執行的任何語句。下一行被執行的程式碼是被你還是員工是不確定的。這意味著使用print語句會欺騙你關於事件的執行順序。
場景2 – 等待結果
在下一個場景中,事情是相反的。這時你想你的員工一被僱傭就立即執行他們的任務。然後你需要等待他們工作的結果。你需要等待是因為在你繼續前你需要他們發來的報告。
清單6
線上演示地址
01 func waitForResult() {
02 ch := make(chan string)
03
04 go func() {
05 time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
06
07 ch <- "paper"
08
09 // Employee is done and free to go.
10 }()
11
12 p := <-ch
13 }
複製程式碼
成本/收益
無緩衝 channel 提供了訊號被髮送就會被接收的保證,這很好,但是沒有任何東西是沒有代價的。這個成本就是保證是未知的延遲。在等待任務場景中,員工不知道你要花費多長時間傳送你的報告。在等待結果場景中,你不知道員工會花費多長時間把報告傳送給你。
在以上兩個場景中,未知的延遲是我們必須面對的,因為它需要保證。沒有這種保證行為,邏輯就不會起作用。
有資料訊號 – 無保證 – 緩衝 Channels > 1
場景1 – 扇出(Fan Out)
扇出模式允許你丟擲明確定義數量的員工在同時工作的問題上。由於你每個任務都有一個員工,你很明確的知道你會接收多少個報告。你可能需要確保你的盒子有適量的空間來接收所有的報告。這就是你員工的收益,不需要等待你來提交他們的報告。但是他們確實需要輪流把報告放進你的盒子,如果他們幾乎同一時間到達盒子。
再次假設你是經理,但是這次你僱傭一個團隊的員工,你有一個單獨的任務,你想每個員工都執行它。作為每個單獨的員工完成他們的任務,他們需要給你提供一張報告放進你桌子上的盒子裡面。
清單7
演示地址
01 func fanOut() {
02 emps := 20
03 ch := make(chan string, emps)
04
05 for e := 0; e < emps; e++ {
06 go func() {
07 time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
08 ch <- "paper"
09 }()
10 }
11
12 for emps > 0 {
13 p := <-ch
14 fmt.Println(p)
15 emps--
16 }
17 }
複製程式碼
在清單7的第3行,一個帶有屬性的有緩衝channel被建立,string
資料將與訊號一起被髮送。這時,由於在第2行宣告的 emps
變數,將建立有 20個緩衝的 channel。
在第5行和第10行之間,20 個員工被僱傭,並且他們立即開始工作。在第7行你不知道每個員工將花費多長時間。這時在第8行,員工傳送他們的報告,但這一次傳送不會阻塞等待接收。因為在盒子裡為每位員工準備的空間,在 channel 上的傳送僅僅與其他在同一時間想傳送他們報告的員工競爭。
在 12 行和16行之間的程式碼全部是你的操作。在這裡你等待20個員工來完成他們的工作並且傳送報告。在12行,你在一個迴圈中,在 13 行你被阻塞在一個 channel 等待接收你的報告。一旦報告接收完成,報告在14被列印,並且本地的計數器變數被消耗來表明一個員工意見完成了他的工作。
場景2 – Drop
Drop模式允許你在你的員工在滿負荷的時候丟掉工作。這有利於繼續接受客戶端的工作,並且從不施加壓力或者是這項工作可接受的延遲。這裡的關鍵是知道你什麼時候是滿負荷的,因此你不承擔或過度承諾你將嘗試完成的工作量。通常整合測試或度量可以幫助你確定這個數字。
假設你是經理,你僱傭了單個員工來完成工作。你有一個單獨的任務想員工去執行。當員工完成他們任務時,你不在乎知道他們已經完成了。最重要的是你能或不能把新工作放入盒子。如果你不能執行傳送,這時你知道你的盒子滿了並且員工是滿負荷的。這時候,新工作需要丟棄以便讓事情繼續進行。
清單8
演示地址
01 func selectDrop() {
02 const cap = 5
03 ch := make(chan string, cap)
04
05 go func() {
06 for p := range ch {
07 fmt.Println("employee : received :", p)
08 }
09 }()
10
11 const work = 20
12 for w := 0; w < work; w++ {
13 select {
14 case ch <- "paper":
15 fmt.Println("manager : send ack")
16 default:
17 fmt.Println("manager : drop")
18 }
19 }
20
21 close(ch)
22 }
複製程式碼
在清單8的第3行,一個有屬性的有緩衝 channel 被建立,string
資料將與訊號一起被髮送。由於在第2行宣告的cap
常量,這時建立了有5個緩衝的 channel。
從第5行到第9行,一個單獨的員工被僱傭來處理工作,一個 for range
被用於迴圈處理 channel 的接收。每次一份報告被接收,在第7行被處理。
在第11行和19行之間,你嘗試傳送20分報告給你的員工。這時一個 select
語句在第14行的第一個case
被用於執行傳送。因為default
從句被用於第16行的select
語句。如果傳送被堵塞,是因為緩衝中沒有多餘的空間,通過執行第17行傳送被丟棄。
最後在第21行,內建函式close
被呼叫來關閉channel。這將傳送沒有資料的訊號給員工表明他們已經完成,並且一旦他們完成分派給他們的工作可以立即離開。
成本/收益
有緩衝的 channel 緩衝大於1提供無保證傳送的訊號被接收到。離開保證是有好處的,在兩個goroutine之間通訊可以降低或者是沒有延遲。在扇出場景,這有一個有緩衝的空間用於存放員工將被髮送的報告。在Drop場景,緩衝是測量能力的,如果容量滿,工作被丟棄以便工作繼續。
在兩個選擇中,這種缺乏保證是我們必須面對的,因為延遲降低非常重要。0到最小延遲的要求不會給系統的整體邏輯造成問題。
有資料訊號 – 延遲保證- 緩衝1的channel
場景1 – 等待任務
清單9
演示地址
01 func waitForTasks() {
02 ch := make(chan string, 1)
03
04 go func() {
05 for p := range ch {
06 fmt.Println("employee : working :", p)
07 }
08 }()
09
10 const work = 10
11 for w := 0; w < work; w++ {
12 ch <- "paper"
13 }
14
15 close(ch)
16 }
複製程式碼
在清單9的第2行,一個帶有屬性的一個緩衝大小的 channel 被建立,string
資料將與訊號一起被髮送。在第4行和第8行之間,一個員工被僱傭來處理工作。for range
被用於迴圈處理 channel 的接收。在第6行每次一份報告被接收就被處理。
在第10行和13行之間,你開始傳送你的任務給員工。如果你的員工可以跑的和你傳送的一樣快,你們之間的延遲會降低。但是每次傳送你成功執行,你需要保證你提交的最後一份工作正在被進行。
在最後的第15行,內建函式close
被呼叫關閉channel,這將會傳送無資料訊號給員工告知他們工作已經完成,可以離開了。儘管如此,你提交的最後一份工作將在 for range
中斷前被接收。
無資料訊號 – Context
在最後這個場景中,你將看到從 Context
包中使用 Context
值怎樣取消一個正在執行的goroutine。這所有的工作是通過改變一個已經關閉的無緩衝channel來執行一個無資料訊號。
最後一次你是經理,你僱傭了一個單獨的員工來完成工作,這次你不會等待員工未知的時間完成他的工作。你分配了一個截止時間,如果你的員工沒有按時完成工作,你將不會等待。
清單10
演示地址
01 func withTimeout() {
02 duration := 50 * time.Millisecond
03
04 ctx, cancel := context.WithTimeout(context.Background(), duration)
05 defer cancel()
06
07 ch := make(chan string, 1)
08
09 go func() {
10 time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
11 ch <- "paper"
12 }()
13
14 select {
15 case p := <-ch:
16 fmt.Println("work complete", p)
17
18 case <-ctx.Done():
19 fmt.Println("moving on")
20 }
21 }
複製程式碼
在清單10的第2行,一個時間值被宣告,它代表了員工將花費多長時間完成他們的工作。這個值被用在第4行來建立一個50毫秒超時的 context.Context
值。context
包的WithTimeout
函式返回一個 Context
值和一個取消函式。
context
包建立一個goroutine,一旦時間值到期,將關閉與Context
值關聯的無緩衝channels。不管事情如何發生,你需要負責呼叫cancel
函式。這將清理被Context
建立的東西。cancel
被呼叫不止一次是可以的。
在第5行,一旦函式中斷,cancel
函式被 deferred 執行。在第7行,1個緩衝的channels被建立,它被用於被員工傳送他們工作的結果給你。在第09行和12行,員工被僱傭兵立即投入工作,你不需要指定員工花費多長時間完成他們的工作。
在第14行和20行之間,你使用 select
語句來在兩個channels接收。在第15行的接收,你等待員工傳送他們的結果。在第18行的接收,你等待看context
包是否正在傳送訊號50毫秒的時間到了。無論你首先收到哪個訊號,都將有一個被處理。
這個演算法的一個重要方面是使用一個緩衝的channels。如果員工沒有按時完成,你將離開而不會給員工任何通知。對於員工而言,在第11行他將一直髮送他的報告,你在或者不在那裡接收,他都是盲目的。如果你使用一個無緩衝channels,如果你離開,員工將一直阻塞在那嘗試你給傳送報告。這會引起goroutine洩漏。因此一個緩衝的channels用來防止這個問題發生。
總結
當使用 channels(或併發) 時,在保證,channel狀態和傳送過程中訊號屬性是非常重要的。它們將幫助你實現你併發程式需要的更好的行為以及你寫的演算法。它們將幫助你找出bug和聞出潛在的壞程式碼。
在本文中,我分享了一些程式示例來展示訊號屬性工作在不同的場景中。凡事都有例外,但是這些模式是非常良好的開端。
作為總結回顧下這些要點,何時,如何有效地思考和使用channels:
語言機制
-
使用 channels 來編排和協作 goroutines:
- 關注訊號屬性而不是資料共享
- 有資料訊號和無資料訊號
- 詢問它們用於同步訪問共享資料的用途
- 有些情況下,對於這個問題,通道可以更簡單一些,但是最初的問題是。
-
無緩衝 channels:
- 接收發生在傳送之前
- 收益:100%保證訊號被接收
- 成本:未知的延遲,不知道訊號什麼時候將被接收。
-
有緩衝 channels:
- 傳送發生在接收之前。
- 收益:降低訊號之間的阻塞延遲。
- 成本:不保證訊號什麼時候被接收。
- 緩衝越大,保證越少。
- 緩衝為1可以給你一個延遲傳送保證。
-
關閉的 channels:
- 關閉發生在接收之前(像緩衝)。
- 無資料訊號。
- 完美的訊號取消或截止。
-
nil channels:
- 傳送和接收都阻塞。
- 關閉訊號。
- 完美的速度限制或短時停工。
設計哲學
-
如果在 channel上任何給定的傳送能引起傳送 goroutine 阻塞:
- 不允許使用大於1的緩衝channels。
- 緩衝大於1必須有原因/測量。
- 必須知道當傳送 goroutine阻塞的時候發生了什麼。
- 不允許使用大於1的緩衝channels。
-
如果在 channel 上任何給定的傳送不會引起傳送阻塞:
- 每個傳送必須有確切的緩衝數字。
- 扇出模式。
- 有緩衝測量最大的容量。
- Drop 模式。
- 每個傳送必須有確切的緩衝數字。
-
對於緩衝而言,少即是多。
- 當考慮緩衝的時候,不要考慮效能。
- 緩衝可以幫助降低訊號之間的阻塞延遲。
- 降低阻塞延遲到0並不一定意味著更好的吞吐量。
- 如果一個緩衝可以給你足夠的吞吐量,那就保持它。
- 緩衝大於1的問題需要測量大小。
- 儘可能找到提供足夠吞吐量的最小緩衝