那些年我們追的 Goroutine Pool

Viney Tai-Li Shih
17LIVE Tech Insight
7 min readMar 22, 2021

Go 語言的開發者一定對 goroutine[1] 不陌生。它非常的簡單容易操作,對於不熟悉 system programming 的人也能快速達到高併發的效果。它的 stack 很小,相較於傳統的 thread,context switch 速度更快。

也因為用起來相當容易,往往都沒有考慮到過多的 goroutine 也是會造成系統負擔的。以下故事會由 2018 年的一個任務說起,說明為什麼 17LIVE 團隊會去實現一個 goroutine pool

起源

在開始之前簡單描述一下我們的生態:任何人只要對自己有自信,想要把自己最即時的一面展現給任何人,只要透過下載我們的APP,就能開啟直播間成為直播主。當你的人氣與實力慢慢展現出來,就會有大批的粉絲開始追蹤你訂閱你的頻道,每當直播間開播,我們就會立刻發起推播通知告知粉絲你追蹤的主播正在開播。這個模式也是我們維持主播跟用戶之間聯繫的橋樑。然而開播通知有時候會發生隨機性的延遲發送,或是有大批粉絲沒有收到通知的現象,一開始可能是零星的案件,但隨著用戶數開始不斷的增長,這個現象也就越來越明顯。

這個問題最棘手的部分就是整個流程相當冗長,詳細如下:主播按下開播的當下,會把所有的粉絲撈出來(在那個年代,有些熱門主播會來到百萬粉絲俱樂部,數量相當龐大),接著還要根據每一個用戶的偏好設定,還有繁雜的系統規則決定哪些人會收到這則訊息,最終藉由第三方的服務 OneSignal 推送到用戶的手機上。先撇開 OneSignal 是否能夠順利完成任務,我們優先從 server 端做分析成效,經過一連串的埋點,發覺到好大一塊消失的數目來自於這段 code (正是要撈出所有的粉絲,透過 goroutine 平行處理送通知給用戶):

這段 code 的初衷是正確的,畢竟發送的對象彼此之間是沒有任何相依關係的,藉由實現非同步式的 batch job 來達到平行處理,盡可能縮短推送通知的時間。

記憶體(Memory)上限

然而他沒有考慮到一個系統內部的資源是有限的,goroutine 的數目不能無限制的增長上去。 從 Go 1.4版 之後,goroutine 的 stack 起始大小為 2KB,之後會根據 goroutine 的使用狀況動態慢慢地長上去。

// The minimum size of stack used by Go code (runtime/stack.go)
_StackMin = 2048

根據簡單的計算,一台機器假使擁有 4GB 的 memory,理論值他可以開啟 200 萬個初始狀態的 goroutine。但實際上每一個 goroutine 的大小會隨你開啟的數目增長上去,有興趣的可以參考網路上的範例[2]跑跑看,同樣大小的 memory 我自己的筆電大概只能開啟 160 萬個左右。

上面的數字是一個理論值,一定不是一個穩定的狀態,因為並沒有計算到 OS 還有各種開銷所需要的 memory。我保守的假設 1/10 是比較穩定的狀態,同樣的配置在一台機器上瞬間只能跑到 10 萬個 goroutine,再套回我們的例子,一個明星主播有一百萬個追蹤數目,每一百人分成一個 batch job,這樣光是要完成一個主播的開播通知在一瞬間就要起 1 萬個 goroutine,晚上熱門時段又是大主播開播的密集時段,假使全部發生同一台機器內一定會發生不預期的錯誤。

效能問題

再從效能面向來看這個問題, 一來高頻率的開啟與銷毀 goroutine 會造成 CPU 的忙碌[3][4]。二來前面也提過,goroutine 的 stack 大小是動態調配的,每一次調整都會建置上次兩倍大的空間,再把資料搬移過去,一來一回中也會造成 CPU 與 memory 的損耗[5][6]。以我們的工作型態來說,每一百人分一個 batch job 後做的事情是差不多的,stack 盡可能只要長一次就好(或是慢慢往上擴大到一個值),接下來只要重新使用既有的 goroutine,而不是把它丟掉重新開一個新的,有助於效能的提升。

實作

重新審視一下我們的需求:

  1. 我們需要一個限制 goroutine 最高數目的機制
  2. 需要有個 queue,讓指定數目的 goroutine 有個緩衝把事情給消化掉
  3. goroutine 要能夠重新使用,而並非每一次重新產生一個

於是實作了一個適合我們的 goroutine pool 來管控資源。主要的核心架構如下:

  1. 如何管理多個 goroutine:用一個 worker array 來紀錄每一個 goroutine 的狀態,每新增或是刪除一個 goroutine 除了反應 array 長度的變化外,也意味著 pool 大小的增減。(後面的說明會把 goroutine 抽象成 worker)
  2. 如何實作 queue:goroutine 之間的溝通就是透過 channel,而 channel 有分成 unbuffered channelbuffered channel,利用這個特性實作出 queue。 設計上保留彈性,讓用戶根據使用場景決定 queue 的長度,預設是 0,也就是沒有 queue 的存在,走 unbuffered channel 的機制,每當要把新工作安排進去的唯一條件就是後面有空閒的 worker 才會被承接。另一種選擇是可以透過 WithQueueLength() 去指定 queue 的長度,這時候是走 buffered channel 的機制,工作會優先塞到 queue 才會被後面的 worker 給消化掉。
  3. 動態增長 goroutine 機制:預設開啟 worker 數目會與 pool 的大小一致,這時候沒有動態增長的問題,永遠是固定數目的 worker 在消化工作,但這也是一個很不經濟實惠的方式,因為我們永遠抓不準到底要開多大的 pool 才適合線上的工作量。於是透過 WithPreAllocWorkers() 可以指定初始化 pool 的大小,隨著線上工作量的需求增加,慢慢讓 worker 的數目增長但不超過 pool 的最大限制。策略上承接上面 channel 的機制,優先把工作放進去 queue,當 queue 放不進去的時候有一定的機率會長出新的 worker,然後把工作直接安排給他,而這樣的實作技巧有助於立即反應現況,經濟實惠的擴展 worker 的數目。當系統閒置時,過多的 worker 也會被 recycler 定期回收,最終 pool 會回到初始化的大小。

以下為簡單的範例。

總結

goroutine pool 機制完成後,我們迫不及待整合到原本的 code base,驗證完其正確性後推到正式的線上環境,再根據先前的埋點觀察結果,實驗設計如下:

  • 實驗樣本為二十多個當下破百萬的大主播,為期一個星期內,用五隻不同平台的手機(iOS,Android)追蹤主播並統計結果。
  • 包含晚上熱門時段在內,所有的開播通知都沒有遺漏
  • 所有的手機最差都能在主播開播後的兩分鐘內收到開播通知。

這次的機會也讓我們學習到寶貴的經驗,一點小小的優化都有可能會對整體帶來巨大的影響,而當時的我們對於這樣的結果相當滿意,把同樣的經驗複製到開播通知以外的功能。很高興能有這個機會把這套機制分享給大家,歡迎有興趣的朋友一起討論,你的反饋也是讓這套機制持續成長的動力。

最後做個宣傳,17LIVE Backend 是一個非常多元的團隊,每天都面臨各式各樣的挑戰,而我們也在當中慢慢成長茁壯。工程團隊也持續徵才中,歡迎有興趣且想挑戰自我的人加入我們。

職缺列表:https://17live.bamboohr.com/jobs/view.php?id=29

--

--

Viney Tai-Li Shih
17LIVE Tech Insight

It’s not who I am underneath, but what I do defines me.