本文轉(zhuǎn)自:https://studygolang.com/articles/01855侠鳄。原文圖碎了膘茎,這里補(bǔ)充上圖片矢炼。
我們都知道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,并且是盡可能公平的使用cpu資源还蹲。
這個(gè)調(diào)度器的原理以及實(shí)現(xiàn)值得我們?nèi)ド钊胙芯恳幌碌摹V握麄€(gè)調(diào)度器的主要有4個(gè)重要結(jié)構(gòu),分別是M谜喊、G潭兽、P、Sched斗遏,前三個(gè)定義在runtime.h中山卦,Sched定義在proc.c中。
- Sched結(jié)構(gòu)就是調(diào)度器诵次,它維護(hù)有存儲(chǔ)M和G的隊(duì)列以及調(diào)度器的一些狀態(tài)信息等账蓉。
- 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ā)生器等等非常多的信息。
- 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)系扩灯。
-
G就是goroutine實(shí)現(xiàn)的核心結(jié)構(gòu)了,G維護(hù)了goroutine需要的棧霜瘪、程序計(jì)數(shù)器以及它所在的M等信息珠插。
理解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)磚塊的设预。
啟動(dòng)過(guò)程
在關(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
,這第一個(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)糊治,也就是還沒(mé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í)行天通。
創(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)建了大量的goroutine咸灿,就輪到調(diào)度器去維護(hù)這些goroutine了。
創(chuàng)建內(nèi)核線程(M)
Go程序中沒(méi)有語(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éi)有使用,那就從別處再借些地鼠(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í)行了马绝,它們將用分配給自己小車去搬磚了。
調(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ù)中的代碼:
} else if(m != &runtime·m0) {
acquirep(m->nextp);
m->nextp = nil;
}
schedule();
if
分支的內(nèi)容就是為當(dāng)前M裝配上P,nextp
就是newm
分配的空閑小車(P)认境,只是到這個(gè)時(shí)候才真正拿到手罷了胚委。沒(méi)有P,M是無(wú)法執(zhí)行g(shù)oroutine的叉信,就像地鼠沒(méi)有小車無(wú)法運(yùn)磚一樣的道理亩冬。對(duì)應(yīng)acquirep的動(dòng)作是releasep
,把M裝配的P給載掉硼身;活干完了硅急,地鼠需要休息了,就把小車還到停車場(chǎng)佳遂,然后睡覺(jué)去营袜。
地鼠(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 atomic
wakep();
execute(gp);
}
schedule
函數(shù)被我簡(jiǎn)化了太多,主要是我不喜歡貼大段大段的代碼吩屹,因此只保留主干代碼了跪另。這里涉及到4大步邏輯:
-
runqget
, 地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當(dāng)然結(jié)果可能失敗祟峦,也就是這個(gè)地鼠的小車已經(jīng)空了罚斗,沒(méi)有磚了徙鱼。 -
findrunnable
, 如果地鼠自己的小車中沒(méi)有磚宅楞,那也不能閑著不干活是吧,所以地鼠就會(huì)試圖跑去工場(chǎng)倉(cāng)庫(kù)取一塊磚來(lái)處理袱吆;工場(chǎng)倉(cāng)庫(kù)也可能沒(méi)磚啊厌衙,出現(xiàn)這種情況的時(shí)候,這個(gè)地鼠也沒(méi)有偷懶停下干活绞绒,而是悄悄跑出去婶希,隨機(jī)盯上一個(gè)小伙伴(地鼠),然后從它的車?yán)镌噲D偷一半磚到自己車?yán)锱詈狻H绻啻螄L試偷磚都失敗了喻杈,那說(shuō)明實(shí)在沒(méi)有磚可搬了,這個(gè)時(shí)候地鼠就會(huì)把小車還回停車場(chǎng)狰晚,然后睡覺(jué)休息了筒饰。如果地鼠睡覺(jué)了,下面的過(guò)程當(dāng)然都停止了壁晒,地鼠睡覺(jué)也就是線程sleep
了瓷们。 -
wakep
, 到這個(gè)過(guò)程的時(shí)候,可憐的地鼠發(fā)現(xiàn)自己小車?yán)镉泻枚啻u啊,自己根本處理不過(guò)來(lái)谬晕;再回頭一看停車場(chǎng)居然有閑置的小車碘裕,立馬跑到宿舍一看,你妹攒钳,居然還有小伙伴在睡覺(jué)帮孔,直接給屁股一腳,“你妹夕玩,居然還在睡覺(jué)你弦,老子都快累死了,趕緊起來(lái)干活燎孟,分擔(dān)點(diǎn)工作禽作。”揩页,小伙伴醒了旷偿,拿上自己的小車,乖乖干活去了爆侣。有時(shí)候萍程,可憐的地鼠跑到宿舍卻發(fā)現(xiàn)沒(méi)有在睡覺(jué)的小伙伴,于是會(huì)很失望兔仰,最后只好向工場(chǎng)老板說(shuō)——”停車場(chǎng)還有閑置的車啊茫负,我快干不動(dòng)了,趕緊從別的工場(chǎng)借個(gè)地鼠來(lái)幫忙吧乎赴∪谭ǎ”,最后工場(chǎng)老板就搞來(lái)一個(gè)新的地鼠干活了榕吼。 -
execute
饿序,地鼠拿著磚放入火種歡快的燒練起來(lái)。
注: “地鼠偷磚”叫work stealing羹蚣,一種調(diào)度算法原探。
到這里,貌似整個(gè)工場(chǎng)都正常的運(yùn)轉(zhuǎn)起來(lái)了顽素,無(wú)懈可擊的樣子咽弦。不對(duì),還有一個(gè)疑點(diǎn)沒(méi)解決啊胁出,假設(shè)地鼠的車?yán)镉泻芏啻u型型,它把一塊磚放入火爐中后,何時(shí)把它取出來(lái)划鸽,放入第二塊磚呢输莺?難道要一直把第一塊磚燒練好戚哎,才取出來(lái)嗎?那估計(jì)后面的磚真的是等得花兒都要謝了嫂用。這里就是要真正解決goroutine的調(diào)度型凳,上下文切換問(wèn)題。
調(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)自己的車沒(méi)码耐,不能繼續(xù)干活了追迟,于是只能把執(zhí)行系統(tǒng)調(diào)用的goroutine放回到工場(chǎng)倉(cāng)庫(kù),自己睡覺(jué)去了骚腥。
從goroutine的調(diào)度點(diǎn)可以看出敦间,調(diào)度器還是挺粗暴的,調(diào)度粒度有點(diǎn)過(guò)大束铭,公平性也沒(méi)有想想的那么好廓块。總之契沫,這個(gè)調(diào)度器還是比較簡(jiǎn)單的带猴。
現(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í)行g(shù)oroutine前,需要重新裝載相應(yīng)的寄存器技肩。