- 原文地址:Part 23: Buffered Channels and Worker Pools
- 原文作者:Naveen R
- 譯者:咔嘰咔嘰 轉載請註明出處。
什麼是緩衝channel
我們在上一個教程中討論的所有channel
基本上都是無緩衝的。正如我們在channel
教程中詳細討論的那樣,傳送和接收到無緩衝的channel
都是阻塞的。
可以使用緩衝區建立channel
。僅當緩衝區已滿時才會阻塞對緩衝channel
的傳送。類似地,僅當緩衝區為空時才阻塞從緩衝channel
接收。
可以通過新增一個capacity
引數傳遞給make
函式來建立緩衝channel
,該函式指定緩衝區的大小。
ch := make(chan type, capacity)
複製程式碼
對於具有緩衝區的channel
,上述語法中的容量應大於 0。預設情況下,無緩衝通道的容量為 0,因此在上一個教程中建立通道時省略了容量引數。
我們來建立一個緩衝channel
,
package main
import (
"fmt"
)
func main() {
ch := make(chan string, 2)
ch <- "naveen"
ch <- "paul"
fmt.Println(<- ch)
fmt.Println(<- ch)
}
複製程式碼
在上面的程式中,第 9 行我們建立一個容量為 2 的緩衝channel
。由於channel
的容量為 2,因此可以將 2 個字串寫入而不會被阻塞。我們在第 10 和 11 行寫入 2 個字串,隨後讀取了寫入的字串並列印,
naveen
paul
複製程式碼
另一個例子
讓我們再看一個緩衝channel
的例子,其中channel
的值寫入Goroutine
並從main Goroutine
讀取。這個例子將幫助我們更好地理解何時寫入緩衝的channel
。
package main
import (
"fmt"
"time"
)
func write(ch chan int) {
for i := 0; i < 5; i++ {
ch <- i
fmt.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)
}
}
複製程式碼
上面的程式中,在第 16 行建立了容量為 2 的緩衝channel ch
。main Goroutine
將ch
傳給write Goroutine
,然後main Goroutine
休眠 2 秒鐘。在此期間,write Goroutine
在執行。write Goroutine
有一個 for 迴圈,它將 0 到 4 的數字迴圈寫入channel ch
。由於容量為 2,因此能夠將值 0 和 1 寫入,然後阻塞直到從channel ch
讀取至少一個值。所以這個程式會立即列印以下 2 行,
successfully wrote 0 to ch
successfully wrote 1 to ch
複製程式碼
在列印上述兩行之後,write Goroutine
中的寫入被阻塞,直到channel ch
的資料被讀取。由於main Goroutine
會休眠 2 秒,因此程式在接下來的 2 秒內不會列印任何內容。當main Goroutine
在被喚醒後,使用for range
迴圈開始從channel ch
讀取並列印讀取值,然後再次休眠 2 秒,此迴圈繼續,直到 ch 關閉。因此程式將在 2 秒後列印以下行,
read value 0 from ch
successfully wrote 2 to ch
複製程式碼
然後繼續直到所有值都寫入並在關閉 channel
。最終的輸出是,
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
複製程式碼
死鎖
package main
import (
"fmt"
)
func main() {
ch := make(chan string, 2)
ch <- "naveen"
ch <- "paul"
ch <- "steve"
fmt.Println(<-ch)
fmt.Println(<-ch)
}
複製程式碼
Run in playgroud
在上面的程式中,我們將 3 個字串寫入容量為 2 的緩衝channel
。當第三個字串寫入的時候已超過其容量,因此寫入操作被阻塞。現在必須等待其他Goroutine
從channel
讀取資料才能繼續寫入,但在上述程式碼中並沒有從該channel
讀取資料的Goroutine
。因此會出現死鎖,程式將在執行時列印以下內容,
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/sandbox274756028/main.go:11 +0x100
複製程式碼
長度 VS 容量
容量是channel
可以容納的值的數量。這是我們使用make
函式建立時指定的值。
長度是當前在channel
中的元素數量。
一個程式會讓理解變得簡單?
package main
import (
"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))
}
複製程式碼
在上面的程式中,建立的channel
容量為 3,即它可以容納 3 個字串。然後我們分別寫入 2 個字串,現在該channel
有 2 個字串,因此其長度為 2。 我們從channel
中讀取一個字串。現在channel
只有一個字串了,因此它的長度變為 1。這個程式將列印,
capacity is 3
length is 2
read value naveen
new length is 1
複製程式碼
WaitGroup
本教程的下一部分是關於Worker Pools
。要了解工作池,我們首先需要了解WaitGroup
,因為它將用於工作池的實現。
WaitGroup
用於阻塞main Goroutines
直到所有Goroutines
完成執行。比如說我們有 3 個從main Goroutine
生成的Goroutines
需要併發執行。main Goroutines
需要等待其他 3 個Goroutines
完成才能終止,否則可能在main Goroutines
終止時,其餘的Goroutines
還沒能得當執行,這種場景下可以使用WaitGroup
來完成。
停止理論上程式碼?
package main
import (
"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 := 3
var wg sync.WaitGroup
for i := 0; i < no; i++ {
wg.Add(1)
go process(i, &wg)
}
wg.Wait()
fmt.Println("All go routines finished executing")
}
複製程式碼
WaitGroup
是一種結構型別,我們在第 18 行建立一個WaitGroup
型別的空值變數。 WaitGroup
的工作方式是使用計數器。當我們在WaitGroup
上呼叫int
型引數呼叫Add
方法,計數器會增加傳遞給Add
的值。遞減計數器的方法是在WaitGroup上
呼叫Done
方法。 Wait
方法阻塞呼叫它的Goroutine
,直到計數器變為零。
在上面的程式中,我們在第 20 行呼叫wg.Add(1)
迴圈迭代 3 次。所以計數器的值現在變成了 3。 for 迴圈也產生 3 個Goroutines
,main Goroutines
在第 23 行呼叫了wg.Wait()
以阻塞直到計數器變為零。在Goroutine
中,通過呼叫wg.Done
來減少計數器的值。 一旦所有 3 個生成的Goroutines
完成執行,也就是wg.Done()
被呼叫三次,計數器被清零,main Goroutine
被解除阻塞,程式執行完成,輸出,
started Goroutine 2
started Goroutine 0
started Goroutine 1
Goroutine 0 ended
Goroutine 2 ended
Goroutine 1 ended
All go routines finished executing
複製程式碼
大家的輸出可能與我的不同,因為Goroutines
的執行順序會有所不同:)。
協程池的實現
緩衝channel
的一個重要用途是協程池的實現。
通常,協程池是一組協程,它們等待分配給它們任務。一旦完成分配的任務,他們就會再次等待下一個任務。
我們將使用緩衝channel
實現協程池。我們的協程池將執行查詢輸入數字的數字之和的任務。例如,如果傳遞 234,則輸出將為 9(9 = 2 + 3 + 4)。協程池的輸入將是偽隨機整數列表。
以下是我們協程池的核心功能
- 建立一個
Goroutines
池,用於監聽緩衝jobs channel
,等待任務分配 - 向
jobs channel
新增任務 - 任務完成後,將結果寫入緩衝
results channel
- 從
results channel
讀取和列印結果
我們將逐步編寫此程式,以便更容易理解。
第一步是建立表示任務和結果的結構。
type Job struct {
id int
randomno int
}
type Result struct {
job Job
sumofdigits int
}
複製程式碼
每個Job
結構都有一個id
和randomno
,用來計算各個數字的總和。
Result
結構有一個job
欄位和sumofdigits
欄位,sumofdigits
欄位用來儲存job
各個數字之和的結果。
下一步是建立用於接收任務和儲存結果的緩衝channel
。
var jobs = make(chan Job, 10)
var results = make(chan Result, 10)
複製程式碼
工作Goroutines
在任務緩衝channel
上偵聽新任務。一旦任務完成,把結果寫入結果緩衝channel
。
digits
函式執行查詢整數的各個數字之和並返回它的。我們為此函式新增 2 秒的休眠,以模擬此函式計算結果需要一些時間的場景。
func digits(number int) int {
sum := 0
no := number
for no != 0 {
digit := no % 10
sum += digit
no /= 10
}
time.Sleep(2 * time.Second)
return sum
}
複製程式碼
接下來將編寫一個建立工作Goroutine
的函式。
func worker(wg *sync.WaitGroup) {
for job := range jobs {
output := Result{job, digits(job.randomno)}
results <- output
}
wg.Done()
}
複製程式碼
上面的函式建立了一個worker
,它從jobs channel
讀取任務,使用當前任務和digits
函式的返回值建立Result
結構,然後將結果寫入結果緩衝channel
。此函式將WaitGroup wg
作為引數,在所有任務完成後,它將呼叫Done
方法結束當前Goroutine
的阻塞。
createWorkerPool
函式將建立一個Goroutines
池。
func createWorkerPool(noOfWorkers int) {
var wg sync.WaitGroup
for i := 0; i < noOfWorkers; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
close(results)
}
複製程式碼
上面的函式將要建立的worker
數量作為引數。它在建立Goroutine
之前呼叫了wg.Add(1)
來增加WaitGroup
計數器。然後它通過將WaitGroup wg
的地址傳遞給worker
函式來建立worker Goroutines
。在建立了所需的worker Goroutines
之後,它通過呼叫wg.Wait()
來阻塞當前協程直到所有Goroutines
完成執行後,關閉results channel
,因為所有的Goroutines
都已完成執行,沒有結果被寫入該results channel
。
現在我們已經寫好了協程池,讓我們繼續編寫將任務分配給協程的功能。
func 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 channel
。它在寫完所有任務後關閉了jobs channel
。
下一步是建立一個函式讀取results channel
並列印輸出。
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
}
複製程式碼
result
函式讀取results channel
並列印任務 ID,輸入隨機數和隨機數的總和。result
函式在列印所有結果後,將true
寫入done channel
。
萬事俱備,讓我們把上面所有的功能用main
函式串聯起來。
func main() {
startTime := time.Now()
noOfJobs := 100
go allocate(noOfJobs)
done := make(chan bool)
go result(done)
noOfWorkers := 10
createWorkerPool(noOfWorkers)
<-done
endTime := time.Now()
diff := endTime.Sub(startTime)
fmt.Println("total time taken ", diff.Seconds(), "seconds")
}
複製程式碼
第 2 行我們首先將程式的執行開始時間儲存起來,在最後一行(第 12 行),我們計算endTime
和startTime
之間的時間差,並顯示程式的總執行時間。這是必要的,因為我們將通過改變Goroutines
的數量來做一些基準測試。
noOfJobs
設定為 100,然後呼叫allocate
以將任務新增到jobs channel
。
然後建立done channel
並將其傳遞給results channel
,以便它可以開始列印輸出並在列印完所有內容後通知。
最後,通過呼叫createWorkerPool
函式建立了一個 10 個work Goroutines
的池,然後main
阻塞直到done channel
寫入true
值,最後列印所有結果。
下面是完整的程式碼。
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Job struct {
id int
randomno int
}
type Result struct {
job Job
sumofdigits int
}
var jobs = make(chan Job, 10)
var results = make(chan Result, 10)
func digits(number int) int {
sum := 0
no := number
for no != 0 {
digit := no % 10
sum += digit
no /= 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.WaitGroup
for 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 := 100
go allocate(noOfJobs)
done := make(chan bool)
go result(done)
noOfWorkers := 10
createWorkerPool(noOfWorkers)
<-done
endTime := time.Now()
diff := endTime.Sub(startTime)
fmt.Println("total time taken ", diff.Seconds(), "seconds")
}
複製程式碼
請在本地計算機上執行此程式,以便計算的總時間更準確。
程式將列印,
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 行,將在最後一行列印該程式執行所花費的總時間。您的輸出將與我的不同,因為Goroutines
可以按任何順序執行,總時間也會因硬體而異。在我的情況下,程式完成大約需要 20 秒。
現在讓我們將main
函式中的noOfWorkers
增加到 20。我們將worker
的數量增加了一倍。由於work Goroutines
已經增加,程式完成所需的總時間應該減少。在我的情況下,它變成 10.004364685 秒,程式列印,
...
total time taken 10.004364685 seconds
複製程式碼
現在我們瞭解到了隨著work Goroutines
數量的增加,完成任務所需的總時間減少了。我把它留作練習,讓你在主函式中使用不同的noOfJobs
和noOfWorkers
的值執行並分析結果。