Golang的調度器

  1. 談到Golang的調度器,繞不開的是操作系統,進程和線程這些概念。多個線程是可以屬於同一個進程的並共享內存空間,因爲多線程
    不需要創建新的虛擬空間,所以不需要內存管理單元處理的上下文的切換,線程之間的通信也是基於共享內存進行的,同重量級的進程相比
    線程顯得比較輕量
  2. 雖然線程比較輕量,但是線程每一次的切換需要耗時1us左右的時間,但是Golang調度器對於goroutine的切換隻要在0.2us
    左右
  3. Go 語言的調度器通過使用與 CPU 數量相等的線程減少線程頻繁切換的內存開銷,同時在每一個線程上執行額外開銷更低的 Goroutine 來降低操作系統和硬件的負載。

調度器種類

  1. 單線程調度器:遵循如下調度過程
  2. 多線程調度器
  3. 任務竊取調度器
  4. 搶佔式調度器
  • 基於協作的搶佔式調度器
  • 基於信號的搶佔式調度器:實現基於信號的搶佔式調度器,垃圾回收在掃描棧時會觸發搶佔式調度;搶佔的時間不夠多,不能覆蓋全部邊緣情況;
    • 掛起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,該函數會執行下面的邏輯:
      1. 將 _Grunning 狀態的 Goroutine 標記成可以被搶佔,即將 preemptStop 設置成 true;
      2. 調用 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

  1. 表示Goroutine 是一個等待執行的任務
  2. 它只存在於Go語言的運行時,它是Go語言在用戶態提供的線程,作爲一種粒度更細的資源調度單元,如果使用得當能夠在
    高併發的場景下更加高效的利用機器CPU.
  3. goroutine在運行的時候會使用私有結構體runtine.g表示,下面對具體的字段進行解釋
    1. stack 字段描述了當前 Goroutine 的棧內存範圍 [stack.lo, stack.hi)
    2. stackguard0 可以用於調度器搶佔式調度
    3. m — 當前 Goroutine 佔用的線程,可能爲空
    4. atomicstatus — Goroutine 的狀態;
    5. sched — 存儲 Goroutine 的調度相關的數據;
      1. sched — 存儲 Goroutine 的調度相關的數據;
      2. pc — 程序計數器(Program Counter);
      3. g — 持有 runtime.gobuf 的 Goroutine
      4. ret — 系統調用的返回值
  4. goroutine的狀態:主要有三種狀態:等待中,可運行,運行中
    1. 等待中:Goroutine 正在等待某些條件滿足,例如:系統調用結束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 幾個狀態
    2. 可運行:Goroutine 已經準備就緒,可以在線程運行,如果當前程序中有非常多的 Goroutine,每個 Goroutine 就可能會等待更多的時間,即 _Grunnable;
    3. 運行中:Goroutine 正在某個線程上運行,即 _Grunning;

M

Go 語言併發模型中的 M 是操作系統線程。調度器最多可以創建 10000 個線程,但是其中大多數的線程都不會執行用戶代碼(可能陷入系統調用),最多隻會有 GOMAXPROCS 個活躍線程能夠正常運行。

在默認情況下,運行時會將 GOMAXPROCS 設置成當前機器的核數,我們也可以使用 runtime.GOMAXPROCS 來改變程序中最大的線程數。

在默認情況下,一個四核機器上會創建四個活躍的操作系統線程,每一個線程都對應一個運行時中的 runtime.m 結構體。

在大多數情況下,我們都會使用 Go 的默認設置,也就是線程數等於 CPU 個數,在這種情況下不會觸發操作系統的線程調度和上下文切換,所有的調度都會發生在用戶態,由 Go 語言調度器觸發,能夠減少非常多的額外開銷。

  1. g0 是持有調度棧的 Goroutine,curg 是在當前線程上運行的用戶 Goroutine,這也是操作系統線程唯一關心的兩個 Goroutine
    1. g0 是一個運行時中比較特殊的 Goroutine,它會深度參與運行時的調度過程,包括 Goroutine 的創建、大內存分配和 CGO 函數的執行

P

調度器中的處理器 P 是線程和 Goroutine 的中間層,它能提供線程需要的上下文環境,也會負責調度線程上的等待隊列,通過處理器 P 的調度,每一個內核線程都能夠執行多個 Goroutine,它能在 Goroutine 進行一些 I/O 操作時及時切換,提高線程的利用率。

因爲調度器在啓動時就會創建 GOMAXPROCS 個處理器,所以 Go 語言程序的處理器數量一定會等於 GOMAXPROCS,這些處理器會綁定到不同的內核線程上並利用線程的計算資源運行 Goroutine。

調度器啓動

  1. 調度器通過 runtime.schedinit 函數初始化調度器:
  2. 在調度器初始函數執行的過程中會將 maxmcount 設置成 10000,這也就是一個 Go 語言程序能夠創建的最大線程數,雖然最多可以創建 10000 個線程,但是可以同時運行的線程還是由 GOMAXPROCS 變量控制。
  3. 從環境變量 GOMAXPROCS 獲取了程序能夠同時運行的最大處理器數之後就會調用 runtime.procresize 更新程序中處理器的數量,在這時整個程序不會執行任何用戶 Goroutine,調度器也會進入鎖定狀態,runtime.procresize 的執行過程如下:
    1. 如果全局變量 allp 切片中的處理器數量少於期望數量,就會對切片進行擴容;
    2. 使用 new 創建新的處理器結構體並調用 runtime.p.init 方法初始化剛剛擴容的處理器;
    3. 通過指針將線程m0同處理器allp[0]綁定到提起
    4. 調用runtime.p.destroy 方法釋放不再使用的處理器結構;
    5. 通過截斷改變全局變量 allp 的長度保證與期望處理器數量相等;
    6. 將除 allp[0] 之外的處理器 P 全部設置成 _Pidle 並加入到全局的空閒隊列中;
    7. 調用 runtime.procresize 就是調度器啓動的最後一步,在這一步過後調度器會完成相應數量處理器的啓動,等待用戶創建運行新的 Goroutine 併爲 Goroutine 調度處理器資源。

創建Goroutine

想要啓動一個新的goroutine來執行任務,我們需要將Go語言中的go關鍵字,這個關鍵字會在編譯期間通過下面方法cmd/compile/internal/gc.state.stmt 和 cmd/compile/internal/gc.state.call 兩個方法將該關鍵字轉換成 runtime.newproc 函數調用:

  1. 編譯器會將所有的go關鍵字轉換爲runtime.newproc 函數,該函數會接受大小和表示函數的指針funcval。在這個函數中我們還會

    獲取goroutine以及調用方的程序計數器,然後調用 runtime.newproc1 函數。runtime.newproc1 會根據傳入參數初始化一個 g 結構體,我們可以將該函數分成以下幾個部分介紹它的實現:

    1. 獲取或者創建新的Groutine結構體
    2. 將傳入的參數移植到Goroutine的棧上
    3. 更新Goroutine的調度相關性
    4. 將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 運行隊列上,這既可能是全局的運行隊列,也可能是處理器本地的運行隊列:

  1. 當 next 爲 true 時,將 Goroutine 設置到處理器的 runnext 上作爲下一個處理器執行的任務;
  2. 當 next 爲 false 並且本地運行隊列還有剩餘空間時,將 Goroutine 加入處理器持有的本地運行隊列;
  3. 當處理器的本地運行隊列已經沒有剩餘空間時就會把本地隊列中的
    一部分 Goroutine 和待加入的 Goroutine 通過 runqputslow 添加到調度器持有的全局運行隊列上;
  4. Go 語言中有兩個運行隊列,其中一個是處理器本地的運行隊列,另一個是調度器持有的全局運行隊列,只有在本地運行隊列沒有剩餘空間時纔會使用全局隊列

調度循環

調度器啓動之後,Go 語言運行時會調用 runtime.mstart 以及 runtime.mstart1,前者會初始化 g0 的 stackguard0 和 stackguard1 字段,後者會初始化線程並調用 runtime.schedule 進入調度循環:

  1. 爲了保證公平,當全局運行隊列中有待執行的 Goroutine 時,通過 schedtick 保證有一定幾率會從全局的運行隊列中查找對應的 Goroutine;
  2. 從處理器本地的運行隊列中查找待執行的 Goroutine;
  3. 如果前兩種方法都沒有找到 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 語言圖形庫等。

  1. runtime.dolockOSThread 會分別設置線程的 lockedg 字段和 Goroutine 的 lockedm 字段,這兩行代碼會綁定線程和 Goroutine。
  2. 當 Goroutine 完成了特定的操作之後,就會調用以下函數 runtime.UnlockOSThread 分離 Goroutine 和線程:

歡迎關注我們的微信公衆號,每天學習Go知識

相關文章