也談goroutine排程器

hongmingover發表於2018-11-02

一個程式設計師的心路歷程

也談goroutine排程器

Go語言在2016年再次拿下TIBOE年度程式語言稱號,這充分證明了Go語言這幾年在全世界範圍內的受歡迎程度。如果要對世界範圍內的gopher發起一次“你究竟喜歡Go的哪一點”的調查,我相信很多Gopher會提到:goroutine

GoroutineGo語言原生支援併發的具體實現,你的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上去執行。傳統的程式語言比如CC++等的併發實現實際上就是基於作業系統排程的,即程式負責建立執行緒(一般通過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演算法,這個模型一直沿用至今:

img{512x368}

有名人曾說過:“電腦科學領域的任何問題都可以通過增加一個間接的中間層來解決”,我覺得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 TaylorGo 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.go
type 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:nowritebarrierrec
func 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 // 10ms

func 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=:6060
SCHED 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=:6060

SCHED 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=1

SCHED 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. 版權所有.

Related posts:

  1. Go語言TCP Socket程式設計
  2. Goroutine是如何工作的
  3. 論golang Timer Reset方法使用的正確姿勢
  4. Go coding in go way
  5. Go程式除錯、分析與優化

相關文章