Go Runtime 的排程器

happy_brother發表於2021-06-10

以 goroutine 形式進行 Go 併發程式設計是一種非常方便的方法,但有沒有想過他是如何有效地執行這些 goroutine?下面從設計的角度,深入瞭解和研究 Go 執行時排程程式,以及如何在效能除錯過程中使用它來解釋 Go 程式的排程程式跟蹤資訊。

要了解為什麼需要有一個執行時的排程以及它是如何工作的,先要回到作業系統的歷史上,在這裡將找到答案,因為如果不瞭解問題的根源。

作業系統的歷史

  1. 單使用者(無作業系統)
  2. 批處理 單程式設計 執行完成
  3. 多程式

多程式的目的是使 CPU 和 I/O 重疊。如何做到的呢?

  • 多批次
    IBM OS / MFT(具有固定數量的任務的多重程式設計)
  • 多批次
    IBM OS / MVT(具有可變數量的任務的多重程式設計)—在這裡,每個作業僅獲得其所需的記憶體量。即,隨著作業的進出,記憶體的分割槽發生變化。
  • 分時
    這是在作業之間快速切換的多道程式設計。決定何時切換以及切換到哪些作業稱為排程。

現代大多數作業系統使用分時排程程式。

這些排程程式排程的是什麼呢?
1 不同的程式正在執行(程式)
2 作為程式子集存在的 CPU 利用率(執行緒)的基本單位

這些都是有代價的。

排程成本

因此,使用一個包含多個執行緒的程式效率更高,因為程式建立既耗時又耗費資源。隨後出現了多執行緒問題:C10k 問題是主要的問題。

例如,如果將排程程式週期定義為 10 毫秒(毫秒),並且有 2 個執行緒,則每個執行緒將分別獲得 5 毫秒。如果您有 5 個執行緒,則每個執行緒將獲得 2ms。但是,如果有 1000 個執行緒怎麼辦?給每個執行緒 10s(微秒)的時間片?你將花費大量時間進行上下文切換,但是無法完成真正的工作。

你需要限制時間片的長度。在最後一種情況下,如果最小時間片是 2ms,並且有 1000 個執行緒,則排程程式週期需要增加到 2s(秒)。如果有 10,000 個執行緒,則排程程式週期為 20 秒。在這個簡單的示例中,如果每個執行緒都使用其全時切片,則所有執行緒一次執行需要 20 秒。因此,我們需要一些可以使併發成本降低而又不會造成過多開銷的東西。

使用者級執行緒
• 執行緒完全由執行時系統(使用者級庫)管理。
• 理想情況下,快速高效:切換執行緒不比函式呼叫貴多少。
• 核心對使用者級執行緒一無所知,並像對待單執行緒程式一樣對其進行管理。

在 Go 中,我們叫它 “ Goroutine”(在邏輯上)

Goroutine

協程是輕量級執行緒,由 Go 執行時管理(邏輯上一個執行的執行緒)。要 go 在函式呼叫之前啟動執行 go 關鍵字。

func main() {
    var wg sync.WaitGroup
    wg.Add(11)
    for i := 0; i <= 10; i++ {

        go func(i int) {
          defer wg.Done()
          fmt.Printf("loop i is - %d\n", i)
        }(i)
    }
    wg.Wait()
    fmt.Println("Hello, Welcome to Go")
}
執行結果

loop i is - 10
loop i is - 0
loop i is - 1
loop i is - 2
loop i is - 3
loop i is - 4
loop i is - 5
loop i is - 6
loop i is - 7
loop i is - 8
loop i is - 9
Hello, Welcome to Go

看一下輸出,就會有兩個問題。

  1. 11 個 goroutine 如何併發執行的?
  2. goroutine 以什麼順序執行?

這兩個問題,又引發新的思考:

  1. 如何將這些多個 goroutine 分佈到在可用 CPU 處理器上執行的多個 OS 執行緒上。
  2. 這些多個 goroutine 應該以什麼順序執行以保持公平性?

其餘的討論將主要圍繞從設計角度解決 Go 執行時排程程式的這些問題。排程程式可能會瞄準許多目標中的一個或多個,對於我們的案例,我們將限制自己滿足以下要求。

  1. 應該是並行的、可擴充套件的、公平的。
  2. 每個程式應可擴充套件到數百萬個 goroutine(10⁶)
  3. 記憶體高效。(RAM 很便宜,但不是免費的。)
  4. 系統呼叫不應導致效能下降。(最大化吞吐量,最小化等待時間)

因此,讓我們開始為排程程式建模,以逐步解決這些問題。

1.每個 Goroutine 執行緒——使用者級執行緒。

  限制

    1.並行和可擴充套件。
      * 並行
      * 可擴充套件
    2. 每個程式不能擴充套件到數百萬個 goroutine(10⁶)

2.M:N 執行緒——混合執行緒

M個核心執行緒執行N個“ goroutine”

M 個核心執行緒執行 N 個 “ goroutine”

程式碼和並行的實際執行需要核心執行緒。但是建立成本很高,所以將 N 個 goroutine 對映到 M Kernel Thread。Goroutine 是 Go Code,所以我們可以完全控制它。此外,它在使用者空間中,因此建立起來很便宜。

但是因為作業系統對 goroutine 一無所知。每個 goroutine 都有一個 state 來幫助 Scheduler 根據 goroutine state 知道要執行哪個 goroutine。與核心執行緒相比,這個狀態資訊很小,goroutine 的上下文切換變得非常快。

  • Running-當前在核心執行緒上執行的 goroutine。
  • Runnable-夠程等待核心執行緒來執行。
  • Blocked-等待某些條件的 Goroutine(例如,在通道,系統呼叫,互斥體等上被阻止)


2 個執行緒一次執行 2 個

因此,Go Runtime Scheduler 通過將 N Goroutine 複用到 M 核心執行緒來管理處於各種狀態的這些 goroutine。

簡單的 MN 排程器
在我們簡單的 M:N Scheduler 中,我們有一個全域性執行佇列,某些操作將一個新的 goroutine 放入執行佇列。M 個核心執行緒訪問排程程式以從 “執行佇列” 中獲取 goroutine 來執行。多個執行緒嘗試訪問相同的記憶體區域,我們將使用 Mutex For Memory Access Synchronization 鎖定此結構。

簡單的 M:N

阻塞的 goroutine 在哪裡?
可以阻塞的 goroutine 一些例項。

  1. 在 channel 上傳送和接收。
  2. 網路 I/O。
  3. 阻止系統呼叫。
  4. 計時器。
  5. 互斥體。

那麼,我們將這些阻塞的 goroutine 放在哪裡?

阻塞的goroutine不應阻塞底層的核心執行緒!(避免執行緒上下文切換成本)

通道操作期間阻止了 Goroutine。
每個通道都有一個 recvq(waitq),用於儲存被阻止的 goroutine,這些 goroutine 試圖從該通道讀取資料。
Sendq(waitq)儲存試圖將資料傳送到通道的被阻止的 goroutine 。

通道操作期間阻止了 Goroutine。
通道操作後的未阻塞的goroutine被通道放入“執行”佇列。

通道操作後接觸阻塞的 goroutine

系統呼叫呢?

首先,讓我們看看阻塞系統呼叫。一個阻塞底層核心執行緒的系統呼叫,所以我們不能在這個執行緒上排程任何其他 Goroutine。

隱含阻塞系統呼叫降低了並行級別。

不能在 M2 執行緒上排程任何其他 Goroutine,導致 CPU 浪費。

我們可以恢復並行度的方法是,當我們進入系統呼叫時,我們可以喚醒另一個執行緒,該執行緒將從執行佇列中選擇可執行的 goroutine。

現在,當系統呼叫完成時,超額執行了 Groutine 計劃。為了避免這種情況,我們不會立即執行 Goroutine 從阻止系統呼叫中返回。但是我們會將其放入排程程式執行佇列中。

避免過度預定的排程

因此,當我們的程式執行時,執行緒數大於核心數。儘管沒有明確說明,執行緒數大於核心數,並且所有空閒執行緒也都由執行時管理,以避免過多的執行緒。

初始設定為 10,000 個執行緒,如果超過則程式崩潰。

非阻塞系統呼叫 --- 在整合執行時輪詢器上阻塞 goroutine ,並釋放執行緒以執行另一個 goroutine。

例如在非阻塞 I/O 的情況下,例如 HTTP 呼叫。第一個系統呼叫 - 遵循先前的工作流程 - 不會成功,因為資源尚未準備好,迫使 Go 使用網路輪詢器並停放 goroutine。

這是部分 net.Read 功能的實現。

n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
  n = 0
  if err == syscall.EAGAIN && fd.pd.pollable() {
    if err = fd.pd.waitRead(fd.isFile); err == nil {
    continue
  }
}

一旦完成第一個系統呼叫並明確指出資源尚未準備好,goroutine 將停放,直到網路輪詢器通知它資源已準備好為止。在這種情況下,執行緒 M 將不會被阻塞。

Poller 將基於作業系統使用 select/kqueue/epoll/IOCP 來了解哪個檔案描述符已準備好,一旦檔案描述符準備好進行讀取或寫入,它將把 goroutine 放回到執行佇列中。

還有一個 Sysmon OS 執行緒,如果輪詢時間不超過 10 毫秒,它將定期輪詢網路,並將就緒 G 新增到佇列中。

基本上所有的 goroutine 都被阻止在

  1. 渠道
  2. 互斥體
  3. 網路 IO
  4. 計時器

現在,執行時具有具有以下功能的排程程式。

  • 它可以處理並行執行(多執行緒)。
  • 處理阻止系統呼叫和網路 I/O。
  • 處理阻止使用者級別(在通道上)的呼叫。

但這不是可擴充套件的

使用 Mutex 的全域性執行佇列

如圖所見,我們有一個 Mutex 全域性執行佇列,最終會遇到一些問題,例如

  1. 快取一致性保證的開銷。
  2. 在建立,銷燬和排程 Goroutine G 時進行激烈的鎖爭用。

使用分散式排程器克服可擴充套件性的問題。

分散式排程程式—每個執行緒執行佇列。

分散式執行佇列排程程式

這樣,我們可以看到的直接好處是,對於每個執行緒本地執行佇列,我們現在都沒有互斥體。仍然有一個帶有互斥量的全域性執行佇列,在特殊情況下使用。它不會影響可伸縮性。

現在,我們有多個執行佇列。

  1. 本地執行佇列
  2. 全域性執行佇列
  3. 網路輪訓器

我們應該從哪裡執行下一個 goroutine?

在 Go 中,輪詢順序定義如下。

  1. 本地執行佇列
  2. 全域性執行佇列
  3. 網路輪訓器
  4. 工作偷竊(Work Stealing)

即首先檢查本地執行佇列,如果為空則檢查全域性執行佇列,然後檢查網路輪詢器,最後進行竊取工作。到目前為止,我們對 1,2,3 有了一些概述。讓我們看一下 “竊取工作”。

工作偷竊

如果本地工作佇列為空,請嘗試 “從其他佇列中竊取工作”

“偷竊” 工作

當一個執行緒有太多的工作要做而另一個執行緒處於空閒狀態時,工作竊取解決了這個問題。在 Go 中,如果本地佇列為空,竊取工作將嘗試滿足以下條件之一。

  • 從全域性佇列中拉取工作。
  • 從網路輪詢器中拉取工作。
  • 從其他本地佇列中竊取工作。

到目前為止,執行時 Go 具有具有以下功能的 Scheduler。

  • 它可以處理並行執行(多執行緒)。
  • 處理阻止系統呼叫和網路 I/O。
  • 處理阻止使用者級別(在通道上)的呼叫。
  • 可擴充套件

但這不是有效的。

還記得我們在阻塞系統呼叫中恢復並行度的方式嗎?

它的含義是,在一個系統呼叫中我們可以有多個核心執行緒(可以是 10 或 1000),這可能會增加核心數。我們最終在以下期間產生了恆定的開銷:

  • 竊取工作時,它必須同時掃描所有核心執行緒(理想情況下並使用 goroutine 執行)本地執行佇列,並且大多數都將為空。
  • 垃圾回收,記憶體分配器都遭受相同的掃描問題。

使用 M:P:N 執行緒克服效率問題。

3.M:P:N(3 級排程程式)執行緒化—邏輯處理器 P 簡介

P — 處理器,可以將其視為線上程上執行的本地排程程式;

M:P:N 執行緒

邏輯程式 P 的數量始終是固定的。(預設為當前程式可以使用的邏輯 CPU)

將本地執行佇列(LRQ)放入固定數量的邏輯處理器(P)中。

分散式三級執行佇列排程程式

Go 執行時將首先根據計算機的邏輯 CPU 數量(或根據請求)建立固定數量的邏輯處理器 P。

每個 goroutine(G)將在分配給邏輯 CPU(P)的 OS 執行緒(M)上執行。

因此,現在我們在以下期間沒有固定的開銷:

  • 竊取工作 - 只需掃描固定數量的邏輯處理器(P)本地執行佇列。
  • 垃圾回收,記憶體分配器也獲得相同的好處。

帶有固定邏輯處理器(P)的系統呼叫怎麼樣?

Go通過將系統呼叫包裝在執行時中來優化系統呼叫-無論它是否阻塞

阻止系統呼叫包裝器

Blocking SYSCALL 方法封裝在 runtime.entersyscall(SB)
runtime.exitsyscall(SB)之間。
從字面上看,某些邏輯在進入系統呼叫之前執行,而某些邏輯在退出系統呼叫之後執行。進行阻塞系統呼叫時,此包裝器將自動從執行緒 M 分離 P,並允許另一個執行緒在其上執行。

阻塞系統呼叫切換 P

這允許 Go 執行時在不增加執行佇列的情況下有效地處理阻塞系統呼叫。

一旦阻止 syscall 退出,會發生什麼?

  • 執行時嘗試獲取完全相同的 P,然後繼續執行。
  • 執行時嘗試在空閒列表中獲取一個 P 並恢復執行。
  • 執行時將 goroutine 放到全域性佇列中,並將關聯的 M 放回空閒列表。

自旋執行緒和理想執行緒(Spinning Thread and Ideal Thread).

當 M2 執行緒在 syscall 返回後變成理想理想執行緒時。該理想的 M2 執行緒該怎麼辦。理論上,一個執行緒如果完成了它需要做的事情就應該被作業系統銷燬,然後其他程式中的執行緒可能會被 CPU 排程執行。這就是我們常說的作業系統中執行緒的 “搶佔式排程”。

考慮上述 syscall 中的情況。如果我們銷燬了 M2 執行緒,而 M3 執行緒即將進入 syscall。此時,在建立新的核心執行緒並將其計劃由 OS 執行之前,無法處理可執行的 goroutine。頻繁的執行緒前搶佔操作不僅會增加 OS 的負載,而且對於效能要求更高的程式幾乎是不可接受的。

因此,為了正確利用作業系統的資源並防止頻繁的執行緒搶佔作業系統上的負載,我們不會破壞核心執行緒 M2,而是進行自旋操作並儲存以備將來使用。儘管這似乎是在浪費一些資源。但是,與執行緒之間的頻繁搶佔以及頻繁的建立和銷燬操作相比,“理想的執行緒” 仍然要付出更少的代價。

Spinning Thread —例如,在具有一個核心執行緒 M(1)和一個邏輯處理器(P)的 Go 程式中,如果正在執行的 M 被 syscall 阻止,則 “ Spinning Threads” 的數目與該數目相同需要 P 的值以允許等待的可執行 goroutine 繼續執行。因此,在此期間,核心執行緒的數量 M 大於 P 的數量(旋轉執行緒 + 阻塞執行緒)。因此,即使將 runtime.GOMAXPROCSvalue 設定為 1,程式也將處於多執行緒狀態。

排程中的公平性如何?—公平選擇下一步要執行的 goroutine。

與許多其他排程程式一樣,Go也具有公平性約束,並且由goroutine的實現所強加,因為Runnable goroutine應該最終執行

以下是 Go Runtime Scheduler 中的四個典型的公平性約束。

任何執行超過 10 毫秒的 goroutine 都被標記為可搶佔(軟限制)。但是,搶佔僅在函式序言中完成。Go 目前在函式 prologues 中使用編譯器插入的合作搶佔點。

    無限迴圈——搶佔(~10ms 時間片)——軟限制

但是要小心無限迴圈,因為 Go 的排程程式不是搶佔式的(直到 1.13)。如果迴圈不包含任何搶佔點(如函式呼叫或分配記憶體),它們將阻止其他 goroutine 執行。一個簡單的例子是:

package main
func main() {
    go println("goroutine ran")
    for {}
}

執行命令

GOMAXPROCS = 1 go run main.go

直到 Go(1.13)才可能列印該語句。由於缺少搶佔點,因此主要的 Goroutine 可以佔用處理器。

  • 本地執行佇列 - 搶佔(〜10ms 時間片)- 軟限制
  • 通過每 61 個排程程式刻度檢查一次全域性執行佇列,可以避免全域性執行佇列飢餓。
  • Network Poller Starvation 後臺執行緒輪詢網路偶爾會被主工作執行緒輪詢。

Go 1.14 有一個新的 “非合作式搶佔”。

有了 Go,Runtime 有了一個 Scheduler,它具有所有必需的功能。

  • 它可以處理並行執行(多執行緒)。
  • 處理阻止系統呼叫和網路 I / O。
  • 處理阻止使用者級別(在通道上)的呼叫。
  • 可擴充套件
  • 高效的。
  • 公平的。

這提供了大量的併發性,並且始終嘗試實現最大的利用率和最小的延遲。

現在,我們總體上對 Go 執行時排程程式有了一些瞭解,我們如何使用它?Go 為我們提供了一個跟蹤工具,即排程程式跟蹤,目的是提供有關行為的見解並除錯與 goroutine 排程程式有關的可伸縮性問題。

排程程式跟蹤

使用 GODEBUG = schedtrace = DURATION 環境變數執行 Go 程式以啟用排程程式跟蹤。(DURATION 是以毫秒為單位的輸出週期。)

更多原創文章乾貨分享,請關注公眾號
  • Go Runtime 的排程器
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章