Go語言 goroutine的生老病死

2018-07-25 17:24 更新

本小節(jié)將通過goroutine的創(chuàng)建,消亡,阻塞和恢復(fù)等過程,來觀察Go語言的調(diào)度策略,這里就稱之為生老病死吧。整個(gè)Go語言的調(diào)度系統(tǒng)是比較復(fù)雜的,為了避免結(jié)構(gòu)體M和結(jié)構(gòu)體P引入的其它干擾,這里主要將注意力集中到結(jié)構(gòu)體G中,以goroutine為主線。

goroutine的創(chuàng)建

前面講函數(shù)調(diào)用協(xié)議時(shí)說過go關(guān)鍵字最終被弄成了runtime.newproc。這就是一個(gè)goroutine的出生,所有新的goroutine都是通過這個(gè)函數(shù)創(chuàng)建的。

runtime.newproc(size, f, args)功能就是創(chuàng)建一個(gè)新的g,這個(gè)函數(shù)不能用分段棧,因?yàn)樗僭O(shè)參數(shù)的放置順序是緊接著函數(shù)f的(見前面函數(shù)調(diào)用協(xié)議一章,有關(guān)go關(guān)鍵字調(diào)用時(shí)的內(nèi)存布局)。分段棧會破壞這個(gè)布局,所以在代碼中加入了標(biāo)記#pragma textflag 7表示不使用分段棧。它會調(diào)用函數(shù)newproc1,在newproc1中可以使用分段棧。真正的工作是調(diào)用newproc1完成的。newproc1進(jìn)行下面這些動(dòng)作。

首先,它會檢查當(dāng)前結(jié)構(gòu)體M中的P中,是否有可用的結(jié)構(gòu)體G。如果有,則直接從中取一個(gè),否則,需要分配一個(gè)新的結(jié)構(gòu)體G。如果分配了新的G,需要將它掛到runtime的相關(guān)隊(duì)列中。

獲取了結(jié)構(gòu)體G之后,將調(diào)用參數(shù)保存到g的棧,將sp,pc等上下文環(huán)境保存在g的sched域,這樣整個(gè)goroutine就準(zhǔn)備好了,整個(gè)狀態(tài)和一個(gè)運(yùn)行中的goroutine被中斷時(shí)一樣,只要等分配到CPU,它就可以繼續(xù)運(yùn)行。

newg->sched.sp = (uintptr)sp;
newg->sched.pc = (byte*)runtime·goexit;
newg->sched.g = newg;
runtime·gostartcallfn(&newg->sched, fn);
newg->gopc = (uintptr)callerpc;
newg->status = Grunnable;
newg->goid = runtime·xadd64(&runtime·sched.goidgen, 1);

然后將這個(gè)“準(zhǔn)備好”的結(jié)構(gòu)體G掛到當(dāng)前M的P的隊(duì)列中。這里會給予新的goroutine一次運(yùn)行的機(jī)會,即:如果當(dāng)前的P的數(shù)目沒有到上限,也沒有正在自旋搶CPU的M,則調(diào)用wakep將P立即投入運(yùn)行。

wakep函數(shù)喚醒P時(shí),調(diào)度器會試著尋找一個(gè)可用的M來綁定P,必要的時(shí)候會新建M。讓我們看看新建M的函數(shù)newm:

// 新建一個(gè)m,它將以調(diào)用fn開始,或者是從調(diào)度器開始
static void
newm(void(*fn)(void), P *p)
{
    M *mp;
    mp = runtime·allocm(p);
    mp->nextp = p;
    mp->mstartfn = fn;
    runtime·newosproc(mp, (byte*)mp->g0->stackbase);
}

runtime.newm功能跟newproc相似,前者分配一個(gè)goroutine,而后者分配一個(gè)M。其實(shí)一個(gè)M就是一個(gè)操作系統(tǒng)線程的抽象,可以看到它會調(diào)用runtime.newosproc。

總算看到了從Go的運(yùn)行時(shí)庫到操作系統(tǒng)的接口,runtime.newosproc(平臺相關(guān)的)會調(diào)用系統(tǒng)的runtime.clone(平臺相關(guān)的)來新建一個(gè)線程,新的線程將以runtime.mstart為入口函數(shù)。runtime.newosproc是個(gè)很有意思的函數(shù),還有一些信號處理方面的細(xì)節(jié),但是對鑒于我們是專注于調(diào)度方面,就不對它進(jìn)行更細(xì)致的分析了,感興趣的讀者可以自行去runtime/os_linux.c看看源代碼。runtime.clone是用匯編實(shí)現(xiàn)的,代碼在sys_linux_amd64.s。

既然線程是以runtime.mstart為入口的,那么接下來看mstart函數(shù)。

mstart是runtime.newosproc新建的系統(tǒng)線程的入口地址,新線程執(zhí)行時(shí)會從這里開始運(yùn)行。新線程的執(zhí)行和goroutine的執(zhí)行是兩個(gè)概念,由于有m這一層對機(jī)器的抽象,是m在執(zhí)行g(shù)而不是線程在執(zhí)行g(shù)。所以線程的入口是mstart,g的執(zhí)行要到schedule才算入口。函數(shù)mstart最后調(diào)用了schedule。

終于到了schedule了!

如果是從mstart進(jìn)入到schedule的,那么schedule中邏輯非常簡單,大概就這幾步:

找到一個(gè)等待運(yùn)行的g
如果g是鎖定到某個(gè)M的,則讓那個(gè)M運(yùn)行
否則,調(diào)用execute函數(shù)讓g在當(dāng)前的M中運(yùn)行

execute會恢復(fù)newproc1中設(shè)置的上下文,這樣就跳轉(zhuǎn)到新的goroutine去執(zhí)行了。從newproc出生一直到運(yùn)行的過程分析,到此結(jié)束!

雖然按這樣a調(diào)用b,b調(diào)用c,c調(diào)用d,d調(diào)用e的方式去分析源代碼誰看都會暈掉,但還是要重復(fù)一遍這里的讀代碼過程,希望感興趣的讀者可以拿著注釋過的源碼按順序走一遍:

newproc -> newproc1 -> (如果P數(shù)目沒到上限)wakep -> startm -> (可能引發(fā))newm -> newosproc -> (線程入口)mstart -> schedule -> execute -> goroutine運(yùn)行

進(jìn)出系統(tǒng)調(diào)用

假設(shè)goroutine"生病"了,它要進(jìn)入系統(tǒng)調(diào)用了,暫時(shí)無法繼續(xù)執(zhí)行。進(jìn)入系統(tǒng)調(diào)用時(shí),如果系統(tǒng)調(diào)用是阻塞的,goroutine會被剝奪CPU,將狀態(tài)設(shè)置成Gsyscall后放到就緒隊(duì)列。Go的syscall庫中提供了對系統(tǒng)調(diào)用的封裝,它會在真正執(zhí)行系統(tǒng)調(diào)用之前先調(diào)用函數(shù).entersyscall,并在系統(tǒng)調(diào)用函數(shù)返回后調(diào)用.exitsyscall函數(shù)。這兩個(gè)函數(shù)就是通知Go的運(yùn)行時(shí)庫這個(gè)goroutine進(jìn)入了系統(tǒng)調(diào)用或者完成了系統(tǒng)調(diào)用,調(diào)度器會做相應(yīng)的調(diào)度。

比如syscall包中的Open函數(shù),它會調(diào)用Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))實(shí)現(xiàn)。這個(gè)函數(shù)是用匯編寫的,在syscall/asm_linux_amd64.s中可以看到它的定義:

TEXT    ·Syscall(SB),7,$0
    CALL    runtime·entersyscall(SB)
    MOVQ    16(SP), DI
    MOVQ    24(SP), SI
    MOVQ    32(SP), DX
    MOVQ    $0, R10
    MOVQ    $0, R8
    MOVQ    $0, R9
    MOVQ    8(SP), AX    // syscall entry
    SYSCALL
    CMPQ    AX, $0xfffffffffffff001
    JLS    ok
    MOVQ    $-1, 40(SP)    // r1
    MOVQ    $0, 48(SP)    // r2
    NEGQ    AX
    MOVQ    AX, 56(SP)  // errno
    CALL    runtime·exitsyscall(SB)
    RET
ok:
    MOVQ    AX, 40(SP)    // r1
    MOVQ    DX, 48(SP)    // r2
    MOVQ    $0, 56(SP)    // errno
    CALL    runtime·exitsyscall(SB)
    RET

可以看到它進(jìn)系統(tǒng)調(diào)用和出系統(tǒng)調(diào)用時(shí)分別調(diào)用了runtime.entersyscall和runtime.exitsyscall函數(shù)。那么,這兩個(gè)函數(shù)做什么特殊的處理呢?

首先,將函數(shù)的調(diào)用者的SP,PC等保存到結(jié)構(gòu)體G的sched域中。同時(shí),也保存到g->gcsp和g->gcpc等,這個(gè)是跟垃圾回收相關(guān)的。

然后檢查結(jié)構(gòu)體Sched中的sysmonwait域,如果不為0,則將它置為0,并調(diào)用runtime·notewakeup(&runtime·sched.sysmonnote)。做這這一步的原因是,目前這個(gè)goroutine要進(jìn)入Gsyscall狀態(tài)了,它將要讓出CPU。如果有人在等待CPU的話,會通知并喚醒等待者,馬上就有CPU可用了。

接下來,將m的MCache置為空,并將m->p->m置為空,表示進(jìn)入系統(tǒng)調(diào)用后結(jié)構(gòu)體M是不需要MCache的,并且P也被剝離了,將P的狀態(tài)設(shè)置為PSyscall。

有一個(gè)與entersyscall函數(shù)稍微不同的函數(shù)叫entersyscallblock,它會告訴提示這個(gè)系統(tǒng)調(diào)用是會阻塞的,因此會有一點(diǎn)點(diǎn)區(qū)別。它調(diào)用的releasep和handoffp。

releasep將P和M完全分離,使p->m為空,m->p也為空,剝離m->mcache,并將P的狀態(tài)設(shè)置為Pidle。注意這里的區(qū)別,在非阻塞的系統(tǒng)調(diào)用entersyscall中只是設(shè)置成Psyscall,并且也沒有將m->p置為空。

handoffp切換P。將P從處于syscall或者locked的M中,切換出來交給其它M。每個(gè)P中是掛了一個(gè)可執(zhí)行的G的隊(duì)列的,如果這個(gè)隊(duì)列不為空,即如果P中還有G需要執(zhí)行,則調(diào)用startm讓P與某個(gè)M綁定后立刻去執(zhí)行,否則將P掛到idlep隊(duì)列中。

出系統(tǒng)調(diào)用時(shí)會調(diào)用到runtime·exitsyscall,這個(gè)函數(shù)跟進(jìn)系統(tǒng)調(diào)用做相反的操作。它會先檢查當(dāng)前m的P和它狀態(tài),如果P不空且狀態(tài)為Psyscall,則說明是從一個(gè)非阻塞的系統(tǒng)調(diào)用中返回的,這時(shí)是仍然有CPU可用的。因此將p->m設(shè)置為當(dāng)前m,將p的mcache放回到m,恢復(fù)g的狀態(tài)為Grunning。否則,它是從一個(gè)阻塞的系統(tǒng)調(diào)用中返回的,因此之前m的P已經(jīng)完全被剝離了。這時(shí)會查看調(diào)用中是否還有idle的P,如果有,則將它與當(dāng)前的M綁定。

如果從一個(gè)阻塞的系統(tǒng)調(diào)用中出來,并且出來的這一時(shí)刻又沒有idle的P了,要怎么辦呢?這種情況代碼當(dāng)前的goroutine無法繼續(xù)運(yùn)行了,調(diào)度器會將它的狀態(tài)設(shè)置為Grunnable,將它掛到全局的就緒G隊(duì)列中,然后停止當(dāng)前m并調(diào)用schedule函數(shù)。

goroutine的消亡以及狀態(tài)變化

goroutine的消亡比較簡單,注意在函數(shù)newproc1,設(shè)置了fnstart為goroutine執(zhí)行的函數(shù),而將新建的goroutine的sched域的pc設(shè)置為了函數(shù)runtime.exit。當(dāng)fnstart函數(shù)執(zhí)行完返回時(shí),它會返回到runtime.exit中。這時(shí)Go就知道這個(gè)goroutine要結(jié)束了,runtime.exit中會做一些回收工作,會將g的狀態(tài)設(shè)置為Gdead等,并將g掛到P的free隊(duì)列中。

從以上的分析中,其實(shí)已經(jīng)基本上經(jīng)歷了goroutine的各種狀態(tài)變化。在newproc1中新建的goroutine被設(shè)置為Grunnable狀態(tài),投入運(yùn)行時(shí)設(shè)置成Grunning。在entersyscall的時(shí)候goroutine的狀態(tài)被設(shè)置為Gsyscall,到出系統(tǒng)調(diào)用時(shí)根據(jù)它是從阻塞系統(tǒng)調(diào)用中出來還是非阻塞系統(tǒng)調(diào)用中出來,又會被設(shè)置成Grunning或者Grunnable的狀態(tài)。在goroutine最終退出的runtime.exit函數(shù)中,goroutine被設(shè)置為Gdead狀態(tài)。

等等,好像缺了什么?是的,Gidle始終沒有出現(xiàn)過。這個(gè)狀態(tài)好像實(shí)際上沒有被用到。只有一個(gè)runtime.park函數(shù)會使goroutine進(jìn)入到Gwaiting狀態(tài),但是park這個(gè)有什么作用我暫時(shí)還沒看懂...

goroutine的狀態(tài)變遷圖:

links


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號