go併發機制學習

Go 原生支持高併發場景,其原因就是提供了goroutine(協程)以及底層提供的GMP調度器。

goroutine協程

協程與線程有什麼區別?

(1)goroutine是非常輕量級的,它就是一段代碼,一個函數入口,以及在堆上爲其分配的一個堆棧(初始大小爲2K,會隨着程序的執行自動增長刪除)。所以它非常廉價,我們可以很輕鬆的創建上萬個goroutine。

(2)線程切換需要陷入內核,然後進行上下文切換,而協程在用戶態由協程調度器完成,不需要陷入內核,這代價就小很多。協程的切換時間點是由協程調度器決定的,而不是系統內核決定的。


Go中提供了一個關鍵字 go 來讓我們創建一個 Go 協程,當我們在函數或方法的調用之前添加一個關鍵字 go,這樣我們就開啓了一個 Go 協程,該函數或者方法就會在這個 Go 協程中運行。在默認情況下,每個獨立的 Go 主程序運行時就創建了一個 Go 協程,其 main 函數就在這個Go 協程中運行,這個 Go 協程就被稱爲 go 主協程(main Goroutine,下面簡稱主協程)。

而使用go + 函數名是異步的,即它不會阻塞原有的執行流。即go出來的協程與原協程是併發執行的。

比如下面的例子:在main函數中go一個新協程,打印一句”do something“,但是實際運行中我們發現do something並沒有打印,這是因爲main函數在go 出新協程後並不會阻塞,而是繼續運行。而當main結束後,他所go出來的協程還沒來及做任何操作,就也被迫退出了。

在這裏插入圖片描述

倘若人爲的阻塞main函數或者使用信道channel,就能看見協程的效果

time.Sleep(time.second)

知道協程自身是併發執行了以後,我們在來看一下go內部的線程模型

線程的實現模型主要有3種:內核級線程模型、用戶級線程模型和混合型線程模型。它們之間最大的區別在於線程與內核調度實體KSE(Kernel Scheduling Entity)之間的對應關係上。內核調度實體KSE 就是指可以被操作系統內核調度器調度的對象實體,有些地方也稱其爲內核級線程,是操作系統內核的最小調度單元。

內核級線程模型

用戶線程與KSE是1對1關係(1:1)。大部分編程語言的線程庫(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是對操作系統的線程(內核級線程)的一層封裝,創建出來的每個線程與一個不同的KSE靜態關聯,因此其調度完全由OS調度器來做。這種方式實現簡單,直接藉助OS提供的線程能力,並且不同用戶線程之間一般也不會相互影響。但其創建,銷燬以及多個線程之間的上下文切換等操作都是直接由OS層面親自來做,在需要使用大量線程的場景下對OS的性能影響會很大。

用戶級線程模型

用戶線程與KSE是多對1關係(M:1),這種線程的創建,銷燬以及多個線程之間的協調等操作都是由用戶自己實現的線程庫來負責,對OS內核透明,一個進程中所有創建的線程都與同一個KSE在運行時動態關聯。現在有許多語言實現的 協程 基本上都屬於這種方式。這種實現方式相比內核級線程可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的數量與上下文切換所花費的代價也會小得多。但該模型有個致命的缺點,如果我們在某個用戶線程上調用阻塞式系統調用(如用阻塞方式read網絡IO),那麼一旦KSE因阻塞被內核調度出CPU的話,剩下的所有對應的用戶線程全都會變爲阻塞狀態(整個進程掛起)。

混合型線程模型

用戶線程與KSE是多對多關係(M:N), 這種實現綜合了前兩種模型的優點,爲一個進程中創建多個KSE,並且線程可以與不同的KSE在運行時進行動態關聯,當某個KSE由於其上工作的線程的阻塞操作被內核調度出CPU時,當前與其關聯的其餘用戶線程可以重新與其他KSE建立關聯關係。Go語言中的併發就是使用的這種實現方式,Go爲了實現該模型自己實現了一個運行時調度器來負責Go中的"線程"與KSE的動態關聯。此模型有時也被稱爲 兩級線程模型,即用戶調度器實現用戶線程到KSE的“調度”,內核調度器實現KSE到CPU上的調度。

GMP調度器

在這裏插入圖片描述
go中的二層線程模型中,有M,P,G等元素:

M:Machine的縮寫,一個M代表了一個內核線程

P :Processor的縮寫,一個P代表了M運行G所需要的資源,他與一個M結合,並能使一個協程在該M上運行。其個數是GOMAXPROCS個, 默認是與CPU核心數目相同。一個P通常管理着多個G形成的隊列。

G:Goroutine的縮寫,一個G代表了對一段需要被執行的Go語言代碼的封裝,使用go創建新協程來執行這段代碼,創建出後掛在當前協程所在的G隊列中,由隊列管理員P決定誰進入對應的M執行。

在這裏插入圖片描述

G的運行需要M+P的支持,由M+P構成了G的運行時所需環境 (內核線程+資源)。同一時間 P僅能使一個G在M中執行,當該G運行結束後,由P決定下一個執行的G。

來看一些調度是可能遇到的問題:

某個G因爲系統調用而阻塞了當前的M,如何保證同個隊列中的其他G也能得到執行?

該種情況下,G在M中運行,而M被G中的syscall阻塞。此時,go運行時會有一個監控線程(sysmon線程)將該 P 與 M分離,讓這個M繼續處理當前G,而P去與其他M結合處理後面的G。當syscall結束後,該G會被添加至全局隊列中,等待別的M+P組合來挖掘運行。

在這裏插入圖片描述

多核心繫統中,怎麼保證G合理分散到多個M上執行?是否會出現空閒的M無G可跑?(不會)

要是在某一個goroutine中調用go+函數名創建了許多新的協程,他們按照規定都會先掛在對應的P的隊列下。但是如果系統中有空閒的P,go會創建一個新的M(又稱自旋M),去尋覓空閒的P並結合,結合完畢後去找可以執行的G,找G的順序是 結合P的隊列,全局隊列,別的P的隊列。如果結合的P中無G可跑,則去全局隊列中找G,如果全局中也沒有,這個M+P組合就會從別的P中取一半的G來運行。

故可以認爲 go會保證有足夠的M與P結合並尋找可執行的G,不會讓CPU閒着。同一時間能保證至少有與CPU核心數相等的 M + P組合在運行G或者在找G運行的路上。

如果當前G運行時間過長怎麼辦,是否會導致同一個隊列中的其他G出現“飢餓問題”?(不會)

go中也是使用的基於時間片和優先級做調度手段,當某個G運行時間到達閾值時,go運行時體統會給該G標記一個搶佔flag,當一個G在發生擴棧操作的時候,會順道檢查自己是否被標記了搶佔flag,如果是,則讓出M,讓其他的G進入執行。

在文章開頭我們提到過,一個G創建時僅2kb,開銷非常低廉,而協程運行時的棧是按需分配的,故當擴棧+flag == true時就會讓出,確保其他的G有機會運行。

那要是沒有擴棧操作呢? = =這個就尷尬了,這個G會一直佔據着M直至運行完成。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章