這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
Go語言在2016年再次拿下TIBOE年度程式設計語言稱號,這充分證明了Go語言這幾年在全世界範圍內的受歡迎程度。如果要對世界範圍內的gopher發起一次“你究竟喜歡Go的哪一點”的調查,我相信很多Gopher會提到:goroutine。
Goroutine是Go語言原生支援並發的具體實現,你的Go代碼都無一例外地跑在goroutine中。你可以啟動許多甚至成千上萬的goroutine,Go的runtime負責對goroutine進行管理。所謂的管理就是“調度”,粗糙地說調度就是決定何時哪個goroutine將獲得資源開始執行、哪個goroutine應該停止執行讓出資源、哪個goroutine應該被喚醒恢複執行等。goroutine的調度是Go team care的事情,大多數gopher們無需關心。但個人覺得適當瞭解一下Goroutine的調度模型和原理,對於編寫出更好的go代碼是大有裨益的。因此,在這篇文章中,我將和大家一起來探究一下goroutine調度器的演化以及模型/原理。
注意:這裡要寫的並不是對goroutine調度器的源碼分析,國內的雨痕老師在其《Go語言學習筆記》一書的下卷“源碼剖析”中已經對Go 1.5.1的scheduler實現做了細緻且高品質的源碼分析了,對Go scheduler的實現特別感興趣的gopher可以移步到這本書中去^0^。這裡關於goroutine scheduler的介紹主要是參考了Go team有關scheduler的各種design doc、國外Gopher發表的有關scheduler的資料,當然雨痕老師的書也給我了很多的啟示。
一、Goroutine調度器
提到“調度”,我們首先想到的就是作業系統對進程、線程的調度。作業系統調度器會將系統中的多個線程按照一定演算法調度到物理CPU上去運行。傳統的程式設計語言比如C、C++等的並發實現實際上就是基於作業系統調度的,即程式負責建立線程(一般通過pthread等lib調用實現),作業系統負責調度。這種傳統支援並發的方式有諸多不足:
複雜
- 建立容易,退出難:做過C/C++ Programming的童鞋都知道,建立一個thread(比如利用pthread)雖然參數也不少,但好歹可以接受。但一旦涉及到thread的退出,就要考慮thread是detached,還是需要parent thread去join?是否需要在thread中設定cancel point,以保證join時能順利退出?
- 並發單元間通訊困難,易錯:多個thread之間的通訊雖然有多種機制可選,但用起來是相當複雜;並且一旦涉及到shared memory,就會用到各種lock,死結便成為家常便飯;
- thread stack size的設定:是使用預設的,還是設定的大一些,或者小一些呢?
難於scaling
- 一個thread的代價已經比進程小了很多了,但我們依然不能大量建立thread,因為除了每個thread佔用的資源不小之外,作業系統調度切換thread的代價也不小;
- 對於很多網路服務程式,由於不能大量建立thread,就要在少量thread裡做網路多工,即:使用epoll/kqueue/IoCompletionPort這套機制,即便有libevent/libev這樣的第三方庫幫忙,寫起這樣的程式也是很不易的,存在大量callback,給程式員帶來不小的心智負擔。
為此,Go採用了使用者層輕量級thread或者說是類coroutine的概念來解決這些問題,Go將之稱為”goroutine“。goroutine佔用的資源非常小(Go 1.4將每個goroutine stack的size預設設定為2k),goroutine調度的切換也不用陷入(trap)作業系統核心層完成,代價很低。因此,一個Go程式中可以建立成千上萬個並發的goroutine。所有的Go代碼都在goroutine中執行,哪怕是go的runtime也不例外。將這些goroutines按照一定演算法放到“CPU”上執行的程式就稱為goroutine調度器或goroutine scheduler。
不過,一個Go程式對於作業系統來說只是一個使用者層程式,對於作業系統而言,它的眼中只有thread,它甚至不知道有什麼叫Goroutine的東西的存在。goroutine的調度全要靠Go自己完成,實現Go程式內goroutine之間“公平”的競爭“CPU”資源,這個任務就落到了Go runtime頭上,要知道在一個Go程式中,除了使用者代碼,剩下的就是go runtime了。
於是Goroutine的調度問題就演變為go runtime如何將程式內的眾多goroutine按照一定演算法調度到“CPU”資源上運行了。在作業系統層面,Thread競爭的“CPU”資源是真實的物理CPU,但在Go程式層面,各個Goroutine要競爭的”CPU”資源是什麼呢?Go程式是使用者層程式,它本身整體是運行在一個或多個作業系統線程上的,因此goroutine們要競爭的所謂“CPU”資源就是作業系統線程。這樣Go scheduler的任務就明確了:將goroutines按照一定演算法放到不同的作業系統線程中去執行。這種在語言層面內建調度器的,我們稱之為原生支援並發。
二、Go調度器模型與演化過程
1、G-M模型
2012年3月28日,Go 1.0正式發布。在這個版本中,Go team實現了一個簡單的調度器。在這個調度器中,每個goroutine對應於runtime中的一個抽象結構:G,而os thread作為“物理CPU”的存在而被抽象為一個結構:M(machine)。這個結構雖然簡單,但是卻存在著許多問題。前Intel blackbelt工程師、現Google工程師Dmitry Vyukov在其《Scalable Go Scheduler Design》一文中指出了G-M模型的一個重要不足: 限制了Go並發程式的伸縮性,尤其是對那些有高吞吐或並行計算需求的服務程式。主要體現在如下幾個方面:
- 單一全域互斥鎖(Sched.Lock)和集中狀態儲存的存在導致所有goroutine相關操作,比如:建立、重新調度等都要上鎖;
- goroutine傳遞問題:M經常在M之間傳遞”可運行”的goroutine,這導致調度延遲增大以及額外的效能損耗;
- 每個M做記憶體緩衝,導致記憶體佔用過高,資料局部性較差;
- 由於syscall調用而形成的劇烈的worker thread阻塞和解除阻塞,導致額外的效能損耗。
2、G-P-M模型
於是Dmitry Vyukov親自操刀改進Go scheduler,在Go 1.1中實現了G-P-M調度模型和work stealing演算法,這個模型一直沿用至今:
有名人曾說過:“電腦科學領域的任何問題都可以通過增加一個間接的中介層來解決”,我覺得Dmitry Vyukov的G-P-M模型恰是這一理論的踐行者。Dmitry Vyukov通過向G-M模型中增加了一個P,實現了Go scheduler的scalable。
P是一個“邏輯Proccessor”,每個G要想真正運行起來,首先需要被分配一個P(進入到P的local runq中,這裡暫忽略global runq那個環節)。對於G來說,P就是運行它的“CPU”,可以說:G的眼裡只有P。但從Go scheduler視角來看,真正的“CPU”是M,只有將P和M綁定才能讓P的runq中G得以真實運行起來。這樣的P與M的關係,就好比Linux作業系統調度層面使用者線程(user thread)與核心線程(kernel thread)的對應關係那樣(N x M)。
3、搶佔式調度
G-P-M模型的實現算是Go scheduler的一大進步,但Scheduler仍然有一個頭疼的問題,那就是不支援搶佔式調度,導致一旦某個G中出現死迴圈或永久迴圈的代碼邏輯,那麼G將永久佔用分配給它的P和M,位於同一個P中的其他G將得不到調度,出現“餓死”的情況。更為嚴重的是,當只有一個P時(GOMAXPROCS=1)時,整個Go程式中的其他G都將“餓死”。於是Dmitry Vyukov又提出了《Go Preemptive Scheduler Design》並在Go 1.2中實現了“搶佔式”調度。
這個搶佔式調度的原理則是在每個函數或方法的入口,加上一段額外的代碼,讓runtime有機會檢查是否需要執行搶佔調度。這種解決方案只能說局部解決了“餓死”問題,對於沒有函數調用,純演算法迴圈計算的G,scheduler依然無法搶佔。
4、NUMA調度模型
從Go 1.2以後,Go似乎將重點放在了對GC的低延遲的最佳化上了,對scheduler的最佳化和改進似乎不那麼熱心了,只是伴隨著GC的改進而作了些小的改動。Dmitry Vyukov在2014年9月提出了一個新的proposal design doc:《NUMA‐aware scheduler for Go》,作為未來Go scheduler演化方向的一個提議,不過至今似乎這個proposal也沒有列入開發計劃。
5、其他最佳化
Go runtime已經實現了netpoller,這使得即便G發起網路I/O操作也不會導致M被阻塞(僅阻塞G),從而不會導致大量M被建立出來。但是對於regular file的I/O操作一旦阻塞,那麼M將進入sleep狀態,等待I/O返回後被喚醒;這種情況下P將與sleep的M分離,再選擇一個idle的M。如果此時沒有idle的M,則會新建立一個M,這就是為何大量I/O操作導致大量Thread被建立的原因。
Ian Lance Taylor在Go 1.9 dev周期中增加了一個Poller for os package的功能,這個功能可以像netpoller那樣,在G操作支援pollable的fd時,僅阻塞G,而不阻塞M。不過該功能依然不能對regular file有效,regular file不是pollable的。不過,對於scheduler而言,這也算是一個進步了。
三、Go調度器原理的進一步理解
1、G、P、M
關於G、P、M的定義,大家可以參見$GOROOT/src/runtime/runtime2.go這個源檔案。這三個struct都是大塊兒頭,每個struct定義都包含十幾個甚至二、三十個欄位。像scheduler這樣的核心代碼向來很複雜,考慮的因素也非常多,代碼“耦合”成一坨。不過從複雜的代碼中,我們依然可以看出來G、P、M的各自大致用途(當然雨痕老師的源碼分析功不可沒),這裡簡要說明一下:
- G: 表示goroutine,儲存了goroutine的執行stack資訊、goroutine狀態以及goroutine的任務函數等;另外G對象是可以重用的。
- P: 表示邏輯processor,P的數量決定了系統內最大可並行的G的數量(前提:系統的物理cpu核心數>=P的數量);P的最大作用還是其擁有的各種G對象隊列、鏈表、一些cache和狀態。
- M: M代表著真正的執行計算資源。在綁定有效p後,進入schedule迴圈;而schedule迴圈的機制大致是從各種隊列、p的本地隊列中擷取G,切換到G的執行棧上並執行G的函數,調用goexit做清理工作並回到m,如此反覆。M並不保留G狀態,這是G可以跨M調度的基礎。
下面是G、P、M定義的程式碼片段://src/runtime/runtime2.gotype g struct { stack stack // offset known to runtime/cgo sched gobuf goid int64 gopc uintptr // pc of go statement that created this goroutine startpc uintptr // pc of goroutine function ... ...}type p struct { lock mutex id int32 status uint32 // one of pidle/prunning/... mcache *mcache racectx uintptr // Queue of runnable goroutines. Accessed without lock. runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr // Available G's (status == Gdead) gfree *g gfreecnt int32 ... ...}type m struct { g0 *g // goroutine with scheduling stack mstartfn func() curg *g // current running goroutine .... ..}
2、G被搶佔調度
和作業系統按時間片調度線程不同,Go並沒有時間片的概念。如果某個G沒有進行system call調用、沒有進行I/O操作、沒有阻塞在一個channel操作上,那麼m是如何讓G停下來並調度下一個runnable G的呢?答案是:G是被搶佔調度的。
前面說過,除非極端的無限迴圈或死迴圈,否則只要G調用函數,Go runtime就有搶佔G的機會。Go程式啟動時,runtime會去啟動一個名為sysmon的m(一般稱為監控線程),該m無需綁定p即可運行,該m在整個Go程式的運行過程中至關重要:
//$GOROOT/src/runtime/proc.go// The main goroutine.func main() { ... ... systemstack(func() { newm(sysmon, nil) }) .... ...}// Always runs without a P, so write barriers are not allowed.////go:nowritebarrierrecfunc sysmon() { // If a heap span goes unused for 5 minutes after a garbage collection, // we hand it back to the operating system. scavengelimit := int64(5 * 60 * 1e9) ... ... if .... { ... ... // retake P's blocked in syscalls // and preempt long running G's if retake(now) != 0 { idle = 0 } else { idle++ } ... ... }}
sysmon每20us~10ms啟動一次,按照《Go語言學習筆記》中的總結,sysmon主要完成如下工作:
- 釋放閑置超過5分鐘的span實體記憶體;
- 如果超過2分鐘沒有記憶體回收,強制執行;
- 將長時間未處理的netpoll結果添加到任務隊列;
- 向長時間啟動並執行G任務發出搶佔調度;
- 收回因syscall長時間阻塞的P;
我們看到sysmon將“向長時間啟動並執行G任務發出搶佔調度”,這個事情由retake實施:
// forcePreemptNS is the time slice given to a G before it is// preempted.const forcePreemptNS = 10 * 1000 * 1000 // 10msfunc retake(now int64) uint32 { ... ... // Preempt G if it's running for too long. t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now continue } if pd.schedwhen+forcePreemptNS > now { continue } preemptone(_p_) ... ...}
可以看出,如果一個G任務運行10ms,sysmon就會認為其已耗用時間太久而發出搶佔式調度的請求。一旦G的搶佔標誌位被設為true,那麼待這個G下一次調用函數或方法時,runtime便可以將G搶佔,並移出運行狀態,放入P的local runq中,等待下一次被調度。
3、channel阻塞或network I/O情況下的調度
如果G被阻塞在某個channel操作或network I/O操作上時,G會被放置到某個wait隊列中,而M會嘗試運行下一個runnable的G;如果此時沒有runnable的G供m運行,那麼m將解除綁定P,並進入sleep狀態。當I/O available或channel操作完成,在wait隊列中的G會被喚醒,標記為runnable,放入到某P的隊列中,綁定一個M繼續執行。
4、system call阻塞情況下的調度
如果G被阻塞在某個system call操作上,那麼不光G會阻塞,執行該G的M也會解除綁定P(實質是被sysmon搶走了),與G一起進入sleep狀態。如果此時有idle的M,則P與其綁定繼續執行其他G;如果沒有idle M,但仍然有其他G要去執行,那麼就會建立一個新M。
當阻塞在syscall上的G完成syscall調用後,G會去嘗試擷取一個可用的P,如果沒有可用的P,那麼G會被標記為runnable,之前的那個sleep的M將再次進入sleep。
四、調度器狀態的查看方法
Go提供了調度器目前狀態的查看方法:使用Go運行時環境變數GODEBUG。
$GODEBUG=schedtrace=1000 godoc -http=:6060SCHED 0ms: gomaxprocs=4 idleprocs=3 threads=3 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0]SCHED 1001ms: gomaxprocs=4 idleprocs=0 threads=9 spinningthreads=0 idlethreads=3 runqueue=2 [8 14 5 2]SCHED 2006ms: gomaxprocs=4 idleprocs=0 threads=25 spinningthreads=0 idlethreads=19 runqueue=12 [0 0 4 0]SCHED 3006ms: gomaxprocs=4 idleprocs=0 threads=26 spinningthreads=0 idlethreads=8 runqueue=2 [0 1 1 0]SCHED 4010ms: gomaxprocs=4 idleprocs=0 threads=26 spinningthreads=0 idlethreads=20 runqueue=12 [6 3 1 0]SCHED 5010ms: gomaxprocs=4 idleprocs=0 threads=26 spinningthreads=1 idlethreads=20 runqueue=17 [0 0 0 0]SCHED 6016ms: gomaxprocs=4 idleprocs=0 threads=26 spinningthreads=0 idlethreads=20 runqueue=1 [3 4 0 10]... ...
GODEBUG這個Go運行時環境變數很是強大,通過給其傳入不同的key1=value1,key2=value2… 組合,Go的runtime會輸出不同的調試資訊,比如在這裡我們給GODEBUG傳入了”schedtrace=1000″,其含義就是每1000ms,列印輸出一次goroutine scheduler的狀態,每次一行。每一行各欄位含義如下:
以上面例子中最後一行為例:SCHED 6016ms: gomaxprocs=4 idleprocs=0 threads=26 spinningthreads=0 idlethreads=20 runqueue=1 [3 4 0 10]SCHED:調試資訊輸出標誌字串,代表本行是goroutine scheduler的輸出;6016ms:即從程式啟動到輸出這行日誌的時間;gomaxprocs: P的數量;idleprocs: 處於idle狀態的P的數量;通過gomaxprocs和idleprocs的差值,我們就可知道執行go代碼的P的數量;threads: os threads的數量,包含scheduler使用的m數量,加上runtime自用的類似sysmon這樣的thread的數量;spinningthreads: 處於自旋狀態的os thread數量;idlethread: 處於idle狀態的os thread的數量;runqueue=1: go scheduler全域隊列中G的數量;[3 4 0 10]: 分別為4個P的local queue中的G的數量。
我們還可以輸出每個goroutine、m和p的詳細調度資訊,但對於Go user來說,絕大多數時間這是不必要的:
$ GODEBUG=schedtrace=1000,scheddetail=1 godoc -http=:6060SCHED 0ms: gomaxprocs=4 idleprocs=3 threads=3 spinningthreads=0 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0 P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0 P1: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 P2: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 M2: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=-1 M1: p=-1 curg=17 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 helpgc=0 spinning=false blocked=false lockedg=17 M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=1 G1: status=8() m=0 lockedm=0 G17: status=3() m=1 lockedm=1SCHED 1002ms: gomaxprocs=4 idleprocs=0 threads=13 spinningthreads=0 idlethreads=7 runqueue=6 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0 P0: status=2 schedtick=2293 syscalltick=18928 m=-1 runqsize=12 gfreecnt=2 P1: status=1 schedtick=2356 syscalltick=19060 m=11 runqsize=11 gfreecnt=0 P2: status=2 schedtick=2482 syscalltick=18316 m=-1 runqsize=37 gfreecnt=1 P3: status=2 schedtick=2816 syscalltick=18907 m=-1 runqsize=2 gfreecnt=4 M12: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 helpgc=0 spinning=false blocked=true lockedg=-1 M11: p=1 curg=6160 mallocing=0 throwing=0 preemptoff= locks=2 dying=0 helpgc=0 spinning=false blocked=false lockedg=-1 M10: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 helpgc=0 spinning=false blocked=true lockedg=-1 ... ...SCHED 2002ms: gomaxprocs=4 idleprocs=0 threads=23 spinningthreads=0 idlethreads=5 runqueue=4 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0 P0: status=0 schedtick=2972 syscalltick=29458 m=-1 runqsize=0 gfreecnt=6 P1: status=2 schedtick=2964 syscalltick=33464 m=-1 runqsize=0 gfreecnt=39 P2: status=1 schedtick=3415 syscalltick=33283 m=18 runqsize=0 gfreecnt=12 P3: status=2 schedtick=3736 syscalltick=33701 m=-1 runqsize=1 gfreecnt=6 M22: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 helpgc=0 spinning=false blocked=true lockedg=-1 M21: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 helpgc=0 spinning=false blocked=true lockedg=-1... ...
關於go scheduler調試資訊輸出的詳細資料,可以參考Dmitry Vyukov的大作:《Debugging performance issues in Go programs》。這也應該是每個gopher必讀的經典文章。當然更詳盡的代碼可參考$GOROOT/src/runtime/proc.go中的schedtrace函數。
微博:@tonybai_cn
公眾號:iamtonybai
github.com: https://github.com/bigwhite
2017, bigwhite. 著作權.