Golang1.7 Goroutine原始碼分析

InsZVA發表於2016-06-29

一、       Golang簡介

1.1概述

 

Golang語言是Google公司開發的新一代程式語言,簡稱Go語言,Go 是有表達力、簡潔、清晰和有效率的。它的並行機制使其很容易編寫多核和網路應用,而新奇的型別系統允許構建有彈性的模組化程式。 Go 編譯到機器碼非常快速,同時具有便利的垃圾回收和強大的執行時反射。而他最廣為人知的特性便是語言層面上對多核程式設計的支援,他有簡單的關鍵字go來實現並行,就像下面這樣:

 

 

Go的並行單元並不是傳統意義上的執行緒,執行緒切換需要很大的上下文,這種切換消耗了大量CPU時間,而Go採用更輕量的協程(goroutine)來處理,大大提高了並行度,被稱為“最並行的語言”。最近引起容器技術浪潮的Docker就是Go寫的。由於GC穿插在goroutine之中,但是本篇文章並不討論GC相關內容,故略過GC,主要討論goroutine的排程問題。本文針對的go版本是截止2016年6月29日最新的Go1.7。

 

1.2與其他併發模型的對比

 

Python等解釋性語言採用的是多程式併發模型,程式的上下文是最大的,所以切換耗費巨大,同時由於多程式通訊只能用socket通訊,或者專門設定共享記憶體,給程式設計帶來了極大的困擾與不便;

 

C++等語言通常會採用多執行緒併發模型,相比程式,執行緒的上下文要小很多,而且多個執行緒之間本來就是共享記憶體的,所以程式設計相比要輕鬆很多。但是執行緒的啟動和銷燬,切換依然要耗費大量CPU時間;

 

於是出現了執行緒池技術,將執行緒先儲存起來,保持一定的數量,來避免頻繁開啟/關閉執行緒的時間消耗,但是這種初級的技術存在一些問題,比如有執行緒一直被IO阻塞,這樣的話這個執行緒一直佔據著坑位,導致後面的任務排不到隊,拿不到執行緒來執行;

 

而Go的併發較為複雜,Go採用了更輕量的資料結構來代替執行緒,這種資料結構相比執行緒更輕量,他有自己的棧,切換起來更快。然而真正執行併發的還是執行緒,Go通過排程器將goroutine排程到執行緒中執行,並適時地釋放和建立新的執行緒,並且當一個正在執行的goroutine進入阻塞(常見場景就是等待IO)時,將其脫離佔用的執行緒,將其他準備好執行的goroutine放在該執行緒上執行。通過較為複雜的排程手段,使得整個系統獲得極高的並行度同時又不耗費大量的CPU資源。

 

1.3 Goroutine的特點

 

Goroutine的引入是為了方便高併發程式的編寫。一個Goroutine在進行阻塞操作(比如系統呼叫)時,會把當前執行緒中的其他Goroutine移交到其他執行緒中繼續執行,從而避免了整個程式的阻塞。

 

由於Golang引入了垃圾回收(gc),在執行gc時就要求Goroutine是停止的。通過自己實現排程器,就可以方便的實現該功能。 通過多個Goroutine來實現併發程式,既有非同步IO的優勢,又具有多執行緒、多程式編寫程式的便利性。

 

引入Goroutine,也意味著引入了極大的複雜性。一個Goroutine既要包含要執行的程式碼,又要包含用於執行該程式碼的棧和PC、SP指標。

 

既然每個Goroutine都有自己的棧,那麼在建立Goroutine時,就要同時建立對應的棧。Goroutine在執行時,棧空間會不停增長。棧通常是連續增長的,由於每個程式中的各個執行緒共享虛擬記憶體空間,當有多個執行緒時,就需要為每個執行緒分配不同起始地址的棧。這就需要在分配棧之前先預估每個執行緒棧的大小。如果執行緒數量非常多,就很容易棧溢位。

 

為了解決這個問題,就有了Split Stacks 技術:建立棧時,只分配一塊比較小的記憶體,如果進行某次函式呼叫導致棧空間不足時,就會在其他地方分配一塊新的棧空間。新的空間不需要和老的棧空間連續。函式呼叫的引數會拷貝到新的棧空間中,接下來的函式執行都在新棧空間中進行。

 

Golang的棧管理方式與此類似,但是為了更高的效率,使用了連續棧( Golang連續棧) 實現方式也是先分配一塊固定大小的棧,在棧空間不足時,分配一塊更大的棧,並把舊的棧全部拷貝到新棧中。這樣避免了Split Stacks方法可能導致的頻繁記憶體分配和釋放。

 

Goroutine的執行是可以被搶佔的。如果一個Goroutine一直佔用CPU,長時間沒有被排程過,就會被runtime搶佔掉,把CPU時間交給其他Goroutine。

 

二、       具體實現

 

2.1概念:

 

M:指go中的工作者執行緒,是真正執行程式碼的單元;

P:是一種排程goroutine的上下文,goroutine依賴於P進行排程,P是真正的並行單元;

G:即goroutine,是go語言中的一段程式碼(以一個函式的形式展現),最小的並行單元;

P必須繫結在M上才能執行,M必須繫結了P才能執行,而一般情況下,最多有MAXPROCS(通常等於CPU數量)個P,但是可能有很多個M,真正執行的只有繫結了M的P,所以P是真正的並行單元。

 

 

每個P有一個自己的runnableG佇列,可以從裡面拿出一個G來執行,同時也有一個全域性的runnable G佇列,G通過P依附在M上面執行。不單獨使用全域性的runnable G佇列的原因是,分散式的佇列有利於減小臨界區大小,想一想多個執行緒同時請求可用的G的時候,如果只有全域性的資源,那麼這個全域性的鎖會導致多少執行緒一直在等待。

 

但是如果一個正在執行的G進入了阻塞,典型的例子就是等待IO,那麼他和它所在的M會在那邊等待,而上下文P會傳遞到其他可用的M上面,這樣這個阻塞就不會影響程式的並行度。


2.2 框架圖


2.3具體函式

 

goroutine排程器的程式碼在/src/runtime/proc.go中,一些比較關鍵的函式分析如下。

 

1.     schedule函式

schedule函式在runtime需要進行排程時執行,為當前的P尋找一個可以執行的G並執行它,尋找順序如下:

1) 呼叫runqget函式來從P自己的runnable G佇列中得到一個可以執行的G;

2) 如果1)失敗,則呼叫findrunnable函式去尋找一個可以執行的G;

3) 如果2)也沒有得到可以執行的G,那麼結束排程,從上次的現場繼續執行。

 

2.     findrunnable函式

findrunnable函式負責給一個P尋找可以執行的G,它的尋找順序如下:

1) 呼叫runqget函式來從P自己的runnable G佇列中得到一個可以執行的G;

2) 如果1)失敗,呼叫globrunqget函式從全域性runnableG佇列中得到一個可以執行的G;

3) 如果2)失敗,呼叫netpoll(非阻塞)函式取一個非同步回撥的G;

4) 如果3)失敗,嘗試從其他P那裡偷取一半數量的G過來;

5) 如果4)失敗,再次呼叫globrunqget函式從全域性runnableG佇列中得到一個可以執行的G;

6) 如果5)失敗,呼叫netpoll(阻塞)函式取一個非同步回撥的G;

7) 如果6)仍然沒有取到G,那麼呼叫stopm函式停止這個M。

 

3.     newproc函式

newproc函式負責建立一個可以執行的G並將其放在當前的P的runnable G佇列中,它是類似”go func() { … }”語句真正被編譯器翻譯後的呼叫,核心程式碼在newproc1函式。這個函式執行順序如下:

1) 獲得當前的G所在的 P,然後從free G佇列中取出一個G;

2) 如果1)取到則對這個G進行引數配置,否則新建一個G;

3) 將G加入P的runnable G佇列。

 

4.     goexit0函式

goexit函式是當G退出時呼叫的。這個函式對G進行一些設定後,將它放入free G列表中,供以後複用,之後呼叫schedule函式排程。

 

5.     handoffp函式

handoffp函式將P從系統呼叫或阻塞的M中傳遞出去,如果P還有runnable G佇列,那麼新開一個M,呼叫startm函式,新開的M不空旋。

 

6.     startm函式

startm函式排程一個M或者必要時建立一個M來執行指定的P。

 

7.     entersyscall_handoff函式

entersyscall_handoff函式用來在goroutine進入系統呼叫(可能會阻塞)時將P傳遞出去。

 

8.     sysmon函式

sysmon函式是Go runtime啟動時建立的,負責監控所有goroutine的狀態,判斷是否需要GC,進行netpoll等操作。sysmon函式中會呼叫retake函式進行搶佔式排程。

 

9.     retake函式

retake函式是實現搶佔式排程的關鍵,它的實現步驟如下:

1) 遍歷所有P,如果該P處於系統呼叫中且阻塞,則呼叫handoffp將其移交其他M;

2)    如果該P處於執行狀態,且上次排程的時間超過了一定的閾值,那麼就呼叫preemptone函式這將導致該 P 中正在執行的 G 進行下一次函式呼叫時,導致棧空間檢查失敗。進而觸發morestack()(彙編程式碼,位於asm_XXX.s中)然後進行一連串的函式呼叫,主要的呼叫過程如下:morestack()(彙編程式碼)-> newstack() -> gopreempt_m() -> goschedImpl() ->schedule()在goschedImpl()函式中,會通過呼叫dropg()將 G 與 M 解除繫結;再呼叫globrunqput()將 G 加入全域性runnable佇列中。最後呼叫schedule() 來為當前 P 設定新的可執行的 G 。

 

三、          小結

 

Go語言由於存在自己的runtime,使得goroutine的實現相對簡單,筆者曾嘗試在C++11中實現類似功能,但是保護現場的搶佔式排程和G被阻塞後傳遞給其他Thread的呼叫很難實現,畢竟Go的所有呼叫都經過了runtime,這麼想來,C#、VB之類的語言實現起來應該容易一點。筆者在C++11中實現的goroutine不支援搶佔式排程和阻塞後傳遞的功能,所以僅僅和直接使用std::thread進行多執行緒操作進行了對比,工作函式為計算密集的操作,下面是效果對比圖(專案地址在https://github.com/InsZVA/cppgo):

 

 

可以看到筆者的庫啟動時間更短(goroutine比執行緒輕量),執行到最高峰的時候也給系統OS空出了一個執行緒,而且用時也要短於多執行緒模型。相比大多數並行設計模型,Go比較優勢的設計就是P上下文這個概念的出現,如果只有G和M的對應關係,那麼當G阻塞在IO上的時候,M是沒有實際在工作的,這樣造成了資源的閒置,而且,沒有了P,那麼所有G的列表都放在全域性,這樣導致臨界區太大,對多核排程造成極大影響。而goroutine在使用上面的特點,感覺既可以用來做密集的多核計算,又可以做高併發的IO應用,做IO應用的時候,寫起來感覺和對程式設計師最友好的同步阻塞一樣,而實際上由於runtime的排程,底層是以同步非阻塞的方式在執行(即IO多路複用),雖然達不到nodejs這樣非同步非阻塞的併發程度,但也接近。而且相比nodejs,go可以更好地利用多核做計算,由於是靜態編譯,可以在很早的時候發現程式的錯誤。這門語言還處於蓬勃發展中,也屬於開源語言,有興趣可以保持持續關注。

 

四、          參考資料

 

Golang程式碼倉庫:https://github.com/golang/go

《ScalableGo Schedule》:https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit

《GoPreemptive Scheduler》:https://docs.google.com/document/d/1ETuA2IOmnaQ4j81AtTGT40Y4_Jr6_IDASEKg0t0dBR8/edit

相關文章