Golang學習筆記-調度器學習
Golang的調度器
- 談到Golang的調度器,繞不開的是操作系統,進程和線程這些概念。多個線程是可以屬於同一個進程的並共享內存空間,因爲多線程
不需要創建新的虛擬空間,所以不需要內存管理單元處理的上下文的切換,線程之間的通信也是基於共享內存進行的,同重量級的進程相比
線程顯得比較輕量 - 雖然線程比較輕量,但是線程每一次的切換需要耗時1us左右的時間,但是Golang調度器對於goroutine的切換隻要在0.2us
左右 - Go 語言的調度器通過使用與 CPU 數量相等的線程減少線程頻繁切換的內存開銷,同時在每一個線程上執行額外開銷更低的 Goroutine 來降低操作系統和硬件的負載。
調度器種類
- 單線程調度器:遵循如下調度過程
- 多線程調度器
- 任務竊取調度器
- 搶佔式調度器
- 基於協作的搶佔式調度器
- 基於信號的搶佔式調度器:實現基於信號的搶佔式調度器,垃圾回收在掃描棧時會觸發搶佔式調度;搶佔的時間不夠多,不能覆蓋全部邊緣情況;
- 掛起goroutine的過程是在垃圾回收的棧掃描時候來完成的
- 調用runtime.suspendG函數時會將處於運行狀態的goroutine的preemptStop標記成爲true
- 調用runtime.preemptPark函數可以掛起當前的goroutine 將其狀態更新爲_Gpreemted並觸發調度器的重新調度,該函數能夠交出線程控制權
- 在X86架構上增加異步搶佔函數
- 支持通過向線程發送信號的方式暫停運行的 Goroutine;
- 在 runtime.sighandler 函數中註冊 SIGURG 信號的處理函數 runtime.doSigPreempt
- 實現 runtime.preemptM 函數,它可以通過 SIGURG 信號向線程發送搶佔請求;
- 修改 runtime.preemptone 函數的實現,加入異步搶佔的邏輯;
- 目前的搶佔式調度也只會在垃圾回收掃描任務時觸發,我們可以梳理一下上述代碼實現的搶佔式調度過程
- 程序啓動時,在 runtime.sighandler 函數中註冊 SIGURG 信號的處理函數 runtime.doSigPreempt;
- 在觸發垃圾回收的棧掃描時會調用 runtime.suspendG 掛起 Goroutine,該函數會執行下面的邏輯:
- 將 _Grunning 狀態的 Goroutine 標記成可以被搶佔,即將 preemptStop 設置成 true;
- 調用 runtime.preemptM 觸發搶佔;
- runtime.preemptM 會調用 runtime.signalM 向線程發送信號 SIGURG;
- 操作系統會中斷正在運行的線程並執行預先註冊的信號處理函數 runtime.doSigPreempt;
- runtime.doSigPreempt 函數會處理搶佔信號,獲取當前的 SP 和 PC 寄存器並調用
- runtime.sigctxt.pushCall 會修改寄存器並在程序回到用戶態時執行
- 彙編指令 runtime.asyncPreempt 會調用運行時函數 runtime.asyncPreempt2;
- runtime.asyncPreempt2 會調用 runtime.preemptPark;
- runtime.preemptPark 會修改當前 Goroutine 的狀態到 _Gpreempted 並調用
- runtime.schedule 讓當前函數陷入休眠並讓出線程,調度器會選擇其它的 Goroutine 繼續執行;
數據結構
G
- 表示Goroutine 是一個等待執行的任務
- 它只存在於Go語言的運行時,它是Go語言在用戶態提供的線程,作爲一種粒度更細的資源調度單元,如果使用得當能夠在
高併發的場景下更加高效的利用機器CPU. - goroutine在運行的時候會使用私有結構體runtine.g表示,下面對具體的字段進行解釋
- stack 字段描述了當前 Goroutine 的棧內存範圍 [stack.lo, stack.hi)
- stackguard0 可以用於調度器搶佔式調度
- m — 當前 Goroutine 佔用的線程,可能爲空
- atomicstatus — Goroutine 的狀態;
- sched — 存儲 Goroutine 的調度相關的數據;
- sched — 存儲 Goroutine 的調度相關的數據;
- pc — 程序計數器(Program Counter);
- g — 持有 runtime.gobuf 的 Goroutine
- ret — 系統調用的返回值
- goroutine的狀態:主要有三種狀態:等待中,可運行,運行中
- 等待中:Goroutine 正在等待某些條件滿足,例如:系統調用結束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 幾個狀態
- 可運行:Goroutine 已經準備就緒,可以在線程運行,如果當前程序中有非常多的 Goroutine,每個 Goroutine 就可能會等待更多的時間,即 _Grunnable;
- 運行中:Goroutine 正在某個線程上運行,即 _Grunning;
M
Go 語言併發模型中的 M 是操作系統線程。調度器最多可以創建 10000 個線程,但是其中大多數的線程都不會執行用戶代碼(可能陷入系統調用),最多隻會有 GOMAXPROCS 個活躍線程能夠正常運行。
在默認情況下,運行時會將 GOMAXPROCS 設置成當前機器的核數,我們也可以使用 runtime.GOMAXPROCS 來改變程序中最大的線程數。
在默認情況下,一個四核機器上會創建四個活躍的操作系統線程,每一個線程都對應一個運行時中的 runtime.m 結構體。
在大多數情況下,我們都會使用 Go 的默認設置,也就是線程數等於 CPU 個數,在這種情況下不會觸發操作系統的線程調度和上下文切換,所有的調度都會發生在用戶態,由 Go 語言調度器觸發,能夠減少非常多的額外開銷。
- g0 是持有調度棧的 Goroutine,curg 是在當前線程上運行的用戶 Goroutine,這也是操作系統線程唯一關心的兩個 Goroutine
- g0 是一個運行時中比較特殊的 Goroutine,它會深度參與運行時的調度過程,包括 Goroutine 的創建、大內存分配和 CGO 函數的執行
P
調度器中的處理器 P 是線程和 Goroutine 的中間層,它能提供線程需要的上下文環境,也會負責調度線程上的等待隊列,通過處理器 P 的調度,每一個內核線程都能夠執行多個 Goroutine,它能在 Goroutine 進行一些 I/O 操作時及時切換,提高線程的利用率。
因爲調度器在啓動時就會創建 GOMAXPROCS 個處理器,所以 Go 語言程序的處理器數量一定會等於 GOMAXPROCS,這些處理器會綁定到不同的內核線程上並利用線程的計算資源運行 Goroutine。
調度器啓動
- 調度器通過 runtime.schedinit 函數初始化調度器:
- 在調度器初始函數執行的過程中會將 maxmcount 設置成 10000,這也就是一個 Go 語言程序能夠創建的最大線程數,雖然最多可以創建 10000 個線程,但是可以同時運行的線程還是由 GOMAXPROCS 變量控制。
- 從環境變量 GOMAXPROCS 獲取了程序能夠同時運行的最大處理器數之後就會調用 runtime.procresize 更新程序中處理器的數量,在這時整個程序不會執行任何用戶 Goroutine,調度器也會進入鎖定狀態,runtime.procresize 的執行過程如下:
- 如果全局變量 allp 切片中的處理器數量少於期望數量,就會對切片進行擴容;
- 使用 new 創建新的處理器結構體並調用 runtime.p.init 方法初始化剛剛擴容的處理器;
- 通過指針將線程m0同處理器allp[0]綁定到提起
- 調用runtime.p.destroy 方法釋放不再使用的處理器結構;
- 通過截斷改變全局變量 allp 的長度保證與期望處理器數量相等;
- 將除 allp[0] 之外的處理器 P 全部設置成 _Pidle 並加入到全局的空閒隊列中;
- 調用 runtime.procresize 就是調度器啓動的最後一步,在這一步過後調度器會完成相應數量處理器的啓動,等待用戶創建運行新的 Goroutine 併爲 Goroutine 調度處理器資源。
創建Goroutine
想要啓動一個新的goroutine來執行任務,我們需要將Go語言中的go關鍵字,這個關鍵字會在編譯期間通過下面方法cmd/compile/internal/gc.state.stmt 和 cmd/compile/internal/gc.state.call 兩個方法將該關鍵字轉換成 runtime.newproc 函數調用:
-
編譯器會將所有的go關鍵字轉換爲runtime.newproc 函數,該函數會接受大小和表示函數的指針funcval。在這個函數中我們還會
獲取goroutine以及調用方的程序計數器,然後調用 runtime.newproc1 函數。runtime.newproc1 會根據傳入參數初始化一個 g 結構體,我們可以將該函數分成以下幾個部分介紹它的實現:
- 獲取或者創建新的Groutine結構體
- 將傳入的參數移植到Goroutine的棧上
- 更新Goroutine的調度相關性
- 將Goroutine加入處理器隊列
初始化結構體
- runtime.gfget通過兩種不同的方式獲取新的 runtime.g 結構體:
- 從Goroutine所在的處理器的gFree列表或者調度器的sched.gFree 列表中獲取 runtime.g 結構體;
- 調用 runtime.malg 函數生成一個新的 runtime.g 函數並將當前結構體追加到全局的 Goroutine 列表 allgs 中。
- runtime.gfget 中包含兩部分邏輯,它會根據處理器中 gFree 列表中 Goroutine 的數量做出不同的決策:
- 當處理器的 Goroutine 列表爲空時,會將調度器持有的空閒 Goroutine 轉移到當前處理器上,直到 gFree 列表中的 Goroutine 數量達到 32;
- 當處理器的 Goroutine 數量充足時,會從列表頭部返回一個新的 Goroutine;
- runtime.newproc1 會從處理器或者調度器的緩存中獲取新的結構體,也可以調用 runtime.malg 函數創建新的結構體。
運行隊列
runtime.runqput 函數會將新創建的 Goroutine 運行隊列上,這既可能是全局的運行隊列,也可能是處理器本地的運行隊列:
- 當 next 爲 true 時,將 Goroutine 設置到處理器的 runnext 上作爲下一個處理器執行的任務;
- 當 next 爲 false 並且本地運行隊列還有剩餘空間時,將 Goroutine 加入處理器持有的本地運行隊列;
- 當處理器的本地運行隊列已經沒有剩餘空間時就會把本地隊列中的
一部分 Goroutine 和待加入的 Goroutine 通過 runqputslow 添加到調度器持有的全局運行隊列上; - Go 語言中有兩個運行隊列,其中一個是處理器本地的運行隊列,另一個是調度器持有的全局運行隊列,只有在本地運行隊列沒有剩餘空間時纔會使用全局隊列
調度循環
調度器啓動之後,Go 語言運行時會調用 runtime.mstart 以及 runtime.mstart1,前者會初始化 g0 的 stackguard0 和 stackguard1 字段,後者會初始化線程並調用 runtime.schedule 進入調度循環:
- 爲了保證公平,當全局運行隊列中有待執行的 Goroutine 時,通過 schedtick 保證有一定幾率會從全局的運行隊列中查找對應的 Goroutine;
- 從處理器本地的運行隊列中查找待執行的 Goroutine;
- 如果前兩種方法都沒有找到 Goroutine,就會通過 runtime.findrunnable 進行阻塞地查找 Goroutine;
觸發調度
運行時還會在線程啓動 runtime.mstart 和 Goroutine 執行結束 runtime.goexit0 觸發調度。我們在這裏會重點介紹運行時觸發調度的幾個路徑:
- 主動掛起 — runtime.gopark -> runtime.park_m
- 系統調用 — runtime.exitsyscall -> runtime.exitsyscall0
- 協作式調度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
- 系統監控 — runtime.sysmon -> runtime.retake -> runtime.preemptone
線程管理
Go 語言的運行時會通過調度器改變線程的所有權,它也提供了 runtime.LockOSThread 和 runtime.UnlockOSThread 讓我們有能力綁定 Goroutine 和線程完成一些比較特殊的操作。Goroutine 應該在調用操作系統服務或者依賴線程狀態的非 Go 語言庫時調用 runtime.LockOSThread 函數11,例如:C 語言圖形庫等。
- runtime.dolockOSThread 會分別設置線程的 lockedg 字段和 Goroutine 的 lockedm 字段,這兩行代碼會綁定線程和 Goroutine。
- 當 Goroutine 完成了特定的操作之後,就會調用以下函數 runtime.UnlockOSThread 分離 Goroutine 和線程:
歡迎關注我們的微信公衆號,每天學習Go知識