線程模型 與 gorountine 的關係


線程的實現模型主要有3種:內核級線程模型、用戶級線程模型 和 兩級線程模型(也稱混合型線程模型),它們之間最大的差異就在於用戶線程與內核調度實體(KSE,Kernel Scheduling Entity)之間的對應關係上。而所謂的內核調度實體 KSE 就是指可以被操作系統內核調度器調度的對象實體,簡單來說, KSE 就是內核級線程,是操作系統內核的最小調度單元,也就是我們寫代碼的時候通俗理解上的線程了。

用戶級線程模型

用戶線程與內核線程KSE是多對一(N : 1)的映射模型,多個用戶線程的一般從屬於單個進程並且多線程的調度是由用戶自己的線程庫來完成,線程的創建、銷燬以及多線程之間的協調等操作都是由用戶自己的線程庫來負責而無須藉助系統調用來實現。一個進程中所有創建的線程都只和同一個KSE在運行時動態綁定,也就是說,操作系統只知道用戶進程而對其中的線程是無感知的,內核的所有調度都是基於用戶進程。許多語言實現的 協程庫 基本上都屬於這種方式(比如python的gevent)。由於線程調度是在用戶層面完成的,也就是相較於內核調度不需要讓CPU在用戶態和內核態之間切換,這種實現方式相比內核級線程可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的線程數量與上下文切換所花費的代價也會小得多。但該模型有個原罪:並不能做到真正意義上的併發,假設在某個用戶進程上的某個用戶線程因爲一個阻塞調用(比如I/O阻塞)而被CPU給中斷(搶佔式調度)了,那麼該進程內的所有線程都被阻塞(因爲單個用戶進程內的線程自調度是沒有CPU時鐘中斷的,從而沒有輪轉調度),整個進程被掛起。即便是多CPU的機器,也無濟於事,因爲在用戶級線程模型下,一個CPU關聯運行的是整個用戶進程,進程內的子線程綁定到CPU執行是由用戶進程調度的,內部線程對CPU是不可見的,此時可以理解爲CPU的調度單位是用戶進程。所以很多的協程庫會把自己一些阻塞的操作重新封裝爲完全的非阻塞形式,然後在以前要阻塞的點上,主動讓出自己,並通過某種方式通知或喚醒其他待執行的用戶線程在該KSE上運行,從而避免了內核調度器由於KSE阻塞而做上下文切換,這樣整個進程也不會被阻塞了。

內核級線程模型

用戶線程與內核線程KSE是一對一(1 : 1)的映射模型,也就是每一個用戶線程綁定一個實際的內核線程,而線程的調度則完全交付給操作系統內核去做,應用程序對線程的創建、終止以及同步都基於內核提供的系統調用來完成,大部分編程語言的線程庫(比如Java的java.lang.Thread、C++11的std::thread等等)都是對操作系統的線程(內核級線程)的一層封裝,創建出來的每個線程與一個獨立的KSE靜態綁定,因此其調度完全由操作系統內核調度器去做。這種模型的優勢和劣勢同樣明顯:優勢是實現簡單,直接藉助操作系統內核的線程以及調度器,所以CPU可以快速切換調度線程,於是多個線程可以同時運行,因此相較於用戶級線程模型它真正做到了並行處理;但它的劣勢是,由於直接藉助了操作系統內核來創建、銷燬和以及多個線程之間的上下文切換和調度,因此資源成本大幅上漲,且對性能影響很大。

兩級線程模型

兩級線程模型是博採衆長之後的產物,充分吸收前兩種線程模型的優點且儘量規避它們的缺點。在此模型下,用戶線程與內核KSE是多對多(N : M)的映射模型:首先,區別於用戶級線程模型,兩級線程模型中的一個進程可以與多個內核線程KSE關聯,於是進程內的多個線程可以綁定不同的KSE,這點和內核級線程模型相似;其次,又區別於內核級線程模型,它的進程裏的所有線程並不與KSE一一綁定,而是可以動態綁定同一個KSE, 當某個KSE因爲其綁定的線程的阻塞操作被內核調度出CPU時,其關聯的進程中其餘用戶線程可以重新與其他KSE綁定運行。所以,兩級線程模型既不是用戶級線程模型那種完全靠自己調度的也不是內核級線程模型完全靠操作系統調度的,而是中間態(自身調度與系統調度協同工作),也就是 — 『薛定諤的模型』(誤),因爲這種模型的高度複雜性,操作系統內核開發者一般不會使用,所以更多時候是作爲第三方庫的形式出現,而Go語言中的runtime調度器就是採用的這種實現方案,實現了Goroutine與KSE之間的動態關聯,不過Go語言的實現更加高級和優雅;該模型爲何被稱爲兩級?即用戶調度器實現用戶線程到KSE的『調度』,內核調度器實現KSE到CPU上的『調度』。

Goroutine

Go語言基於併發(並行)編程給出的自家的解決方案。goroutine 是什麼?通常 goroutine 會被當做 coroutine(協程)的 golang實現,從比較粗淺的層面來看,這種認知也算是合理,但實際上,goroutine 並非傳統意義上的協程,現在主流的線程模型分三種:內核級線程模型、用戶級線程模型和兩級線程模型(也稱混合型線程模型),傳統的協程庫屬於用戶級線程模型,而 goroutine 和它的 Go Scheduler 在底層實現上其實是屬於兩級線程模型,因此,有時候爲了方便理解可以簡單把 goroutine 類比成協程,但心裏一定要有個清晰的認知 ---- goroutine 並不等同於協程。

不要用共享內存的方式來通信。作爲代替,應該以通信作爲手段來共享內存。

Go 語言線程模型的 3 個重要核心元素:

  • M (machine): 一個 M 代表一個內核線程。
  • P (processer): 一個 P 代表執行一個 Go 代碼片段所必須的資源(上下文環境)。
  • G (goroutine): 一個 G 代表一個 Go 代碼片段。一個 G 的執行需要 P 和 M 的支持。

一個 M 在與一個 P 關聯後,就形成了一個有效的 G 運行環境(內核線程 + 上下文環境)。每個 P 都會包含一個可運行的 G 的隊列(runq)。該隊列中的 G 會被依次傳遞給與當前 P 所關聯的 M,並獲得運行時機。

M 與內核調度實體(KSE)一對一關係。Go 的運行時系統(runtime system)用 M 表示一個內核調度實體。M 與 KSE 之間的關聯非常穩固,在一個 M 生命週期內,會且僅會與一個 KSE 產生關聯。相比之下,M 與 P、P 與 G 之間的關聯都是易變的,他們之間的關係會在實際的調度中發生改變。

goroutine 的調度

首先,引導程序會爲 go 程序的運行建立必要的環境,執行初始化程序,然後纔開始執行工程代碼中的 main 函數(封裝後的 main 函數就是一個 G)。

調度開始,調度器首先判斷當前 M 是否一個已被鎖定(鎖定是指 M 與執行 CGO 程序的 G 綁定,該 G 不可更換 M),若當前 M 已經被鎖定,就立即停止調度並阻塞當前 M,一旦發現與之綁定的 G 處於可運行狀態(當查找可運行 G 時),就喚醒當前 M 繼續執行 G。

如果當前 M 沒有被鎖定,調度器會檢查是否有運行時串行任務正在等待執行(gc 操作),有:停止並阻塞並阻塞當前 M(Stop the world),等待串行任務執行完畢,然後該 M 纔會被喚醒。

最後,M 既沒有被鎖定 又沒有串行任務需要執行,就開始尋找可執行 G(在本地 P 可運行隊列、調度器可運行隊列、網絡 I/O 輪詢器、其他 P 可運行隊列、GC標記的G 中尋找 …),找到一個可執行的 G,首先判斷它是否與 M 綁定,是則喚醒與其綁定的 M,否則立即讓當前的 M 運行它。若找不到可執行的 G,就停止當前 M,並加入全局 M 隊列。

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