高伸縮性Go調度器設計(譯)

閱讀該文檔前假設你已經對go語言及其當前調度實現的有所瞭解

當前調度器所存在的問題

當前的調度器限制了go併發的伸縮性,特別是在高吞吐量和並行計算方面.在一臺8核的機器中跑 Vtocc 服務, cpu佔用率高達70%, 性能分析數據顯示14%cpu佔用用在runtime.futex()上.通常,調度器可能會禁止用戶在性能至關重要的地方使用慣用的細粒度併發。

當前調度器的不足:

  1. 全局鎖和中心化狀態, 協程相關的操作(創建、執行完成、重新調度等等) 都需要依賴鎖的保護.
  2. 協程遷移, 協程在各個工作線程M之間頻繁遷移,這可能會增加延遲和額外的開銷.每個M必須能夠執行任何可運行的協程,特別是剛剛創建協程的M.
  3. 線程存在本地緩存(M.cache),所有的M中都會有內存緩存和堆棧緩存, 然而只有當M在運行協程的時候才需要用到它們(處於系統調用中的線程M由於被阻塞而不要使用本地緩存). 真正在執行協程的線程M和所有的線程M比例高達1:100.這會導致過度資源消耗(每個MCache佔用2MB的內存)和非常差的內存親和性.
  4. 線程頻繁的阻塞喚醒,處於系統調用中的線程會頻繁阻塞和喚醒,這會增加很多性能開銷.

新的調度器設計

Processors

總得來說就是在運行時引入P(處理器)的概念,並在處理器之上實現竊取工作的調度機制.

M是表操作系統線程, P提供所有G執行所需的資源環境, 當M執行G時, M會綁定一個P,當M處於空閒或者系統調用時(這種情況下, M 和 P解綁了),M需要一個P.

有一個跟P數量的相關的變量GOMAXPROCS, 所有的P會放到一個數組中, work-stealing(工作竊取)機制會用到這個變量.GOMAXPROCS的修改會調整運行時P的數量.

全局sched中的一些變量現在移到了P中, M中的一些變量(與協程執行相關的)也移到了P中.

struct P
{
Lock;
G *gfree; // freelist, moved from sched
G *ghead; // runnable, moved from sched
G *gtail;
MCache *mcache; // moved from M
FixAlloc *stackalloc; // moved from M
uint64 ncgocall;
GCStats gcstats;
// etc
...
};

P *allp; // [GOMAXPROCS]

還有一個空閒p列表

P *idlep; // lock-free list

當M將要執行G時, 它從p的空閒列表中取出一個p. 當M結束G的執行時, 把P放回空閒列表中,因此 當M要執行G時, M一定要綁定一個P, 這種機制代替了之前全局 sched的原子操作 (mcpu/mcpumax ).

調度

當一個新創建的G或者舊的G變成runnable狀態時, 這個G會被放到P的本地隊列中,當P執行完一個G後,會從自己的本地隊列中取出下一個G繼續執行, 如果本地隊列爲空, 則隨機從其他P的本地隊列中竊取一半G過來執行.

系統調用(M 掛起/喚醒)

當M創建一個G時,M必須確保有其他的M來執行這個G(如果有空閒的M),同樣的,當M進入系統調用時,確保有其他M來執行G.

有兩種選擇, 我們可以迅速掛起和喚醒M, 或者使M自旋.這是在性能和消耗不必要的CPU週期之間做的選擇,其思想是使M空轉並消耗CPU週期.但是,當我們把GOMAXPROCS 設置成1時, 這種方式不能影響命令行等程序的運行.

自旋有兩種: 1.一個綁定了P的空閒M在自旋,目的是尋找G來執行 2. 沒有綁定P的M自旋等待可用的P.最多有GOMAXPROCS 個M自旋(包括第一種和第二種).當存在第二種空閒M時,第一種的空閒M不會被阻塞.

當有一個新的G創建時,或者M進入系統調用時,或者M從空閒便成繁忙時,確保至少有一個處於自旋的M(所有的p都處於忙碌中).這確保了沒有任何可以運行的G;同時避免了過多的M在頻繁阻塞喚醒.

cpu空轉是沒有意義的

終止/死鎖檢測

在分佈式系統中,終止/死鎖檢測問題更大,通用的做法是僅在所有P空閒時進行檢查(空閒P的全局原子計數器),這允許執行更昂貴的檢查,包括每P狀態的聚合。

鎖定操作系統線程

此功能不是性能關鍵.

  1. 當M鎖定的G會變成非運行態(Gwaiting). M會立刻把P放到空閒列表中, 喚醒其他的M,然後阻塞住.
  2. 當M鎖定的G變成可運行狀態(G在runq的頭部),當前M將自己的P和鎖定的G交給與G關聯的M,並喚醒M,當前M變爲空閒。

空閒G

此功能不是性能關鍵.

有一個全局G隊列,M在多次竊取G失敗後會檢查這個隊列

實現計劃

我們的目標是將整個事情分解成可以獨立審查和提交的最小部分.

  1. 定義P結構體;實現 allp/idlep 隊列(idlep 需要使用互斥鎖保護);M執行G的的代碼時要綁定一個P,全局互斥鎖和原子狀態仍然保留.
  2. 把G的freelist 移到P中.
  3. 把mcache移到P中.
  4. 把stackalloc移到P中
  5. 把ncgocall/gcstats移到P中
  6. 分散運行隊列,實現工作竊取.取消G在M之間的輪轉.仍然使用全局互斥
  7. 移除全局鎖,實現分散終止檢測, 鎖定操作系統線程
  8. 實現自旋代替立即阻塞/喚醒.

這項計劃可能行不通,因爲還有很多未經探索的細節。

潛在的進一步改進

  1. 嘗試後進先出的調度, 這將改善局部性.然而,它必須提供一定程度的公平性,並優雅地處理讓出cpu的goroutine。

  2. G和棧的內存分配應該等到它即將執行時來進行, 對於一個新創建的G,只需要callerpc, fn, narg, nret 和 args 參數, 佔用大約6個機器字的內.這將允許創建大量從運行到完成的goroutine,並顯著降低內存開銷。

  3. G和P更好的親和性,嘗試將未阻塞的G排隊到它上次運行的P.

  4. P和M更好的親和性,嘗試在上次運行的M上執行P

  5. 創建M的限制,調度器可以很容易地每秒創建數千個M,直到操作系統拒絕創建更多線程. 在達到k*GOMAXPROCS個M之前,M可以快速創建,之後可以通過計時器添加新的M。

其他事項

GOMAXPROCS不會因爲這項改進而消失

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章