關於golang的goroutine scheduler

weixin_34146805發表於2019-01-04

前言

G:指的是Goroutine
M:工作執行緒或機器
P:處理器,用來執行Go程式碼的資源
每個M需要有一個關聯P來執行Go程式碼,

當前scheduler的問題

當前goroutine scheduler限制使用go語言編寫的併發程式可擴充套件性,特別是,高吞吐服務和平行計算程式方面。Vtocc服務在8核機器上CPU高達70%,而profile顯示14%是花費在runtime.futex()這個地方。通常來說scheduler可能會禁止使用者在效能優關的地方使用慣用細粒度的併發。

當前實現存在的問題

  1. 單一的全域性互斥鎖(Sched.Lock)和中心式狀態,全域性互斥鎖保護了所有goroutine的關聯操作(建立、實現、重新排程等)
    2、Goroutine並不干涉其他的goroutine(G.nextG).同樣工作執行緒經常也不干涉runnable goroutine在彼此之間,而這些可能會導致增加延遲和帶來額外花費。每個M能夠執行任意執行中的G,實際上M僅僅建立G。
    3、每個M記憶體快取(M.mcache),與所有M關聯的記憶體快取和其他快取(比如堆分配記憶體),僅僅實在M執行go程式碼時才需要被分配(M在系統呼叫阻塞中是不需要分配記憶體快取的),在執行Go程式碼的M和所有M的記憶體比例最高達到1:100.這也導致資源浪費(每個MCache分配大小達到2M)及糟糕的資料存放位置
    4、頻繁的blocking和unblocking,在系統呼叫時,工作執行緒會頻繁阻塞和非阻塞,會帶來額外的花費

scheduler 設計

Processors(處理器)

基本的思想是在執行引入P(Processors)的概念,並實現在Processor上實現搶奪式工作排程,而M表示作業系統執行緒,P表示執行Go程式碼時所需要的資源,當M執行Go程式碼時, 它會有對應關聯的P。在M空閒或在進行系統呼叫時,是需要P的。GOMAXPROCES即為P,所有的P都會被存放到一個array中,這也是搶奪式工作模式的要求,對應的GOMAXPROCS的改變會帶來停止/啟動這個GC World來重新調整P的陣列大小。在sched中的一些變數被分散並移到P中,M中的一些變數被移到P中,這些變數一般是與go程式碼活動執行有關的。

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 gcstates;
     // etc
     ...
};
P *allp;  // [GOMAXPROCS]
p *idlep; // lock-free list of idle P

說明:當一個M將要執行Go程式碼,必須從無鎖空閒列表獲取一個P。而當M完成Go程式碼執行,就需要將執行GO程式碼的P放入到該列表中,以便後續的操作使用。因此在M執行Go程式碼時,都必有一個與之關聯的P。這種機制也取代了sched.atomic(mcpu/mcpumax)

排程scheduling

當新建一個G或已有的G重新執行,那麼該G則將被推到當前P的執行goroutine列表中。在該P完成執行G,P則嘗試從自己本地的執行goroutines列表pop一個G;若是本地列表為空,該P則會隨機選擇一個其他的P,並從選擇的P本地執行goroutine列表獲取一半的goroutine,這樣就提升P資源的利用,提升效率。

系統呼叫/M parking和unparking

在M建立一個新的g時,在所有的M都不是很忙的情況下,必須能夠確保有另外一個M來執行這個G。同樣,當一個M正處於系統呼叫時,也必須確保有另外一個M來執行Go程式碼。
這裡有兩種選擇,可以立即鎖定或解鎖M,也使用旋轉。這裡會存在必然的衝突在效能和消耗不必要的CPU時鐘週期之間。通過使用旋轉和消耗一定CPU時鐘週期來實現,然而對於GOMAXPROCS=1的程式(命令列程式、app引擎等)是沒有影響的。
關於旋轉有兩中型別:
(1)、一個關聯P的空閒M通過自旋查詢新的G
(2)、一個關聯P的w/o M等待可用的P
型別(1)的空閒M在有型別(2)空閒M時並不會阻塞/鎖定。常見的GOMAXPROCS旋轉M基本上就在兩種型別中。在一個新的G處於旋轉狀態或M在進行系統呼叫時甚至M從空閒轉變為忙碌,都能確保至少有一個M是旋轉的(或所有的P都處於忙碌狀態),同時這也確保了沒有runnable的G以其他方式執行,並且也避免了同一時間過多的M進行block或unblock。一般來說旋轉多半都是被動是由os呼叫sched_yield()來觸發,但也可能存在些主動自旋轉(迴圈消耗CPU),這些是需要研究並進行調優的。

終止/死鎖檢查

在分散式系統中,終止/死鎖檢查存在很多的不確定性。通常的想法是通過是在所有的P都處於空閒狀態下(全域性空閒P原子計數器),這允許進行更昂貴的檢查涉及每個P狀態的聚合。

LockOSThread

該功能並不是效能的關鍵
1、處於Locked的G則是不能進行runnable(狀態=Gwaiting),M立即將P返回到空閒列表(idle-list),並喚醒其他的M並阻塞
2、當處於Locked的G變成runnable並處於執行佇列的頭部,當前的M並不干涉自己對應的P和Locked G到Locked G相關的M上,並釋放,最終當前M變為idle

空閒G

該功能也不是效能的關鍵
這裡有一個全域性的空閒G佇列,一個M在多次嘗試失敗之後來檢查該佇列。

實現計劃

最終的目標通過將整個專案分割成能夠獨立評估和提交的最小單元。
1、引入結構體P(目前為空),實現allp/idlep容器(針對初學者來說idlep是一個互斥-受保護的);分配一個P給對應的M來執行Go程式碼,全域性的互斥和原子狀態是保持不變的
2、移動G freelist給P
3、移動M到P
4、移動堆分配到P
5、移動ncgocall/gsstats到P
6、分散執行佇列並實現搶奪式工作模式,清除G狀態轉換,仍處於全域性互斥下
7、移除全域性互斥,實現分散式終止檢查:LockOSThread
8、實現旋轉而不是blocking、unblocking
(該計劃有可能會失敗,有很多未被探索的細節)

進一步改善

1、嘗試LIFO排程,將提高區域性效能。然而,它仍然必須提供一定程度的公平性和優雅地處理生成goroutines
2、不要在goroutine首次執行時分配對應的G和stack;針對一個新建一個新的goroutine只需要callerpc、fn、narg、nret、args等6個命令。這也允許建立一些執行到完成的goroutine來顯著的降低記憶體的佔用
3、更好區域性G-P過程嘗試確保一個unblock的G到上次執行它的P上
4、更好的區域性P-M嘗試執行P在上次執行相同的M上
5、M建立限制,由於scheduler很容易每秒鐘建立數千個M,直到OS拒絕建立更多的執行緒。M就必須立即建立直至k * GOMAXPROCS,在這以後新建M都是通過計數器來完成新增的

後記

GOMAXPROCS不會因為這些調整而消失。

相關文章