1. 程式人生 > >Go語言模型:Linux執行緒排程 vs Goroutine排程

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的“搶佔式”排程。排程的原理細節可以看文末的參考文獻。

參考