goroutine 那些事
? ?我們都知道Go語(yǔ)言是原生支持語(yǔ)言級(jí)并發(fā)的,這個(gè)并發(fā)的最小邏輯單元就是goroutine。goroutine就是Go語(yǔ)言提供的一種用戶態(tài)線程,當(dāng)然這種用戶態(tài)線程是跑在內(nèi)核級(jí)線程之上的。當(dāng)我們創(chuàng)建了很多的goroutine,并且它們都是跑在同一個(gè)內(nèi)核線程之上的時(shí)候,就需要一個(gè)調(diào)度器來(lái)維護(hù)這些goroutine,確保所有的goroutine盡可能公平的使用cpu資源。
????????這個(gè)調(diào)度器的原理以及實(shí)現(xiàn)值得我們?nèi)ド钊胙芯恳幌隆V握麄€(gè)調(diào)度器的主要有4個(gè)重要結(jié)構(gòu),分別是Sched、M、P、G,Sched定義在proc.c中,隨后三個(gè)都定義在runtime.h中。
(1)Sched 結(jié)構(gòu)就是調(diào)度器,它維護(hù)有存儲(chǔ)M和G的隊(duì)列以及調(diào)度器的一些狀態(tài)信息等。
(2)M 代表內(nèi)核級(jí)線程,一個(gè)M就是一個(gè)線程,goroutine就是跑在M之上的;M是一個(gè)很大的結(jié)構(gòu),里面維護(hù)小對(duì)象內(nèi)存cache(mcache)、當(dāng)前執(zhí)行的goroutine、隨機(jī)數(shù)發(fā)生器等等非常多的信息。
(3)P 全稱是Processor(處理器),它的主要用途就是用來(lái)執(zhí)行g(shù)oroutine的,所以它也維護(hù)了一個(gè)goroutine隊(duì)列,里面存儲(chǔ)了所有需要它來(lái)執(zhí)行的goroutine,這個(gè)P的角色可能有一點(diǎn)讓人迷惑,一開始容易和M沖突,后面重點(diǎn)聊一下它們的關(guān)系。
(4)G 就是goroutine實(shí)現(xiàn)的核心結(jié)構(gòu)了,G維護(hù)了goroutine
????????理解M、P、G三者的關(guān)系對(duì)理解整個(gè)調(diào)度器非常重要,我從網(wǎng)絡(luò)上找了一個(gè)圖來(lái)說(shuō)明其三者關(guān)系:
????????地鼠(gopher)用小車運(yùn)著一堆待加工的磚。M就可以看作圖中的地鼠,P就是小車,G就是小車?yán)镅b的磚。一圖勝千言啊,弄清楚了它們?nèi)叩年P(guān)系,下面我們就開始重點(diǎn)聊地鼠是如何在搬運(yùn)磚塊的。
1.?初始化Processor(P)
????????在關(guān)心絕大多數(shù)程序的內(nèi)部原理的時(shí)候,我們都試圖去弄明白其啟動(dòng)初始化過(guò)程,弄明白這個(gè)過(guò)程對(duì)后續(xù)的深入分析至關(guān)重要。asm_amd64.s文件包含的匯編代碼_rt0_amd64就是整個(gè)啟動(dòng)過(guò)程,核心過(guò)程如下:
CALL runtime·args(SB) CALL runtime·osinit(SB) CALL runtime·hashinit(SB) CALL runtime·schedinit(SB)// create a new goroutine to start program PUSHQ $runtime·main·f(SB) // entry PUSHQ $0 // arg size CALL runtime·newproc(SB) POPQ AX POPQ AX// start this M CALL runtime·mstart(SB)? ? ??啟動(dòng)過(guò)程做了調(diào)度器初始化 runtime·schedinit 后,調(diào)用 runtime·newproc 創(chuàng)建出第一個(gè) goroutine,這個(gè)goroutine將執(zhí)行的函數(shù)是 runtime·main·f,這第一個(gè) goroutine 也就是所謂的主goroutine。我們寫的最簡(jiǎn)單的Go程序”hello,world”就是完全跑在這個(gè) goroutine 里,當(dāng)然任何一個(gè)Go程序的入口都是從這個(gè) goroutine 開始的。最后調(diào)用的 runtime·mstart 就是真正的執(zhí)行上一步創(chuàng)建的主 goroutine。
????????啟動(dòng)過(guò)程中的調(diào)度器初始化 runtime·schedinit 函數(shù)主要根據(jù)用戶設(shè)置的?GOMAXPROCS?值來(lái)創(chuàng)建一批小車(P),不管 GOMAXPROCS 設(shè)置為多大,最多也只能創(chuàng)建256個(gè)小車(P)。這些小車(p)初始創(chuàng)建好后都是閑置狀態(tài),也就是還沒開始使用,所以它們都放置在調(diào)度器結(jié)構(gòu)(Sched)的 pidle 字段維護(hù)的鏈表中存儲(chǔ)起來(lái)了,以備后續(xù)之需。
????????查看 runtime·main 函數(shù)可以了解到主 goroutine 開始執(zhí)行后,做的第一件事情是創(chuàng)建了一個(gè)新的內(nèi)核線程(地鼠M),不過(guò)這個(gè)線程是一個(gè)特殊線程,它在整個(gè)運(yùn)行期專門負(fù)責(zé)做特定的事情——系統(tǒng)監(jiān)控(sysmon)。接下來(lái)就是進(jìn)入Go程序的 main 函數(shù)開始Go程序的執(zhí)行。
????????至此,Go程序就被啟動(dòng)起來(lái)開始運(yùn)行了。一個(gè)真正干活的Go程序,一定創(chuàng)建有不少的 goroutine,所以在Go程序開始運(yùn)行后,就會(huì)向調(diào)度器添加 goroutine,調(diào)度器就要負(fù)責(zé)維護(hù)好這些 goroutine 的正常執(zhí)行。
2.?創(chuàng)建 goroutine(G)
????????在Go程序中,時(shí)常會(huì)有類似代碼:
go do_something()????????go關(guān)鍵字就是用來(lái)創(chuàng)建一個(gè) goroutine 的,后面的函數(shù)就是這個(gè) goroutine 需要執(zhí)行的代碼邏輯。go關(guān)鍵字對(duì)應(yīng)到調(diào)度器的接口就是?runtime·newproc?。runtime·newproc干的事情很簡(jiǎn)單,就負(fù)責(zé)制造一塊磚(G),然后將這塊磚(G)放入當(dāng)前這個(gè)地鼠(M)的小車(P)中。
????????每個(gè)新的 goroutine 都需要有一個(gè)自己的棧,G結(jié)構(gòu)的 sched 字段維護(hù)了棧地址以及程序計(jì)數(shù)器等信息,這是最基本的調(diào)度信息,也就是說(shuō)這個(gè)goroutine放棄cpu的時(shí)候需要保存這些信息,待下次重新獲得cpu的時(shí)候,需要將這些信息裝載到對(duì)應(yīng)的cpu寄存器中。
????????假設(shè)這個(gè)時(shí)候已經(jīng)創(chuàng)建了大量的 goroutne,就輪到調(diào)度器去維護(hù)這些 goroutine了。
3.?創(chuàng)建內(nèi)核線程(M)
????????Go程序中沒有語(yǔ)言級(jí)的關(guān)鍵字讓你去創(chuàng)建一個(gè)內(nèi)核線程,你只能創(chuàng)建 goroutine,內(nèi)核線程只能由 runtime根據(jù)實(shí)際情況去創(chuàng)建。runtime 什么時(shí)候創(chuàng)建線程?以地鼠運(yùn)磚圖來(lái)講,磚(G)太多了,地鼠(M)又太少了,實(shí)在忙不過(guò)來(lái),剛好還有空閑的小車(P)沒有使用,那就從別處再借些地鼠(M)過(guò)來(lái)直到把小車(p)用完為止。這里有一個(gè)地鼠(M)不夠用,從別處借地鼠(M)的過(guò)程,這個(gè)過(guò)程就是創(chuàng)建一個(gè)內(nèi)核線程(M)。創(chuàng)建M的接口函數(shù)是:
void newm(void (*fn)(void), P *p)????????newm 函數(shù)的核心行為就是調(diào)用 clone 系統(tǒng)調(diào)用創(chuàng)建一個(gè)內(nèi)核線程,每個(gè)內(nèi)核線程的開始執(zhí)行位置都是 runtime·mstart 函數(shù),參數(shù)p就是一輛空閑的小車(p)。每個(gè)創(chuàng)建好的內(nèi)核線程都從 runtime·mstart 函數(shù)開始執(zhí)行了,它們將用分配給自己小車去搬磚了。
4. 調(diào)度器(Sched)
(1)調(diào)度核心
????????newm 接口只是給新創(chuàng)建的M分配了一個(gè)空閑的P,也就是相當(dāng)于告訴借來(lái)的地鼠(M)——“接下來(lái)的日子,你將使用1號(hào)小車搬磚,記住是1號(hào)小車,待會(huì)自己到停車場(chǎng)拿車?!钡厥?M)去拿小車(P)這個(gè)過(guò)程就是?acquirep。runtime·mstart 在進(jìn)入 schedule 之前會(huì)給當(dāng)前M裝配上P,runtime·mstart 函數(shù)中的代碼:
if(m != &runtime·m0) {acquirep(m->nextp);m->nextp = nil; } schedule();????????if分支的內(nèi)容就是為當(dāng)前M裝配上P,nextp 就是newm分配的空閑小車(P),只是到這個(gè)時(shí)候才真正拿到手罷了。沒有P,M是無(wú)法執(zhí)行 goroutine 的,就像地鼠沒有小車無(wú)法運(yùn)磚一樣的道理。對(duì)應(yīng) acquirep 的動(dòng)作是 releasep,把M裝配的P給載掉;活干完了,地鼠需要休息了,就把小車還到停車場(chǎng),然后睡覺去。
? ? ? ?地鼠(M)拿到屬于自己的小車(P)后,就進(jìn)入工場(chǎng)開始干活了,也就是上面的 schedule 調(diào)用。簡(jiǎn)化 schedule 的代碼如下:
static void schedule(void) {G *gp;gp = runqget(m->p);if(gp == nil)gp = findrunnable();if (m->p->runqhead != m->p->runqtail &&runtime·atomicload(&runtime·sched.nmspinning) == 0 &&runtime·atomicload(&runtime·sched.npidle) > 0) // TODO: fast atomicwakep();execute(gp); }這里涉及到4大步邏輯:
(1) runqget,地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當(dāng)然結(jié)果可能失敗,也就是這個(gè)地鼠的小車已經(jīng)空了,沒有磚了。
(2) findrunnable,如果地鼠自己的小車中沒有磚,那也不能閑著不干活是吧,所以地鼠就會(huì)試圖跑去工場(chǎng)倉(cāng)庫(kù)取一塊磚來(lái)處理;工場(chǎng)倉(cāng)庫(kù)也可能沒磚啊,出現(xiàn)這種情況的時(shí)候,這個(gè)地鼠也沒有偷懶停下干活,而是悄悄跑出去,隨機(jī)盯上一個(gè)小伙伴(地鼠),然后從它的車?yán)镌噲D偷一半磚到自己車?yán)?。如果多次嘗試偷磚都失敗了,那說(shuō)明實(shí)在沒有磚可搬了,這個(gè)時(shí)候地鼠就會(huì)把小車還回停車場(chǎng),然后睡覺休息了。如果地鼠睡覺了,下面的過(guò)程當(dāng)然都停止了,地鼠睡覺也就是線程 sleep 了(“地鼠偷磚”叫work stealing,一種調(diào)度算法)。
(3) wakep,到這個(gè)過(guò)程的時(shí)候,可憐的地鼠發(fā)現(xiàn)自己小車?yán)镉泻枚啻u啊,自己根本處理不過(guò)來(lái);再回頭一看停車場(chǎng)居然有閑置的小車,立馬跑到宿舍一看,你妹,居然還有小伙伴在睡覺,直接給屁股一腳,“你妹,居然還在睡覺,老子都快累死了,趕緊起來(lái)干活,分擔(dān)點(diǎn)工作”。小伙伴醒了,拿上自己的小車,乖乖干活去了。有時(shí)候,可憐的地鼠跑到宿舍卻發(fā)現(xiàn)沒有在睡覺的小伙伴,于是會(huì)很失望,最后只好向工場(chǎng)老板說(shuō)”停車場(chǎng)還有閑置的車啊,我快干不動(dòng)了,趕緊從別的工場(chǎng)借個(gè)地鼠來(lái)幫忙吧”。最后工場(chǎng)老板就搞來(lái)一個(gè)新的地鼠干活了。
(4) execute,地鼠拿著磚放入火種歡快的燒練起來(lái)。
????????到這里,貌似整個(gè)工場(chǎng)都正常的運(yùn)轉(zhuǎn)起來(lái)了,無(wú)懈可擊的樣子。不對(duì),還有一個(gè)疑點(diǎn)沒解決啊,假設(shè)地鼠的車?yán)镉泻芏啻u,它把一塊磚放入火爐中后,何時(shí)把它取出來(lái),放入第二塊磚呢?難道要一直把第一塊磚燒練好,才取出來(lái)嗎?那估計(jì)后面的磚真的是等得花兒都要謝了。這里就是要真正解決goroutine的調(diào)度,上下文切換問題。
(2)調(diào)度點(diǎn)
????????當(dāng)我們翻看 channel 的實(shí)現(xiàn)代碼可以發(fā)現(xiàn),對(duì) channel 讀寫操作的時(shí)候會(huì)觸發(fā)調(diào)用?runtime·park 函數(shù)。goroutine 調(diào)用 park 后,這個(gè) goroutine 就會(huì)被設(shè)置位 waiting 狀態(tài),放棄 cpu。被 park 的 goroutine 處于 waiting 狀態(tài),并且這個(gè) goroutine 不在小車(P)中。如果不對(duì)其調(diào)用 runtime·ready,它是永遠(yuǎn)不會(huì)再被執(zhí)行的。除了 channel 操作外,定時(shí)器中,網(wǎng)絡(luò)poll等都有可能park goroutine。
????????除了park可以放棄cpu外,調(diào)用?runtime·gosched 函數(shù)也可以讓當(dāng)前 goroutine 放棄 cpu,但和 park 完全不同:gosched 是將 goroutine 設(shè)置為 runnable 狀態(tài),然后放入到調(diào)度器全局等待隊(duì)列(也就是上面提到的工場(chǎng)倉(cāng)庫(kù),這下就明白為何工場(chǎng)倉(cāng)庫(kù)會(huì)有磚塊(G)了吧)。
????????除此之外,就輪到系統(tǒng)調(diào)用了,有些系統(tǒng)調(diào)用也會(huì)觸發(fā)重新調(diào)度。Go語(yǔ)言完全是自己封裝的系統(tǒng)調(diào)用,所以在封裝系統(tǒng)調(diào)用的時(shí)候,可以做不少手腳,也就是進(jìn)入系統(tǒng)調(diào)用的時(shí)候執(zhí)行 entersyscall,退出后又執(zhí)行 exitsyscall 函數(shù)。 也只有封裝了 entersyscall 的系統(tǒng)調(diào)用才有可能觸發(fā)重新調(diào)度,它將改變小車(P)的狀態(tài)為syscall。還記一開始提到的 sysmon 線程嗎?這個(gè)系統(tǒng)監(jiān)控線程會(huì)掃描所有的小車(P),發(fā)現(xiàn)一個(gè)小車(P)處于了syscall的狀態(tài),就知道這個(gè)小車(P)遇到了 goroutine 在做系統(tǒng)調(diào)用,于是系統(tǒng)監(jiān)控線程就會(huì)創(chuàng)建一個(gè)新的地鼠(M)去把這個(gè)處于syscall的小車給搶過(guò)來(lái),開始干活,這樣這個(gè)小車中的所有磚塊(G)就可以繞過(guò)之前系統(tǒng)調(diào)用的等待了。被搶走小車的地鼠等系統(tǒng)調(diào)用返回后,發(fā)現(xiàn)自己的車沒,不能繼續(xù)干活了,于是只能把執(zhí)行系統(tǒng)調(diào)用的goroutine放回到工場(chǎng)倉(cāng)庫(kù),自己睡覺去了。
????????從 goroutine 的調(diào)度點(diǎn)可以看出,調(diào)度器還是挺粗暴的,調(diào)度粒度有點(diǎn)過(guò)大,公平性也沒有想想的那么好。總之,這個(gè)調(diào)度器還是比較簡(jiǎn)單的。
(3)現(xiàn)場(chǎng)處理
????????goroutine 在cpu上換入換出,不斷上下文切換的時(shí)候,必須要保證的事情就是保存現(xiàn)場(chǎng)和恢復(fù)現(xiàn)場(chǎng),保存現(xiàn)場(chǎng)就是在 goroutine 放棄cpu的時(shí)候,將相關(guān)寄存器的值給保存到內(nèi)存中;恢復(fù)現(xiàn)場(chǎng)就是在 goroutine 重新獲得 cpu 的時(shí)候,需要從內(nèi)存把之前的寄存器信息全部放回到相應(yīng)寄存器中去。
????????goroutine 在主動(dòng)放棄cpu的時(shí)候(park/gosched),都會(huì)涉及到調(diào)用?runtime·mcall?函數(shù),此函數(shù)也是匯編實(shí)現(xiàn),主要將 goroutine 的棧地址和程序計(jì)數(shù)器保存到G結(jié)構(gòu)的?sched?字段中,mcall 就完成了現(xiàn)場(chǎng)保存。恢復(fù)現(xiàn)場(chǎng)的函數(shù)是?runtime·gogocall,這個(gè)函數(shù)主要在 execute 中調(diào)用,就是在執(zhí)行 goroutine 前,需要重新裝載相應(yīng)的寄存器。
https://studygolang.com/articles/9211總結(jié)
以上是生活随笔為你收集整理的goroutine 那些事的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 用_beginthreadex不用 Cr
- 下一篇: boost库安装编译指南