Go語言的併發與WorkerPool - 第一部分

點擊上方“ Go語言進階學習 ”,進行關注

回覆“Go語言”即可獲贈從入門到進階共10本電子書

潯陽江頭夜送客,楓葉荻花秋瑟瑟。

via:
https://hackernoon.com/concurrency-in-golang-and-workerpool-part-1-e9n31ao
作者:Hasan
四哥水平有限,如有翻譯或理解錯誤,煩請幫忙指出,感謝!

昨天分享關於 workerPool 的文章,有同學在後臺說,昨天的 Demo 恰好符合項目的業務場景,真的非常棒!

所以今天就再來分享一篇 。

原文如下:


現代編程語言中,併發已經成爲必不可少的特性。現在絕大多數編程語言都有一些方法實現併發。

其中一些實現方式非常強大,能將負載轉移到不同的系統線程,比如 Java 等;一些則在同一線程上模擬這種行爲,比如 Ruby 等。

Golang 的併發模型非常強大,稱爲 CSP(通信順序進程),它將一個問題分解成更小的順序進程,然後調度這些進程的實例(稱爲 Goroutine)。這些進程通過 channel 傳遞信息實現通信。

本文,我們將探討如何利用 golang 的併發性,以及如何在 workerPool 使用。系列文章的第二篇,我們將探討如何構建一個強大的併發解決方案。

一個簡單的例子

假設我們需要調用一個外部 API 接口,整個過程需要花費 100ms。如果我們需要同步地調用該接口 1000 次,則需要花費 100s。

//// model/data.go

package model

type SimpleData struct {
 ID int
}

//// basic/basic.go

package basic

import (
 "fmt"
 "github.com/Joker666/goworkerpool/model"
 "time"
)

func Work(allData []model.SimpleData) {
 start := time.Now()
 for i, _ := range allData {
  Process(allData[i])
 }
 elapsed := time.Since(start)
 fmt.Printf("Took ===============> %s\n", elapsed)
}

func Process(data model.SimpleData) {
 fmt.Printf("Start processing %d\n", data.ID)
 time.Sleep(100 * time.Millisecond)
 fmt.Printf("Finish processing %d\n", data.ID)
}

//// main.go

package main

import (
 "fmt"
 "github.com/Joker666/goworkerpool/basic"
 "github.com/Joker666/goworkerpool/model"
 "github.com/Joker666/goworkerpool/worker"
)

func main() {
 // Prepare the data
 var allData []model.SimpleData
 for i := 0; i < 1000; i++ {
  data := model.SimpleData{ ID: i }
  allData = append(allData, data)
 }
 fmt.Printf("Start processing all work \n")

 // Process
 basic.Work(allData)
}
Start processing all work
Took ===============> 1m40.226679665s

上面的代碼創建了 model 包,包裏包含一個結構體,這個結構體只有一個 int 類型的成員。我們同步地處理 data,這顯然不是最佳方案,因爲可以併發處理這些任務。我們換一種方案,使用 goroutine 和 channel 來處理。

異步

//// worker/notPooled.go

func NotPooledWork(allData []model.SimpleData) {
 start := time.Now()
 var wg sync.WaitGroup

 dataCh := make(chan model.SimpleData, 100)

 wg.Add(1)
 go func() {
  defer wg.Done()
  for data := range dataCh {
   wg.Add(1)
   go func(data model.SimpleData) {
    defer wg.Done()
    basic.Process(data)
   }(data)
  }
 }()

 for i, _ := range allData {
  dataCh <- allData[i]
 }

 close(dataCh)
 wg.Wait()
 elapsed := time.Since(start)
 fmt.Printf("Took ===============> %s\n", elapsed)
}

//// main.go

// Process
worker.NotPooledWork(allData)
Start processing all work
Took ===============> 101.191534ms

上面的代碼,我們創建了容量 100 的緩存 channel,並通過 NoPooledWork() 將數據 push 到 channel 裏。channel 長度滿 100 之後,我們是無法再向其中添加元素直到有元素被讀取走。使用 for range 讀取 channel,並生成 goroutine 處理。這裏我們沒有限制生成 goroutine 的數量,這可以儘可能多地處理任務。從理論上來講,在給定所需資源的情況下,可以處理儘可能多的數據。執行代碼,完成 1000 個任務只花費了 100ms。很瘋狂吧!不全是,接着往下看。

問題

除非我們擁有地球上所有的資源,否則在特定時間內能夠分配的資源是有限的。一個 goroutine 佔用的最小內存是 2k,但也能達到 1G。上述併發執行所有任務的解決方案中,假設有一百萬個任務,就會很快耗盡機器的內存和 CPU。我們要麼升級機器的配置,要麼就尋找其他更好的解決方案。

計算機科學家很久之前就考慮過這個問題,並提出了出色的解決方案 - 使用 Thread Pool 或者 Worker Pool。這個方案是使用 worker 數量受限的工作池來處理任務,workers 會按順序一個接一個處理任務,這樣就避免了 CPU 和內存使用急速增長。

解決方案:Worker Pool

我們通過實現 worker pool 來修復之前遇到的問題。

//// worker/pooled.go

func PooledWork(allData []model.SimpleData) {
 start := time.Now()
 var wg sync.WaitGroup
 workerPoolSize := 100

 dataCh := make(chan model.SimpleData, workerPoolSize)

 for i := 0; i < workerPoolSize; i++ {
  wg.Add(1)
  go func() {
   defer wg.Done()

   for data := range dataCh {
    basic.Process(data)
   }
  }()
 }

 for i, _ := range allData {
  dataCh <- allData[i]
 }

 close(dataCh)
 wg.Wait()
 elapsed := time.Since(start)
 fmt.Printf("Took ===============> %s\n", elapsed)
}

//// main.go

// Process
worker.PooledWork(allData)
Start processing all work
Took ===============> 1.002972449s

上面的代碼,worker 數量限制在 100,我們創建了相應數量的 goroutine 來處理任務。我們可以把 channel 看作是隊列,worker goroutine 看作是消費者。多個 goroutine 可以監聽同一個 channel,但是 channel 裏的每一個元素只會被處理一次。

Go 語言的 channel 可以當作隊列使用。

這是一個比較好的解決方案,執行代碼,我們看到完成所有任務花費 1s。雖然沒有 100ms 這麼快,但已經能滿足業務需要,而且我們得到了一個更好的解決方案,能將負載均攤在不同的時間片上。

處理錯誤

我們能做的還沒完。上面看起來是一個完整的解決方案,但卻不是的,我們沒有處理錯誤情況。所以需要模擬出錯的情形,並且看下我們需要怎麼處理。

//// worker/pooledError.go

func PooledWorkError(allData []model.SimpleData) {
 start := time.Now()
 var wg sync.WaitGroup
 workerPoolSize := 100

 dataCh := make(chan model.SimpleData, workerPoolSize)
 errors := make(chan error, 1000)

 for i := 0; i < workerPoolSize; i++ {
  wg.Add(1)
  go func() {
   defer wg.Done()

   for data := range dataCh {
    process(data, errors)
   }
  }()
 }

 for i, _ := range allData {
  dataCh <- allData[i]
 }

 close(dataCh)

 wg.Add(1)
 go func() {
  defer wg.Done()
  for {
   select {
   case err := <-errors:
    fmt.Println("finished with error:", err.Error())
   case <-time.After(time.Second * 1):
    fmt.Println("Timeout: errors finished")
    return
   }
  }
 }()

 defer close(errors)
 wg.Wait()
 elapsed := time.Since(start)
 fmt.Printf("Took ===============> %s\n", elapsed)
}

func process(data model.SimpleData, errors chan<- error) {
 fmt.Printf("Start processing %d\n", data.ID)
 time.Sleep(100 * time.Millisecond)
 if data.ID % 29 == 0 {
  errors <- fmt.Errorf("error on job %v", data.ID)
 } else {
  fmt.Printf("Finish processing %d\n", data.ID)
 }
}

//// main.go

// Process
worker.PooledWorkError(allData)

我們修改了 process() 函數,處理一些隨機的錯誤並將錯誤 push 到 errors chnanel 裏。所以,爲了處理併發出現的錯誤,我們可以使用 errors channel 保存錯誤數據。在所有任務處理完成之後,可以檢查錯誤 channel 是否有數據。錯誤 channel 裏的元素保存了任務 ID,方便需要的時候再處理這些任務。

比之前沒處理錯誤,很明顯這是一個更好的解決方案。但我們還可以做得更好,

我們將在下篇文章討論如何編寫一個強大的 worker pool 包,並且在 worker 數量受限的情況下處理併發任務。

總結

Go 語言的併發模型足夠強大給力,只需要構建一個 worker pool 就能很好地解決問題而無需做太多工作,這就是它沒有包含在標準庫中的原因。但是,我們自己可以構建一個滿足自身需求的方案。很快,我會在下一篇文章中講到,敬請期待!

點擊【閱讀原文】直達代碼倉庫[1]

參考資料

[1]

代碼倉庫: https://github.com/Joker666/goworkerpool?ref=hackernoon.com


------------------- End -------------------

往期精彩文章推薦:

歡迎大家點贊,留言,轉發,轉載,感謝大家的相伴與支持

想加入Go學習羣請在後臺回覆【入羣

萬水千山總是情,點個【在看】行不行

本文分享自微信公衆號 - Go語言進階學習(gh_dced3d6523fb)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章