這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
一、 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