(翻譯)The Behavior Of Channels

原文: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 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)

狀態決定傳送接收的操作行為。

信號透過一個 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 保證發送的信號已經被接收。
      • 因為信號的接收發生在信號傳送完成之前。
  • 無保證

    • 一個大於 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 OutDrop

一個緩衝 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 := 20
ch := 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 := <-ch
emps--
}
}

在 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 = 5
ch := make(chan string, cap)
go func() {
for p := range ch {
fmt.Println("employee : received :", p)
}
}()
const work = 20
for 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 = 10
for 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.Millisecond
ctx, 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.Contextcontext 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 時會發生什麼事
  • 如果 channel 上任何給定的發送都不會導致發送 goroutine block:
    • 你對於每個傳送都有明確的緩衝的數量
      • Fan Out 模式
    • 你可以測量最大容量的緩衝
      • Drop 模式
  • 緩衝越少越好
    • 當你考慮緩衝區時,不要考慮效能
    • 緩衝可以幫助你減少與信號間的 block 延遲
      • 將阻塞延遲降低到零並不一定代表有更好的吞吐量
      • 如果一個緩衝區給你足夠的吞吐量,那麼保持它
      • 詢問大於 1 的緩衝區並測量大小
      • 找到可用的最小緩衝區,提供足夠的吞吐量