Golang 後臺守護程式佇列處理方式小結

pathbox發表於2018-03-18

在寫一個 Golang Server的時候,比如 http 介面,最簡單的就是使用 net/http 包,每個請求就會起一個 goroutine 來進行操作。很方便,但是,當併發量大的時候,就會起了成千上萬的 goroutine,當 goroutine 的量達到一個很大的數量,服務效能也就出現了瓶頸。

我們可以手動構建簡單的 goroutine 池,再借助 channel 做佇列,後臺起守護 goroutine,來處理 channel 中的資料。模型大概是這樣的:

var myChan = make(chan int, 1000) // 全域性channel佇列

for i := 0; i < poolSize; i++{
  go initWorker()
}

func ProcessWorker() {
  for {
    select {
    case value := <-myChan:
      fmt.Println(value)
      // ... do something with value
    case done = <-done: // 關閉機制
      break
    default: // 這裡不用超時處理,就是希望goroutine一直在後臺執行
    }
  }
}

func indexHandler() {
  //...
  myChan <- value
}

這樣我們就建立了 poolSize 數量的 goroutine,在不斷的從 myChan 中獲取資料,Golang 的排程會幫我們做這一切。

很容易就可以發現,這裡的一個瓶頸就是 myChan 的 size 大小。當 myChan 很快就滿了,後續的請求就會阻塞了。在我的專案的嘗試中,我將 size 設為 10000, 進行 ab 測試,發現測試結果比用普通的多 goroutine 處理方式要好。我加到了 10w,效能反而下降了。所以,myChan Size 的代銷不是越多越好,而是根據你實際情況來測試出一個合適的值。

在我的另一個專案中,是對檔案的格式轉換,對核心程式碼的執行,希望不要支援高併發,在高併發下核心的命令程式碼會執行報錯。所以,我只建立了一個守護 goroutine,並且 make myChan 的 Size 為 200.這樣,用 ab 測試時,能支援 200 併發,並且幾乎沒有執行失敗的報錯。

我似乎喜歡上了使用上述的方式來處理請求,Golang select 和 channel 的設計讓我可以很愉悅的使用上面的方式,並且達到預期的效果。

我並沒有發現程式執行的異常,直到今天,我忽然發現服務在沒有請求的時候,CPU 佔用率達到了 100%(4 核心的機器)。我十分詫異,第一時間想到了可能是 ProcessWorker 的問題。

這裡涉及到了 select 排程的方法:

  • 當某個 case 的 channel 資料可以取到了就執行它;

  • 當多個 case 同時取到資料了,會隨機執行一個;

  • 當沒有 case 取到資料,都阻塞時,會執行 default。

當服務沒有接收請求的時候,ProcessWorker 方法中會執行的應該是 default,這個沒錯。而在外層我用了 for 的死迴圈,以保證 goroutine 一直執行。這個問題就來了,ProcessWorker在沒有請求的時候會一直執行 default,而不會阻塞在 case。如果你在 default 列印日誌

func ProcessWorker() {
  for {
    select {
    case value := <-myChan:
      fmt.Println(value)
      // ... do something with value
    case done = <-done: // 關閉機制
      break
    default: // 這裡不用超時處理,就是希望goroutine一直在後臺執行
      log.Println("default")
    }
  }
}

你會發現後臺在不斷的輸出日誌。

一個簡單的程式碼例子
// example.go
package main

import (
    "fmt"
    "log"
    "net/http"
)
var c = make(chan int, 100)

func main() {
    go worker()
    addr := ":9090"
    http.HandleFunc("/", index)
    log.Fatal(http.ListenAndServe(addr, nil))
}

func worker() {
    for {
        select {
        case d := <-c:
            fmt.Println(d)
            default:
        }
    }
}

func index(w http.ResponseWriter, r *http.Request) {
    c <- 1
    w.Write([]byte("OK"))
}

當你 go run example.go 的時候,開啟 htop 或 top,檢視這個服務,在我的 Ubuntu 14.04 環境下 (Mac 下也是),CPU 佔用到了 100%(用了一個核心執行緒).

當把 default 註釋了變成

func worker() {
    for {
        select {
        case d := <-c:
            fmt.Println(d)
            // default:
        }
    }
}

CPU 佔用率就恢復了正常。這時候,worker()是阻塞在了 case d:= <-c: 上,這種阻塞並不會佔用 CPU 的排程處理,CPU 會閒置或去處理別的任務。直到 channel 中有資料了,會喚醒該 channel 的資料結構中對應的 goroutine,設定為 runnable 的狀態,該 goroutine 的排程才會繼續進行。

解決方法: 將default: 刪除即可
default 並非是罪惡之人

只是在上述的使用情況下,default 變成了不必要的了。 當你的使用方式並非如此時,比如下面的方法:

func TryRun() {
  select {
  case a := <-c:
    //...
  // case t := time.After(10 * time.Second):
  //   return
  default:
    return
  }
}

外層沒有 for 死迴圈,當 <-c 阻塞時,default 會裡執行 return,而起到立即釋放 goroutine 的效果,或者你可以加一定的超時機制。要不然,在大量使用 goroutine 的時候,極有可能造成 goroutine 洩露或僵死。

Golang 設計了便捷的 goroutine 的建立方式: go 一下,方便的進行併發處理。並且設計了使用select方法來進行排程處理。讓併發程式設計變得簡單。

不過,我們還是需要對其原理和底層結構有所掌握,這樣才能寫出合適的程式碼。

關於 channel 的參考:

https://about.sourcegraph.com/go/understanding-channels-kavya-joshi/<span class="embed-responsive embed-responsive-16by9"><iframe class="embed-responsive-item" src="//www.youtube.com/embed/KBZlN0izeiY" allowfullscreen></iframe></span>
更多原創文章乾貨分享,請關注公眾號
  • Golang 後臺守護程式佇列處理方式小結
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章