(翻譯)The Behavior Of Channels
本文章已經過作者 William Kennedy 同意翻譯,由作者經由推特分享。
Channel 的行為
當我第一次開始使用 Go 的 channel 來工作時,我誤把 channel 作為一個資料結構,我看過把 channel 作為 queue 來使用,提供 goroutine 之間的自動同步存取。這種結構的理解使我寫了很多不好而且複雜的 concurrent 程式碼。
我不斷的學習,我不去記住如何結構化 channel,而是專注在它們的行為。所以現在回到 channel 我思考一件事情:信號。一個 channel 允許一個 goroutine 對於一個特定的事件去通知另一個 goroutine。信號在你的 channel 中應該是一切核心。將 channel 作為一個信號機制的思考將讓你撰寫更良好的程式碼,並具有明確定義和更精確的行為。
要了解信號的工作原理,我們必須了解它的三個屬性:
- 交付保證(Guarantee Of Delivery)
- 狀態(State)
- 有無資料(With or Without Data)
這三個屬性共同圍繞在建立一個設計信號的原理。之後我會討論這些屬性,我將提供一些程式碼範例來示範信號與這些屬性的應用。
交付保證
交付保證是基於一個問題:「我是否需要保證透過一個特定的 goroutine 發送的信號已經被接收? 」
換句話說,在 Listing 1 的範例:
go func() {p := <-ch // 接收}()ch <- "paper" // 傳送
發送 goroutine 是否需要保證通過第 5 行的 channel 發送 paper
是否會在第 2 行的 goroutine 被接收?
根據這個問題的答案,你要了解這兩種類型的 channel:無緩衝和緩衝。每個 channel 提供一個不同行為的交付保證。
圖一:交付保證
交付保證是相當重要的,而且如果你不這麼想的話,我有很多知識要賣給你。當然,我只是開個玩笑,但是當你在生活中沒有保證的時候,你不會緊張嗎?
在撰寫 concurrency 程式時,無論如何,你都要強烈的了解到保證是非常重要的。隨著往下的繼續,你將會學習如何決定。
狀態
Channel 的行為直接受到目前狀態的影響。Channel 的狀態可以是 nil、open 或是 close。
Listing 2 示範如何宣告或放置一個 channel 到這三個狀態內:
// ** nil channel// A channel is in a nil state when it is declared to its zero valuevar 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)
狀態決定傳送和接收的操作行為。
信號透過一個 channel 傳送和接收。不要說成讀取和寫入,因為 channel 不是執行 I/O。
圖二:狀態
當一個 channel 是在一個 nil 狀態時,在 channel 嘗試任何傳送或是接收都會被 block。當一個 channel 是一個 open 信號可以被傳送和接收。當一個 channel 被放入到一個 close 的狀態,信號沒辦法再發送,但是它仍然可以接收信號。
這些狀態將提供你在遇到的不同情況時,所需要的不同行為。當在合併狀態與交付保證時,你可以開始分析你的設計選擇而導致的成本和效益。在很多情況下,你也可以透過閱讀程式碼快速發現錯誤,因為你了解 channel 的行為。
有無資料
最後一個信號屬性要考慮的是你需要或者是不需要傳送資料。
透過在 channel 執行傳送一個有資料的信號。
Listing 3
ch <- "paper"
當你的信號有資料時,通常是因為:
- 一個 goroutine 被要求啟動一個新的 task。
- 一個 goroutine 回報結果。
透過關閉 channel,你的信號不會接收到任何的資料。
Listing 4
close(ch)
當你的信號沒有資料時,通常是因為:
- 一個 goroutine 被告知要停止工作。
- 一個 goroutine 完成後回報並沒有任何結果。
- 一個 goroutine 回報已經完成處理並且關閉。
這些規則是有例外的,但這些是主要的情況,這是我們將這篇文章重點介紹的內容。
沒有帶資料的信號的優點是 goroutine 可以一次通知許多 goroutine。帶有資料的信號總是在 goroutine 之間互相交換資料。
帶有資料的信號
當你要使用帶有資料的信號時,這裡有 3 種 channel 設定選項,你可以根據你所需要保證的類型來選擇。
圖 3:帶有資料的信號
這 3 個 channel 選項分別是:無緩衝、緩衝 > 1、緩衝 = 1
保證
- 一個無緩衝的 channel 保證發送的信號已經被接收。
- 因為信號的接收發生在信號傳送完成之前。
- 一個無緩衝的 channel 保證發送的信號已經被接收。
無保證
- 一個大於 1 的緩衝 channel 無法保證發送的信號已經被接收。
- 因為傳送信號的發生在信號接收完成之前。
- 一個大於 1 的緩衝 channel 無法保證發送的信號已經被接收。
延遲保證
- 一個大小為 1 的緩衝 channel 給你一個延遲保證。它可以保證先前發送的信號已經被接收。
- 因為第一個信號的接收在第二個信號傳送完成之前發生。
- 一個大小為 1 的緩衝 channel 給你一個延遲保證。它可以保證先前發送的信號已經被接收。
緩衝的大小不能為隨意的亂數,它需要有明確的約束被計算過。在計算中沒有無窮大(infinity)這件事,無論是時間或是空間,一切都必須有一些明確的約束。
沒有資料的信號
沒有資料的信號主要是為了保留取消。它允許一個 goroutine 去通知其他 goroutine 取消它們正在做的事情。取消可以使用無緩衝和緩衝 channel 被實作來傳送,但是當沒有資料時使用緩衝 channel 來傳送是不太好的。
圖四:沒有資料的信號
內建的 close
函式被用於沒有資料的信號。在上方狀態部分解釋過,你在 channel 被關閉後仍然可以接收信號。事實上,在任何被關閉的 channel 上接收不會被 block,而且總是回傳接收操作。
在大部分的情況下,你想要使用標準函式庫的 context
package 來實作沒有資料的信號。context
package 底層使用一個無緩衝的 channel 來發送信號,而且內建的 close
函式會關閉沒有資料的信號。
如果你選擇使用你的 channel 來取消,而不是使用 context package,你的 channel 應該是 chan struct{}
的類型。它是一個 zero-space,用於說明 channel 只被用來發送信號的慣用方式。
場景
有了這些屬性,要更進一步的了解它們是如何工作的方式,讓我們透過執行一系列不同場景的程式碼。當我在閱讀和撰寫基於 channel 的程式碼,我喜歡把 goroutine 作為人來思考。這種形象化非常的有幫助,我將使用這些形象化作為以下場景的描述。
帶有資料的信號 - 保證 - 無緩衝的 Channel
當你需要知道發送的信號被是否被接收,會有兩種場景:等待 Task 和等待結果。
場景一 - 等待 Task
思考如果你是一位經理,你需要雇用一名新的員工。在這個場景,你想要你的新員工去執行一個 task,但是他們需要等待你準備完成。這是因為在開始之前,你需要給他一份文件。
Listing 5 https://play.golang.org/p/BnVEHRCcdh
func waitForTask() {ch := make(chan string)go func() {p := <-ch// 員工在這裡執行工作。// 員工完成工作後可以自由地離開。}()time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)ch <- "paper"}
在 Listing 5 的第 2 行,建立一個無緩衝且屬性為 string
的 channel,資料將會隨著信號被傳送。在第 4 行員工開始工作之前,告知被雇用的員工在第 5 行等待你的信號。第 5 行是 channel 的接收,因為員工在你將發送文件之前會被 block。一旦文件透過員工被接收到了,員工執行工作並在完成時可以隨意地離開。
你作為一個經理與你的新員工同時一起工作。所以當你在第 4 行雇用了員工,你在第 11 行做你需要做的事情來解除 block 並通知員工。請注意,準備這些需要發送的文件不知道需要多久的時間。
最後,你準備好通知員工。在第 14 行,你執行一個帶有資料的信號,資料是一份文件。由於正在使用無緩衝 channel,一旦你完成送出操作,你就可以保證該員工已經接收到文件。接收發生在傳送之前。
從技術上來說,在你的 channel 傳送操作完成的時候,你就會知道員工擁有文件。在兩個 channel 的操作之後,調度程序可以選擇執行任何所需的語句。透過你或者是員工執行的下一行程式碼是非確定性的。意思說,使用 Print 語句會混亂你對於事情發生的順序。
場景二 - 等待結果
在接下來的場景是相反的。這個時候你希望當新員工被雇用時,新員工立即去執行 task,而且你需要等待他們工作的結果。在你繼續之前,你需要等待員工把你需要的文件拿回來。
Listing 6 https://play.golang.org/p/VFAWHxIQTP
func waitForResult() {ch := make(chan string)go func() {time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)ch <- "paper"// 員工完成工作後可以自由地離開。}()p := <-ch}
在 Listing 6 的第二行,建立一個無緩衝且屬性為 string
的 channel,資料將會隨著信號被傳送。接著在第 4 行,員工被雇用後立刻工作。在第 4 行你雇用員工之後,你會發現你在第 12 行等候文件的報告。
一旦員工在第 5 行完成工作後,他在第 7 行透過 channel 傳送資料回傳結果。由於這是一個無緩衝的 channel,接收發生在傳送之前,員工保證你已經收到結果。一旦員工保證傳送完之後,他們可以自由地離開。在這個場景下,你不知道員工完成 task 需要多久的時間。
成本和效益
一個無緩衝的 channel 提供一個信號發送後被接收的保證。這非常好,但是這是有代價的。保證的成本就是未知的延遲。在等待 Task 的場景下,員工不知道你發送文件需要多久的時間。在等待結果的場景下,你不知道員工發送文件需要多久的時間。
在這兩個場景當中,這個未知的延遲時間是我們需要忍受的,因為需要保證信號的傳送和接收。
帶有資料的信號 - 沒有保證 - 緩衝 Channel > 1
當你不知道有多少信號訊要被傳送和接收,這兩個場景可以發揮作用:Fan Out 和 Drop。
一個緩衝 channel 有一個明確被定義的空間可以被用來儲存傳送的資料。所以你要決定你需要多少空間呢?以下是回答:
- 我有一個明確的工作量要完成嗎?
- 這裡有多少工作?
- 如果我的員工來不及完成,我可以放棄任何新的工作嗎?
- 有多少沒完成的工作在我的待辦清單?
- 如果我的程式意外終止,我能承擔什麼級別的風險?
- 任何在緩衝內等待的任何資料都會遺失。
如果這些問題對於你正在建立的模型沒有任何意義,使用任何大於 1 的緩衝 channel 可能是錯誤的。
場景一 - Fan Out
Fan out 模式允許你在一個 concurrency 的工作問題上拋出一個明確的員工數量。由於你每個 task 都有一位員工,你可以明確的知道你會收到幾份報告。你可以確保在你的 box 內所接收的報告的正確數量。對於你的員工來說這是有好處的,不需要等你給他們報告。但是,如果他們在同一時間或幾乎同時放入 box,他們每個人都需要輪流把報告放入 box 內。
再次想像你是一位經理,但是這個時候你雇用了一個團隊。你有一個獨立的 task,你想要每位員工去執行。在每個獨立員工完成了他們的任務時,他們需要把報告放在你的辦公桌上的 box 內。
Listing 7 https://play.golang.org/p/8HIt2sabs_
func fadeOut() {emps := 20ch := make(chan string, emps)for e := 0; e < emps; e++ {go func() {time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)ch <- "paper"}()}for emps > 0 {p := <-chemps--}}
在 Listing 7 的第 3 行,建立一個緩衝且屬性為 string
的 channel,資料將會隨著信號被傳送。在第 2 行宣告了 emps
變數,channel 由 20 個緩衝被建立。
在第 5 到 10 行,員工被雇用後立即馬上開始工作。你可能不知道每個員工在第 7 行需要多久工作時間。接著在第 8 行,員工傳送文件報告,但是這時候傳送不會 block 等待接收。
在程式碼第 12 行到 16 行是關於你收到的文件。這裡是你等待 20 位員工完成他們的工作後,傳送他們的報告。在第 12 行,你在一個無窮迴圈,在第 13 行,你被 block 在 channel 等待接收員工的報告。一旦接收到了報告,在第 14 行列印出報告並且 local counter 變數會遞減,說明一位員工已經完成了工作。由於每個員工都有自己的空間,所以在 channel 上的傳送只會與其他可能想要在同一時間發送報告的員工競爭。
場景二 - Drop
Drop 模式允許當你的員工(們)都處於忙碌狀態時丟棄工作。這麼做的好處是可以持續地接受來自客戶的工作,而且不會因為接受工作而造成時程的壓力和延遲。這裡的關鍵是你了解能力上限,你不會低估或是接受超過你能完成的工作量。通常集成測試或指標是幫助你識別此數字。
再次想像你是一位經理,你雇用一位員工去完成工作。你有一個獨立的 task,想要員工去執行它。當員工完成了他們的任務,你不會知道員工已經完成了。重要的是,你是否可以在 box 放置新的工作項目。如果你不能執行傳送的話,你就可以知道你的 box 內的工作是滿的,而且員工目前沒辦法再負荷多餘的工作。在這一點上,新工作需要被丟棄,所有工作才可以繼續下去。
Listing 8 https://play.golang.org/p/PhFUN5itiv
func selectDrop() {const cap = 5ch := make(chan string, cap)go func() {for p := range ch {fmt.Println("employee : received :", p)}}()const work = 20for w := 0; w < work; w++ {select {case ch <- "paper":fmt.Println("manager : send ack")default:fmt.Println("manager : drop")}}close(ch)}
在 Listing 8 的第 3 行,建立一個緩衝且屬性為 string
的 channel。在第 2 行宣告了 cap
常數變數,channel 由 5 個緩衝被建立。
在第 5 到 9 行,一位被雇用的員工去處理工作。for...range
被用來接收 channel。每次接受到的文件會在第 7 行被處理。
在第 11 行到 19 行你嘗試送出 20 份文件給你的員工。這個時候的 select
語句使用第 14 行的第一種 case
情況執行發送。如果傳送因為緩衝沒有更多的空間而被 block,default
在第 16 行會被 select
使用,透過執行第 17 行放棄發送。
最後在第 21 行,呼叫內建的 close
函式針對 channel 做關閉。一旦員工完成分配的工作,它們可以自由地離開。
成本和效益
大於 1 的緩衝 channel 不能保證發送的信號被接收。對於這種沒有保證的信號有個好處是,在兩個 goroutine 之間的溝通可以減少或是無延遲的。在 Fan Out 場景下,每個員工都有一個緩衝空間可以用來傳送報告。在 Drop 場景下,測量緩衝區的容量,如果容量滿了則丟棄接收到的工作,讓目前正在進行的工作可以繼續。
在這兩個選擇中,這種缺乏保證是我們必須要存在的,因為減少延遲更重要。最小的延遲要求不會對系統整體邏輯造成問題。
帶有資料的信號 - 延遲保證 - 緩衝 Channel = 1
在發送一個新的信號之前,必須要知道先前傳送的信號是否已經被接收。等待 Task 可以在這個場景下發揮作用。
場景一 - 等待 Task
在這個場景下,你的新員工身上的 task 都不只有一項。你一個接著一個給了他們許多的 task。然而,在他們開始新 task 之前,他們必須完成每個獨立的 task。由於它們一次只能處理一個任務,因此在工作切換之間可能存在延遲問題。如果可以減少延遲而且不失去保證,對員工在進行下個任務之前可能會有所幫助。
這是緩衝為 1 的 channel 的好處。如果一切都以你和員工之間的預期執行,你們都不需要等待對方。每次你傳送文件後,緩衝就會變空的。每次你的員工要完成更多的工作時,緩衝區已滿。它是完美對稱的工作流程。
最好的部分是在於:如果你在任何時候嘗試傳送文件時,因為緩衝滿了所以你無法傳送,你可以了解到你的員工可能有些狀況,所以你停止傳送文件。這是延遲保證的地方。當緩衝是空的,你執行傳送,可以保證你的員工可以接收到你最新發送的工作。如果你不能執行傳送,你可以確保他們也不會收到。
Listing 9 https://play.golang.org/p/4pcuKCcAK3
func waitForTasks() {ch := make(chan string, 1)go func() {for p := range ch {fmt.Println("employee : working :", p)}}()const work = 10for w := 0; w < work; w++ {ch <- "paper"}close(ch)}
在 Listing 9 的第 2 行,建立一個緩衝且屬性為 string
的 channel,資料將會隨著信號被傳送。在第 4 行到第 8 行,員工被雇用來處理工作。for..range
被 channel 用來接收資料。每次接收到文件時,會在第 6 行被處理。
在第 10 到第 13 行,你開始傳送 task 給員工。如果你的員工可以像發送一樣快速的執行,你和員工之間的延遲就會減少。每次發送執行都能執行成功,你可以保證你最後提交的工作正在被處理中。
最後在第 15 行,內建的 close
函式針對 channel 做關閉。一旦員工完成分配的工作,它們可以自由地離開。然而,在 for...range
終止之前,你最後送出的工作將會被接收(刷新)。
沒有資料的信號 - Context
在最後的場景中,你將看到如何使用 context
package 的 Context
來取消一個正在執行的 goroutine。這一切都是利用無緩衝的 channel 關閉執行不帶資料的信號。
最後一次想像你是一位經理,你雇用了一名員工去完成工作。這次你不願意等待員工需要更多的時間來完成工作。你正在離職的前夕,如果員工沒有及時完成工作,你不願意等待。
Listing 10 https://play.golang.org/p/6GQbN5Z7vC
func withTimeout() {duration := 50 * time.Millisecondctx, cancel := context.WithTimeout(context.Background(), duration)defer cancel()ch := make(chan string, 1)go func() {time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)ch <- "paper"}()select {case p := <-ch:fmt.Println("work complete", p)case <-ctx.Done():fmt.Println("moving on")}}
在 Listing 10 的第 2 行,duration
被宣告為員工完成 task 所需要的時間。duration
和 50 毫秒的延遲被用在第 4 行來建立一個 context.Context
。context
package 的 WithTimeout
函式回傳一個 Context
值和 cacel()
函式。
context
package 建立一個 goroutine,一旦 duration
時間到了,它將關閉與 Context
值關聯的無緩衝 channel。你負責呼叫 cancel
函式,無論事情如何發生。這將會清除由 Context
所建立的內容。cancel
函式可以多次被呼叫。
在第 5 行,一旦函式終止,cancel
函式被延遲執行。在第 7 行,一個大小 1 的緩衝 channel 被建立,被員工用來傳送他們工作的結果。在第 9 行到第 12 行,員工被雇用後,立即開始工作。你不知道員工完成工作要多少時間。
在第 14 行到第 20 行,你使用 select
語句來接收兩種 channel。在第 15 行的接收,你等待員工傳送他們的結果。在第 18 行的接收,context
package 信號在 50 毫秒後起了作用。無論你先收到哪個信號,都將被處理。
這個算法很重要的是使用緩衝大小為 1 的 channel。如果員工沒在時間內完成,你不會再向員工發出任何通知。從員工的角度來看,他們會在第 11 行傳送報告給你,但他們不知道你到底有沒有接收到。如果你使用一個無緩衝的 channel,員工將永遠會被 block 傳送報告給你。這會產生一個 goroutine leak。所以大小為 1 的緩衝 channel 被用來防止這件事的發生。
結論
信號的屬性圍繞著保證,當使用 channel(或 concurrency) 時,了解 channel 的狀態和傳送非常重要。他們將幫助並引導你實作你正在撰寫的 concurrency 程式碼和算法所需要的最佳行為。他們將幫助你找到 Bug 並察覺潛在不好的程式碼。
在這篇文章我分享了一些範例程式,示範信號在不同場景下的屬性。每個規則都有例外,但這些模式是開始的良好基礎。
如何有效地思考和使用 channel,複習這些大綱作為總結:
語言機制
- 使用 channel 來安排和協調 goroutine
- 專注於信號的屬性,並不要共享資料
- 信號資料的有無
- 詢問用於同步訪問共享狀態的用途
- 有些情況下,channel 可以很簡單,但最初的問題
- 無緩衝的 channel:
- 接收發生在傳送之前
- 效益:100% 保證信號會被接收
- 成本:在信號被接收時的未知延遲時間
- 可緩衝 channel:
- 傳送發生在接收之前
- 效益:減少與信號間的 block 延遲
- 成本:當信號被接收時,不能保證
- 緩衝區越大,保證就越少
- 緩衝(1)可以給你一個延遲發送保證
- 關閉 channel:
- 關閉發生在接收之前(類似可緩衝)
- 信號沒有資料
- 完美的信號取消和截止
- nil channel:
- 傳送和接收 block
- 關閉信號
- 適用於速率限制或短暫阻塞
設計哲學
- 如果 channel 上任何給定的發送都可能導致發送 goroutine block:
- 不允許使用大小超過 1 的可緩衝 channel
- 大小超過 1 的緩衝必須要有原因和測量
- 必須知道發送 goroutine block 時會發生什麼事
- 不允許使用大小超過 1 的可緩衝 channel
- 如果 channel 上任何給定的發送都不會導致發送 goroutine block:
- 你對於每個傳送都有明確的緩衝的數量
- Fan Out 模式
- 你可以測量最大容量的緩衝
- Drop 模式
- 你對於每個傳送都有明確的緩衝的數量
- 緩衝越少越好
- 當你考慮緩衝區時,不要考慮效能
- 緩衝可以幫助你減少與信號間的 block 延遲
- 將阻塞延遲降低到零並不一定代表有更好的吞吐量
- 如果一個緩衝區給你足夠的吞吐量,那麼保持它
- 詢問大於 1 的緩衝區並測量大小
- 找到可用的最小緩衝區,提供足夠的吞吐量