Goroutine 併發調度模型深度解析之手擼一個高性能 goroutine 池

在這裏插入圖片描述
在這裏插入圖片描述

併發(並行),一直以來都是一個編程語言裏的核心主題之一,也是被開發者關注最多的話題;Go 語言作爲一個出道以來就自帶
『高併發』光環的富二代編程語言,它的併發(並行)編程肯定是值得開發者去探究的,而 Go 語言中的併發(並行)編程是經由
goroutine 實現的,goroutine 是 golang 最重要的特性之一,具有使用成本低、消耗資源低、能效高等特點,官方宣稱原生
goroutine併發成千上萬不成問題,於是它也成爲 Gopher 們經常使用的特性。

Goroutine 是優秀的,但不是完美的,在極大規模的高併發場景下,也可能會暴露出問題,什麼問題呢?又有什麼可選的解決方案?
本文將通過 runtime 對 goroutine 的調度分析,幫助大家理解它的機理和發現一些內存和調度的原理和問題,並且基於此提出一種個人
的解決方案 — 一個高性能的 Goroutine Pool(協程池)。

Goroutine & Scheduler

Goroutine,Go 語言基於併發(並行)編程給出的自家的解決方案。goroutine 是什麼?通常 goroutine 會被當做 coroutine(協程)的 golang 實現,從比較粗淺的層面來看,這種認知也算是合理,但實際上,goroutine 並非傳統意義上的協程,現在主流的線程模型分三種:內核級線程模型、用戶級線程模型和兩級線程模型(也稱混合型線程模型),傳統的協程庫屬於用戶級線程模型,而 goroutine 和它的Go Scheduler在底層實現上其實是屬於兩級線程模型,因此,有時候爲了方便理解可以簡單把 goroutine 類比成協程,但心裏一定要有個清晰的認知 — goroutine 並不等同於協程。

線程那些事兒

互聯網時代以降,由於在線用戶數量的爆炸,單臺服務器處理的連接也水漲船高,迫使編程模式由從前的串行模式升級到併發模型,而幾十年來,併發模型也是一代代地升級,有 IO 多路複用、多進程以及多線程,這幾種模型都各有長短,現代複雜的高併發架構大多是幾種模型協同使用,不同場景應用不同模型,揚長避短,發揮服務器的最大性能,而多線程,因爲其輕量和易用,成爲併發編程中使用頻率最高的併發模型,而後衍生的協程等其他子產品,也都基於它,而我們今天要分析的 goroutine 也是基於線程,因此,我們先來聊聊線程的三大模型:

線程的實現模型主要有 3 種:內核級線程模型、用戶級線程模型和兩級線程模型(也稱混合型線程模型),它們之間最大的差異就在於用戶線程與內核調度實體(KSE,Kernel Scheduling Entity)之間的對應關係上。而所謂的內核調度實體 KSE 就是指可以被操作系統內核調度器調度的對象實體(這說的啥玩意兒,敢不敢通俗易懂一點?)。簡單來說 KSE 就是內核級線程,是操作系統內核的最小調度單元,也就是我們寫代碼的時候通俗理解上的線程了(這麼說不就懂了嘛!裝什麼 13)。

用戶級線程模型

用戶線程與內核線程 KSE 是多對一(N : 1)的映射模型,多個用戶線程的一般從屬於單個進程並且多線程的調度是由用戶自己的線程庫來完成,線程的創建、銷燬以及多線程之間的協調等操作都是由用戶自己的線程庫來負責而無須藉助系統調用來實現。一個進程中所有創建的線程都只和同一個 KSE 在運行時動態綁定,也就是說,操作系統只知道用戶進程而對其中的線程是無感知的,內核的所有調度都是基於用戶進程。許多語言實現的 協程庫 基本上都屬於這種方式(比如 python 的 gevent)。由於線程調度是在用戶層面完成的,也就是相較於內核調度不需要讓 CPU 在用戶態和內核態之間切換,這種實現方式相比內核級線程可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的線程數量與上下文切換所花費的代價也會小得多。但該模型有個原罪:並不能做到真正意義上的併發,假設在某個用戶進程上的某個用戶線程因爲一個阻塞調用(比如 I/O 阻塞)而被 CPU 給中斷(搶佔式調度)了,那麼該進程內的所有線程都被阻塞(因爲單個用戶進程內的線程自調度是沒有 CPU 時鐘中斷的,從而沒有輪轉調度),整個進程被掛起。即便是多 CPU 的機器,也無濟於事,因爲在用戶級線程模型下,一個 CPU 關聯運行的是整個用戶進程,進程內的子線程綁定到 CPU 執行是由用戶進程調度的,內部線程對 CPU 是不可見的,此時可以理解爲 CPU 的調度單位是用戶進程。所以很多的協程庫會把自己一些阻塞的操作重新封裝爲完全的非阻塞形式,然後在以前要阻塞的點上,主動讓出自己,並通過某種方式通知或喚醒其他待執行的用戶線程在該 KSE 上運行,從而避免了內核調度器由於 KSE 阻塞而做上下文切換,這樣整個進程也不會被阻塞了。

內核級線程模型

用戶線程與內核線程 KSE 是一對一(1 : 1)的映射模型,也就是每一個用戶線程綁定一個實際的內核線程,而線程的調度則完全交付給操作系統內核去做,應用程序對線程的創建、終止以及同步都基於內核提供的系統調用來完成,大部分編程語言的線程庫(比如 Java 的 java.lang.Thread、C++11 的 std::thread 等等)都是對操作系統的線程(內核級線程)的一層封裝,創建出來的每個線程與一個獨立的 KSE 靜態綁定,因此其調度完全由操作系統內核調度器去做,也就是說,一個進程裏創建出來的多個線程每一個都綁定一個 KSE。這種模型的優勢和劣勢同樣明顯:優勢是實現簡單,直接藉助操作系統內核的線程以及調度器,所以 CPU 可以快速切換調度線程,於是多個線程可以同時運行,因此相較於用戶級線程模型它真正做到了並行處理;但它的劣勢是,由於直接藉助了操作系統內核來創建、銷燬和以及多個線程之間的上下文切換和調度,因此資源成本大幅上漲,且對性能影響很大。

兩級線程模型

兩級線程模型是博採衆長之後的產物,充分吸收前兩種線程模型的優點且儘量規避它們的缺點。在此模型下,用戶線程與內核 KSE 是多對多(N : M)的映射模型:首先,區別於用戶級線程模型,兩級線程模型中的一個進程可以與多個內核線程 KSE 關聯,也就是說一個進程內的多個線程可以分別綁定一個自己的 KSE,這點和內核級線程模型相似;其次,又區別於內核級線程模型,它的進程裏的線程並不與 KSE 唯一綁定,而是可以多個用戶線程映射到同一個 KSE,當某個 KSE 因爲其綁定的線程的阻塞操作被內核調度出 CPU 時,其關聯的進程中其餘用戶線程可以重新與其他 KSE 綁定運行。所以,兩級線程模型既不是用戶級線程模型那種完全靠自己調度的也不是內核級線程模型完全靠操作系統調度的,而是中間態(自身調度與系統調度協同工作),也就是 — 『薛定諤的模型』(誤),因爲這種模型的高度複雜性,操作系統內核開發者一般不會使用,所以更多時候是作爲第三方庫的形式出現,而 Go 語言中的 runtime 調度器就是採用的這種實現方案,實現了 Goroutine 與 KSE 之間的動態關聯,不過 Go 語言的實現更加高級和優雅;該模型爲何被稱爲兩級?即用戶調度器實現用戶線程到 KSE 的『調度』,內核調度器實現 KSE 到 CPU 上的『調度』。

G-P-M 模型概述

每一個 OS 線程都有一個固定大小的內存塊(一般會是 2MB)來做棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。因爲 2MB 的棧對於一個小小的 goroutine 來說是很大的內存浪費,而對於一些複雜的任務(如深度嵌套的遞歸)來說又顯得太小。因此,Go 語言做了它自己的『線程』。

在 Go 語言中,每一個 goroutine 是一個獨立的執行單元,相較於每個 OS 線程固定分配 2M 內存的模式,goroutine 的棧採取了動態擴容方式, 初始時僅爲 2KB,隨着任務執行按需增長,最大可達 1GB(64 位機器最大是 1G,32 位機器最大是 256M),且完全由 golang 自己的調度器 Go Scheduler 來調度。此外,GC 還會週期性地將不再使用的內存回收,收縮棧空間。 因此,Go 程序可以同時併發成千上萬個 goroutine 是得益於它強勁的調度器和高效的內存模型。Go 的創造者大概對 goroutine 的定位就是屠龍刀,因爲他們不僅讓 goroutine 作爲 golang 併發編程的最核心組件(開發者的程序都是基於 goroutine 運行的)而且 golang 中的許多標準庫的實現也到處能見到 goroutine 的身影,比如 net/http 這個包,甚至語言本身的組件 runtime 運行時和 GC 垃圾回收器都是運行在 goroutine 上的,作者對 goroutine 的厚望可見一斑。

任何用戶線程最終肯定都是要交由 OS 線程來執行的,goroutine(稱爲 G)也不例外,但是 G 並不直接綁定 OS 線程運行,而是由 Goroutine Scheduler 中的 P - Logical Processor (邏輯處理器)來作爲兩者的『中介』,P 可以看作是一個抽象的資源或者一個上下文,一個 P 綁定一個 OS 線程,在 golang 的實現裏把 OS 線程抽象成一個數據結構:M,G 實際上是由 M 通過 P 來進行調度運行的,但是在 G 的層面來看,P 提供了 G 運行所需的一切資源和環境,因此在 G 看來 P 就是運行它的 “CPU”,由 G、P、M 這三種由 Go 抽象出來的實現,最終形成了 Go 調度器的基本結構:

  • G: 表示 Goroutine,每個 Goroutine 對應一個 G 結構體,G 存儲 Goroutine的運行堆棧、狀態以及任務函數,可重用。 G 並非執行體,每個 G 需要綁定到 P 才能被調度執行。
  • P: Processor,表示邏輯處理器, 對 G 來說,P 相當於 CPU 核,G 只有綁定到 P(在 P 的 local runq 中)才能被調度。對 M 來說,P 提供了相關的執行環境(Context),如內存分配狀態(mcache),任務隊列(G)等,P 的數量決定了系統內最大可並行的 G 的數量(前提:物理 CPU 核數 >= P 的數量),P 的數量由用戶設置的 GOMAXPROCS 決定,但是不論 GOMAXPROCS 設置爲多大,P 的數量最大爲 256。
  • M: Machine,OS 線程抽象,代表着真正執行計算的資源,在綁定有效的 P 後,進入 schedule 循環;而 schedule 循環的機制大致是從 Global 隊列、P 的 Local 隊列以及 wait 隊列中獲取 G,切換到 G 的執行棧上並執行 G 的函數,調用 goexit 做清理工作並回到 M,如此反覆。M 並不保留 G 狀態,這是 G 可以跨 M 調度的基礎,M 的數量是不定的,由 Go Runtime 調整,爲了防止創建過多 OS 線程導致系統調度不過來,目前默認最大限制爲 10000 個。

關於 P,我們需要再絮叨幾句,在 Go 1.0 發佈的時候,它的調度器其實 G-M 模型,也就是沒有 P 的,調度過程全由 G 和 M 完成,這個模型暴露出一些問題:

  • 單一全局互斥鎖(Sched.Lock)和集中狀態存儲的存在導致所有 goroutine 相關操作,比如:創建、重新調度等都要上鎖;
  • goroutine 傳遞問題:M 經常在 M 之間傳遞『可運行』的 goroutine,這導致調度延遲增大以及額外的性能損耗;
  • 每個 M 做內存緩存,導致內存佔用過高,數據局部性較差;
  • 由於 syscall 調用而形成的劇烈的 worker thread 阻塞和解除阻塞,導致額外的性能損耗。

這些問題實在太扎眼了,導致 Go1.0 雖然號稱原生支持併發,卻在併發性能上一直飽受詬病,然後,Go 語言委員會中一個核心開發大佬看不下了,親自下場重新設計和實現了 Go 調度器(在原有的 G-M 模型中引入了 P)並且實現了一個叫做 work-stealing 的調度算法:

  • 每個 P 維護一個 G 的本地隊列;
  • 當一個 G 被創建出來,或者變爲可執行狀態時,就把他放到 P 的可執行隊列中;
  • 當一個 G 在 M 裏執行結束後,P 會從隊列中把該 G 取出;如果此時 P 的隊列爲空,即沒有其他 G 可以執行, M 就隨機選擇另外一個 P,從其可執行的 G 隊列中取走一半。

該算法避免了在 goroutine 調度時使用全局鎖。

至此,Go 調度器的基本模型確立:
在這裏插入圖片描述

G-P-M 模型調度

Go 調度器工作時會維護兩種用來保存 G 的任務隊列:一種是一個 Global 任務隊列,一種是每個 P 維護的 Local 任務隊列。

當通過go關鍵字創建一個新的 goroutine 的時候,它會優先被放入 P 的本地隊列。爲了運行 goroutine,M 需要持有(綁定)一個 P,接着 M 會啓動一個 OS 線程,循環從 P 的本地隊列裏取出一個 goroutine 並執行。當然還有上文提及的 work-stealing調度算法:當 M 執行完了當前 P 的 Local 隊列裏的所有 G 後,P 也不會就這麼在那躺屍啥都不幹,它會先嚐試從 Global 隊列尋找 G 來執行,如果 Global 隊列爲空,它會隨機挑選另外一個 P,從它的隊列裏中拿走一半的 G 到自己的隊列中執行。

如果一切正常,調度器會以上述的那種方式順暢地運行,但這個世界沒這麼美好,總有意外發生,以下分析 goroutine 在兩種例外情況下的行爲。

Go runtime 會在下面的 goroutine 被阻塞的情況下運行另外一個 goroutine:

  • blocking syscall (for example opening a file)
  • network input
  • channel operations
  • primitives in the sync package

這四種場景又可歸類爲兩種類型:

用戶態阻塞/喚醒

當 goroutine 因爲 channel 操作或者 network I/O 而阻塞時(實際上 golang 已經用 netpoller 實現了 goroutine 網絡 I/O 阻塞不會導致 M 被阻塞,僅阻塞 G,這裏僅僅是舉個栗子),對應的 G 會被放置到某個 wait 隊列(如 channel 的 waitq),該 G 的狀態由_Gruning變爲_Gwaitting,而 M 會跳過該 G 嘗試獲取並執行下一個 G,如果此時沒有 runnable 的 G 供 M 運行,那麼 M 將解綁 P,並進入 sleep 狀態;當阻塞的 G 被另一端的 G2 喚醒時(比如 channel 的可讀/寫通知),G 被標記爲 runnable,嘗試加入 G2 所在 P 的 runnext,然後再是 P 的 Local 隊列和 Global 隊列。

系統調用阻塞

當 G 被阻塞在某個系統調用上時,此時 G 會阻塞在_Gsyscall狀態,M 也處於 block on syscall 狀態,此時的 M 可被搶佔調度:執行該 G 的 M 會與 P 解綁,而 P 則嘗試與其它 idle 的 M 綁定,繼續執行其它 G。如果沒有其它 idle 的 M,但 P 的 Local 隊列中仍然有 G 需要執行,則創建一個新的 M;當系統調用完成後,G 會重新嘗試獲取一個 idle 的 P 進入它的 Local 隊列恢復執行,如果沒有 idle 的 P,G 會被標記爲 runnable 加入到 Global 隊列。

以上就是從宏觀的角度對 Goroutine 和它的調度器進行的一些概要性的介紹,當然,Go 的調度中更復雜的搶佔式調度、阻塞調度的更多細節,大家可以自行去找相關資料深入理解,本文只講到 Go 調度器的基本調度過程,爲後面自己實現一個 Goroutine Pool 提供理論基礎,這裏便不再繼續深入上述說的那幾個調度了,事實上如果要完全講清楚 Go 調度器,一篇文章的篇幅也實在是捉襟見肘,所以想了解更多細節的同學可以去看看 Go 調度器 G-P-M 模型的設計者 Dmitry Vyukov 寫的該模型的設計文檔《Go Preemptive Scheduler Design》以及直接去看源碼,G-P-M 模型的定義放在src/runtime/runtime2.go裏面,而調度過程則放在了src/runtime/proc.go裏。

大規模 Goroutine 的瓶頸

既然 Go 調度器已經這麼牛逼優秀了,我們爲什麼還要自己去實現一個 golang 的 Goroutine Pool 呢?事實上,優秀不代表完美,任何不考慮具體應用場景的編程模式都是耍流氓!有基於 G-P-M 的 Go 調度器背書,go 程序的併發編程中,可以任性地起大規模的 goroutine 來執行任務,官方也宣稱用 golang 寫併發程序的時候隨便起個成千上萬的 goroutine 毫無壓力。

然而,你起 1000 個 goroutine 沒有問題,10000 也沒有問題,10w 個可能也沒問題;那,100w 個呢?1000w 個呢?(這裏只是舉個極端的例子,實際編程起這麼大規模的 goroutine 的例子極少)這裏就會出問題,什麼問題呢?

  1. 首先,即便每個 goroutine 只分配 2KB 的內存,但如果是恐怖如斯的數量,聚少成多,內存暴漲,就會對 GC 造成極大的負擔,寫過 Java 的同學應該知道 jvm GC 那萬惡的 STW(Stop The World)機制,也就是 GC 的時候會掛起用戶程序直到垃圾回收完,雖然 Go1.8 之後的 GC 已經去掉了 STW 以及優化成了並行 GC,性能上有了不小的提升,但是,如果太過於頻繁地進行 GC,依然會有性能瓶頸;

  2. 其次,還記得前面我們說的 runtime 和 GC 也都是 goroutine 嗎?是的,如果 goroutine 規模太大,內存吃緊,runtime 調度和垃圾回收同樣會出問題,雖然 G-P-M 模型足夠優秀,韓信點兵,多多益善,但你不能不給士兵發口糧(內存)吧?巧婦難爲無米之炊,沒有內存,Go 調度器就會阻塞 goroutine,結果就是 P 的 Local 隊列積壓,又導致內存溢出,這就是個死循環…,甚至極有可能程序直接 Crash 掉,本來是想享受 golang 併發帶來的快感效益,結果卻得不償失。

一個 http 標準庫引發的血案

我想,作爲 golang 擁躉的 Gopher 們一定都使用過它的 net/http 標準庫,很多人都說用 golang 寫 Web server 完全可以不用藉助第三方的 Web framework,僅用 net/http 標準庫就能寫一個高性能的 Web server,的確,我也用過它寫過 Web server,簡潔高效,性能表現也相當不錯,除非有比較特殊的需求否則一般的確不用藉助第三方 Web framework,但是天下沒有白吃的午餐,net/http 爲啥這麼快?要搞清這個問題,從源碼入手是最好的途徑。孔子曾經曰過:源碼面前,如同裸奔。所以,高清無碼是阻礙程序猿發展大大滴絆腳石啊,源碼纔是我們進步階梯,切記切記!

接下來我們就來先看看 net/http 內部是怎麼實現的。

net/http 接收請求且開始處理的源碼放在src/net/http/server.go裏,先從入口函數ListenAndServe進去:

func (srv *Server) ListenAndServe() error {
	addr := srv.Addr
	if addr == "" {
		addr = ":http"
	}
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(tcpKeepAliveListener{ln.(*net.TCPListener)})
}

看到最後那個 srv.Serve 調用了嗎?沒錯,這個Serve方法裏面就是實際處理 http 請求的邏輯,我們再進入這個方法內部:

func (srv *Server) Serve(l net.Listener) error {
	defer l.Close()
	...
    // 不斷循環取出TCP連接
	for {
        // 看我看我!!!
		rw, e := l.Accept()
        ...
        // 再看我再看我!!!
		go c.serve(ctx)
	}
}

首先,這個方法的參數(l net.Listener) ,是一個 TCP 監聽的封裝,負責監聽網絡端口,rw, e := l.Accept()則是一個阻塞操作,從網絡端口取出一個新的 TCP 連接進行處理,最後go c.serve(ctx)就是最後真正去處理這個 http 請求的邏輯了,看到前面的 go 關鍵字了嗎?沒錯,這裏啓動了一個新的 goroutine 去執行處理邏輯,而且這是在一個無限循環體裏面,所以意味着,每來一個請求它就會開一個 goroutine 去處理,相當任性粗暴啊…,不過有 Go 調度器背書,一般來說也沒啥壓力,然而,如果,我是說如果哈,突然一大波請求涌進來了(比方說黑客搞了成千上萬的肉雞 DDOS 你,沒錯!就這麼倒黴!),這時候,就很成問題了,他來 10w 個請求你就要開給他 10w 個 goroutine,來 100w 個你就要老老實實開給他 100w 個,線程調度壓力陡升,內存爆滿,再然後,你就跪了…

釜底抽薪

有問題,就一定有解決的辦法,那麼,有什麼方案可以減緩大規模 goroutine 對系統的調度和內存壓力?要想解決問題,最重要的是找到造成問題的根源,這個問題根源是什麼?goroutine 的數量過多導致資源侵佔,那要解決這個問題就要限制運行的 goroutine 數量,合理複用,節省資源,具體就是 — goroutine 池化。

超大規模併發的場景下,不加限制的大規模的 goroutine 可能造成內存暴漲,給機器帶來極大的壓力,吞吐量下降和處理速度變慢還是其次,更危險的是可能使得程序 crash。所以,goroutine 池化是有其現實意義的。

首先,100w 個任務,是不是真的需要 100w 個 goroutine 來處理?未必!用 1w 個 goroutine 也一樣可以處理,讓一個 goroutine 多處理幾個任務就是了嘛,池化的核心優勢就在於對 goroutine 的複用。此舉首先極大減輕了 runtime 調度 goroutine 的壓力,其次,便是降低了對內存的消耗。

在這裏插入圖片描述

有一個商場,來了 1000 個顧客買東西,那麼該如何安排導購員服務這 1000 人呢?有兩種方案:

第一,我僱 1000 個導購員實行一對一服務,這種當然是最高效的,但是太浪費資源了,僱 1000 個人的成本極高且管理困難,這些可以先按下不表,但是每個顧客到商場買東西也不是一進來就馬上買,一般都得逛一逛,選一選,也就是得花時間挑,1000 個導購員一對一盯着,效率極低;這就引出第二種方案:我只僱 10 個導購員,就在商場裏待命,有顧客需要諮詢的時候招呼導購員過去進行處理,導購員處理完之後就回來,等下一個顧客需要諮詢的時候再去,如此往返反覆…

第二種方案有沒有覺得很眼熟?沒錯,其基本思路就是模擬一個 I/O 多路複用,通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。關於多路複用,不在本文的討論範圍之內,便不再贅述,詳細原理可以參考 I/O 多路複用

第一種方案就是 net/http 標準庫採用的:來一個請求開一個 goroutine 處理;第二種方案就是 Goroutine Pool(I/O 多路複用)。

實現一個 Goroutine Pool

因爲上述陳列的一些由於 goroutine 規模過大而可能引發的問題,需要有方案來解決這些問題,上文已經分析過,把 goroutine 池化是一種行之有效的方案,基於此,可以實現一個 Goroutine Pool,複用 goroutine,減輕 runtime 的調度壓力以及緩解內存壓力,依託這些優化,在大規模 goroutine 併發的場景下可以極大地提高併發性能。

哎瑪!前面絮絮叨叨了這麼多,終於進入正題了,接下來就開始講解如何實現一個高性能的 Goroutine Pool,秒殺原生併發的 goroutine,在執行速度和佔用內存上提高併發程序的性能。好了,話不多說,開始裝逼分析。

設計思路

Goroutine Pool 的實現思路大致如下:

啓動服務之時先初始化一個 Goroutine Pool 池,這個 Pool 維護了一個類似棧的 LIFO 隊列 ,裏面存放負責處理任務的 Worker,然後在 client 端提交 task 到 Pool 中之後,在 Pool 內部,接收 task 之後的核心操作是:

  1. 檢查當前 Worker 隊列中是否有可用的 Worker,如果有,取出執行當前的 task;
  2. 沒有可用的 Worker,判斷當前在運行的 Worker 是否已超過該 Pool 的容量:{是 —> 再判斷工作池是否爲非阻塞模式:[是 ——> 直接返回 nil,否 ——> 阻塞等待直至有 Worker 被放回 Pool],否 —> 新開一個 Worker(goroutine)處理};
  3. 每個 Worker 執行完任務之後,放回 Pool 的隊列中等待。

核心調度流程如下:
在這裏插入圖片描述
按照這個設計思路,我實現了一個高性能的 Goroutine Pool,較好地解決了上述的大規模調度和資源佔用的問題,在執行速度和內存佔用方面相較於原生 goroutine 併發佔有明顯的優勢,尤其是內存佔用,因爲複用,所以規避了無腦啓動大規模 goroutine 的弊端,可以節省大量的內存。

此外,該調度系統還有一個清理過期 Worker 的定時任務,該任務在初始化一個 Pool 之時啓動,每隔一定的時間間隔去檢查空閒 Worker 隊列中是否有已經過期的 Worker,有則清理掉,通過定時清理過期 worker,進一步節省系統資源。

完整的項目代碼可以在我的 GitHub 上獲取:傳送門,也歡迎提意見和交流。

實現細節

Goroutine Pool 的設計原理前面已經講過了,整個調度過程相信大家應該可以理解了,但是有一句老話說得好,空談誤國,實幹興邦,設計思路有了,具體實現的時候肯定會有很多細節、難點,接下來我們通過分析這個 Goroutine Pool 的幾個核心實現以及它們的聯動來引導大家過一遍 Goroutine Pool 的原理。

首先是Pool struct:
type sig struct{}

type f func() error

// Pool accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type Pool struct {
	// capacity of the pool.
	capacity int32

	// running is the number of the currently running goroutines.
	running int32

	// expiryDuration set the expired time (second) of every worker.
	expiryDuration time.Duration

	// workers is a slice that store the available workers.
	workers []*Worker

	// release is used to notice the pool to closed itself.
	release chan sig

	// lock for synchronous operation.
	lock sync.Mutex

	once sync.Once
}

Pool是一個通用的協程池,支持不同類型的任務,亦即每一個任務綁定一個函數提交到池中,批量執行不同類型任務,是一種廣義的協程池;本項目中還實現了另一種協程池 — 批量執行同類任務的協程池PoolWithFunc,每一個PoolWithFunc只會綁定一個任務函數pf,這種 Pool 適用於大批量相同任務的場景,因爲每個 Pool 只綁定一個任務函數,因此PoolWithFunc相較於Pool會更加節省內存,但通用性就不如前者了,爲了讓大家更好地理解協程池的原理,這裏我們用通用的Pool來分析。

capacity是該 Pool 的容量,也就是開啓 worker 數量的上限,每一個 worker 綁定一個 goroutine;running是當前正在執行任務的 worker 數量;expiryDuration是 worker 的過期時長,在空閒隊列中的 worker 的最新一次運行時間與當前時間之差如果大於這個值則表示已過期,定時清理任務會清理掉這個 worker;workers是一個 slice,用來存放空閒 worker,請求進入 Pool 之後會首先檢查workers中是否有空閒 worker,若有則取出綁定任務執行,否則判斷當前運行的 worker 是否已經達到容量上限,是—阻塞等待,否—新開一個 worker 執行任務;release是當關閉該 Pool 支持通知所有 worker 退出運行以防 goroutine 泄露;lock是一個鎖,用以支持 Pool 的同步操作;once用在確保 Pool 關閉操作只會執行一次。

初始化 Pool 並啓動定期清理過期 worker 任務
// NewPool generates a instance of ants pool
func NewPool(size int) (*Pool, error) {
	return NewTimingPool(size, DefaultCleanIntervalTime)
}

// NewTimingPool generates a instance of ants pool with a custom timed task
func NewTimingPool(size, expiry int) (*Pool, error) {
	if size <= 0 {
		return nil, ErrInvalidPoolSize
	}
	if expiry <= 0 {
		return nil, ErrInvalidPoolExpiry
	}
	p := &Pool{
		capacity:       int32(size),
		freeSignal:     make(chan sig, math.MaxInt32),
		release:        make(chan sig, 1),
		expiryDuration: time.Duration(expiry) * time.Second,
	}
	// 啓動定期清理過期worker任務,獨立goroutine運行,
	// 進一步節省系統資源
	p.monitorAndClear()
	return p, nil
}

提交任務到 Pool

p.Submit(task f)如下:

// Submit submit a task to pool
func (p *Pool) Submit(task f) error {
	if len(p.release) > 0 {
		return ErrPoolClosed
	}
	w := p.getWorker()
	w.task <- task
	return nil
}

第一個 if 判斷當前 Pool 是否已被關閉,若是則不再接受新任務,否則獲取一個 Pool 中可用的 worker,綁定該task執行。

獲取可用 worker(核心)

p.getWorker()源碼:

// getWorker returns a available worker to run the tasks.
func (p *Pool) getWorker() *Worker {
	var w *Worker
	// 標誌變量,判斷當前正在運行的worker數量是否已到達Pool的容量上限
	waiting := false
	// 加鎖,檢測隊列中是否有可用worker,並進行相應操作
	p.lock.Lock()
	idleWorkers := p.workers
	n := len(idleWorkers) - 1
	// 當前隊列中無可用worker
	if n < 0 {
		// 判斷運行worker數目已達到該Pool的容量上限,置等待標誌
		waiting = p.Running() >= p.Cap()

		// 當前隊列有可用worker,從隊列尾部取出一個使用
	} else {
		w = idleWorkers[n]
		idleWorkers[n] = nil
		p.workers = idleWorkers[:n]
	}
	// 檢測完成,解鎖
	p.lock.Unlock()
	// Pool容量已滿,新請求等待
	if waiting {
		// 利用鎖阻塞等待直到有空閒worker
		for {
			p.lock.Lock()
			idleWorkers = p.workers
			l := len(idleWorkers) - 1
			if l < 0 {
				p.lock.Unlock()
				continue
			}
			w = idleWorkers[l]
			idleWorkers[l] = nil
			p.workers = idleWorkers[:l]
			p.lock.Unlock()
			break
		}
		// 當前無空閒worker但是Pool還沒有滿,
		// 則可以直接新開一個worker執行任務
	} else if w == nil {
		w = &Worker{
			pool: p,
			task: make(chan f, 1),
		}
		w.run()
		// 運行worker數加一
		p.incRunning()
	}
	return w
}

上面的源碼中加了較爲詳細的註釋,結合前面的設計思路,相信大家應該能理解獲取可用 worker 綁定任務執行這個協程池的核心操作,主要就是實現一個 LIFO 隊列用來存取可用 worker 達到資源複用的效果,之所以採用 LIFO 後進先出隊列是因爲後進先出可以保證空閒 worker 隊列是按照每個 worker 的最後運行時間從遠到近的順序排列,方便在後續定期清理過期 worker 時排序以及清理完之後重新分配空閒 worker 隊列,這裏還要關注一個地方:達到 Pool 容量限制之後,額外的任務請求需要阻塞等待 idle worker,這裏是爲了防止無節制地創建 goroutine,事實上 Go 調度器有一個複用機制,每次使用go關鍵字的時候它會檢查當前結構體 M 中的 P 中,是否有可用的結構體 G。如果有,則直接從中取一個,否則,需要分配一個新的結構體 G。如果分配了新的 G,需要將它掛到 runtime 的相關隊列中,但是調度器卻沒有限制 goroutine 的數量,這在瞬時性 goroutine 爆發的場景下就可能來不及複用 G 而依然創建了大量的 goroutine,所以ants除了複用還做了限制 goroutine 數量。

其他部分可以依照註釋理解,這裏不再贅述。

任務執行
// Worker is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type Worker struct {
	// pool who owns this worker.
	pool *Pool

	// task is a job should be done.
	task chan f

	// recycleTime will be update when putting a worker back into queue.
	recycleTime time.Time
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *Worker) run() {
	go func() {
		// 循環監聽任務列表,一旦有任務立馬取出運行
		for f := range w.task {
			if f == nil {
				// 退出goroutine,運行worker數減一
				w.pool.decRunning()
				return
			}
			f()
			// worker回收複用
			w.pool.putWorker(w)
		}
	}()
}

結合前面的p.Submit(task f)和p.getWorker(),提交任務到 Pool 之後,獲取一個可用 worker,每新建一個 worker 實例之時都需要調用w.run()啓動一個 goroutine 監聽 worker 的任務列表task,一有任務提交進來就執行;所以,當調用 worker 的sendTask(task f)方法提交任務到 worker 的任務隊列之後,馬上就可以被接收並執行,當任務執行完之後,會調用w.pool.putWorker(w *Worker)方法將這個已經執行完任務的 worker 從當前任務解綁放回 Pool 中,以供下個任務可以使用,至此,一個任務從提交到完成的過程就此結束,Pool 調度將進入下一個循環。

Worker 回收(goroutine 複用)
// putWorker puts a worker back into free pool, recycling the goroutines.
func (p *Pool) putWorker(worker *Worker) {
	// 寫入回收時間,亦即該worker的最後一次結束運行的時間
	worker.recycleTime = time.Now()
	p.lock.Lock()
	p.workers = append(p.workers, worker)
	p.lock.Unlock()
}

動態擴容或者縮小池容量
// ReSize change the capacity of this pool
func (p *Pool) ReSize(size int) {
	if size == p.Cap() {
		return
	}
	atomic.StoreInt32(&p.capacity, int32(size))
	diff := p.Running() - size
	if diff > 0 {
		for i := 0; i < diff; i++ {
			p.getWorker().task <- nil
		}
	}
}

定期清理過期 Worker
// clear expired workers periodically.
func (p *Pool) periodicallyPurge() {
	heartbeat := time.NewTicker(p.expiryDuration)
	for range heartbeat.C {
		currentTime := time.Now()
		p.lock.Lock()
		idleWorkers := p.workers
		if len(idleWorkers) == 0 && p.Running() == 0 && len(p.release) > 0 {
			p.lock.Unlock()
			return
		}
		n := 0
		for i, w := range idleWorkers {
			if currentTime.Sub(w.recycleTime) <= p.expiryDuration {
				break
			}
			n = i
			w.task <- nil
			idleWorkers[i] = nil
		}
		n++
		if n >= len(idleWorkers) {
			p.workers = idleWorkers[:0]
		} else {
			p.workers = idleWorkers[n:]
		}
		p.lock.Unlock()
	}
}

定期檢查空閒 worker 隊列中是否有已過期的 worker 並清理:因爲採用了 LIFO 後進先出隊列存放空閒 worker,所以該隊列默認已經是按照 worker 的最後運行時間由遠及近排序,可以方便地按順序取出空閒隊列中的每個 worker 並判斷它們的最後運行時間與當前時間之差是否超過設置的過期時長,若是,則清理掉該 goroutine,釋放該 worker,並且將剩下的未過期 worker 重新分配到當前 Pool 的空閒 worker 隊列中,進一步節省系統資源。

概括起來,ants Goroutine Pool 的調度過程圖示如下:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

彩蛋

還記得前面我說除了通用的Pool struct之外,本項目還實現了一個PoolWithFunc struct—一個執行批量同類任務的協程池,PoolWithFunc相較於Pool,因爲一個池只綁定一個任務函數,省去了每一次 task 都需要傳送一個任務函數的代價,因此其性能優勢比起Pool更明顯,這裏我們稍微講一下一個協程池只綁定一個任務函數的細節:

上碼!

type pf func(interface{}) error

// PoolWithFunc accept the tasks from client,it limits the total
// of goroutines to a given number by recycling goroutines.
type PoolWithFunc struct {
	// capacity of the pool.
	capacity int32

	// running is the number of the currently running goroutines.
	running int32

	// expiryDuration set the expired time (second) of every worker.
	expiryDuration time.Duration

	// workers is a slice that store the available workers.
	workers []*WorkerWithFunc

	// release is used to notice the pool to closed itself.
	release chan sig

	// lock for synchronous operation.
	lock sync.Mutex

	// pf is the function for processing tasks.
	poolFunc pf

	once sync.Once
}

PoolWithFunc struct中的大部分字段和Pool struct基本一致,重點關注poolFunc pf,這是一個函數類型,也就是該 Pool 綁定的指定任務函數,而 client 提交到這種類型的 Pool 的數據就不再是一個任務函數task f了,而是poolFunc pf任務函數的形參,然後交由WorkerWithFunc處理:

// WorkerWithFunc is the actual executor who runs the tasks,
// it starts a goroutine that accepts tasks and
// performs function calls.
type WorkerWithFunc struct {
	// pool who owns this worker.
	pool *PoolWithFunc

	// args is a job should be done.
	args chan interface{}

	// recycleTime will be update when putting a worker back into queue.
	recycleTime time.Time
}

// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *WorkerWithFunc) run() {
	go func() {
		for args := range w.args {
			if args == nil {
				w.pool.decRunning()
				return
			}
			w.pool.poolFunc(args)
			w.pool.putWorker(w)
		}
	}()
}

上面的源碼可以看到WorkerWithFunc是一個類似Worker的結構,只不過監聽的是函數的參數隊列,每接收到一個參數包,就直接調用PoolWithFunc綁定好的任務函數poolFunc pf任務函數執行任務,接下來的流程就和Worker是一致的了,執行完任務後就把 worker 放回協程池,等待下次使用。

至於其他邏輯如提交task、獲取Worker綁定任務等基本複用自Pool struct,具體細節有細微差別,但原理一致,萬變不離其宗,有興趣的同學可以看我在 GitHub 上的源碼:Goroutine Pool 協程池 ants 。

Benchmarks

吹了這麼久的 Goroutine Pool,那都是虛的,理論上池化可以複用 goroutine,提升性能節省內存,沒有 benchmark 數據之前,好像也不能服衆哈!所以,本章就來進行一次實測,驗證一下再大規模 goroutine 併發的場景下,Goroutine Pool 的表現是不是真的比原生 Goroutine 併發更好!

測試機器參數:

Pool 測試

測試代碼傳送門

測試結果:
在這裏插入圖片描述
這裏爲了模擬大規模 goroutine 的場景,兩次測試的併發次數分別是 100w 和 1000w,前兩個測試分別是執行 100w 個併發任務不使用 Pool 和使用了ants的 Goroutine Pool 的性能,後兩個則是 1000w 個任務下的表現,可以直觀的看出在執行速度和內存使用上,ants的 Pool 都佔有明顯的優勢。100w 的任務量,使用ants,執行速度與原生 goroutine 相當甚至略快,但只實際使用了不到 5w 個 goroutine 完成了全部任務,且內存消耗僅爲原生併發的 40%;而當任務量達到 1000w,優勢則更加明顯了:用了 70w 左右的 goroutine 完成全部任務,執行速度比原生 goroutine 提高了 100%,且內存消耗依舊保持在不使用 Pool 的 40% 左右。

PoolWithFunc 測試

測試代碼傳送門

測試結果:
在這裏插入圖片描述

  • Benchmarkxxx-4 格式爲基準測試函數名-GOMAXPROCS,後面的-4 代表測試函數運行時對應的 CPU 核數
  • 1 表示執行的次數
  • xx ns/op 表示每次的執行時間
  • xx B/op 表示每次執行分配的總字節數(內存消耗)
  • xx allocs/op 表示每次執行發生了多少次內存分配
    因爲PoolWithFunc這個 Pool 只綁定一個任務函數,也即所有任務都是運行同一個函數,所以相較於Pool對原生 goroutine 在執行速度和內存消耗的優勢更大,上面的結果可以看出,執行速度可以達到原生 goroutine 的 300%,而內存消耗的優勢已經達到了兩位數的差距,原生 goroutine 的內存消耗達到了ants的 35 倍且原生 goroutine 的每次執行的內存分配次數也達到了ants45 倍,1000w 的任務量,ants的初始分配容量是 5w,因此它完成了所有的任務依舊只使用了 5w 個 goroutine!事實上,ants的 Goroutine Pool 的容量是可以自定義的,也就是說使用者可以根據不同場景對這個參數進行調優直至達到最高性能。
吞吐量測試

上面的 benchmarks 出來以後,我當時的內心是這樣的:
在這裏插入圖片描述
但是太順利反而讓我疑惑,因爲結合我過去這 20 幾年的坎坷人生來看,事情應該不會這麼美好纔對,果不其然,細細一想,雖然ants Groutine Pool 能在大規模併發下執行速度和內存消耗都對原生 goroutine 佔有明顯優勢,但前面的測試 demo 相信大家注意到了,裏面使用了 WaitGroup,也就是用來對 goroutine 同步的工具,所以上面的 benchmarks 中主進程會等待所有子 goroutine 完成任務後纔算完成一次性能測試,然而又有多少場景是單臺機器需要扛 100w 甚至 1000w 同步任務的?基本沒有啊!結果就是造出了屠龍刀,可是世界上沒有龍啊!也是無情…

彼時,我內心變成了這樣:
幸好,ants在同步批量任務方面有點曲高和寡,但是如果是異步批量任務的場景下,就有用武之地了,也就是說,在大批量的任務無須同步等待完成的情況下,可以再測一下ants和原生 goroutine 併發的性能對比,這個時候的性能對比也即是吞吐量對比了,就是在相同大規模數量的請求涌進來的時候,ants和原生 goroutine 誰能用更快的速度、更少的內存『吞』完這些請求。

測試代碼傳送門

測試結果:

10w 吞吐量

在這裏插入圖片描述

100w 吞吐量

在這裏插入圖片描述

1000w 吞吐量

在這裏插入圖片描述
因爲在我的電腦上測試 1000w 吞吐量的時候原生 goroutine 已經到了極限,因此程序直接把電腦拖垮了,無法正常測試了,所以 1000w 吞吐的測試數據只有antsPool 的。

從該 demo 測試吞吐性能對比可以看出,使用ants的吞吐性能相較於原生 goroutine 可以保持在 26 倍的性能壓制,而內存消耗則可以達到 1020 倍的節省優勢。

總結

至此,一個高性能的 Goroutine Pool 開發就完成了,事實上,原理不難理解,總結起來就是一個『複用』,具體落實到代碼細節就是鎖同步、原子操作、channel 通信等這些技巧的使用,ant這整個項目沒有藉助任何第三方的庫,用 golang 的標準庫就完成了所有功能,因爲本身 golang 的語言原生庫已經足夠優秀,很多時候開發 golang 項目的時候是可以保持輕量且高性能的,未必事事需要藉助第三方庫。

關於ants的價值,其實前文也提及過了,ants在大規模的異步&同步批量任務處理都有着明顯的性能優勢(特別是異步批量任務),而單機上百萬上千萬的同步批量任務處理現實意義不大,但是在異步批量任務處理方面有很大的應用價值,所以我個人覺得,Goroutine Pool 真正的價值還是在:

  1. 限制併發的 goroutine 數量;
  2. 複用 goroutine,減輕 runtime 調度壓力,提升程序性能;
  3. 規避過多的 goroutine 侵佔系統資源(CPU&內存)。
後記

Go 語言的三位最初的締造者 — Rob Pike、Robert Griesemer 和 Ken Thompson 中,Robert Griesemer 參與設計了 Java 的 HotSpot 虛擬機和 Chrome 瀏覽器的 JavaScript V8 引擎,Rob Pike 在大名鼎鼎的 bell lab 侵淫多年,參與了 Plan9 操作系統、C 編譯器以及多種語言編譯器的設計和實現,Ken Thompson 更是圖靈獎得主、Unix 之父、C 語言之父。這三人在計算機史上可是元老級別的人物,特別是 Ken Thompson ,是一手締造了 Unix 和 C 語言計算機領域的上古大神,所以 Go 語言的設計哲學有着深深的 Unix 烙印:簡單、模塊化、正交、組合、pipe、功能短小且聚焦等;而令許多開發者青睞於 Go 的簡潔、高效編程模式的原因,也正在於此。

在這裏插入圖片描述
本文從三大線程模型到 Go 併發調度器再到自定製的 Goroutine Pool,算是較爲完整的窺探了整個 Go 語言併發模型的前世今生,我們也可以看到,Go 的設計當然不完美,比如一直被詬病的 error 處理模式、不支持泛型、差強人意的包管理以及面向對象模式的過度抽象化等等,實際上沒有任何一門編程語言敢說自己是完美的,還是那句話,任何不考慮應用場景和語言定位的爭執都毫無意義,而 Go 的定位從出道開始就是系統編程語言&雲計算編程語言(這個有點模糊),而 Go 的作者們也一直堅持的是用最簡單抽象的工程化設計完成最複雜的功能,所以如果從這個層面去看 Go 的併發模型,就可以看出其實除了 G-P-M 模型中引入的 P ,並沒有太多革新的原創理論,兩級線程模型是早已成熟的理論,搶佔式調度更不是什麼新鮮的調度模式,Go 的偉大之處是在於它誕生之初就是依照Go 在谷歌:以軟件工程爲目的的語言設計而設計的,Go 其實就是將這些經典的理論和技術以一種優雅高效的工程化方式組合了起來,並用簡單抽象的 API 或語法糖開放給使用者,Go 一直致力於找尋一個高性能&開發效率的雙贏點,目前爲止,它做得遠不夠完美,但足夠優秀。另外 Go 通過引入 channel 與 goroutine 協同工作,將一種區別於鎖&原子操作的併發編程模式 — CSP 帶入了 Go 語言,對開發人員在併發編程模式上的思考有很大的啓發。

從本文中對 Go 調度器的分析以及antsGoroutine Pool 的設計與實現過程,對 Go 的併發模型做了一次解構和優化思考,在ants中的代碼實現對鎖同步、原子操作、channel 通信的使用也做了一次較爲全面的實踐,希望對 Gopher 們在 Go 語言併發模型與併發編程的理解上能有所裨益。

感謝閱讀。

參考
  • Go 併發編程實戰(第 2 版)
  • Go 語言學習筆記
  • go-coding-in-go-way
  • 也談 goroutine 調度器
  • [The Go scheduler]
發佈了8 篇原創文章 · 獲贊 18 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章