[譯] part23: 緩衝channel和協程池

咔嘰咔嘰發表於2019-04-02

什麼是緩衝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)
}
複製程式碼

Run in playgroud

在上面的程式中,第 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)

    }
}
複製程式碼

Run in playgroud

上面的程式中,在第 16 行建立了容量為 2 的緩衝channel chmain Goroutinech傳給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。當第三個字串寫入的時候已超過其容量,因此寫入操作被阻塞。現在必須等待其他Goroutinechannel讀取資料才能繼續寫入,但在上述程式碼中並沒有從該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))
}
複製程式碼

Run in playgroud

在上面的程式中,建立的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")
}
複製程式碼

Run in playgroud

WaitGroup是一種結構型別,我們在第 18 行建立一個WaitGroup型別的空值變數。 WaitGroup的工作方式是使用計數器。當我們在WaitGroup上呼叫int型引數呼叫Add 方法,計數器會增加傳遞給Add的值。遞減計數器的方法是在WaitGroup上呼叫Done方法。 Wait方法阻塞呼叫它的Goroutine,直到計數器變為零。

在上面的程式中,我們在第 20 行呼叫wg.Add(1)迴圈迭代 3 次。所以計數器的值現在變成了 3。 for 迴圈也產生 3 個Goroutinesmain 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結構都有一個idrandomno,用來計算各個數字的總和。

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 行),我們計算endTimestartTime之間的時間差,並顯示程式的總執行時間。這是必要的,因為我們將通過改變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")
}
複製程式碼

Run in playgroud

請在本地計算機上執行此程式,以便計算的總時間更準確。

程式將列印,

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數量的增加,完成任務所需的總時間減少了。我把它留作練習,讓你在主函式中使用不同的noOfJobsnoOfWorkers的值執行並分析結果。

相關文章