這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。歡迎來到 [Golang 系列教程](https://studygolang.com/subject/2)的第 23 篇。 ## 什麼是緩衝通道?在[上一教程](https://studygolang.com/articles/12402)裡,我們討論的主要是無緩衝通道。我們在[通道](https://studygolang.com/articles/12402)的教程裡詳細討論了,無緩衝通道的發送和接收過程是阻塞的。 我們還可以建立一個有緩衝(Buffer)的通道。只在緩衝已滿的情況,才會阻塞向緩衝通道(Buffered Channel)發送資料。同樣,只有在緩衝為空白的時候,才會阻塞從緩衝通道接收資料。 通過向 `make` 函數再傳遞一個表示容量的參數(指定緩衝的大小),可以建立緩衝通道。 ```goch := make(chan type, capacity) ```要讓一個通道有緩衝,上面文法中的 `capacity` 應該大於 0。無緩衝通道的容量預設為 0,因此我們在[上一教程](https://studygolang.com/articles/12402)建立通道時,省略了容量參數。 我們開始編寫代碼,建立一個緩衝通道。 ## 樣本一```gopackage mainimport ( "fmt")func main() { ch := make(chan string, 2)ch <- "naveen"ch <- "paul"fmt.Println(<- ch)fmt.Println(<- ch)}```[線上運行程式](https://play.golang.org/p/It-em11etK) 在上面程式裡的第 9 行,我們建立了一個緩衝通道,其容量為 2。由於該通道的容量為 2,因此可向它寫入兩個字串,而且不會發生阻塞。在第 10 行和第 11 行,我們向通道寫入兩個字串,該通道並沒有發生阻塞。我們又在第 12 行和第 13 行分別讀取了這兩個字串。該程式輸出: ```naveen paul ```## 樣本二我們再看一個緩衝通道的樣本,其中有一個並發的 Go 協程來向通道寫入資料,而 Go 主協程負責讀取資料。該樣本協助我們進一步理解,在向緩衝通道寫入資料時,什麼時候會發生阻塞。 ```gopackage mainimport ( "fmt""time")func write(ch chan int) { for i := 0; i < 5; i++ {ch <- ifmt.Println("successfully wrote", i, "to ch")}close(ch)}func main() { ch := make(chan int, 2)go write(ch)time.Sleep(2 * time.Second)for v := range ch {fmt.Println("read value", v,"from ch")time.Sleep(2 * time.Second)}}```[線上運行程式](https://play.golang.org/p/bKe5GdgMK9) 在上面的程式中,第 16 行在 Go 主協程中建立了容量為 2 的緩衝通道 `ch`,而第 17 行把 `ch` 傳遞給了 `write` 協程。接下來 Go 主協程休眠了兩秒。在這期間,`write` 協程在並發地運行。`write` 協程有一個 for 迴圈,依次向通道 `ch` 寫入 0~4。而緩衝通道的容量為 2,因此 `write` 協程裡立即會向 `ch` 寫入 0 和 1,接下來發生阻塞,直到 `ch` 內的值被讀取。因此,該程式立即列印出下面兩行: ```successfully wrote 0 to ch successfully wrote 1 to ch ```列印上面兩行之後,`write` 協程中向 `ch` 的寫入發生了阻塞,直到 `ch` 有值被讀取到。而 Go 主協程休眠了兩秒後,才開始讀取該通道,因此在休眠期間程式不會列印任何結果。主協程結束休眠後,在第 19 行使用 for range 迴圈,開始讀取通道 `ch`,列印出了讀取到的值後又休眠兩秒,這個迴圈一直到 `ch` 關閉才結束。所以該程式在兩秒後會列印下面兩行: ```read value 0 from ch successfully wrote 2 to ch ```該過程會一直進行,直到通道讀取完所有的值,並在 `write` 協程中關閉通道。最終輸出如下: ```successfully wrote 0 to ch successfully wrote 1 to ch read value 0 from ch successfully wrote 2 to ch read value 1 from ch successfully wrote 3 to ch read value 2 from ch successfully wrote 4 to ch read value 3 from ch read value 4 from ch ```## 死結```gopackage mainimport ( "fmt")func main() { ch := make(chan string, 2)ch <- "naveen"ch <- "paul"ch <- "steve"fmt.Println(<-ch)fmt.Println(<-ch)}```[線上運行程式](https://play.golang.org/p/FW-LHeH7oD) 在上面程式裡,我們向容量為 2 的緩衝通道寫入 3 個字串。當在程式控制到達第 3 次寫入時(第 11 行),由於它超出了通道的容量,因此這次寫入發生了阻塞。現在想要這次寫操作能夠進行下去,必須要有其它協程來讀取這個通道的資料。但在本例中,並沒有並發協程來讀取這個通道,因此這裡會發生**死結**(deadlock)。程式會在運行時觸發 panic,資訊如下: ```fatal error: all goroutines are asleep - deadlock!goroutine 1 [chan send]: main.main() /tmp/sandbox274756028/main.go:11 +0x100```## 長度 vs 容量緩衝通道的容量是指通道可以儲存的值的數量。我們在使用 `make` 函數建立緩衝通道的時候會指定容量大小。 緩衝通道的長度是指通道中當前排隊的元素個數。 代碼可以把一切解釋得很清楚。:) ```gopackage mainimport ( "fmt")func main() { ch := make(chan string, 3)ch <- "naveen"ch <- "paul"fmt.Println("capacity is", cap(ch))fmt.Println("length is", len(ch))fmt.Println("read value", <-ch)fmt.Println("new length is", len(ch))}```[線上運行程式](https://play.golang.org/p/2ggC64yyvr) 在上面的程式裡,我們建立了一個容量為 3 的通道,於是它可以儲存 3 個字串。接下來,我們分別在第 9 行和第 10 行向通道寫入了兩個字串。於是通道有兩個字串排隊,因此其長度為 2。在第 13 行,我們又從通道讀取了一個字串。現在該通道內只有一個字串,因此其長度變為 1。該程式會輸出: ```capacity is 3 length is 2 read value naveen new length is 1```## WaitGroup在本教程的下一節裡,我們會講到**工作池**(Worker Pools)。而 `WaitGroup` 用於實現工作池,因此要理解工作池,我們首先需要學習 `WaitGroup`。 `WaitGroup` 用於等待一批 Go 協程執行結束。程式控制會一直阻塞,直到這些協程全部執行完畢。假設我們有 3 個並發執行的 Go 協程(由 Go 主協程產生)。Go 主協程需要等待這 3 個協程執行結束後,才會終止。這就可以用 `WaitGroup` 來實現。 理論說完了,我們編寫點兒代碼吧。:) ```gopackage mainimport ( "fmt""sync""time")func process(i int, wg *sync.WaitGroup) { fmt.Println("started Goroutine ", i)time.Sleep(2 * time.Second)fmt.Printf("Goroutine %d ended\n", i)wg.Done()}func main() { no := 3var wg sync.WaitGroupfor i := 0; i < no; i++ {wg.Add(1)go process(i, &wg)}wg.Wait()fmt.Println("All go routines finished executing")}```[線上運行程式](https://play.golang.org/p/CZNtu8ktQh) [WaitGroup](https://golang.org/pkg/sync/#WaitGroup) 是一個結構體類型,我們在第 18 行建立了 `WaitGroup` 類型的變數,其初始值為零值。`WaitGroup` 使用計數器來工作。當我們調用 `WaitGroup` 的 `Add` 並傳遞一個 `int` 時,`WaitGroup` 的計數器會加上 `Add` 的傳參。要減少計數器,可以調用 `WaitGroup` 的 `Done()` 方法。`Wait()` 方法會阻塞調用它的 Go 協程,直到計數器變為 0 後才會停止阻塞。 上述程式裡,for 迴圈迭代了 3 次,我們在迴圈內調用了 `wg.Add(1)`(第 20 行)。因此計數器變為 3。for 迴圈同樣建立了 3 個 `process` 協程,然後在第 23 行調用了 `wg.Wait()`,確保 Go 主協程等待計數器變為 0。在第 13 行,`process` 協程內調用了 `wg.Done`,可以讓計數器遞減。一旦 3 個子協程都執行完畢(即 `wg.Done()` 調用了 3 次),那麼計數器就變為 0,於是主協程會解除阻塞。 **在第 21 行裡,傳遞 `wg` 的地址是很重要的。如果沒有傳遞 `wg` 的地址,那麼每個 Go 協程將會得到一個 `WaitGroup` 值的拷貝,因而當它們執行結束時,`main` 函數並不會知道**。 該程式輸出: ```started Goroutine 2 started Goroutine 0 started Goroutine 1 Goroutine 0 ended Goroutine 2 ended Goroutine 1 ended All go routines finished executing ```由於 Go 協程的執行順序不一定,因此你的輸出可能和我不一樣。:) ## 工作池的實現緩衝通道的重要應用之一就是實現[工作池](https://en.wikipedia.org/wiki/Thread_pool)。 一般而言,工作池就是一組等待任務分配的線程。一旦完成了所分配的任務,這些線程可繼續等待任務的分配。 我們會使用緩衝通道來實現工作池。我們工作池的任務是計算所輸入數位每一位的和。例如,如果輸入 234,結果會是 9(即 2 + 3 + 4)。向工作池輸入的是一列偽隨機數。 我們工作池的核心功能如下: - 建立一個 Go 協程池,監聽一個等待作業分配的輸入型緩衝通道。 - 將作業添加到該輸入型緩衝通道中。 - 作業完成後,再將結果寫入一個輸出型緩衝通道。 - 從輸出型緩衝通道讀取並列印結果。 我們會逐步編寫這個程式,讓代碼易於理解。 第一步就是建立一個結構體,表示作業和結果。 ```gotype Job struct { id intrandomno int}type Result struct { job Jobsumofdigits int}```所有 `Job` 結構體變數都會有 `id` 和 `randomno` 兩個欄位,`randomno` 用於計算其每位元之和。 而 `Result` 結構體有一個 `job` 欄位,表示所對應的作業,還有一個 `sumofdigits` 欄位,表示計算的結果(每位元字之和)。 第二步是分別建立用於接收作業和寫入結果的緩衝通道。 ```govar jobs = make(chan Job, 10) var results = make(chan Result, 10) ```工作協程(Worker Goroutine)會監聽緩衝通道 `jobs` 裡更新的作業。一旦工作協程完成了作業,其結果會寫入緩衝通道 `results`。如下所示,`digits` 函數的任務實際上就是計算整數的每一位之和,最後返回該結果。為了類比出 `digits` 在計算過程中花費了一段時間,我們在函數內添加了兩秒的休眠時間。 ```gofunc digits(number int) int { sum := 0no := numberfor no != 0 {digit := no % 10sum += digitno /= 10}time.Sleep(2 * time.Second)return sum}```然後,我們寫一個建立工作協程的函數。 ```gofunc worker(wg *sync.WaitGroup) { for job := range jobs {output := Result{job, digits(job.randomno)}results <- output}wg.Done()}```上面的函數建立了一個工作者(Worker),讀取 `jobs` 通道的資料,根據當前的 `job` 和 `digits` 函數的傳回值,建立了一個 `Result` 結構體變數,然後將結果寫入 `results` 緩衝通道。`worker` 函數接收了一個 `WaitGroup` 類型的 `wg` 作為參數,當所有的 `jobs` 完成的時候,調用了 `Done()` 方法。 `createWorkerPool` 函數建立了一個 Go 協程的工作池。 ```gofunc createWorkerPool(noOfWorkers int) { var wg sync.WaitGroupfor i := 0; i < noOfWorkers; i++ {wg.Add(1)go worker(&wg)}wg.Wait()close(results)}```上面函數的參數是需要建立的工作協程的數量。在建立 Go 協程之前,它調用了 `wg.Add(1)` 方法,於是 `WaitGroup` 計數器遞增。接下來,我們建立工作協程,並向 `worker` 函數傳遞 `wg` 的地址。建立了需要的工作協程後,函數調用 `wg.Wait()`,等待所有的 Go 協程執行完畢。所有協程完成執行之後,函數會關閉 `results` 通道。因為所有協程都已經執行完畢,於是不再需要向 `results` 通道寫入資料了。 現在我們已經有了工作池,我們繼續編寫一個函數,把作業分配給工作者。 ```gofunc allocate(noOfJobs int) { for i := 0; i < noOfJobs; i++ {randomno := rand.Intn(999)job := Job{i, randomno}jobs <- job}close(jobs)}```上面的 `allocate` 函數接收所需建立的作業數量作為輸入參數,產生了最大值為 998 的偽隨機數,並使用該隨機數建立了 `Job` 結構體變數。這個函數把 for 迴圈的計數器 `i` 作為 id,最後把建立的結構體變數寫入 `jobs` 通道。當寫入所有的 `job` 時,它關閉了 `jobs` 通道。 下一步是建立一個讀取 `results` 通道和列印輸出的函數。 ```gofunc result(done chan bool) { for result := range results {fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)}done <- true}````result` 函數讀取 `results` 通道,並列印出 `job` 的 `id`、輸入的隨機數、該隨機數的每位元之和。`result` 函數也接受 `done` 通道作為參數,當列印所有結果時,`done` 會被寫入 true。 現在一切準備充分了。我們繼續完成最後一步,在 `main()` 函數中調用上面所有的函數。 ```gofunc main() { startTime := time.Now()noOfJobs := 100go allocate(noOfJobs)done := make(chan bool)go result(done)noOfWorkers := 10createWorkerPool(noOfWorkers)<-doneendTime := time.Now()diff := endTime.Sub(startTime)fmt.Println("total time taken ", diff.Seconds(), "seconds")}```我們首先在 `main` 函數的第 2 行,儲存了程式的起始時間,並在最後一行(第 12 行)計算了 `endTime` 和 `startTime` 的差值,顯示出程式啟動並執行總時間。由於我們想要通過改變協程數量,來做一點基準指標(Benchmark),所以需要這麼做。 我們把 `noOfJobs` 設定為 100,接下來調用了 `allocate`,向 `jobs` 通道添加作業。 我們建立了 `done` 通道,並將其傳遞給 `result` 協程。於是該協程會開始列印結果,並在完成列印時發出通知。 通過調用 `createWorkerPool` 函數,我們最終建立了一個有 10 個協程的工作池。`main` 函數會監聽 `done` 通道的通知,等待所有結果列印結束。 為了便於參考,下面是整個程式。我還引用了必要的包。 ```gopackage mainimport ( "fmt""math/rand""sync""time")type Job struct { id intrandomno int}type Result struct { job Jobsumofdigits int}var jobs = make(chan Job, 10) var results = make(chan Result, 10)func digits(number int) int { sum := 0no := numberfor no != 0 {digit := no % 10sum += digitno /= 10}time.Sleep(2 * time.Second)return sum}func worker(wg *sync.WaitGroup) { for job := range jobs {output := Result{job, digits(job.randomno)}results <- output}wg.Done()}func createWorkerPool(noOfWorkers int) { var wg sync.WaitGroupfor i := 0; i < noOfWorkers; i++ {wg.Add(1)go worker(&wg)}wg.Wait()close(results)}func allocate(noOfJobs int) { for i := 0; i < noOfJobs; i++ {randomno := rand.Intn(999)job := Job{i, randomno}jobs <- job}close(jobs)}func result(done chan bool) { for result := range results {fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)}done <- true}func main() { startTime := time.Now()noOfJobs := 100go allocate(noOfJobs)done := make(chan bool)go result(done)noOfWorkers := 10createWorkerPool(noOfWorkers)<-doneendTime := time.Now()diff := endTime.Sub(startTime)fmt.Println("total time taken ", diff.Seconds(), "seconds")}```[線上運行程式](https://play.golang.org/p/au5islUIbx) 為了更精確地計算總時間,請在你的本地機器上運行該程式。 該程式輸出: ```Job id 1, input random no 636, sum of digits 15 Job id 0, input random no 878, sum of digits 23 Job id 9, input random no 150, sum of digits 6 ...total time taken 20.01081009 seconds ```程式總共會列印 100 行,對應著 100 項作業,然後最後會列印一行程式消耗的總時間。你的輸出會和我的不同,因為 Go 協程的運行順序不一定,同樣總時間也會因為硬體而不同。在我的例子中,運行程式大約花費了 20 秒。 現在我們把 `main` 函數裡的 `noOfWorkers` 增加到 20。我們把工作者的數量加倍了。由於工作協程增加了(準確說來是兩倍),因此程式花費的總時間會減少(準確說來是一半)。在我的例子裡,程式會列印出 10.004364685 秒。 ```...total time taken 10.004364685 seconds ```現在我們可以理解了,隨著工作協程數量增加,完成作業的總時間會減少。你們可以練習一下:在 `main` 函數裡修改 `noOfJobs` 和 `noOfWorkers` 的值,並試著去分析一下結果。 本教程到此結束。祝你愉快。 **上一教程 - [通道](https://studygolang.com/articles/12402)****下一教程 - [Select](https://studygolang.com/articles/12522)**
via: https://golangbot.com/buffered-channels-worker-pools/
作者:Nick Coghlan 譯者:Noluye 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
3902 次點擊 ∙ 1 贊