溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

如何進(jìn)行Golang和JVM中并發(fā)模型實現(xiàn)的探討

發(fā)布時間:2021-11-15 23:48:31 來源:億速云 閱讀:145 作者:柒染 欄目:云計算

今天就跟大家聊聊有關(guān)如何進(jìn)行Golang和JVM中并發(fā)模型實現(xiàn)的探討,可能很多人都不太了解,為了讓大家更加了解,小編給大家總結(jié)了以下內(nèi)容,希望大家根據(jù)這篇文章可以有所收獲。

說起來貌似有好久沒有更新過博客了,主要是因為最近一段時間都在各種看書和看源碼,所做的記錄大部分也都是屬于讀書筆記性質(zhì),所以就沒有整理到博客上來,之后會陸續(xù)整理一些東西上來。

引子

話說最近終于決定把之前收藏了很久的mit-6.824課程的lab拿出來做一下,這門課最有價值的地方就在于它設(shè)計了一些列的lab,讓你能夠在一定程度可控的工作量的coding之后比較深入地體會到眾多分布式程序中所面臨的一些列公共的問題以及如何去解決它們。例如,分布式容錯、并發(fā)、網(wǎng)絡(luò)底層實現(xiàn)等等。這門課的targeted language是golang。原因自然不說,因為golang的簡潔所以非常適合用來替代C++等語言來作為lab的實現(xiàn)語言。

在實現(xiàn)的過程當(dāng)中,我遇到的一個最主要的問題就是,如何在CPU密集型任務(wù)、I/O密集型任務(wù)以及充分利用多核CPU提升程序性能上找到一個平衡點。當(dāng)然,這之中最容易想到的解決方案就是多線程。但是由于分布式程序的特殊性,它可能擁有大量的網(wǎng)絡(luò)I/O或者計算任務(wù)。這就不可避免需要將使用同步的方式來抒寫異步的情懷,解決方案就是將這些計算或者IO放到新的線程中去做,具體的線程調(diào)度交給操作系統(tǒng)來完成(雖然我們可以使用異步IO,但是異步IO由于存在大量的callback,不便于程序邏輯組織,所以這里不考慮直接使用異步IO)。這樣有一個問題就在于,這之中會有大量的線程在context中,所以線程的上下文切換的開銷不可忽視。如果我們在jvm中實現(xiàn)的話,大量的thread可能會很快耗盡jvm堆的內(nèi)存,不僅會造成堆溢出,而且增大GC時間和不穩(wěn)定性。因此,最近我就考察了幾種常見的并發(fā)編程模型以及其對應(yīng)常見的實現(xiàn)方式。

常見并發(fā)編程模型分類

并發(fā)編程模型,顧名思義就是為了解決高并發(fā)充分利用多核特性減少CPU等待提高吞吐量而提出的相關(guān)的編程范式。目前為止,我覺得比較常見的并發(fā)編程模型大致可以分為兩類:

  • 基于消息(事件)的活動對象

  • 基于CSP模型的協(xié)程的實現(xiàn)

是的,貌似有大神已經(jīng)做過了,學(xué)術(shù)界太可怕了!

首先第一個問題,當(dāng)M發(fā)現(xiàn)在P中的gorouine鏈表已經(jīng)全部執(zhí)行完畢時,將會從其他的P中偷取goroutine然后執(zhí)行,其策略就是一個工作密取的機(jī)制。當(dāng)其他的P也沒有可執(zhí)行的goroutine時,就會從全局等待隊列中尋找runnable的goroutine進(jìn)行執(zhí)行,如果還找不到,則M讓出CPU調(diào)度。

第二個問題,例如阻塞IO讀取本地文件,此時調(diào)用會systemcall會陷入內(nèi)核,不可避免地會使得調(diào)用線程阻塞,因此這里goroutine的做法是將所有可能阻塞的系統(tǒng)調(diào)用均封裝為gorouine友好的接口。具體做法為,在每次進(jìn)行系統(tǒng)調(diào)用之前,從一個線程池從獲取一個OS Thread并執(zhí)行該系統(tǒng)調(diào)用,而本來運行的gorouine則將自己的狀態(tài)改為Gwaiting,并將控制權(quán)交給scheduler繼續(xù)調(diào)度,系統(tǒng)調(diào)用的返回通過channel進(jìn)行同步即可。因此,這里其實goroutine也沒有辦法做到完全的協(xié)程化,因為系統(tǒng)調(diào)用總會阻塞線程。具體可以參考stackoverflow上的討論:鏈接

第三個問題,go支持簡單的搶占式調(diào)度,在goruntime中有一個sysmon線程,負(fù)責(zé)檢測goruntime的各種狀態(tài)。sysmon其中一項職責(zé)就是檢測是否有長時間占用CPU的goroutine,如果發(fā)現(xiàn)了就將其搶占過來。

JDK上無法實現(xiàn)goroutine的原因

到這里,我們已經(jīng)大致了解了goroutine的原理,可見goroutine中最重要的一個設(shè)計就在于它將所有的語言層次上的api都限制在了goroutine這一層,進(jìn)而屏蔽了執(zhí)行代碼與具體線程交互的機(jī)會。所以在goroutine中,我們實際上就可以忽略線程的存在,把goroutine當(dāng)成是一個非常廉價能夠大量創(chuàng)建的Thread。

然而在Java中或者說打算和JDK交互的JVM系語言(如scala,clojure),本質(zhì)上都無法完全實現(xiàn)goroutine(clojure雖然有async,但是依然無法和JDK中的阻塞api結(jié)合良好)。假設(shè),我們在Java中基于Thread之上實現(xiàn)一個scheduler,一個輕量級的協(xié)程以及協(xié)程相關(guān)的原語(如resume, pause等),我們也只能基于我們自己封裝的api來協(xié)助協(xié)程調(diào)度。如果在創(chuàng)建的協(xié)程中直接使用Java阻塞api,結(jié)果就是使得用來調(diào)度協(xié)程的OS Thread陷入阻塞,無法繼續(xù)運行scheduler進(jìn)行調(diào)度。

綜上所述,如果在不更改JDK Native實現(xiàn)的前提下,我們是無法徹底實現(xiàn)類似goroutine的協(xié)程的。

TODO

于是乎,要使得JDK支持coroutine,那么我們就只能改JDK了,是的我就是這么執(zhí)著!=。=

先列出一些Related Work:

JCSP CSP for Java Part 1 CSP for Java Part 2

如何進(jìn)行Golang和JVM中并發(fā)模型實現(xiàn)的探討

如上圖所示,我們可以看到圖中有兩個M,即兩個OS Thread線程,分別對應(yīng)一個P,每一個P有負(fù)責(zé)調(diào)度多個G。如此一來,就組成的goroutine運行時的基本結(jié)構(gòu)。

下面我們對G M P的具體代碼進(jìn)行分析

 

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;// 用于棧保護(hù),但可以設(shè)置為StackPreempt,用于實現(xiàn)搶占式調(diào)度

uintptr stackbase; // 棧頂

Gobuf sched; // 執(zhí)行上下文,G的暫停執(zhí)行和恢復(fù)執(zhí)行,都依靠它

uintptr stackguard; // 跟stackguard0一樣,但它不會被設(shè)置為StackPreempt

uintptr stack0; // 棧底

uintptr stacksize; // 棧的大小

int16 status; // G的六個狀態(tài)

int64 goid; // G的標(biāo)識id

int8* waitreason; // 當(dāng)status==Gwaiting有用,等待的原因,可能是調(diào)用time.Sleep之類

G* schedlink; // 指向鏈表的下一個G

uintptr gopc; // 創(chuàng)建此goroutine的Go語句的程序計數(shù)器PC,通過PC可以獲得具體的函數(shù)和代碼行數(shù)

};

struct P

{

Lock; // plan9 C的擴(kuò)展語法,相當(dāng)于Lock lock;

int32 id; // P的標(biāo)識id

uint32 status; // P的四個狀態(tài)

P* link; // 指向鏈表的下一個P

M* m; // 它當(dāng)前綁定的M,Pidle狀態(tài)下,該值為nil

MCache* mcache; // 內(nèi)存池

// Grunnable狀態(tài)的G隊列

uint32 runqhead;

uint32 runqtail;

G* runq[256];

// Gdead狀態(tài)的G鏈表(通過G的schedlink)

// gfreecnt是鏈表上節(jié)點的個數(shù)

G* gfree;

int32 gfreecnt;

};

struct M

{

G* g0; // M默認(rèn)執(zhí)行G

void (*mstartfn)(void); // OS線程執(zhí)行的函數(shù)指針

G* curg; // 當(dāng)前運行的G

P* p; // 當(dāng)前關(guān)聯(lián)的P,要是當(dāng)前不執(zhí)行G,可以為nil

P* nextp; // 即將要關(guān)聯(lián)的P

int32 id; // M的標(biāo)識id

M* alllink; // 加到allm,使其不被垃圾回收(GC)

M* schedlink; // 指向鏈表的下一個M

};

這里,G最重要的三個狀態(tài)為Grunnable Grunning Gwaiting。具體的狀態(tài)遷移為Grunnable -> Grunning -> Gwaiting -> Grunnable。goroutine在狀態(tài)發(fā)生轉(zhuǎn)變時,會對棧的上下文進(jìn)行保存和恢復(fù)。下面讓我們來開一下G中的Gobuf的定義

 

1

2

3

4

5

6

 

struct Gobuf

{

uintptr sp; // 棧指針

uintptr pc; // 程序計數(shù)器PC

G* g; // 關(guān)聯(lián)的G

};

當(dāng)具體要保存棧上下文時,最重要的就是保存這個Gobuf結(jié)構(gòu)中的內(nèi)容。goroutine具體是通過void gosave(Gobuf*)以及void gogo(Gobuf*)這兩個函數(shù)來實現(xiàn)棧上下文的保存和恢復(fù)的,具體的底層實現(xiàn)為匯編代碼,因此goroutine的context swtich會非???。

接下來,我們來具體看一下goroutine scheduler在幾個主要場景下的調(diào)度策略。

goroutine將scheduler的執(zhí)行交給具體的M,即OS Thread。每一個M就執(zhí)行一個函數(shù),即void schedule(void)。這個函數(shù)具體做得事情就是從各個運行隊列中選擇合適的goroutine然后執(zhí)行g(shù)oroutine中對應(yīng)的func。

具體的schedule函數(shù)如下:

 

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

 

// 調(diào)度的一個回合:找到可以運行的G,執(zhí)行

// 從不返回

static void schedule(void)

{

G *gp;

uint32 tick;

top:

gp = nil;

// 時不時檢查全局的可運行隊列,確保公平性

// 否則兩個goroutine不斷地互相重生,完全占用本地的可運行隊列

tick = m->p->schedtick;

// 優(yōu)化技巧,其實就是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來執(zhí)行該G

if(gp->lockedm) {

// 把P給指定的m,然后阻塞等新的P

startlockedm(gp);

goto top;

}

execute(gp); // 執(zhí)行G

}

于是這里拋出幾個問題:

當(dāng)M發(fā)現(xiàn)分配給自己的goroutine鏈表已經(jīng)執(zhí)行完畢時怎么辦? 當(dāng)goroutine陷入系統(tǒng)調(diào)用阻塞后,M是否也一起阻塞? 當(dāng)某個gorouine長時間占用CPU怎么辦?

首先,我們假設(shè)能夠在JDK中建立起一套基于已有Thread模型的coroutine機(jī)制,并且可以通過調(diào)用某些方法來創(chuàng)建coroutine對象,分配coroutine任務(wù)并執(zhí)行。但是JDK中存在許多已有的阻塞操作,而這些阻塞操作的調(diào)用會直接讓線程被阻塞,這樣一來依托于線程的coroutine就會失去重新調(diào)度的能力。也許你有很多其他的方法進(jìn)行設(shè)計,但是這里本質(zhì)問題是不管你怎么進(jìn)行設(shè)計,你都始終無法擺脫JDK中協(xié)程狀態(tài)和線程狀態(tài)不統(tǒng)一的情況。除非做到像Go中一樣,所有的阻塞操作均被wrap到協(xié)程的層次來進(jìn)行操作。所以,一旦我們用到JDK中和線程綁定的阻塞API時,那么這種并發(fā)模型就基本歇菜了。

那么下面我們來分析一下goroutine的實現(xiàn)原理從而解釋為什么Java無法做到goroutine那樣的協(xié)程。

Goroutine原理

在操作系統(tǒng)的OS Thread和編程語言的User Thread之間,實際上存在3中線程對應(yīng)模型,也就是:1:1,1:N,M:N。

JVM specification中規(guī)定JVM線程和操作系統(tǒng)線程為1:1的關(guān)系,也就是說Java中的Thread和OS Thread為1:1的關(guān)系。也有把線程模型實現(xiàn)為1:N的模式,不過這樣做其實不夠靈活。goroutine google runtime默認(rèn)的實現(xiàn)為M:N的模型,于是這樣可以根據(jù)具體的操作類型(操作系統(tǒng)阻塞或非阻塞操作)調(diào)整goroutine和OS Thread的映射情況,顯得更加的靈活。

在goroutine實現(xiàn)中,有三個最重要的數(shù)據(jù)結(jié)構(gòu),分別為G M P:

G:代表一個goroutine M:代表 一個OS Thread P:一個P和一個M進(jìn)行綁定,代表在這個OS Thread上的調(diào)度器

其中基于消息(事件)的活動對象的并發(fā)模型,最典型的代表就是Akka的actor。actor的并發(fā)模型是把一個個計算序列按抽象為一個一個Actor對象,每一個Actor之間通過異步的消息傳遞機(jī)制來進(jìn)行通訊。這樣一來,本來順序阻塞的計算序列,就被分散到了一個一個Actor中。我們在Actor中的操作應(yīng)該盡量保證非阻塞性。當(dāng)然,在akka中actor是根據(jù)具體的Dispatcher來決定如何處理某一個actor的消息,默認(rèn)的dispatcher是ForkJoinExecutor,只適合用來處理非阻塞非CPU密集型的消息;akka中還有另外一些Dispatcher可以用于處理阻塞或者CPU密集型的消息,具體的底層實現(xiàn)用到CachedThreadPool。這兩種Dispatcher結(jié)合起來,我們便能在jvm上建立完整的并發(fā)模型。

基于協(xié)程的實現(xiàn),這里主要的代表就是goroutine。Golang的runtime實現(xiàn)了goroutine和OS thread的M:N模型,因此實際的goroutine是基于線程的更加輕量級的實現(xiàn),我們便可以在Golang中大量創(chuàng)建goroutine而不用擔(dān)心昂貴的context swtich所帶來的開銷。goroutine之間,我們可以通過channel來進(jìn)行交互。由于go已將將所有system call都wrap到了標(biāo)準(zhǔn)庫中,在針對這些systemcall進(jìn)行調(diào)用時會主動標(biāo)記goroutine為阻塞狀態(tài)并保存現(xiàn)場,交由scheduler執(zhí)行。所以在golang中,在大部分情況下我們可以非常安心地在goroutine中使用阻塞操作而不用擔(dān)心并發(fā)性受到影響。

goroutine的這種并發(fā)模型有一個非常明顯的優(yōu)勢,我們可以簡單地使用人見人愛的阻塞編程方式來抒發(fā)異步的情懷,只要能合理運用go關(guān)鍵字。相比較于akka的actor而言,goroutine的程序可讀性更強(qiáng)且更好定位錯誤。

Java能否做到goroutine這樣?

既然goroutine這么好用,那么我們能否基于jdk來實現(xiàn)一套類似goroutine的并發(fā)模型庫??(這里我在知乎提了一個相關(guān)的問題,詳見這里)很遺憾,如果基于JDK的話,是無法實現(xiàn)的。下面我們來分析一下這個問題的本質(zhì)。

下面我將goroutine的并發(fā)模型定義為以下幾個要點:

基于Thread的輕量級協(xié)程 通過channel來進(jìn)行協(xié)程間的消息傳遞 只暴露協(xié)程,屏蔽線程操作的接口

看完上述內(nèi)容,你們對如何進(jìn)行Golang和JVM中并發(fā)模型實現(xiàn)的探討有進(jìn)一步的了解嗎?如果還想了解更多知識或者相關(guān)內(nèi)容,請關(guān)注億速云行業(yè)資訊頻道,感謝大家的支持。

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI