關於Golang和JVM中併發模型實現的探討

  • 提及來貌似有很久沒有更新過博客了,主要是由於最近一段時間都在各類看書和看源碼,所作的記錄大部分也都是屬於讀書筆記性質,因此就沒有整理到博客上來,以後會陸續整理一些東西上來。html

    引子

    話說最近終於決定把以前收藏了好久的mit-6.824課程的lab拿出來作一下,這門課最有價值的地方就在於它設計了一些列的lab,讓你可以在必定程度可控的工做量的coding以後比較深刻地體會到衆多分佈式程序中所面臨的一些列公共的問題以及如何去解決它們。例如,分佈式容錯、併發、網絡底層實現等等。這門課的targeted language是golang。緣由天然不說,由於golang的簡潔因此很是適合用來替代C++等語言來做爲lab的實現語言。golang

    在實現的過程中,我遇到的一個最主要的問題就是,如何在CPU密集型任務、I/O密集型任務以及充分利用多核CPU提高程序性能上找到一個平衡點。固然,這之中最容易想到的解決方案就是多線程。可是因爲分佈式程序的特殊性,它可能擁有大量的網絡I/O或者計算任務。這就不可避免須要將使用同步的方式來抒寫異步的情懷,解決方案就是將這些計算或者IO放到新的線程中去作,具體的線程調度交給操做系統來完成(雖然咱們可使用異步IO,可是異步IO因爲存在大量的callback,不便於程序邏輯組織,因此這裏不考慮直接使用異步IO)。這樣有一個問題就在於,這之中會有大量的線程在context中,因此線程的上下文切換的開銷不可忽視。若是咱們在jvm中實現的話,大量的thread可能會很快耗盡jvm堆的內存,不只會形成堆溢出,並且增大GC時間和不穩定性。所以,最近我就考察了幾種常見的併發編程模型以及其對應常見的實現方式。編程

    常見併發編程模型分類

    併發編程模型,顧名思義就是爲了解決高併發充分利用多核特性減小CPU等待提升吞吐量而提出的相關的編程範式。目前爲止,我以爲比較常見的併發編程模型大體能夠分爲兩類:api

  • 基於消息(事件)的活動對象
  • 基於CSP模型的協程的實現

 

是的,貌似有大神已經作過了,學術界太可怕了!網絡

首先第一個問題,當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,若是發現了就將其搶佔過來。併發

JDK上沒法實現goroutine的緣由

到這裏,咱們已經大體瞭解了goroutine的原理,可見goroutine中最重要的一個設計就在於它將全部的語言層次上的api都限制在了goroutine這一層,進而屏蔽了執行代碼與具體線程交互的機會。因此在goroutine中,咱們實際上就能夠忽略線程的存在,把goroutine當成是一個很是廉價可以大量建立的Thread。異步

然而在Java中或者說打算和JDK交互的JVM系語言(如scala,clojure),本質上都沒法徹底實現goroutine(clojure雖然有async,可是依然沒法和JDK中的阻塞api結合良好)。假設,咱們在Java中基於Thread之上實現一個scheduler,一個輕量級的協程以及協程相關的原語(如resume, pause等),咱們也只能基於咱們本身封裝的api來協助協程調度。若是在建立的協程中直接使用Java阻塞api,結果就是使得用來調度協程的OS Thread陷入阻塞,沒法繼續運行scheduler進行調度。jvm

綜上所述,若是在不更改JDK Native實現的前提下,咱們是沒法完全實現相似goroutine的協程的。

TODO

因而乎,要使得JDK支持coroutine,那麼咱們就只能改JDK了,是的我就是這麼執着!=。=

先列出一些Related Work:

JCSP CSP for Java Part 1 CSP for Java Part 2

Alt text

如上圖所示,咱們能夠看到圖中有兩個M,即兩個OS Thread線程,分別對應一個P,每個P有負責調度多個G。如此一來,就組成的goroutine運行時的基本結構。

下面咱們對G M P的具體代碼進行分析

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

 

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的定義

 

1

2

3

4

5

6

 

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函數以下:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

 

// 調度的一個回合:找到能夠運行的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怎麼辦?

首先,咱們假設可以在JDK中創建起一套基於已有Thread模型的coroutine機制,而且能夠經過調用某些方法來建立coroutine對象,分配coroutine任務並執行。可是JDK中存在許多已有的阻塞操做,而這些阻塞操做的調用會直接讓線程被阻塞,這樣一來依託於線程的coroutine就會失去從新調度的能力。也許你有不少其餘的方法進行設計,可是這裏本質問題是無論你怎麼進行設計,你都始終沒法擺脫JDK中協程狀態和線程狀態不統一的狀況。除非作到像Go中同樣,全部的阻塞操做均被wrap到協程的層次來進行操做。因此,一旦咱們用到JDK中和線程綁定的阻塞API時,那麼這種併發模型就基本歇菜了。

那麼下面咱們來分析一下goroutine的實現原理從而解釋爲何Java沒法作到goroutine那樣的協程。

Goroutine原理

在操做系統的OS Thread和編程語言的User Thread之間,實際上存在3中線程對應模型,也就是:1:1,1:N,M:N。

JVM specification中規定JVM線程和操做系統線程爲1:1的關係,也就是說Java中的Thread和OS Thread爲1:1的關係。也有把線程模型實現爲1:N的模式,不過這樣作其實不夠靈活。goroutine google runtime默認的實現爲M:N的模型,因而這樣能夠根據具體的操做類型(操做系統阻塞或非阻塞操做)調整goroutine和OS Thread的映射狀況,顯得更加的靈活。

在goroutine實現中,有三個最重要的數據結構,分別爲G M P:

G:表明一個goroutine M:表明 一個OS Thread P:一個P和一個M進行綁定,表明在這個OS Thread上的調度器

其中基於消息(事件)的活動對象的併發模型,最典型的表明就是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的程序可讀性更強且更好定位錯誤。

Java可否作到goroutine這樣?

既然goroutine這麼好用,那麼咱們可否基於jdk來實現一套相似goroutine的併發模型庫??(這裏我在知乎提了一個相關的問題,詳見這裏)很遺憾,若是基於JDK的話,是沒法實現的。下面咱們來分析一下這個問題的本質。

下面我將goroutine的併發模型定義爲如下幾個要點:

基於Thread的輕量級協程 經過channel來進行協程間的消息傳遞 只暴露協程,屏蔽線程操做的接口

相關文章
相關標籤/搜索