這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
本文譯自 Rakyll 的 scheduler著作權@歸原文所有.
Go 調度器的工作是將可啟動並執行 goroutine 分發到一個或多個處理器上啟動並執行多個作業系統背景工作執行緒.在多線程計算裡, 調度出現了兩種模式: work-sharing (工作共用) 和 work-stealing (工作竊取).
- work-sharing 當一個處理器產生新的線程時, 它試圖將其中的一些遷移到其他處理器上, 希望它們能被空閑或未充分利用的處理器所利用.
- work-stealing 未充分利用的處理器會主動去尋找其他處理器的線程並
竊取
一些.
work-stealing 中線程遷移的頻率少於 work-sharing. 當所有處理器都有工作要運行時, 沒有線程會被遷移. 而一旦有閒置處理器, 就會考慮遷移.
Go 從 1.1 開始就有一個 work-stealing 的調度器, 由 Dmitry Vyukov 貢獻. 本文將深入解釋什麼是 work-stealing 調度器, 以及 Go 如何?它.
調度基礎
Go 有一個可以利用多核處理器的 M:N 調度器. 任何時候, M 個 goroutine 都需要在 N 個 OS 線程上進行調度, 這些線程運行在最多 GOMAXPROCS 數量的處理器上.Go 調度器使用以下術語解釋 goroutine, 線程以及處理器:
- G: goroutine
- M: OS 線程 (機器)
- P: 處理器 (譯者: 此處不是指 CPU, 可以認為是 Go 調度上下文或調度處理器, 所以下文的處理器如無特別說明都是指 P)
有一個 P 相關的本地和全域 goroutine 隊列. 每個 M 應該被分配給一個 P. 如果被阻塞或者在系統調用中, P (們) 可能沒有 M (們). 任何時候,最多隻有 GOMAXPROCS 數量的 P. 任何時候, 每個 P 只能有一個 M 運行. 如果需要, 更多的 M (們) 可以由調度器建立.
每輪調度只是簡單找到一個可啟動並執行 goroutine 並執行它. 在每輪調度中, 搜尋按以下順序進行:
runtime.schedule() { // only 1/61 of the time, check the global runnable queue for a G. 僅 1/61 的時間, 檢查全域運行隊列裡面的 G. // if not found, check the local queue. 如果沒找到, 檢查本地隊列. // if not found, 還是沒找到 ? // try to steal from other Ps. 嘗試從其他 P 偷. // if not, check the global runnable queue. 還是沒有, 檢查全域運行隊列. // if not found, poll network. 還是沒有, 輪詢網路.}
一旦找到可啟動並執行 G, 它會一直執行直到被阻塞.
注意 看起來好像全域隊列比本地隊列有優勢, 但是偶爾檢查全域隊列是至關重要的, 以避免 M 只是從本地隊列調度, 直到沒有本地排隊的 goroutine 留下.
Stealing (竊取)
當一個新的 G 被建立或者一個現有的 G 變成可啟動並執行時候, 它被壓入當前 P 的可啟動並執行 goroutine 列表. 當 P 完成 G 時, 它會嘗試從自己的可運行 goroutine 列表中彈出一個 G. 如果列表現在是空的, P 會隨機的選擇其他的 P, 並嘗試從其隊列中偷取一半可啟動並執行 goroutine(s).
在上面的例子中, P2 找不到任何可啟動並執行 goroutine. 因此, 它隨機播放另一個 P1, 並將其三個 goroutine(s) 竊取到自己的本地隊列中. P2 將能夠運行這些 goroutine, 並且調度器的工作會在多個處理器之間更加公平地分配.
自旋線程 (Spinning threads)
調度器總是希望將儘可能多的可啟動並執行 goroutine(s) 分配給 M (們) 來利用處理器, 但是同時我們需要停留過多的工作來節省 CPU 和電力. 與此相矛盾的是, 調度器還需要能夠擴充到高輸送量和 CPU 密集型的程式.如果效能是至關重要的, 那麼對高輸送量程式來說持續搶佔既是昂貴又是有問題的. 作業系統線程不應該頻繁地在 goroutine(s) 之間切換, 因為這會增加延遲. 除此之外, 在發生系統調用的時候, 作業系統線程需要不斷地被阻塞和解除阻塞. 這是昂貴的, 並增加了很多開銷.
為了盡量減少切換, Go 調度器實現了 自旋線程
. 自旋線程消耗一點額外的 CPU, 但是它們最小化了 OS 線程的搶佔. 一個線程是自旋的, 如果:
- 分配了 P 的 M 正在尋找一個可執行 goroutine;
- 沒有分配 P 的 M 正在尋找可用的 P;
- 調度器還會釋放一個附加的線程, 當它正準備一個 goroutine 並且沒有閒置 P 也沒有其他自旋線程的時候讓它自旋.
任何時候最多有 GOMAXPROCS 個自旋的 M (們). 當一個自旋的線程找到工作, 它就脫離了自旋狀態.
如果有閒置 M 沒有被賦予 P, 那麼被賦予 P 的空閑線程不會被阻塞. 當新的 goroutine(s) 被建立或 M 被阻塞時, 調度器確保至少有一個自旋 M. 這確保了沒有可啟動並執行 goroutine(s) 不被運行; 並且避免過多的 M 阻塞或者解除阻塞.
結論
Go 調度器做了很多事情來避免過多的作業系統線程搶佔, 通過竊取(stealing)調度它們到正確和未充分利用的處理器, 以及實現 自旋
線程以避免過高阻塞或者解除阻塞切換的發生.
調度事件可以用執行追蹤器(execution tracer)追蹤. 如果你碰巧認為自己的處理器利用率很差, 則可以用它探究發生了什麼事情.
參考資料
- Go 運行時調度器源碼
- 可擴充 Go 調度器設計文檔
- Daniel Morsing: Go 調度器