Go語言模型:Linux線程調度 vs Goroutine調度

調度本質上體現了對CPU資源的搶佔。調度的方式可以分爲:

  1. 搶佔式調度。依賴的是中斷機制,通過中斷搶回CPU執行權限然後進行調度,如Linux內核對線程的調度。
  2. 協作式調度。需要主動讓出CPU,調用調度代碼進行調度,如協程,沒有中斷機制一般無法真正做到搶佔。

Linux NPTL 線程庫

看操作系統方面的文章時,要注意區分其描述的是通用操作系統還是某種特定的操作系統(如: Windows/Linux/macOS),如果是某種具體的操作系統的實現,還要看其基於哪個版本(如Linux2.6前後的線程模型就有變化),因爲操作系統是在不斷完善和演進的。

NPTL(Native POSIX Thread Library)是Linux 2.6引入的新線程庫,底層調用的內核優化過的clone系統調用。NPTL最初由Red hat開發,替代的是Linux 2.6版本以前的LinuxThreads,更加符合POSIX標準,可以更好的利用多核並行(parallel)運行。這裏注意併發(parallel)和並行(concurrency)的區別: 併發可能是對CPU單核的分時複用,並行是真正的CPU多核同時執行。

NPTL線程庫的一個設計理念就是用戶級線程和內核級線程是1:1的關係,調度完全依賴內核,這樣可以充分利用多核。NTPL最初被提出時的一篇文章這樣闡述了這個問題,(當然這篇文章時間較早,不能好很好的描述最新實現了,文章開頭也有提到),不過設計的思路還是可以學習思考的,後續的具體實現還要看跟進最新的代碼與文檔。

The most basic design decision which has to be made is what relationship there should be between the kernel threads and the user-level threads. It need not be mentioned that kernel threads are used; a pure user-level implementation could not take advantage of multi-processor machines which was one of the goals listed previously. One valid possibility is the 1-on-1 model of the old implementation where each user-level thread has an underlying kernel thread. The whole thread library could be a relatively thin layer on top of the kernel functions.

需要注意的是,上面引文中說的用戶級線程指的是完全在用戶態調度管理的線程,內核級線程則指的是有task_struct與之對應並由內核進行統一管理調度的線程。而現在,用戶線程這個術語一般指擁有用戶空間的的用戶態程序,內核線程則是指完全運行在內核態的常駐系統的線程(如1號init線程)。

Linux 線程調度時機

對於Linux調度,簡單來說就是在內核態執行schedule函數,按照一定策略選出這個CPU核接下來要執行的線程,上下文切換到對應線程執行。

對於用戶線程調度,首先要切換到內核態,用戶棧切到內核棧,在內核態調用schedule函數,選出下一個要被執行的線程,上下文切換,執行。用戶線程的調度時機有:

  1. 線程運行結束或睡眠,主動進行調度,如:程序運行中調sleep、結束時調用的exit,最終都會調到schedule,進行調度;
  2. 調用阻塞的系統調用,陷入內核後會進行調度,如各種阻塞的IO調用;
  3. 從系統調用、中斷處理返回用戶空間的前夕,根據task_structneed_resched判斷是否進行調度,如:從時鐘中斷返回發現時間片用完,進行調度。

對於內核線程調度,這裏的內核線程指運行在內核態的線程。Linux 2.6開始支持內核搶佔,如果沒有加鎖,內核就可以進行搶佔。內核線程的調度時機有:

  1. 內核線程被阻塞,顯示調用schedule進行調度;
  2. 從中斷返回內核空間前夕,發現內核線程無鎖,根據對應need_resched自斷判斷是否進行調度;
  3. 內核代碼再一次具有可搶佔性的時候;

Linux 線程調度上下文切換

Linux線程調度的上下文切換,主要由函數context_switch完成,主要完成相關寄存器和棧的切換,如果涉及到了進程(進程是資源管理的單位)切換,還會切換頁目錄進而切換進程地址空間。下面是選自MIT xv6的一幅圖:
這裏寫圖片描述

Goroutine 調度模型

Go語言天然支持併發靠的就是輕量級的goroutine,goroutine算是一種協程,從Go 1.4後默認goroutine棧初始大小爲2k,由Go的runtime完全在用戶態調度,goroutine切換也不用陷入內核,相對OS線程調度開銷很小。但是,物理CPU核只能由內核來調度,內核只能調度由task_struct結構管理的OS線程,這樣就涉及到了一個goroutine和OS線程的關聯關係,Go 1.1後採用的就是著名的G-P-M模型。Go runtime中有關調度的代碼很多都是用匯編語言編寫,內核也是如此,看調度的源碼細節就需要對棧幀結構、調用約定、內存模型等有一定深度的瞭解。

G-P-M模型

G是goroutine,P是抽象的邏輯processor,M是操作系統線程Machine。P作爲go runtime抽象的處理器,其個數肯定要小於等於物理CPU核數的。具體的描述可以看The Go scheduler這篇文章,闡述的比較深入。總體來說,goroutine是用戶態的輕量級的線程,通過Go的runtime來調度獲取P,P最終會關聯到一個系統線程M上,從而使得這個P下面的goroutine獲得執行。也談goroutine調度器這篇文章中的圖很形象:
這裏寫圖片描述

Goroutine 調度時機

與Linux線程調度相比,Goroutine調度不支持搶佔。搶佔式調度依賴的是中斷機制。不過在Go 1.2後,如果goroutine涉及了函數調用,那麼就可以做到一定程度的“搶佔”。原理也比較容易理解,如下:

這個搶佔式調度的原理則是在每個函數或方法的入口,加上一段額外的代碼,讓runtime有機會檢查是否需要執行搶佔度。這種解決方案只能說局部解決了“餓死”問題,對於沒有函數調用,純算法循環計算的G,scheduler依然無法搶佔。

實測也是如此,對於無函數調用的死循環goroutine,如果其個數等於當前CPU核數,就會導致所有其他goroutine得不到調度。因爲再也沒有時機能夠運行到go的調度代碼了。而對於線程的死循環,會有時鐘中斷來搶佔CPU,從中斷返回時會進行調度完成搶佔。

總結

Linux2.6後完全支持了內核搶佔,並引入了NPTL線程庫。Go 1.2後也在一定程度上支持了goroutine的“搶佔式”調度。調度的原理細節可以看文末的參考文獻。

參考

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