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並且進行重新調度。

參考資料