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