淺談goroutine

以下內容均屬個人理解,如果錯誤,還請斧正

What

goroutine是golang中的coroutine,也叫協程,微軟大法稱之纖程(Fiber)。

協程是一種更細粒度的調度,可以滿足多個不同處理邏輯的協程共享一個線程資源。

Why

在談goroutine之前,先解釋下為什么要使用這種技術:

大家應該知道最初操作系統最細粒度的調度是內核級線程(Thread),線程其實就是一個棧加一堆資源。操作系統一會將CPU的時間片分給線程A,一會將CPU的時間片分給線程B,靠A和B的棧來保存A和B的執行狀態。起初軟件的并發處理并不多,線程池完全夠用,但隨著軟件的復雜度增高,并發量越來越大,線程成了稀缺資源,所以go發明了goroutine,提高線程在異步處理中的利用率。

How

golang有一個強大的調度器維護goroutine在內核級線程上運行,確保所有的goroutine都使用且盡可能公平的使用CPU資源。支撐整個調度器的主要有4個重要結構,分別是M、G、P、Sched:

  • M代表內核級線程,一個M就是一個線程,goroutine就是跑在M之上的;M是一個很大的結構,里面維護小對象內存cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的信息。
  • P全稱是Processor,處理器,它的主要用途就是用來執行goroutine的,所以它也維護了一個goroutine隊列,里面存儲了所有需要它來執行的goroutine,這個P的角色可能有一點讓人迷惑,一開始容易和M沖突,后面重點聊一下它們的關系。
  • G就是goroutine實現的核心結構了,G維護了goroutine需要的棧、程序計數器以及它所在的M等信息。
  • Sched結構就是調度器,它維護有存儲M和G的隊列以及調度器的一些狀態信息等。

網絡上有一個圖來比較準確的描述了M、P和G的關系:


地鼠用小車運著一堆待加工的磚。M就可以看作圖中的地鼠,P就是小車,G就是小車里裝的磚(以下描述摘錄)。

  1. runqget, 地鼠(M)試圖從自己的小車(P)取出一塊磚(G),當然結果可能失敗,也就是這個地鼠的小車已經空了,沒有磚了。
  2. findrunnable, 如果地鼠自己的小車中沒有磚,那也不能閑著不干活是吧,所以地鼠就會試圖跑去工場倉庫取一塊磚來處理;工場倉庫也可能沒磚啊,出現這種情況的時候,這個地鼠也沒有偷懶停下干活,而是悄悄跑出去,隨機盯上一個小伙伴(地鼠),然后從它的車里試圖偷一半磚到自己車里。如果多次嘗試偷磚都失敗了,那說明實在沒有磚可搬了,這個時候地鼠就會把小車還回停車場,然后睡覺休息了。如果地鼠睡覺了,下面的過程當然都停止了,地鼠睡覺也就是線程sleep了。
  3. wakep, 到這個過程的時候,可憐的地鼠發現自己小車里有好多磚啊,自己根本處理不過來;再回頭一看停車場居然有閑置的小車,立馬跑到宿舍一看,你妹,居然還有小伙伴在睡覺,直接給屁股一腳,“你妹,居然還在睡覺,老子都快累死了,趕緊起來干活,分擔點工作。”,小伙伴醒了,拿上自己的小車,乖乖干活去了。有時候,可憐的地鼠跑到宿舍卻發現沒有在睡覺的小伙伴,于是會很失望,最后只好向工場老板說——”停車場還有閑置的車啊,我快干不動了,趕緊從別的工場借個地鼠來幫忙吧。”,最后工場老板就搞來一個新的地鼠干活了。
  4. execute,地鼠拿著磚放入火種歡快的燒練起來。

注: “地鼠偷磚”叫work stealing,一種調度算法。

到這里,貌似整個工場都正常的運轉起來了,無懈可擊的樣子。不對,還有一個疑點沒解決,假設地鼠的車里有很多磚,它把一塊磚放入火爐中后,何時把它取出來,放入第二塊磚呢?難道要一直把第一塊磚燒練好,才取出來嗎?那估計后面的磚真的是等得花兒都要謝了。這里就是要真正解決goroutine的調度,上下文切換問題。

調度點:

當我們翻看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的調度點可以看出,調度器還是挺粗暴的,調度粒度有點過大,公平性也沒有想想的那么好。

現場處理:

goroutine在cpu上換入換出,不斷上下文切換的時候,必須要保證的事情就是保存現場和恢復現場,保存現場就是在goroutine放棄cpu的時候,將相關寄存器的值給保存到內存中;恢復現場就是在goroutine重新獲得cpu的時候,需要從內存把之前的寄存器信息全部放回到相應寄存器中去。

goroutine在主動放棄cpu的時候(park/gosched),都會涉及到調用runtime·mcall函數,此函數也是匯編實現,主要將goroutine的棧地址和程序計數器保存到G結構的sched字段中,mcall就完成了現場保存。恢復現場的函數是runtime·gogocall,這個函數主要在execute中調用,就是在執行goroutine前,需要重新裝載相應的寄存器。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容