Go 的搶佔式排程

lsj1342發表於2021-05-13

Go 的搶佔式排程

我正在研究 Go 中 goroutine 的搶佔。如果您能指出文中任何錯誤並告知我,將感激不盡。

Go1.14 版本中的搶佔行為已經發生了變化。在 Go1.14 中,goroutine 是 “非同步搶佔” 的,如發行版本所述。這意味著什麼呢?

首先,讓我們看一個簡單的例子。思考下面的 Go 程式。

package main

import (
    "fmt"
)

func main() {
    go fmt.Println("hi")
    for {
    }
}

在主函式中,啟動了一個只輸出 “hi” 的 goroutine。此外,存在一個無限迴圈 for {}

如果我們攜帶引數 GOMAXPROCS=1 執行程式時,將發生什麼呢?程式似乎在輸出 “hi” 後,由於無限迴圈而沒有任何反應。實際上,我使用 Go1.14 或更高版本執行該程式時(我使用 Go1.16 上執行了該程式(在 WSL2 上的 Ubuntu )),它能按照預期工作。

有兩種方法可以阻止此程式執行。一種是使用 1.14 之前的 Go 版本執行它。另一種是執行它時攜帶引數 GODEBUG=asyncpreemptoff=1

當我在本地計算機上嘗試時,它的工作方式如下。

$ GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 go run main.go
# it blocks here

程式沒有輸出 “hi” 。在描述為什麼會發生這種情況之前,讓我先說明幾種使該程式按預期方式執行的方法。

一種方法是在迴圈中新增以下程式碼。



*************** package main
*** 2,11 ****
--- 2,13 ----

  import (
      "fmt"
+     "runtime"
  )

  func main() {
      go fmt.Println("hi")
      for {
+         runtime.Gosched()
      }
  }

runtime.Gosched() 類似於 POSIX 的 sched_yieldsched_yield 強制當前執行緒放棄 CPU,以便其他執行緒可以執行。之所以命名為 Gosched,因為 Go 中是 goroutine,而不是執行緒(這是一個猜測)。換句話說,顯式呼叫 runtime.Gosched() 將強制對 goroutines 進行重新安排,並且我們期望將當前執行的 goroutine 切換到另一個。

另一種方法是使用 GOEXPERIMENT=preemptibleloops。它強制 Go 執行時在 “迴圈” 上進行搶佔。這種方式不需要更改程式碼。

協作式排程 vs 搶佔式排程

首先,有兩種主要的多工排程方法:“協作” 和 “搶佔”。協作式多工處理也稱為 “非搶佔”。在協作式多工處理中,程式的切換方式取決於程式本身。“協作” 一詞是指這樣一個事實:程式應設計為可互操作的,並且它們必須彼此 “協作”。在搶佔式多工處理中,程式的切換交給作業系統。排程是基於某種演算法的,例如基於優先順序,FCSV,輪詢等。

那麼現在,goroutine 的排程是協作式還是搶佔式的?至少在 Go1.13 之前,它是協作式的。

我沒有找到任何官方文件,但是我發現在以下情況會進行 goroutine 切換(並不詳盡)。

  • 等待讀取或寫入未緩衝的通道
  • 由於系統呼叫而等待
  • 由於 time.Sleep() 而等待
  • 等待互斥量釋放

此外,Go 會啟動一個執行緒,一直執行著 “sysmon” 函式,該函式實現了搶佔式排程(以及其他諸如使網路處理的等待狀態變為非阻塞狀態)的功能。sysmon 執行在 M(Machine,實際上是一個系統執行緒),且不需要 P(Processor)。術語 M,P 和 G 在類似這樣的各種文章中都有解釋。我建議您在需要時參考此類文章。

當 sysmon 發現 M 已執行同一個 G(Goroutine)10ms 以上時,它會將該 G 的內部引數 preempt 設定為 true。然後,在函式序言中,當 G 進行函式呼叫時,G 會檢查自己的 preempt 標誌,如果它為 true,則它將自己與 M 分離並推入 “全域性佇列”。現在,搶佔就成功完成。順便說一下,全域性佇列是與 “本地佇列” 不同的佇列,本地佇列是儲存 P 具有的 G。全域性佇列有以下幾個作用。

  • 儲存那些超過本地佇列容量(256)的 G
  • 儲存由於各種原因而等待的 G
  • 儲存由搶佔標誌分離的 G

這是 Go1.13 及其之前版本的實現。現在,您將瞭解為什麼上面的無限迴圈程式碼無法按預期工作。for{} 僅僅是一個死迴圈,所以如前所述它不會觸發 goroutine 切換。您可能會想,“sysmon 是否設定了搶佔標誌,因為它已經執行了 10ms 以上?” 然而,如果沒有函式呼叫,即使設定了搶佔標誌,也不會進行該標誌的檢查。如前所述,搶佔標誌的檢查發生在函式序言中,因此不執行任何操作的死迴圈不會發生搶佔。

是的,隨著 Go1.14 中引入 “非協作式搶佔”(非同步搶佔),這種行為已經改變。

“非同步搶佔” 是什麼意思?

讓我們總結到目前為止的要點;Go 具有一種稱為 “sysmon” 的機制,可以監視執行 10ms 以上的 goroutine 並在必要時強制搶佔。但是,由於它的工作方式,在 for{} 的情況下並不會發生搶佔。

Go1.14 引入非協作式搶佔,即搶佔式排程,是一種使用訊號的簡單有效的演算法。

首先,sysmon 仍然會檢測到執行了 10ms 以上的 G(goroutine)。然後,sysmon 向執行 G 的 P 傳送訊號(SIGURG)。Go 的訊號處理程式會呼叫 P 上的一個叫作 gsignal 的 goroutine 來處理該訊號,將其對映到 M 而不是 G,並使其檢查該訊號。gsignal 看到搶佔訊號,停止正在執行的 G。

由於此機制會顯式發出訊號,因此無需呼叫函式,就能將正在執行死迴圈的 goroutine 切換到另一個 goroutine。

通過使用訊號的非同步搶佔機制,上面的程式碼現在就可以按預期工作。GODEBUG=asyncpreemptoff=1 可用於禁用非同步搶佔。

順便說一句,他們選擇使用 SIGURG,是因為 SIGURG 不會干擾現有偵錯程式和其他訊號的使用,並且因為它不在 libc 中使用。(參考)

總結

不執行任何操作的無限迴圈不會將 CPU 傳遞給其他 goroutine,並不意味著 Go1.13 之前的機制是不好的。正如 @davecheney 所說,通常不認為這是一個特殊問題。起初,非同步搶佔不是為了解決無限迴圈問題引出的。

儘管非同步搶佔的引入使排程更具搶佔性,但也有必要在 GC 期間更加謹慎地處理 “不安全點”。在這方面對實現上的考慮也非常有趣。有興趣的讀者可以自己閱讀議題:非協作式 goroutine 搶佔

參考

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

相關文章