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函數。

 

基礎

Go運行時管理調度、垃圾收集和goroutines的運行時環境。在這里,我將只關注調度程序。

運行時調度器通過將它們映射到操作系統線程來運行goroutines。Goroutines是線程的輕量級版本,啟動成本非常低。每一個goroutine都是由一個名為G的結構體描述的,它包含了跟蹤其堆棧和當前狀態所必需的字段。所以,G = goroutine。

運行時跟蹤每個G,並將它們映射到邏輯處理器上,命名為P。P可以被看作是一個抽象的資源或上下文,需要被獲取,因此OS線程(稱為M或機器)可以執行G。你可以通過調用 runtime.GOMAXPROCS(numLogicalProcessors) 來控制運行時的邏輯處理器,如果你打算調整這個參數(或許不應該),設置一次並忘記它,因為它需要“停止一切”GC暫停。

從本質上講,操作系統運行線程,執行你的代碼。Go的訣竅是,編譯器在不同的地方插入調用到Go運行時,例如通過通道發送一個值,對運行時包進行調用),這樣就可以通知調度程序並采取行動。

 

Ms,Ps&Gs之間的互動

Ms、Ps和Gs之間的交互有點復雜。看一下這個工作流程圖:

在這里我們可以看到,對於G來說有兩種類型的隊列:在“schedt”結構中有一個全局隊列(很少使用),並且每個P維護一個可運行的G隊列。

為了執行一個goroutine,M需要保存上下文P.機器,然后彈出它的goroutines,執行代碼。

當你安排一個新的goroutine(做一個go func()調用)時,它被放置到P的隊列中。這里有一個有趣的偷工調度算法,當M完成了某個G的執行,然后它試圖從隊列中取出另一個G,它是空的,然后它隨機地選擇另一個P並試圖從它中偷取一半的可運行的G!

當你的goroutine做一個阻塞的系統調用時,會發生一些有趣的事情。阻塞系統調用將被攔截,如果要運行Gs,運行時將從P中分離出線程並創建一個新的OS線程(如果空閑線程不存在的話)來服務該處理器。

當一個系統調用恢復時,goroutine被放回一個本地運行隊列,線程會自動放置(意味着線程不會運行),並將自己插入到空閑線程列表中。

如果goroutine進行網絡調用,運行時也會執行類似的操作。這個調用將被攔截,但是因為Go有一個集成的網絡輪詢器,它有自己的線程,它將被分配給它。

如果當前的goroutine被阻塞,那么運行時將運行一個不同的goroutine:

  • 阻塞系統調用(例如打開一個文件),

  • 網絡輸入,

  • 通道操作,

  • 同步包中的原語。

 

調度程序跟蹤

Go允許跟蹤運行時調度程序。這是通過GODEBUG環境變量完成的:

$ GODEBUG=scheddetail=1,schedtrace=1000 ./program

下面是它給出的輸出示例:

SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=2 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 P4: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 P5: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 P6: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 P7: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=-1 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

注意,它使用了與G、M和P以及它們的狀態相同的概念,比如P的隊列大小。通常,你不需要那么多的細節,所以你可以使用:

$ GODEBUG=schedtrace=1000 ./program

此外,還有一個名為go tool trace的高級工具,它有一個UI,允許我們探索,程序運行時正在做什么。

 

MPG畫圖示意

調度模型簡介

groutine能擁有強大的並發實現是通過GPM調度模型實現,下面就來解釋下goroutine的調度模型。

Go的調度器內部有四個重要的結構:M,P,S,Sched,如上圖所示(Sched未給出)
M:M代表內核級線程,一個M就是一個線程,goroutine就是跑在M之上的;M是一個很大的結構,里面維護小對象內存cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的信息
G:代表一個goroutine,它有自己的棧,instruction pointer和其他信息(正在等待的channel等等),用於調度。
P:P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine隊列,里面存儲了所有需要它來執行的goroutine Sched:代表調度器,它維護有存儲M和G的隊列以及調度器的一些狀態信息等。  

調度實現

 從上圖中看,有2個物理線程M,每一個M都擁有一個處理器P,每一個也都有一個正在運行的goroutine。
P的數量可以通過GOMAXPROCS()來設置,它其實也就代表了真正的並發度,即有多少個goroutine可以同時運行。
圖中灰色的那些goroutine並沒有運行,而是出於ready的就緒態,正在等待被調度。P維護着這個隊列(稱之為runqueue),
Go語言里,啟動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue隊列就在其末尾加入一個
goroutine,在下一個調度點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。

 

當一個OS線程M0陷入阻塞時(如下圖),P轉而在運行M1,圖中的M1可能是正被創建,或者從線程緩存中取出。

 


當MO返回時,它必須嘗試取得一個P來運行goroutine,一般情況下,它會從其他的OS線程那里拿一個P過來,
如果沒有拿到的話,它就把goroutine放在一個global runqueue里,然后自己睡眠(放入線程緩存里)。所有的P也會周期性的檢查global runqueue並運行其中的goroutine,否則global runqueue上的goroutine永遠無法執行。   另一種情況是P所分配的任務G很快就執行完了(分配不均),這就導致了這個處理器P很忙,但是其他的P還有任務,此時如果global runqueue沒有任務G了,那么P不得不從其他的P里拿一些G來執行。一般來說,如果P從其他的P那里要拿任務的話,一般就拿run queue的一半,這就確保了每個OS線程都能充分的使用,如下圖:  

參考地址:

http://morsmachine.dk/go-scheduler    

https://www.cnblogs.com/zkweb/p/7815600.html