你覺得 Go 在什么時候會搶占 P?
在 Go 語言中,Goroutine 是并發模型的核心,而 P(Processor) 是 Go 調度器中的一個關鍵抽象。理解 Goroutine 調度模型 中的 G(Goroutine)、M(Machine,內核線程)、P(Processor,邏輯處理器) 的關系可以幫助我們理解 Go 的搶占式調度策略。
Go 調度器使用 G-M-P 模型:
- Goroutine (G):一個 Goroutine 代表一個 Go 協程。
- Processor (P):P 是邏輯處理器,負責調度和管理 Goroutine,最多有 GOMAXPROCS 個 P。每個 P 可以運行一個 Goroutine。
- Machine (M):M 是操作系統的內核線程。每個 M 需要綁定一個 P 來執行 Goroutine。
#Go 中的搶占式調度
Go 的調度器采用的是協作式調度為主,搶占式調度為輔。協作式調度意味著 Goroutine 需要主動放棄控制權來讓其他 Goroutine 運行,比如調用系統調用或者 Goroutine 自己調用 runtime.Gosched()。
搶占式調度則是為了防止某些 Goroutine 占用 CPU 太久(比如某個 Goroutine 在長時間執行計算密集型任務),Go 1.14 引入了針對 計算密集型 Goroutine 的 搶占式調度。搶占式調度可以在以下場景下觸發:
- Goroutine 執行時間過長,特別是沒有主動進行系統調用、調度讓出等行為時。
- Goroutine 執行在較長的函數調用鏈上,或者在一些函數的棧幀擴展時(例如深度遞歸調用或大數組操作時)。
#搶占 P 的時機
- 系統調用 (syscall) 后:當 Goroutine 執行系統調用后,Goroutine 會讓出 P,此時調度器可能會選擇調度其他的 Goroutine 來運行。
- 垃圾回收 (GC) 階段:當觸發垃圾回收時,調度器會在合適時機搶占 Goroutine,確保 GC 可以進行。
- 計算密集型任務被長時間運行:從 Go 1.14 開始,調度器會定期檢查長時間運行的 Goroutine,并進行搶占。
#搶占式調度與長時間運行的 Goroutine
下面的例子展示了一個 Goroutine 在執行計算密集型任務時如何可能會被 Go 的搶占式調度機制打斷。
package main
import (
"fmt"
"runtime"
"time"
)
// 模擬一個計算密集型任務
func busyLoop() {
for i := 0; i < 1e10; i++ {
// 占用 CPU,但沒有主動讓出調度權
}
fmt.Println("Finished busy loop")
}
func main() {
runtime.GOMAXPROCS(1) // 設置只有 1 個 P
go func() {
for {
fmt.Println("Running another goroutine...")
time.Sleep(500 * time.Millisecond) // 每 500 毫秒休息一次
}
}()
busyLoop() // 執行計算密集型任務
time.Sleep(2 * time.Second)
}
#代碼解析:
- runtime.GOMAXPROCS(1):我們將 GOMAXPROCS 設置為 1,意味著整個程序中只有一個 P,這樣所有 Goroutine 都只能在這個 P 上調度。
- busyLoop:這是一個計算密集型任務,在沒有主動進行系統調用或讓出調度權的情況下,循環執行大量的操作,耗盡 CPU 時間。
- 搶占:雖然 busyLoop 沒有主動讓出 CPU,但由于 Go 的搶占式調度機制,調度器可能會在合適的時間點打斷 busyLoop,讓其他 Goroutine(比如打印 "Running another goroutine..." 的那個 Goroutine)得到執行機會。
#輸出示例:
Running another goroutine...
Running another goroutine...
...
Finished busy loop
我們可以看到,盡管 busyLoop 是一個計算密集型任務,其他的 Goroutine 仍然會間歇性地被調度并執行。這個就是 Go 搶占式調度的效果。
#搶占的實現機制
搶占式調度的核心機制是 定期檢查 Goroutine 的執行時間。Go 調度器在后臺維護一個時間戳,記錄 Goroutine 上次被調度的時間。調度器每隔一段時間會檢查當前運行的 Goroutine,如果 Goroutine 占用了 CPU 超過一定時間,調度器就會標記這個 Goroutine 需要被搶占,然后調度其他的 Goroutine 來執行。
搶占式調度通過以下方式觸發:
- 函數調用邊界:當 Goroutine 進行函數調用時,Go runtime 會在合適的時機插入搶占檢查點。
- 棧增長:當 Goroutine 的棧增長(如遞歸調用導致棧內存增長)時,調度器也會插入搶占檢查。
- GC 安全點:垃圾回收過程中,調度器也會嘗試搶占。
#通過代碼觀察搶占效果
我們可以通過使用 GODEBUG 環境變量,啟用搶占式調度的調試日志,觀察搶占調度的具體行為。運行如下代碼時,啟用調試模式:
GODEBUG=schedtrace=1000,scheddetail=1 go run main.go
- schedtrace=1000 表示每隔 1000 毫秒輸出一次調度器狀態。
- scheddetail=1 表示輸出詳細的調度器信息。
#輸出內容解釋
在輸出的調試信息中,我們可以看到調度器何時搶占了 Goroutine,何時讓出了 P,以及具體的調度行為。調試信息會包括如下內容:
- idle M:表示某個 M(線程)變成空閑狀態。
- new work:表示調度器找到了新的工作,分配給 P。
- steal work:表示調度器從其他 P 中竊取任務來運行。
#最后我們來總結一下
- Go 的調度器主要基于 協作式調度,但是對于計算密集型任務會通過 搶占式調度 機制防止長時間占用 CPU。
- 搶占調度在計算密集型 Goroutine、系統調用后、垃圾回收等場景下被觸發。
- Go 1.14 引入了針對長時間運行的 Goroutine 的搶占式調度,使得 Goroutine 不會因為計算密集任務長時間阻塞 CPU。
這使得 Go 語言能更加高效地運行并發程序,避免單個 Goroutine 長時間霸占 CPU,影響其他 Goroutine 的執行。