Golang原理之goroutine與channel
常見併發程式設計模型分類
併發程式設計模型,顧名思義就是為了解決高併發充分利用多核特性減少CPU等待提高吞吐量而提出的相關的程式設計正規化。目前為止,我覺得比較常見的併發程式設計模型大致可以分為兩類:
基於訊息(事件)的活動物件 基於CSP模型的協程的實現 其中基於訊息(事件)的活動物件的併發模型,最典型的代表就是Akka的actor。actor的併發模型是把一個個計算序列按抽象為一個一個Actor物件,每一個Actor之間通過非同步的訊息傳遞機制來進行通訊。這樣一來,本來順序阻塞的計算序列,就被分散到了一個一個Actor中。我們在Actor中的操作應該儘量保證非阻塞性。當然,在akka中actor是根據具體的Dispatcher來決定如何處理某一個actor的訊息,預設的dispatcher是ForkJoinExecutor,只適合用來處理非阻塞非CPU密集型的訊息;akka中還有另外一些Dispatcher可以用於處理阻塞或者CPU密集型的訊息,具體的底層實現用到CachedThreadPool。這兩種Dispatcher結合起來,我們便能在jvm上建立完整的併發模型。
基於協程的實現,這裡主要的代表就是goroutine。Golang的runtime實現了goroutine和OS thread的M:N模型,因此實際的goroutine是基於執行緒的更加輕量級的實現,我們便可以在Golang中大量建立goroutine而不用擔心昂貴的context swtich所帶來的開銷。goroutine之間,我們可以通過channel來進行互動。由於go已將將所有system call都wrap到了標準庫中,在針對這些systemcall進行呼叫時會主動標記goroutine為阻塞狀態並儲存現場,交由scheduler執行。所以在golang中,在大部分情況下我們可以非常安心地在goroutine中使用阻塞操作而不用擔心併發性受到影響。
goroutine的這種併發模型有一個非常明顯的優勢,我們可以簡單地使用人見人愛的阻塞程式設計方式來抒發非同步的情懷,只要能合理運用go關鍵字。相比較於akka的actor而言,goroutine的程式可讀性更強且更好定位錯誤。
goroutine特點
goroutine的併發模型定義為以下幾個要點:
基於Thread的輕量級協程 通過channel來進行協程間的訊息傳遞 只暴露協程,遮蔽執行緒操作的介面 goroutine原理
在作業系統的OS Thread和程式語言的User Thread之間,實際上存在3中執行緒對應模型,也就是:1:1,1:N,M:N。
N:1是說,多個(N)使用者執行緒始終在一個核心執行緒上跑,context上下文切換確實很快,但是無法真正的利用多核。 1:1是說,一個使用者執行緒就只在一個核心執行緒上跑,這時可以利用多核,但是上下文switch很慢,頻繁切換效率很低。 M:N是說, 多個goroutine在多個核心執行緒上跑,這個看似可以集齊上面兩者的優勢,但是無疑增加了排程的難度。
goroutine google runtime預設的實現為M:N的模型,於是這樣可以根據具體的操作型別(作業系統阻塞或非阻塞操作)調整goroutine和OS Thread的對映情況,顯得更加的靈活。
在goroutine實現中,有三個最重要的資料結構,分別為G M P:
G:代表一個goroutine M:代表 一個OS Thread P:一個P和一個M進行繫結,代表在這個OS Thread上的排程器
如上圖所示,我們可以看到圖中有兩個M,即兩個OS Thread執行緒,分別對應一個P,每一個P有負責排程多個G。如此一來,就組成的goroutine執行時的基本結構。
P的數量可以通過GOMAXPROCS()來設定,它其實也就代表了真正的併發度,即有多少個goroutine可以同時執行。
圖中灰色的那些goroutine並沒有執行,而是出於ready的就緒態,正在等待被排程。
P維護著這個佇列(稱之為runqueue),Go語言裡,啟動一個goroutine很容易:go function 就行,所以每有一個go語句被執行,runqueue佇列就在其末尾加入一個goroutine,在下一個排程點,就從runqueue中取出(如何決定取哪個goroutine?)一個goroutine執行。
為何要維護多個上下文P?因為當一個OS執行緒被阻塞時,P可以轉而投奔另一個OS執行緒!圖中看到,當一個OS執行緒M0陷入阻塞時,P轉而在OS執行緒M1上執行。排程器保證有足夠的執行緒來執行所以的context P。
當MO返回時,它必須嘗試取得一個context P來執行goroutine,一般情況下,它會從其他的OS執行緒那裡steal偷一個context過來,如果沒有偷到的話,它就把goroutine放在一個global runqueue裡,然後自己就去睡大覺了(放入執行緒快取裡)。Contexts們也會週期性的檢查global runqueue,否則global runqueue上的goroutine永遠無法執行。
另一種情況是P所分配的任務G很快就執行完了(分配不均),這就導致了一個上下文P閒著沒事兒幹而系統卻任然忙碌。但是如果global runqueue沒有任務G了,那麼P就不得不從其他的上下文P那裡拿一些G來執行。一般來說,如果上下文P從其他的上下文P那裡要偷一個任務的話,一般就‘偷’run queue的一半,這就確保了每個OS執行緒都能充分的使用。
下面我們對G M P的具體程式碼進行分析
struct G { uintptr stackguard0;// 用於棧保護,但可以設定為StackPreempt,用於實現搶佔式排程 uintptr stackbase; // 棧頂 Gobuf sched; // 執行上下文,G的暫停執行和恢復執行,都依靠它 uintptr stackguard; // 跟stackguard0一樣,但它不會被設定為StackPreempt uintptr stack0; // 棧底 uintptr stacksize; // 棧的大小 int16 status; // G的六個狀態 int64 goid; // G的標識id int8* waitreason; // 當status==Gwaiting有用,等待的原因,可能是呼叫time.Sleep之類 G* schedlink; // 指向連結串列的下一個G uintptr gopc; // 建立此goroutine的Go語句的程式計數器PC,通過PC可以獲得具體的函式和程式碼行數 }; struct P { Lock; // plan9 C的擴充套件語法,相當於Lock lock; int32 id; // P的標識id uint32 status; // P的四個狀態 P* link; // 指向連結串列的下一個P M* m; // 它當前繫結的M,Pidle狀態下,該值為nil MCache* mcache; // 記憶體池 // Grunnable狀態的G佇列 uint32 runqhead; uint32 runqtail; G* runq[256]; // Gdead狀態的G連結串列(通過G的schedlink) // gfreecnt是連結串列上節點的個數 G* gfree; int32 gfreecnt; }; struct M { G* g0; // M預設執行G void (*mstartfn)(void); // OS執行緒執行的函式指標 G* curg; // 當前執行的G P* p; // 當前關聯的P,要是當前不執行G,可以為nil P* nextp; // 即將要關聯的P int32 id; // M的標識id M* alllink; // 加到allm,使其不被垃圾回收(GC) M* schedlink; // 指向連結串列的下一個M }; 這裡,G最重要的三個狀態為Grunnable Grunning Gwaiting。具體的狀態遷移為Grunnable -> Grunning -> Gwaiting -> Grunnable。goroutine在狀態發生轉變時,會對棧的上下文進行儲存和恢復。下面讓我們來開一下G中的Gobuf的定義
struct Gobuf { uintptr sp; // 棧指標 uintptr pc; // 程式計數器PC G* g; // 關聯的G }; 當具體要儲存棧上下文時,最重要的就是儲存這個Gobuf結構中的內容。goroutine具體是通過void gosave(Gobuf*)以及void gogo(Gobuf*)這兩個函式來實現棧上下文的儲存和恢復的,具體的底層實現為彙編程式碼,因此goroutine的context swtich會非常快。
接下來,我們來具體看一下goroutine scheduler在幾個主要場景下的排程策略。
goroutine將scheduler的執行交給具體的M,即OS Thread。每一個M就執行一個函式,即void schedule(void)。這個函式具體做得事情就是從各個執行佇列中選擇合適的goroutine然後執行goroutine中對應的func。
具體的schedule函式如下:
// 排程的一個回合:找到可以執行的G,執行 // 從不返回 static void schedule(void) { G *gp; uint32 tick; top: gp = nil; // 時不時檢查全域性的可執行佇列,確保公平性 // 否則兩個goroutine不斷地互相重生,完全佔用本地的可執行佇列 tick = m->p->schedtick; // 優化技巧,其實就是tick%61 == 0 if(tick - (((uint64)tick*0x4325c53fu)>>36)*61 == 0 && runtime·sched.runqsize > 0) { runtime·lock(&runtime·sched); gp = globrunqget(m->p, 1); // 從全域性可執行佇列獲得可用的G runtime·unlock(&runtime·sched); if(gp) resetspinning(); } if(gp == nil) { gp = runqget(m->p); // 如果全域性佇列裡沒找到,就在P的本地可執行佇列裡找 if(gp && m->spinning) runtime·throw("schedule: spinning with local work"); } if(gp == nil) { gp = findrunnable(); // 阻塞住,直到找到可用的G resetspinning(); } // 是否啟用指定某M來執行該G if(gp->lockedm) { // 把P給指定的m,然後阻塞等新的P startlockedm(gp); goto top; } execute(gp); // 執行G } 於是這裡丟擲幾個問題:
當M發現分配給自己的goroutine連結串列已經執行完畢時怎麼辦? 當goroutine陷入系統呼叫阻塞後,M是否也一起阻塞? 當某個gorouine長時間佔用CPU怎麼辦? 首先第一個問題,當M發現在P中的gorouine連結串列已經全部執行完畢時,將會從其他的P中偷取goroutine然後執行,其策略就是一個工作密取的機制。當其他的P也沒有可執行的goroutine時,就會從全域性等待佇列中尋找runnable的goroutine進行執行,如果還找不到,則M讓出CPU排程。
第二個問題,例如阻塞IO讀取本地檔案,此時呼叫會systemcall會陷入核心,不可避免地會使得呼叫執行緒阻塞,因此這裡goroutine的做法是將所有可能阻塞的系統呼叫均封裝為gorouine友好的介面。具體做法為,在每次進行系統呼叫之前,從一個執行緒池從獲取一個OS Thread並執行該系統呼叫,而本來執行的gorouine則將自己的狀態改為Gwaiting,並將控制權交給scheduler繼續排程,系統呼叫的返回通過channel進行同步即可。因此,這裡其實goroutine也沒有辦法做到完全的協程化,因為系統呼叫總會阻塞執行緒。具體可以參考stackoverflow上的討論:連結
第三個問題,go支援簡單的搶佔式排程,在goruntime中有一個sysmon執行緒,負責檢測goruntime的各種狀態。sysmon其中一項職責就是檢測是否有長時間佔用CPU的goroutine,如果發現了就將其搶佔過來。 --------------------- 作者:uxff 來源:CSDN 原文:https://blog.csdn.net/xuduorui/article/details/78731650 版權宣告:本文為博主原創文章,轉載請附上博文連結!