併發(並行),一直以來都是一個編程語言裏的核心主題之一,也是被開發者關注最多的話題;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靜態綁定,因此其調度完全由操作系統內核調度器去做。這種模型的優勢和劣勢同樣明顯:優勢是實現簡單,直接藉助操作系統內核的線程以及調度器,所以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
裏。
通過地鼠搬磚更爲形象的理解:
我們都知道Go語言是原生支持語言級併發的,這個併發的最小邏輯單元就是goroutine。goroutine就是Go語言提供的一種用戶態線程,當然這種用戶態線程是跑在內核級線程之上的。當我們創建了很多的goroutine,並且它們都是跑在同一個內核線程之上的時候,就需要一個調度器來維護這些goroutine,確保所有的goroutine都使用cpu,並且是儘可能公平的使用cpu資源。
這個調度器的原理以及實現值得我們去深入研究一下。支撐整個調度器的主要有4個重要結構,分別是M、G、P、Sched,前三個定義在runtime.h中,Sched定義在proc.c中。
- Sched結構就是調度器,它維護有存儲M和G的隊列以及調度器的一些狀態信息等。
- M代表內核級線程,一個M就是一個線程,goroutine就是跑在M之上的;M是一個很大的結構,裏面維護小對象內存cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的信息。
- P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine隊列,裏面存儲了所有需要它來執行的goroutine,這個P的角色可能有一點讓人迷惑,一開始容易和M衝突,後面重點聊一下它們的關係。
- G就是goroutine實現的核心結構了,G維護了goroutine需要的棧、程序計數器以及它所在的M等信息。
理解M、P、G三者的關係對理解整個調度器非常重要,我從網絡上找了一個圖來說明其三者關係:
地鼠(gopher)用小車運着一堆待加工的磚。M就可以看作圖中的地鼠,P就是小車,G就是小車裏裝的磚。一圖勝千言啊,弄清楚了它們三者的關係,下面我們就開始重點聊地鼠是如何在搬運磚塊的。
#####啓動過程
在關心絕大多數程序的內部原理的時候,我們都試圖去弄明白其啓動初始化過程,弄明白這個過程對後續的深入分析至關重要。在asm_amd64.s文件中的彙編代碼_rt0_amd64就是整個啓動過程,核心過程如下:
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·hashinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
PUSHQ $runtime·main·f(SB) // entry
PUSHQ $0 // arg size
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// start this M
CALL runtime·mstart(SB)
啓動過程做了調度器初始化runtime·schedinit後,調用runtime·newproc創建出第一個goroutine,這個goroutine將執行的函數是runtime·main,這第一個goroutine也就是所謂的主goroutine。我們寫的最簡單的Go程序”hello,world”就是完全跑在這個goroutine裏,當然任何一個Go程序的入口都是從這個goroutine開始的。最後調用的runtime·mstart就是真正的執行上一步創建的主goroutine。
啓動過程中的調度器初始化runtime·schedinit函數主要根據用戶設置的GOMAXPROCS值來創建一批小車(P),不管GOMAXPROCS設置爲多大,最多也只能創建256個小車(P)。這些小車(p)初始創建好後都是閒置狀態,也就是還沒開始使用,所以它們都放置在調度器結構(Sched)的pidle
字段維護的鏈表中存儲起來了,以備後續之需。
查看runtime·main函數可以瞭解到主goroutine開始執行後,做的第一件事情是創建了一個新的內核線程(地鼠M),不過這個線程是一個特殊線程,它在整個運行期專門負責做特定的事情——系統監控(sysmon)。接下來就是進入Go程序的main函數開始Go程序的執行。
至此,Go程序就被啓動起來開始運行了。一個真正幹活的Go程序,一定創建有不少的goroutine,所以在Go程序開始運行後,就會向調度器添加goroutine,調度器就要負責維護好這些goroutine的正常執行。
#####創建goroutine(G)
在Go程序中,時常會有類似代碼:
go do_something()
go關鍵字就是用來創建一個goroutine的,後面的函數就是這個goroutine需要執行的代碼邏輯。go關鍵字對應到調度器的接口就是runtime·newproc
。runtime·newproc乾的事情很簡單,就負責製造一塊磚(G),然後將這塊磚(G)放入當前這個地鼠(M)的小車(P)中。
每個新的goroutine都需要有一個自己的棧,G結構的sched
字段維護了棧地址以及程序計數器等信息,這是最基本的調度信息,也就是說這個goroutine放棄cpu的時候需要保存這些信息,待下次重新獲得cpu的時候,需要將這些信息裝載到對應的cpu寄存器中。
假設這個時候已經創建了大量的goroutne,就輪到調度器去維護這些goroutine了。
#####創建內核線程(M)
Go程序中沒有語言級的關鍵字讓你去創建一個內核線程,你只能創建goroutine,內核線程只能由runtime根據實際情況去創建。runtime什麼時候創建線程?以地鼠運磚圖來講,磚(G)太多了,地鼠(M)又太少了,實在忙不過來,剛好還有空閒的小車(P)沒有使用,那就從別處再借些地鼠(M)過來直到把小車(p)用完爲止。這裏有一個地鼠(M)不夠用,從別處借地鼠(M)的過程,這個過程就是創建一個內核線程(M)。創建M的接口函數是:
void newm(void (*fn)(void), P *p)
newm函數的核心行爲就是調用clone系統調用創建一個內核線程,每個內核線程的開始執行位置都是runtime·mstart函數。參數p就是一輛空閒的小車(p)。
每個創建好的內核線程都從runtime·mstart函數開始執行了,它們將用分配給自己小車去搬磚了。
#####調度核心
newm接口只是給新創建的M分配了一個空閒的P,也就是相當於告訴借來的地鼠(M)——“接下來的日子,你將使用1號小車搬磚,記住是1號小車;待會自己到停車場拿車。”,地鼠(M)去拿小車(P)這個過程就是acquirep
。runtime·mstart在進入schedule
之前會給當前M裝配上P,runtime·mstart函數中的代碼:
} else if(m != &runtime·m0) {
acquirep(m->nextp);
m->nextp = nil;
}
schedule();
if分支的內容就是爲當前M裝配上P,nextp
就是newm分配的空閒小車(P),只是到這個時候才真正拿到手罷了。沒有P,M是無法執行goroutine的,就像地鼠沒有小車無法運磚一樣的道理。對應acquirep的動作是releasep,把M裝配的P給載掉;活幹完了,地鼠需要休息了,就把小車還到停車場,然後睡覺去。
地鼠(M)拿到屬於自己的小車(P)後,就進入工場開始幹活了,也就是上面的schedule
調用。簡化schedule的代碼如下:
static void
schedule(void)
{
G *gp;
gp = runqget(m->p);
if(gp == nil)
gp = findrunnable();
if (m->p->runqhead != m->p->runqtail &&
runtime·atomicload(&runtime·sched.nmspinning) == 0 &&
runtime·atomicload(&runtime·sched.npidle) > 0) // TODO: fast atomic
wakep();
execute(gp);
}
schedule函數被我簡化了太多,主要是我不喜歡貼大段大段的代碼,因此只保留主幹代碼了。這裏涉及到4大步邏輯:
runqget
, 地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當然結果可能失敗,也就是這個地鼠的小車已經空了,沒有磚了。findrunnable
, 如果地鼠自己的小車中沒有磚,那也不能閒着不幹活是吧,所以地鼠就會試圖跑去工場倉庫取一塊磚來處理;工場倉庫也可能沒磚啊,出現這種情況的時候,這個地鼠也沒有偷懶停下幹活,而是悄悄跑出去,隨機盯上一個小夥伴(地鼠),然後從它的車裏試圖偷一半磚到自己車裏。如果多次嘗試偷磚都失敗了,那說明實在沒有磚可搬了,這個時候地鼠就會把小車還回停車場,然後睡覺
休息了。如果地鼠睡覺了,下面的過程當然都停止了,地鼠睡覺也就是線程sleep了。wakep
, 到這個過程的時候,可憐的地鼠發現自己小車裏有好多磚啊,自己根本處理不過來;再回頭一看停車場居然有閒置的小車,立馬跑到宿舍一看,你妹,居然還有小夥伴在睡覺,直接給屁股一腳,“你妹,居然還在睡覺,老子都快累死了,趕緊起來幹活,分擔點工作。”,小夥伴醒了,拿上自己的小車,乖乖幹活去了。有時候,可憐的地鼠跑到宿舍卻發現沒有在睡覺的小夥伴,於是會很失望,最後只好向工場老闆說——”停車場還有閒置的車啊,我快乾不動了,趕緊從別的工場借個地鼠來幫忙吧。”,最後工場老闆就搞來一個新的地鼠幹活了。execute
,地鼠拿着磚放入火種歡快的燒練起來。
注: “地鼠偷磚”叫work stealing,一種調度算法。
到這裏,貌似整個工場都正常的運轉起來了,無懈可擊的樣子。不對,還有一個疑點沒解決啊,假設地鼠的車裏有很多磚,它把一塊磚放入火爐中後,何時把它取出來,放入第二塊磚呢?難道要一直把第一塊磚燒練好,才取出來嗎?那估計後面的磚真的是等得花兒都要謝了。這裏就是要真正解決goroutine的調度,上下文切換問題。
#####調度點 當我們翻看channel的實現代碼可以發現,對channel讀寫操作的時候會觸發調用runtime·park函數。goroutine調用park後,這個goroutine就會被設置位waiting狀態,放棄cpu。被park的goroutine處於waiting狀態,並且這個goroutine不在小車(P)中,如果不對其調用runtime·ready,它是永遠不會再被執行的。除了channel操作外,定時器中,網絡poll等都有可能park goroutine。
除了park可以放棄cpu外,調用runtime·gosched函數也可以讓當前goroutine放棄cpu,但和park完全不同;gosched是將goroutine設置爲runnable狀態,然後放入到調度器全局等待隊列(也就是上面提到的工場倉庫,這下就明白爲何工場倉庫會有磚塊(G)了吧)。
除此之外,就輪到系統調用了,有些系統調用也會觸發重新調度。Go語言完全是自己封裝的系統調用,所以在封裝系統調用的時候,可以做不少手腳,也就是進入系統調用的時候執行entersyscall,退出後又執行exitsyscall函數。 也只有封裝了entersyscall的系統調用纔有可能觸發重新調度,它將改變小車(P)的狀態爲syscall。還記一開始提到的sysmon線程嗎?這個系統監控線程會掃描所有的小車(P),發現一個小車(P)處於了syscall的狀態,就知道這個小車(P)遇到了goroutine在做系統調用,於是系統監控線程就會創建一個新的地鼠(M)去把這個處於syscall的小車給搶過來,開始幹活,這樣這個小車中的所有磚塊(G)就可以繞過之前系統調用的等待了。被搶走小車的地鼠等系統調用返回後,發現自己的車沒,不能繼續幹活了,於是只能把執行系統調用的goroutine放回到工場倉庫,自己睡覺
去了。
從goroutine的調度點可以看出,調度器還是挺粗暴的,調度粒度有點過大,公平性也沒有想想的那麼好。總之,這個調度器還是比較簡單的。
#####現場處理 goroutine在cpu上換入換出,不斷上下文切換的時候,必須要保證的事情就是保存現場
和恢復現場
,保存現場就是在goroutine放棄cpu的時候,將相關寄存器的值給保存到內存中;恢復現場就是在goroutine重新獲得cpu的時候,需要從內存把之前的寄存器信息全部放回到相應寄存器中去。
goroutine在主動放棄cpu的時候(park/gosched),都會涉及到調用runtime·mcall函數,此函數也是彙編實現,主要將goroutine的棧地址和程序計數器保存到G結構的sched
字段中,mcall就完成了現場保存。恢復現場的函數是runtime·gogocall,這個函數主要在execute
中調用,就是在執行goroutine前,需要重新裝載相應的寄存器。
大規模Goroutine的瓶頸
既然Go調度器已經這麼~~牛逼~~優秀了,我們爲什麼還要自己去實現一個golang的 Goroutine Pool 呢?事實上,優秀不代表完美,任何不考慮具體應用場景的編程模式都是耍流氓!有基於G-P-M的Go調度器背書,go程序的併發編程中,可以任性地起大規模的goroutine來執行任務,官方也宣稱用golang寫併發程序的時候隨便起個成千上萬的goroutine毫無壓力。
然而,你起1000個goroutine沒有問題,10000也沒有問題,10w個可能也沒問題;那,100w個呢?1000w個呢?(這裏只是舉個極端的例子,實際編程起這麼大規模的goroutine的例子極少)這裏就會出問題,什麼問題呢?
- 首先,即便每個goroutine只分配2KB的內存,但如果是恐怖如斯的數量,聚少成多,內存暴漲,就會對GC造成極大的負擔,寫過java的同學應該知道jvm GC那萬惡的STW(Stop The World)機制,也就是GC的時候會掛起用戶程序直到垃圾回收完,雖然Go1.8之後的GC已經去掉了STW以及優化成了並行GC,性能上有了不小的提升,但是,如果太過於頻繁地進行GC,依然會有性能瓶頸;
- 其次,還記得前面我們說的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維護了一個類似棧的FILO隊列 ,裏面存放負責處理任務的Worker,然後在client端提交task到Pool中之後,在Pool內部,接收task之後的核心操作是:
- 檢查當前Worker隊列中是否有空閒的Worker,如果有,取出執行當前的task;
- 沒有空閒Worker,判斷當前在運行的Worker是否已超過該Pool的容量,是 — 阻塞等待直至有Worker被放回Pool;否 — 新開一個Worker(goroutine)處理;
- 每個Worker執行完任務之後,放回Pool的隊列中等待。
調度過程如下:
按照這個設計思路,我實現了一個高性能的Goroutine Pool,較好地解決了上述的大規模調度和資源佔用的問題,在執行速度和內存佔用方面相較於原生goroutine併發佔有明顯的優勢,尤其是內存佔用,因爲複用,所以規避了無腦啓動大規模goroutine的弊端,可以節省大量的內存。
完整的項目代碼可以在我的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
// freeSignal is used to notice pool there are available
// workers which can be sent to work.
freeSignal chan sig
// 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數量;freeSignal
是一個信號,因爲Pool開啓的worker數量有上限,因此當全部worker都在執行任務的時候,新進來的請求就需要阻塞等待,那當執行完任務的worker被放回Pool之時,如何通知阻塞的請求綁定一個空閒的worker運行呢?freeSignal
就是來做這個事情的;workers
是一個slice,用來存放空閒worker,請求進入Pool之後會首先檢查workers
中是否有空閒worker,若有則取出綁定任務執行,否則判斷當前運行的worker是否已經達到容量上限,是—阻塞等待,否—新開一個worker執行任務;release
是當關閉該Pool支持通知所有worker退出運行以防goroutine泄露;lock
是一個鎖,用以支持Pool的同步操作;once
用在確保Pool關閉操作只會執行一次。
提交任務到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.sendTask(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數量是否已達容量上限
waiting := false
// 涉及從workers隊列取可用worker,需要加鎖
p.lock.Lock()
workers := p.workers
n := len(workers) - 1
// 當前worker隊列爲空(無空閒worker)
if n < 0 {
// 運行worker數目已達到該Pool的容量上限,置等待標誌
if p.running >= p.capacity {
waiting = true
// 否則,運行數目加1
} else {
p.running++
}
// 有空閒worker,從隊列尾部取出一個使用
} else {
<-p.freeSignal
w = workers[n]
workers[n] = nil
p.workers = workers[:n]
}
// 判斷是否有worker可用結束,解鎖
p.lock.Unlock()
if waiting {
// 阻塞等待直到有空閒worker
<-p.freeSignal
p.lock.Lock()
workers = p.workers
l := len(workers) - 1
w = workers[l]
workers[l] = nil
p.workers = workers[:l]
p.lock.Unlock()
// 當前無空閒worker但是Pool還沒有滿,
// 則可以直接新開一個worker執行任務
} else if w == nil {
w = &Worker{
pool: p,
task: make(chan f),
}
w.run()
}
return w
}
上面的源碼中加了較爲詳細的註釋,結合前面的設計思路,相信大家應該能理解獲取可用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
}
// run starts a goroutine to repeat the process
// that performs the function calls.
func (w *Worker) run() {
//atomic.AddInt32(&w.pool.running, 1)
go func() {
//監聽任務列表,一旦有任務立馬取出運行
for f := range w.task {
if f == nil {
atomic.AddInt32(&w.pool.running, -1)
return
}
f()
//回收複用
w.pool.putWorker(w)
}
}()
}
// stop this worker.
func (w *Worker) stop() {
w.sendTask(nil)
}
// sendTask sends a task to this worker.
func (w *Worker) sendTask(task f) {
w.task <- task
}
Worker回收(goroutine複用)
// putWorker puts a worker back into free pool, recycling the goroutines.
func (p *Pool) putWorker(worker *Worker) {
p.lock.Lock()
p.workers = append(p.workers, worker)
p.lock.Unlock()
p.freeSignal <- sig{}
}
結合前面的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調度將進入下一個循環。
動態擴容或者縮小池容量
// ReSize change the capacity of this pool
func (p *Pool) ReSize(size int) {
if size < p.Cap() {
diff := p.Cap() - size
for i := 0; i < diff; i++ {
p.getWorker().stop()
}
} else if size == p.Cap() {
return
}
atomic.StoreInt32(&p.capacity, int32(size))
}
概括起來,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
// freeSignal is used to notice pool there are available
// workers which can be sent to work.
freeSignal chan sig
// 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{}
}
// 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 || len(w.pool.release) > 0 {
atomic.AddInt32(&w.pool.running, -1)
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併發更好!
測試機器參數:
OS : macOS High Sierra
Processor : 2.7 GHz Intel Core i5
Memory : 8 GB 1867 MHz DDR3
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的每次執行的內存分配次數也達到了ants
45倍,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吞吐的測試數據只有ants
Pool的。
從該demo測試吞吐性能對比可以看出,使用ants
的吞吐性能相較於原生goroutine可以保持在2~6倍的性能壓制,而內存消耗則可以達到10~20倍的節省優勢。
總結
至此,一個高性能的 Goroutine Pool 開發就完成了,事實上,原理不難理解,總結起來就是一個『複用』,具體落實到代碼細節就是鎖同步、原子操作、channel通信等這些技巧的使用,ant
這整個項目沒有藉助任何第三方的庫,用golang的標準庫就完成了所有功能,因爲本身golang的語言原生庫已經足夠優秀,很多時候開發golang項目的時候是可以保持輕量且高性能的,未必事事需要藉助第三方庫。
關於ants
的價值,其實前文也提及過了,ants
在大規模的異步&同步批量任務處理都有着明顯的性能優勢(特別是異步批量任務),而單機上百萬上千萬的同步批量任務處理現實意義不大,但是在異步批量任務處理方面有很大的應用價值,所以我個人覺得,Goroutine Pool真正的價值還是在:
- 限制併發的goroutine數量;
- 複用goroutine,減輕runtime調度壓力,提升程序性能;
- 規避過多的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調度器的分析以及ants
Goroutine Pool 的設計與實現過程,對Go的併發模型做了一次解構和優化思考,在ants
中的代碼實現對鎖同步、原子操作、channel通信的使用也做了一次較爲全面的實踐,希望對Gopher們在Go語言併發模型與併發編程的理解上能有所裨益。