Go從入門到放棄19--goroutine的排程原理1

語言: CN / TW / HK

Goroutine是由Go執行時管理的使用者層輕量級執行緒。相較於作業系統執行緒,Goroutine的資源佔用和使用代價都要小得多。Go的執行時負責對goroutine進行管理。所謂的管理就是“排程”,將 Goroutine 按照一定演算法放到不同的作業系統執行緒中去執行

Goroutine排程模型與演進過程

Goroutine排程器的實現不是一蹴而就的,它的排程模型與演算法也是幾經演化,從最初的G-M模型、到G-P-M模型,從不支援搶佔,到支援協作式搶佔,再到支援基於訊號的非同步搶佔,Goroutine 排程器經歷了不斷地優化與打磨。

G-M模型

2012年3月28日,Go 1.0正式釋出。在這個版本中,Go開發團隊實現了一個簡單的goroutine排程器。在這個排程器中,每個goroutine對應於執行時中的一個抽象結構——G(goroutine),而被視作“物理CPU”的作業系統執行緒則被抽象為另一個結構——M(machine)

螢幕快照 2022-09-13 下午11.08.36.png M想要執行、放回G都必須訪問全域性G佇列,並且M有多個,即多執行緒訪問同一資源需要加鎖進行保證互斥/同步,所以全域性G佇列是有互斥鎖進行保護的。

帶來的問題主要體現在如下幾個方面。 * 單一全域性互斥鎖(Sched.Lock)和集中狀態儲存的存在導致所有goroutine相關操作(如建立、重新排程等)都要上鎖。 * goroutine傳遞問題:經常在M之間傳遞“可執行”的goroutine會導致排程延遲增大,帶來額外的效能損耗。 * 每個M都做記憶體快取,導致記憶體佔用過高,資料區域性性較差。 * 因系統呼叫(syscall)而形成的頻繁的工作執行緒阻塞和解除阻塞會帶來額外的效能損耗。

G-M-P模型

面對之前排程器的問題,Go設計了新的排程器。在新排程器中,出了M(thread)和G(goroutine),又引進了P(Processor)。 * G — 表示 Goroutine,它是一個待執行的任務; * M — 表示作業系統的執行緒,它由作業系統的排程器排程和管理; * P — 表示處理器,它可以被看做執行線上程上的本地排程器;

43ffdbc6b2203d9400ac98423192caa8.webp P是一個“邏輯處理器”,每個G要想真正執行起來,首先需要被分配一個P,即進入P的本地執行佇列(local runq)中,這裡暫忽略全域性執行佇列(global runq)那個環節。對於G來說,P就是執行它的“CPU”,可以說在G的眼裡只有P 。但從goroutine排程器的視角來看,真正的“CPU”是M,只有將P和M繫結才能讓P的本地執行佇列中的G真正執行起來。這樣的P與M的關係就好比Linux作業系統排程層面使用者執行緒(user thread)與核心執行緒(kernel thread)的對應關係:多對多(N:M)。

基於協作的搶佔式排程

G-P-M模型的實現是goroutine排程器的一大進步,但排程器仍然有一個頭疼的問題,那就是不支援搶佔式排程,這導致一旦某個G中出現死迴圈的程式碼邏輯,那麼G將永久佔用分配給它的P和M,而位於同一個P中的其他G將得不到排程,出現“餓死 ”的情況。更為嚴重的是,當只有一個P(GOMAXPROCS=1)時,整個Go程式中的其他G都將“餓死”。於是Dmitry Vyukov又提出了“Go搶佔式排程器設計”(Go Preemptive Scheduler Design),並在Go 1.2版本中實現了搶佔式排程。

這個搶佔式排程的原理是在每個函式或方法的入口加上一段額外的程式碼,讓執行時有機會檢查是否需要執行搶佔排程。這種協作式搶佔排程 的解決方案只是區域性解決了“餓死”問題,對於沒有函式呼叫而是純演算法迴圈計算的G,goroutine排程器依然無法搶佔

基於訊號的搶佔式排程

在Go1.14中加入了基於訊號的協程排程搶佔。原理是這樣的,首先註冊繫結SIGURG訊號及處理方法runtime.doSigPreempt,sysmon會間隔性檢測超時的P,然後傳送訊號,M收到訊號後休眠執行的goroutine並且進行重新排程。

參考資料