也談goroutine調度器

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

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. 著作權.

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.