1. 程式人生 > 其它 >Golang 協程排程器原理及GPM模型

Golang 協程排程器原理及GPM模型

目錄

程序和執行緒

程序:執行中的程式,是對應用程式的封裝,一個應用程式的啟動到關閉的過程對應著一個程序的出生到死亡的過程,從程序中可以獲取到程式執行的相關資訊。是作業系統排程和執行的基本單位。
執行緒:存在於程序中的一條執行路徑,是CPU進行排程和資源分配的最小單元。

執行緒和程序的區別

  • 執行緒只擁有啟動所需的最小資源,一個程序中至少有一個以上的執行緒,執行緒又稱之為輕量級程序。
  • 執行緒的資源和地址空間都取自程序映像。
  • 執行緒擁有執行緒上下文,執行緒的上下文儲存了當前執行緒所指向程式碼的PC計數器,一個數據棧、處理器狀態和私有的一些資料。
  • 執行緒是CPU排程的最小單位,是程序中的一條執行路徑,是資源分配的最小單位。

在作業系統中,執行緒通常以CPU時間片輪轉的方式進行排程,CPU將一個連續的時間劃分為多個時間片,指定執行緒在特定時間片內執行,並且進行輪轉,使得多個執行緒可以在一個CPU核心的排程下,在一個連續的時間併發執行。通常一個作業系統最大的執行緒併發數為CPU核數總和,也就是一個CPU核心同一個時刻只能執行一個執行緒。

在這種執行緒排程方式中,需要進行頻繁的執行緒上下文切換,儲存執行緒執行現場以及狀態、堆疊資訊和計數器,所以使用執行緒時,如果執行緒過多排程的效能損耗也會加大,甚至很多時候由於上下文切換開銷過大,導致執行緒併發執行效率不如序列執行效率高,這就是傳統的核心態執行緒排程的缺點。

執行緒按照其排程所在空間,可分為核心級執行緒及使用者級執行緒

核心級執行緒

核心級執行緒依賴於作業系統的執行緒實現,每個核心級執行緒都對應著作業系統程序內部的執行緒實現,執行緒的排程和控制依賴於作業系統核心的執行緒,通常作業系統對外提供相應的核心執行緒操作API供程式使用。作業系統核心可以感知到執行緒的存在和操作。

核心級執行緒的優點:

  • 藉助作業系統的實現,可利用CPU多核處理器的優勢實現併發執行
  • 一個程序內的執行緒被阻塞後,其他執行緒仍可繼續執行

核心級執行緒的缺點:

  • 執行緒的上下文切換需要藉助作業系統核心,存在兩次使用者態和核心態的轉化,效率較低

使用者級執行緒

使用者級執行緒指的是通過執行緒庫來實現執行緒的排程,執行緒庫執行在使用者空間中,不依賴於核心的實現,所以使用者級執行緒(又被稱之為協程)可以做到對核心無感知,核心不會參與使用者級執行緒的排程和控制,作業系統仍對程序進行直接控制。

使用者級執行緒的優點:

  • 使用者級執行緒上下文切換是在使用者空間完成,無需藉助核心,所以不用進行核心態轉化,效率比較高
  • 使用者級執行緒與具體作業系統無關,只依賴於執行緒庫的實現
  • 使用者級執行緒可以根據自身需要實現相應的排程演算法,而無需受作業系統控制

使用者級執行緒的缺點:

  • 作業系統側以程序為排程單位,當執行緒阻塞時,該程序內所有執行緒都阻塞
  • 由於不依賴於作業系統的實現,無法直接利用多核CPU的優勢

協程

一個"使用者態執行緒"必須繫結一個"核心態執行緒",但CPU並不知道有"使用者態執行緒"的存在,它只知道執行的是一個"核心態執行緒"。
使用者執行緒可稱之為"協程(co-routine)"
一個協程(co-routine)可以繫結一個執行緒(thread),那麼多個協程(co-routine)是否可以繫結一個或者多個執行緒(thread)呢?

協程與執行緒的關係

N:1

N個協程繫結一個執行緒,優點是協程在使用者態執行緒即完成切換,不會陷入到核心態,這種切換非常輕量快速,但缺點是,一個程序的所有協程都繫結在一個執行緒上。

缺點:

  • 某個程式用不了硬體的多核加速能力
  • 一旦某協程阻塞,造成執行緒阻塞,本程序的其他協程都無法執行了,根本就沒有併發的能力了

1:1

1個協程繫結1個執行緒,這種最容易實現。協程的排程都由CPU完成了,不存在N:1缺點

缺點:

  • 協程的建立、刪除和切換的代價都由CPU完成,有點略顯昂貴

M:N

M個協程繫結N個執行緒,是N:1和1:1型別的結合,克服了以上2種模型的缺點,但實現起來最為複雜

協程跟執行緒是有區別的,執行緒由CPU排程是搶佔式的,協程由使用者態排程是協作式的,一個協程讓出CPU後,才執行下一個協程。

goroutine

Go為了提供更容易使用的併發方法,使用了goroutine和channel。goroutine來自協程的概念,讓一組可複用的函式執行在一組執行緒之上,即使有執行緒阻塞,該執行緒的其他協程也可以被runtime排程,轉移到其他可執行的執行緒上。

Go中,協程被稱為goroutine,它非常輕量,一個goroutine只佔幾KB,並且幾KB就足夠goroutine執行完,
這就能在有限的記憶體空間內支援大量goroutine,支援了更多的併發。雖然一個goroutine的棧只佔幾KB,但實際是可伸縮的,如果需要更多內容,runtime會自動為goroutine分配。

Goroutine特點

  • 佔用記憶體更小(幾KB)
  • 排程更靈活(runtime排程)

舊版本goroutine排程器

在舊版本的Goroutine的排程器中,僅有Goroutine(G表示)和執行緒(M表示)兩種概念,如下。

排程器的實現


M想要執行、放回G都必須訪問全域性G佇列,並且M有多個,即多執行緒訪問同一資源需要加鎖進行保證互斥/同步,所以全域性G佇列是有互斥鎖進行保護的。

缺點:

  • 建立、銷燬、排程G都需要每個M獲取鎖,這就形成了激烈的鎖競爭。
  • M轉移G會造成延遲和額外的系統負載。比如當G中包含建立新協程G1的時,為了繼續執行G,需要把G1交給M1執行,這就造成了很差的區域性性,因為G1和G是相關的,最好放在M上執行,而不是其他M1。
  • 系統呼叫(CPU在M之家的切換)導致頻繁的執行緒阻塞和取消阻塞增加了系統開銷。

Goroutine排程器的GMP模型設計思想

面對舊版本排程器的問題,Go出現了新版本的排程器。

Golang為了減少作業系統核心級執行緒上下文切換的開銷以及提升排程效率,提出了GPM協程排程模型,GPM模型藉助了使用者級執行緒的實現思路,通過使用者態的協程排程,能夠線上程上實現多個協程的併發執行。

GPM三個字母分別表示的是Goroutine、Processor及Machine。

  • Goroutine代表著Golang中的協程,通過Goroutine封裝的程式碼片段將以協程方式併發執行,是GPM排程器排程的基本單位。
  • Processor代表執行Goroutine的上下文環境及資源,是GPM排程器中關聯核心級執行緒與協程的中間排程器。如果執行緒想執行goroutine,必須先獲取P,P中還包含了可執行的G佇列。
  • Machine是核心執行緒的封裝,一個M與一個核心級執行緒一一對應,為Goroutine的執行提供了底層執行緒能力支援。

GPM結構組成

GPM三大核心組成結構如下

GPM中,M與核心執行緒一一對應,M可以關聯多個P,而P也可以排程多個G

GPM執行模型

在Go中,執行緒是執行goroutine的實體,排程器的功能是把可執行的goroutine分配到工作執行緒上。

P在Golang的實現中對應著一個排程佇列,其中儲存著多個G用於排程,需要注意的是P具備狀態的,當其達到特定狀態時,其含有的G才可被排程,並且P的數量也代表著實際上的最大Goroutine並行執行數(因為一個P需要在執行時取出一個G與M關聯,所以當有N個P時最多可同時取出N個G關聯M執行)。

P的數量可通過runtime.GOMAXPROCS函式進行設定,預設為當前系統的CPU核數。

  • 全域性佇列(Global Queue):存放等待執行的G
  • P的本地佇列:同全域性佇列類似,存放的也是等待執行的G,存的數量有限,不超過256個。新建G'時,G'優先加入到P的本地佇列,如果佇列滿了,則會把本地佇列中一半的G移動到全域性佇列。
  • P列表:所有的P都在程式啟動時建立,並儲存在陣列中,最多有GOMAXPROCS(可配置)個。
  • M:執行緒想執行任務就得獲取P,從P的本地佇列獲取G,P佇列為空時,M也會嘗試從全域性佇列拿一批G放到P的本地佇列,或從其他P的本地佇列偷一半放到自己P的本地佇列。M執行G,G執行之後,M會從P獲取下一個G,不斷重複下去。

Goroutine排程器和OS排程器是通過M結合起來的,每個M都代表了1個核心執行緒,OS排程器負責把核心執行緒分配到CPU的核上執行。

P、M數量限制

P的數量:

  • 由啟動時環境變數$GOMAXPROCS或者由runtime的方法GOMAXPROCS()決定。意味著在程式執行的任意時刻都只有$GOMAXPROCS個goruntine在同時執行。

M的數量:

  • go語言本身的限制: go程式啟動時,會設定最大M的最大數量,預設10000,但是核心很難支援這麼多的執行緒數,所以這個值可以忽略
  • runtime/debug中的SetMaxThreads函式,設定M的最大數量
  • 一個M阻塞了,會建立新的M

M與P的數量沒有絕對關係,一個M阻塞,P就會去建立或者切換另一個M,所以,即使P的預設數量是1,也有可能會建立很多個M出來。

P和M何時會被建立

  • P何時建立:在確定了P的最大數量n後,執行時系統會根據這個數量建立n個P。
  • M何時建立:沒有足夠的M來關聯P並執行其中的可執行的G。比如所有的M此時都阻塞住了,而P中還有很多就緒任務,就會去尋找空閒的M,而沒有空閒的,就會去建立新的M。

P的結構及狀態轉換

核心欄位
const (
       _Pidle = iota
       _Prunning
       _Psyscall 
       _Pgcstop
       _Pdead
    )

type p struct {
   status      uint32 
   schedtick   uint32 
   syscalltick uint32 
   m           muintptr
   runqhead uint32
   runqtail uint32
   runq     [256]guintptr
   runnext guintptr
   gFree struct {
      gList
      n int32
   }
}

P結構體中,重要的欄位如下:

status:表示當前P的狀態,為上述五個狀態之一
schedtick :排程計數器,每被排程一次則自增1
syscalltick:系統呼叫計數器,每進行一次系統呼叫則自增1
m:即將要關聯的m,M的nextp欄位對應著該P
runq:可執行的G佇列,預設容量為256個G
runqhead:可執行G佇列頭,標識目前正在執行的G
runnext:下一個將要執行的G
gFree:空閒G列表,儲存著狀態為Gdead的G,當其數目過多時,將會被轉移到排程器全域性G列表,用於被其他P再次使用(相當於一個G快取池)
P狀態及狀態流轉

五個狀態如下:

  • Pidle:當前p尚未與任何m關聯,處於空閒狀態
  • Prunning:當前p已經和m關聯,並且正在執行g程式碼
  • Psyscall:當前p正在執行系統呼叫
  • Pgcstop:當前p需要停止排程,一般在GC前或者剛被建立時
  • Pdead:當前p已死亡,不會再被排程

P的狀態流轉圖如下

在P建立之初,會被置為Pgcstop狀態,在完成初始化之後,會馬上進入Pidel狀態,進入該狀態後的P可被排程器排程,當P與某個M相關聯時,會進入到Prunning狀態,當其執行系統呼叫時,會進入到Psyscall狀態,當P應為全域性P列表的縮小而被刪除時會進入Pdead狀態,不會再進行狀態流轉和排程。當正在執行的P由於某些原因停止排程時,會統一流轉成Pidle空閒狀態,等待排程,避免執行緒飢餓。

M的結構及狀態轉換

一個G就代表一個goroutine,也與go函式對應。我們使用go語句時,實際上是向Go排程器提交了一個併發任務。Go的編譯器會把go語句變成內部函式newproc的呼叫,並把go函式以及其引數部分傳遞給這個函式,G和P一樣具有著多個狀態進行轉換。

核心欄位
const (
   _Gidle = iota
   _Grunnable
   _Grunning 
   _Gsyscall 
   _Gwaiting 
   _Gmoribund_unused
   _Gdead
  _Genqueue_unused
   _Gcopystack
   _Gscan         = 0x1000
   _Gscanrunnable = _Gscan + _Grunnable
   _Gscanrunning  = _Gscan + _Grunning
   _Gscansyscall  = _Gscan + _Gsyscall
   _Gscanwaiting  = _Gscan + _Gwaiting
)

type g struct {
   stack       stack   // offset known to runtime/cgo
   stackguard0 uintptr // offset known to liblink
   stackguard1 uintptr
   m              *m      // current m; offset known to arm liblink
   sched          gobuf 
   atomicstatus   uint32
   waitreason     waitReason // if status==Gwaiting
   preempt        bool       // preemption signal, duplicates stackguard0 = st
   startpc        uintptr         // pc of goroutine function
}

G結構體中重要欄位的含義:

stack:當前G所被分配的棧記憶體空間,由lo及hi兩個記憶體指標組成
stackguard0:g0的最大棧記憶體地址,當超過了這個數值則需要進行棧擴張
stackguard1:普通使用者G的最大棧記憶體地址,當超過了這個數值則需要進行棧擴張
m:當前關聯該G例項的M例項
sched:記錄G上下文環境,用於上下文切換
atomicstatus:G的狀態值,表示上述幾個狀態
waitreason:處於Gwaiting的原因
preempt:當前G是否可搶佔
startpc:當前G所繫結的函式記憶體地址
G的狀態及轉換

G有如下狀態

  • Gidle:當前G剛被分配,還未初始化
  • Grunable:正在可執行佇列等待執行
  • Gruning:正在執行中,執行G函式
  • Gsyscall:正在執行系統呼叫
  • Gwaiting:正在被阻塞,一般是該G正在執行網路I/O操作,或正在執行time.Timer、time.Sleep
  • Gdead:已經使用完正在閒置,放入空閒G列表中,可被再次使用(和P不同,P處於Pdead狀態則無法被再次排程)
  • Gcopystack:表示當前G的棧正在被移動,可能是因為棧的收縮或擴容
  • Gscan:表明當前正在進行GC掃描,由於在GC掃描的過程中肯定會處於某個前置狀態,所以又有以下組合
    • Gscanrunable :代表當前G正等待執行,同時棧正被GC掃描
    • Gscanrunning :表示正處於Grunning狀態,同時棧在被GC掃描
    • Gscanwaiting:表示正處於Gwaiting狀態,同時棧在被GC掃描
    • Gscansyscall:表示正處於Gsyscall狀態,同時棧在被GC掃描

狀態流轉圖

  • 任何G都會存在於全域性G列表中,其餘4個容器只存放當前作用域內具有某個狀態的G
  • 從Gsyscall狀態轉出的G都會被放到排程器的可執行G佇列
  • 剛被執行時系統初始化的G都會被放入本地P的可執行G佇列
  • 從Gwaiting狀態轉出的G,有的會被放入本地P的可執行G佇列,有的會被放到排程器的可執行G佇列,還有的會被直接執行(比如剛完成網路I/O)
  • 如果本地P的可執行佇列G已滿,其中的一半G會被轉移到排程器的可執行G佇列
  • 排程器可執行G佇列遵循FIFO(先進先出)

GPM排程器的結構

GPM排程器負責協調G、P、M三者具體的排程工作,每個GO程式中只存在一個GPM排程器,其原始碼位於runtime/runtime2.go之中,結構體名稱為schedt,對應著的全域性唯一例項為sched,結構體核心欄位如下

type schedt struct {
   // 全域性唯一id
   goidgen  uint64
   // 記錄的最後一次從i/o中查詢G的時間
   lastpoll uint64
   // 互斥鎖 
   lock mutex
   // M的空閒連結串列,通過m.schedlink組成一個M空閒連結串列
   midle        muintptr
   // 正處於自旋狀態的M數量
   nmidle       int32
   // 已經被鎖定且正在自旋的M數量
   nmidlelocked int32
   // 下一個M的id,或者是目前已存在的M數量
   mnext        int64
   // M數量的最大值
   maxmcount    int32
   // 已被釋放掉的M數量
   nmfreed      int64
   // 系統所開啟的協程數量(非使用者協程)
   ngsys uint32
   // 空閒P列表
   pidle      puintptr
   // 空閒的P數量
   npidle     uint32
   // 全域性的G佇列
   // 根據runqhead可以獲取佇列頭的G及g.schedlink形成G連結串列
   runqhead guintptr
   runqtail guintptr
   // 全域性G佇列大小
   runqsize int32
   // 等待釋放的M列表
   freem *m
   // 是否需要暫停排程(通常因為GC帶來的STW)
   gcwaiting  uint32
   // 需要停止但是仍為停止的P數量
   stopwait   int32
   // 實現stopwait事件通知
   stopnote   note
   // 停止排程期間是否進行系統監控任務
   sysmonwait uint32
   // 實現sysmonwait事件通知
   sysmonnote note
}

排程器的設計策略

複用執行緒:避免頻繁的建立、銷燬執行緒,而是對執行緒的複用

  • work stealing機制
    • 當本執行緒無可執行的G時,嘗試從其他執行緒繫結的P偷取G,而不是銷燬執行緒。
  • hand off機制
    • 當本執行緒因為G進行系統呼叫阻塞時,執行緒釋放繫結的P,把P轉移給其他空閒的執行緒執行。

利用並行:GOMAXPROCS設定P的數量,最多有GOMAXPROCS個執行緒分佈在多個CPU上同時執行。GOMAXPROCS也限制了併發的程度,比如GOMAXPROCS = 核數/2,則最多利用了一半的CPU核進行並行。

搶佔:在coroutine中要等待一個協程主動讓出CPU才執行下一個協程,在Go中,一個goroutine最多佔用CPU 10ms,防止其他goroutine被餓死,這就是goroutine不同於coroutine的一個地方。

全域性G佇列:在新的排程器中依然有全域性G佇列,但功能已經被弱化了,當M執行work stealing從其他P偷不到G時,它可以從全域性G佇列獲取G。

work-stealing觸發排程

work-stealing排程演算法:當M執行完了當前P的本地佇列佇列裡的所有G後,P也不會就這麼在那躺屍啥都不幹,它會先嚐試從全域性佇列佇列尋找G來執行,如果全域性佇列為空,它會隨機挑選另外一個P,從它的佇列裡中拿走一半的G到自己的佇列中執行。如果一切正常,排程器會以上述的那種方式順暢地執行,但這個世界沒這麼美好,總有意外發生,以下分析goroutine在兩種例外情況下的行為。

Go runtime會在下面的goroutine被阻塞的情況下執行另外一個goroutine:

使用者態阻塞/喚醒

當goroutine因為channel操作或者network I/O而阻塞時(實際上golang已經用netpoller實現了goroutine網路I/O阻塞不會導致M被阻塞,僅阻塞G,這裡僅僅是舉個栗子),對應的G會被放置到某個wait佇列(如channel的waitq),該G的狀態由_Gruning變為_Gwaitting,而M會跳過該G嘗試獲取並執行下一個G,如果此時沒有可執行的G供M執行,那麼M將解綁P,並進入sleep狀態;當阻塞的G被另一端的G2喚醒時(比如channel的可讀/寫通知),G被標記為,嘗試加入G2所在P的runnext(runnext是執行緒下一個需要執行的 Goroutine), 然後再是P的本地佇列和全域性佇列。

系統呼叫阻塞

當M執行某一個G時候如果發生了阻塞操作,M會阻塞,如果當前有一些G在執行,排程器會把這個執行緒M從P中摘除,然後再建立一個新的作業系統的執行緒(如果有空閒的執行緒可用就複用空閒執行緒)來服務於這個P。當M系統呼叫結束時候,這個G會嘗試獲取一個空閒的P執行,並放入到這個P的本地佇列。如果獲取不到P,那麼這個執行緒M變成休眠狀態, 加入到空閒執行緒中,然後這個G會被放入全域性佇列中。

佇列輪轉

可見每個P維護著一個包含G的佇列,不考慮G進入系統呼叫或IO操作的情況下,P週期性的將G排程到M中執行,執行一小段時間,將上下文儲存下來,然後將G放到佇列尾部,然後從佇列中重新取出一個G進行排程。

除了每個P維護的G佇列以外,還有一個全域性的佇列,每個P會週期性地檢視全域性佇列中是否有G待執行並將其排程到M中執行,全域性佇列中G的來源,主要有從系統呼叫中恢復的G。之所以P會週期性地檢視全域性佇列,也是為了防止全域性佇列中的G被餓死。

go func() 排程流程

  1. 通過go func() 來建立一個goroutine
  2. 有兩個儲存G的佇列,一個是區域性排程器的本地佇列、一個是全域性G佇列。新建立的G會先儲存在P的本地佇列中,如果P的本地佇列已經滿了就會儲存在全域性佇列中。
  3. G只能執行在M中,一個M必須持有一個P,M與P是1:1的關係。M會從P的本地佇列彈出一個可執行狀態的G來執行,如果P的本地佇列為空,就會想其他的MP組合偷取一個可執行的G來執行;
  4. 一個M排程G執行的過程是一個迴圈機制;
  5. 當M執行某一個G時候如果發生了syscall或則其餘阻塞操作,M會阻塞,如果當前有一些G在執行,runtime會把這個執行緒M從P中摘除(detach),然後再建立一個新的作業系統的執行緒(如果有空閒的執行緒可用就複用空閒執行緒)來服務於這個P;
  6. 當M系統呼叫結束時候,這個G會嘗試獲取一個空閒的P執行,並放入到這個P的本地佇列。如果獲取不到P,那麼這個執行緒M變成休眠狀態, 加入到空閒執行緒中,然後這個G會被放入全域性佇列中。

排程器的生命週期

特殊的M0和G0

  • M0
    M0是啟動程式後的編號為0的主執行緒,這個M對應的例項會在全域性變數runtime.m0中,不需要在heap上分配,M0負責執行初始化操作和啟動第一個G, 在之後M0就和其他的M一樣了。
  • G0
    G0是每次啟動一個M都會第一個建立的gourtine,G0僅用於負責排程的G,G0不指向任何可執行的函式, 每個M都會有一個自己的G0。在排程或系統呼叫時會使用G0的棧空間, 全域性變數的G0是M0的G0。

一個G由於排程被中斷,此後如何恢復
中斷的時候將暫存器裡的棧資訊,儲存到自己的G物件裡面。當再次輪到自己執行時,將自己儲存的棧資訊複製到暫存器裡面,這樣就接著上次之後運行了。

排程分析示例

程式碼示例

package main
import "fmt"

func main() {
    fmt.Println("Hello world")
}

針對上面的程式碼對排程器裡面的結構做一個分析

  1. runtime建立最初的執行緒m0和goroutine g0,並把2者關聯。
  2. 排程器初始化:初始化m0、棧、垃圾回收,以及建立和初始化由GOMAXPROCS個P構成的P列表。
  3. 示例程式碼中的main函式是main.main,runtime中也有1個main函式——runtime.main,程式碼經過編譯後,runtime.main會呼叫main.main,程式啟動時會為runtime.main建立goroutine,稱它為main goroutine吧,然後把main goroutine加入到P的本地佇列。
  4. 啟動m0,m0已經綁定了P,會從P的本地佇列獲取G,獲取到main goroutine。
  5. G擁有棧,M根據G中的棧資訊和排程資訊設定執行環境
  6. M執行G
  7. G退出,再次回到M獲取可執行的G,這樣重複下去,直到main.main退出,runtime.main執行Defer和Panic處理,或呼叫runtime.exit退出程式

runtime.main的goroutine執行之前都是為排程器做準備工作,runtime.main的goroutine執行,才是排程器的真正開始,直到runtime.main結束而結束。

視覺化GMP程式設計

  • go tool trace
  • Debug trace