這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
簡介
當我第一次使用 Go 的 channels 工作的時候,我犯了一個錯誤,把 channels 考慮為一個資料結構。我把 channels 看作為 goroutines 之間提供自動同步訪問的隊列。這種結構上的理解導致我寫了很多不好且結構複雜的並發代碼。
隨著時間的推移,我認識到最好的方式是忘記 channels 是資料結構,轉而關注它的行為。所以現在談論到 channels,我只考慮一件事情:signaling(訊號)。一個 channel 允許一個 goroutine 給另外一個發特定事件的訊號。訊號是使用 channel 做一切事情的核心。將 channel 看作是一種訊號機制,可以讓你寫出明確定義和精確行為的更好代碼。
為了理解訊號怎樣工作,我們必須理解以下三個特性:
這三個特性共同構成了圍繞訊號的設計哲學,在討論這些特性之後,我將提供一系列程式碼範例,這些樣本將示範使用這些屬性的訊號。
交付保證
交付保證基於一個問題:“我是否需要保證由特定的 goroutine 發送的訊號已經被接收?”
換句話說,我們可以給出清單1的樣本:
清單1
01 go func() {02 p := <-ch // Receive03 }()0405 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 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)
狀態決定了怎樣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。
緩衝大小絕對不能是一個隨機數字,它必須是為一些定義好的約束而計算出來的。在計算中沒有無窮大,無論是空間還是時間,所有的東西都必須要有良好的定義約束。
無資料訊號
無資料訊號主要用於取消,它允許一個 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)0304 go func() {05 p := <-ch0607 // Employee performs work here.0809 // Employee is done and free to go.10 }()1112 time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)1314 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)0304 go func() {05 time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)0607 ch <- "paper"0809 // Employee is done and free to go.10 }()1112 p := <-ch13 }
成本/收益
無緩衝 channel 提供了訊號被發送就會被接收的保證,這很好,但是沒有任何東西是沒有代價的。這個成本就是保證是未知的延遲。在等待任務情境中,員工不知道你要花費多長時間發送你的報告。在等待結果情境中,你不知道員工會花費多長時間把報告發送給你。
在以上兩個情境中,未知的延遲是我們必須面對的,因為它需要保證。沒有這種保證行為,邏輯就不會起作用。
有資料訊號 - 無保證 - 緩衝 Channels > 1
情境1 - 扇出(Fan Out)
扇出模式允許你拋出明確定義數量的員工在同時工作的問題上。由於你每個任務都有一個員工,你很明確的知道你會接收多少個報告。你可能需要確保你的盒子有適量的空間來接收所有的報告。這就是你員工的收益,不需要等待你來提交他們的報告。但是他們確實需要輪流把報告放進你的盒子,如果他們幾乎同一時間到達盒子。
再次假設你是經理,但是這次你僱傭一個團隊的員工,你有一個單獨的任務,你想每個員工都執行它。作為每個單獨的員工完成他們的任務,他們需要給你提供一張報告放進你桌子上的盒子裡面。
清單7
示範地址
01 func fanOut() {02 emps := 2003 ch := make(chan string, emps)0405 for e := 0; e < emps; e++ {06 go func() {07 time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)08 ch <- "paper"09 }()10 }1112 for emps > 0 {13 p := <-ch14 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 = 503 ch := make(chan string, cap)0405 go func() {06 for p := range ch {07 fmt.Println("employee : received :", p)08 }09 }()1011 const work = 2012 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 }2021 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)0304 go func() {05 for p := range ch {06 fmt.Println("employee : working :", p)07 }08 }()0910 const work = 1011 for w := 0; w < work; w++ {12 ch <- "paper"13 }1415 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.Millisecond0304 ctx, cancel := context.WithTimeout(context.Background(), duration)05 defer cancel()0607 ch := make(chan string, 1)0809 go func() {10 time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)11 ch <- "paper"12 }()1314 select {15 case p := <-ch:16 fmt.Println("work complete", p)1718 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:
語言機制
設計哲學