ants - 目前開源最優的協程池

轩脉刃發表於2024-03-07

ants - 目前開源最優的協程池

目前我們的專案重度使用 ants 協程池,在開啟一個 go 的時候並不是用 go 關鍵字,而是用一個封裝的 go 函式來開啟協程。框架底層,則是使用 ants 專案來實現協程池。

ants 是一個協程池的實現,這個專案短小精悍,非常適合用來做程式碼研究。ants 的作者是國人panjf2000,該專案目前已經廣泛應用在騰訊,位元組,百度,新浪等大廠了。

相關資料

github倉庫地址:https://github.com/panjf2000/ants

文件地址:https://pkg.go.dev/github.com/panjf2000/ants/v2

主體思路研究

研究專案有必要先想通下專案的意義和架構思路:

首先的第一個問題,為什麼需要有 Golang 的協程池呢?

Golang 提供的 go 關鍵字能很方便地將多個協程塞在一個程序中。但是在實際開發過程中,我們容易遇到協程濫用的問題。這點我是深有體會:一個專案越複雜,交接次數越多,後續的接手者越不願意修改主邏輯。而一旦有一些非主邏輯的業務,我們都傾向於開啟獨立程式碼分支邏輯,同時封裝為獨立的協程來完成。這樣不僅美其名曰在效能上能達到一個最優,而且在業務邏輯上也能保持單獨的獨立性,讓程式碼 bug 的出生率達到最低。

但是這種不斷疊加分支邏輯、不斷增加獨立協程的方式本質上就是一種協程濫用,我們不斷增加協程數,忽略了協程的本身開銷和上下文切換成本,很容易造成一個程序的 goroutine 數量過多,記憶體增加。不僅如此,這種做法還必須要保證分支程式碼質量。一個程式碼分支寫的質量不行(比如沒有設定 ctx 超時卡在 io 請求上),那麼新啟動的 goroutine 長時間無法釋放,這就可能導致 goroutine 的洩露。這種洩露的 goroutine 如果沒有被及時發現,那就是一個災難。

所以在這裡,我們更希望能將一個程式的併發度進行一定的控制,將程序消耗的資源控制在一定比例,比如我希望我的程序最多隻執行 1000 個 goroutine,程序能長期保持在 1G 記憶體以下。所以我們就有協程池的需求了,ants 也為此應運而生。

順帶說下,ants 的名字非常有意思:蟻群,非常多的螞蟻組成一個蟻群,煩亂但是又瑾然有序。和這個專案的願景一樣,亂而有序。

理解了ants 專案的意義和目的,再思考下,我們使用協程池來控制了協程數,一旦協程池滿了之後,想新建立一個協程,這時候應該有什麼表現呢?是直接在新建立協程的地方失敗,還是在新建立協程的時候阻塞?是的,其實無非就是這兩種方式。但是使用失敗 or 阻塞 的選擇權,應該是交給業務方的,也就是庫的使用者。所以 ants 庫需要同時能支援這兩種的表現。

再思考一下,我們要如何控制 go 這個關鍵字的使用呢?根據不知道誰的名言,封裝能解決程式世界裡的所有問題。是的,封裝,我們需要將 goroutine 進行封裝,並且將 go 關鍵字也進行一下封裝。goroutine 不就是一個協程來執行我們的函式麼,我們就封裝一個 goWorker 結構來執行我們的函式,goWorker 結構在 run 的時候,再啟動實際的 goroutine 。 go 關鍵字呢,我們也替換為一個方法 Submit,這個方法就只有一個引數,就是我們要執行的函式。考慮到我們要執行的函式是各式各樣的,所以我們還需要用一個閉包 func() 來包住我們的實際執行函式。

想到這些,我們有一些大致思路了,首先基於 OO 思想,我們為這個協程池定義一個結構 Pool,他有一系列的 goWorker,我們定義單個 goWorker 的結構,同時我們也定義一系列 goWorker 的結構 workerQueue(這裡的思路是我們一定會對這個批次的 workerQueue 有一些需要封裝的方法,比如獲取一個可執行的 goWorker 等,所以這裡並不是簡單的實用 slice[goWorker])。回到 Pool 結構,我們定義好 Submit 方法,能提交一個函式。初始化的方法呢,我們要定義好這個 Pool 的goroutine 容量。

按照上述思考,我們基本能得到如下的協程池的框架設計:

classDiagram class Pool { + NewPool(size int, options ...Option) (*Pool, error) + Submit(task func()) error + workers workerQueue // 可用的 workers + capacity int32 // 協程池容量 + running int32 // 執行中的協程池 } class goWorker { + run() // 執行 worker } class workerQueue { + detach() worker // 獲取一個 worker } goWorker --> workerQueue workerQueue --> Pool

再繼續思考下細節,一個 goWorker,本質上是對 goroutine 的封裝,而這個 goroutine 我們一旦 run 起來了,我們就不希望它會停止,而是在一個 for 迴圈中,不斷等待有新的任務進入。而 submit又是在另外一個主業務的 goroutine 中執行,它負責把 task 從當前主業務 goroutine 傳遞給 goWorker run 所在的 goroutine。這裡是不是就涉及到兩個 goroutine 之間的任務傳遞了。goroutine 傳遞我們用什麼方法呢?channel?- 對的。

基於以上分析, worker 的 run 函式和 pool 的 submit 函式的聯動我們能想象到虛擬碼大致是這樣的:

type goWorker struct {
	task chan func() // task 的 channel
}

func (w *goWorker) run() {
	go func() {
		for f := range w.tasks { // 一旦有 tasks
			f() // 實際執行 func
		}
	}
}

func (p *Pool) Submit(task func()) error {
  w, err := p.workers.detach()
  if err != nil {
    w.task <- task // 將 task 任務直接傳遞到 worker 的 tasks channel 中 
  }
  
  if w 為空,且 pool 的容量大於執行的 worker 數 {
    worker = newWorker()
    worker.run()
    w.task <- task
  }
  
  if w 為空,且 pool 的容量小於等於執行的 worker 數 {
    if pool 標記為阻塞 {
      p.cond.Wait() // 實用 sync.Cond 阻塞住
    } else {
      return ErrPoolOverload // 返回 pool 已經過載的錯誤
    }
  }
}


是的,上面這段程式碼這就是 ants 庫最核心的程式碼邏輯了。

兩個goroutine,一個是 goworker 的 run 的 goruotine,for 迴圈中不斷獲取任務執行,另外一個是業務 submit goroutine,不斷投遞任務。submit 投遞的時候,一旦達到了容量,就使用 wait 阻塞住,或者返回已經過載的錯誤。

細節分析

魔鬼在細節,我們瞭解了 ants 庫最核心的程式碼邏輯,其實只是瞭解了皮毛。為什麼之前也有很多庫都是類似的協程池功能,但是隻有 ants 脫穎而出呢?原因就是在於 ants 的細節做的非常優異,我們深入研究一下。

使用 sync.Pool 初始化 goworker

當我們在 pool 中獲取不到 空閒的goWorker,且 pool 的容量還未滿的時候,我們就需要初始化一個 goWorker(上述虛擬碼的 newWorker 函式),直接 new 是最簡單的辦法。

但是對於協程池來說, goWorker 的初始化、回收是一個非常頻繁的動作,這種動作消耗非常大。

所以我們考慮,是否可以使用物件池 sync.Pool 來最佳化初始化呢?這樣這種大量的獲取回收 worker 的行為就可以直接從 pool 中獲取,降低記憶體的消耗回收了。

關於 sync.Pool 的使用和原理這裡就不說了,參考官網:https://pkg.go.dev/sync#Pool 。ants 這裡就是使用了物件池的技術最佳化了 goWorker 的效率。

在 NewPool 函式中,我們定義了一個 workerCache sync.Pool

func NewPool(size int, options ...Option) (*Pool, error) {
	p := &Pool{
		capacity: int32(size),
		...
		options:  opts,
	}
	p.workerCache.New = func() interface{} {
		return &goWorker{
			pool: p,
			task: make(chan func(), workerChanCap),
		}
	}
	...
}

goWorker 的存取同時支援佇列和棧方式

前面說過,pool 有一個 workers 的欄位,它儲存的是可用的/當前正在執行的 goWorker。那麼這裡就有一個問題,這個 workers 是否需要預先分配呢?

如果預先分配,那麼在 Submit 函式的時候,就少了很多 new 的操作,提升了程式執行效率,但是同時帶來的問題是程序啟動的時候就會多佔用記憶體。

反之,如果不預先分配,我們在每次Submit 的時候就要去初始化,這也是一種方法,特別在 goworker 並不需要特別多的時候,這種模式很合適,能很大程度節省記憶體。

這兩種就是一種是地主做法,地主家有餘量,先屯糧, 滿足你所有的需求,另外一種就是貧農做法,加中無餘量,你要多少,我種多少。本質上是

ants 考慮到了使用者的這種需求,為這兩種模式都提供了方法,根據引數 PreAlloc 進行區別。

如果設定了 PreAlloc,則使用迴圈佇列(loopQueue)的方式存取這個 workers。在初始化 pool 的時候,就初始化了 capacity 長度的迴圈佇列,取的時候從隊頭取,插入的時候往佇列尾部插入,整體 queue 保持在 capacity 長度。

如果沒有設定 PreAlloc,則使用堆疊(stack)的方式存取這個 workers。初始化的時候不初始化任何的 worker,在第一次取的時候在 stack 中取不到,則會從 sync.Pool 中初始化並取到物件,然後使用完成後插入到 當前這個棧中。下次取就直接從 stack 中再獲取了。

type workerQueue interface {
	len() int
	isEmpty() bool
	insert(worker) error
	detach() worker
	refresh(duration time.Duration) []worker // clean up the stale workers and return them
	reset()
}
func newWorkerQueue(qType queueType, size int) workerQueue {
	switch qType {
	case queueTypeStack:
		return newWorkerStack(size)
	case queueTypeLoopQueue:
		return newWorkerLoopQueue(size)
	default:
		return newWorkerStack(size)
	}
}

自定義自旋鎖

在存取和回收worker 的時候,是需要使用到鎖的,然而 ants 沒有使用 sync.mutex 這樣的鎖,而是自己實現了一個自旋鎖(spinlock)。

這個自旋鎖和其他鎖不同的是,它遵循指數退避(Exponential Backoff)策略。就是說,當我獲取不到這個鎖的時候,我也會阻塞,但是我的阻塞方案並不是不斷 for 迴圈,在迴圈中不斷獲取鎖。

指數退避原則認為,我取不到鎖的次數越多,說明當前系統越繁忙,即取鎖的協程越多,所以從大局出發,我應該再慢一些嘗試取鎖。即每次重試之後,等待的時間逐漸增加,以避免連續重試造成系統擁塞。

ants 自己實現的自旋鎖就是基於這個指數退避原則,讓整個系統不至於協程數獲取鎖的數量太多而導致崩潰。

ants 取鎖的過程使用了一個 backoff 變數,當取鎖失敗之後,backoff 就增加固定倍數(2 倍),然後會等待 backoff 次 goroutine 的排程(runtime.GoSched())再進行下一次取鎖。

func (sl *spinLock) Lock() {
	backoff := 1
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		// Leverage the exponential backoff algorithm, see https://en.wikipedia.org/wiki/Exponential_backoff.
		for i := 0; i < backoff; i++ {
			runtime.Gosched()
		}
		if backoff < maxBackoff {
			backoff <<= 1
		}
	}
}

時間戳使用 ticker 來更新

ants 在使用 worker 的時候,每次任務完成後,會希望記錄 worker 上次任務時間,這樣後續的回收機制能根據這個任務時間判斷是否回收。原本這是很簡單的一個需求,使用 time.Now()就行,但是 time.Now() 實際上是有系統消耗的,當 ants 這樣底層的協程庫頻繁使用 time.Now() 是會對底層有一定壓力的。那麼有什麼辦法呢?我們能不能自己在記憶體中保持一個當前時間,這樣每次要獲取當前時間,就從記憶體中獲取就行了,避免系統消耗?

ants 就是這麼做的,在啟動 pool 的時候,會啟動一個 ticker,每 500ms排程一次,來更新 pool 中的一個 now 欄位,now 欄位這裡還不是簡單儲存 time.Time 型別,而是使用了 atomic.Value 型別(提供併發安全的存取機制)。

type Pool struct {
	now atomic.Value
}

func NewPool(size int, options ...Option) (*Pool, error) {
	...
	p.goTicktock()
}

func (p *Pool) goTicktock() {
	p.now.Store(time.Now())
	var ctx context.Context
	ctx, p.stopTicktock = context.WithCancel(context.Background())
	go p.ticktock(ctx)
}

func (p *Pool) ticktock(ctx context.Context) {
	ticker := time.NewTicker(nowTimeUpdateInterval)
	...

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}

     ...

		p.now.Store(time.Now())
	}
}


總結

ants 是一個非常完善的協程庫,它不僅僅在主體邏輯上非常完備,而且在細節上也處理的非常好,非常值得學習和使用。

參考資料

深入解析Golang 協程池 Ants實現原理

Go 每日一庫之 ants - 大俊的部落格

相關文章