上一篇我們提到過進程狀態,而進程調度主要是針對TASK_RUNNING運行狀態進行調度,因為其他狀態是不可執行比如睡眠,不需要調度。
1、進程調度概念
進程調度程序,簡稱調度程序,它是確保進程能有效工作的一個內核子系統。調度程序負責決定哪個進程投入運行,何時運行以及運行多長時間。
多任務
多任務操作系統是指能同時並發執行多個進程的操作系統。
多任務系統划分為兩類:非搶占式多任務(cooperative multitasking)和搶占式多任務(preemptive multitasking)。
非搶占式是一種協作的方式,一個進程一直執行直到任務結束或者主動退出才切換到下一個進程。
搶占式是大部分操作系統采用的方式,是指給每個進程分配一個時間片(time slice),當時運行時間達到規定的時間時則會切換到下一個進程。
2、調度策略
上面提到的時間片策略是比較傳統的方式,后面Linux系統進行了多次改進,比如O(1)算法和CFS等。那么改進的動機和依據是什么呢,我們來看看。
2.1 I/O 消耗型和 CPU 消耗型
進程根據資源使用可以分為這兩大類。
I/O 消耗型:進程的大部分時間用來進行 I/O 的請求或者等待,比如鍵盤。這種類型的進程經常處於可以運行的狀態,但是都只是運行一點點時間,絕大多數的時間都在處於阻塞(睡眠)的狀態。
CPU 消耗型:進程的大部分時間用在執行代碼上即CPU運算,比如開啟 Matlab 做一個大型的運算。除非被搶占,否則它們可以一直運行,所以它們沒有太多的 I/O 需求。調度策略往往是盡量降低他們的調度頻率,而延長其運行時間。
當然這種划分不是絕對的,一般的應用程序同時包含兩種行為。
所以調度策略通常需要在兩個矛盾的目標中尋求平衡:進程響應迅速(響應時間短)和最大系統利用率(高吞吐量)。
Linux 系統為了提升響應的速度,傾向於優先調度 I/O 消耗型。
2.2 進程優先級
調度算法中最基本的一類就是基於優先級的調度,根據進程的價值(重要性)和對處理器時間的需求來對進程分級的想法。簡單的說是優先級高的先運行,低的后運行。
Linux采用了兩種不同的優先級范圍。
(1)nice值
它的范圍從-20到+19,默認值0。越大的nice值優先級越低,19優先級最低,-20優先級最高。ps -ef命令中,NI標記就是進程對應的nice值。
這是普通進程的優先級。
(2)實時優先級
范圍是 0~99,與 nice 值相反,值越大優先級越高。
這是實時進程的優先級,相對普通進程的,所以任何實時進程的優先級都高於普通進程的優先級。
時間片是一個數值,它表明在搶占前所能持續運行的時間。調度策略必須規定一個默認的時間片,這並非易事。因為時間片過長I/O消耗型的線程得不到及時響應,而太短CPU消耗型的需要頻繁被切換,吞吐量會下降。而最新的Linux調度策略CFS不采用固定的時間片,而是采用了處理器的使用比。我們接下來詳細介紹。
3、調度算法
Linux調度器是以分類(模塊化)的方式提供的,即對不同類型的進程進行分組並且分別選擇相應的算法。
這種調度結構被稱為調度器類(scheduler classes),它允許不同的可動態添加的調度算法並存,調度屬於自己范疇的進程。
如下圖Linux調度器包含了多種調度器類。
這些調度器類的優先級順序為: Stop_Task > Real_Time > Fair > Idle_Task。
開發者可以根據己的設計需求把所屬的Task配置到不同的scheduler classes中。其中的Real_Time和Fair是最常用的,也對應了我們上面提到的實時進程和普通進程。
3.1 完全公平調度
Fair調度使用的完全公平調度器(Completely Fair Scheduler,CFS)。
這是一個針對普通進程的調度類,在Linux中稱為SCHED_NORMAL(在POSIX中稱為SCHED_OTHER)。
傳統的時間片方式是每個進程固定一個時間,那么當進程個數變化時,整個調度周期順延。時間片還會跟着系統定時器節拍隨時改變,那么整個周期再次跟着變化。那么優先級低的進程可能遲遲得不到調度。
而CFS把整個調度周期的時間固定,該周期叫目標延遲(target latency),也不再采用時間片,而是根據每個進程的nice值得到的權重再計算得到處理器比例,進而得到進程自己的時間。該時間和節拍沒有任何關系,也可以精確到ns。例如“目標延遲”設置為20ms,2個進程各10毫秒,如果4個進程則是各5毫秒。如果100個進程呢,是不是就是0.2毫秒呢?
不一定,CFS引入了一個關鍵特性:最小粒度。即每個進程獲得時間片的最小值,默認是1毫秒。
為了公平起見,CFS總是選擇運行最少(vruntime)的進程作為下一個運行進程。所以這樣照顧了I/O消耗型短時間處理的需求,也將更多時間留給了CPU消耗型的程序。確實解決了多進程環境下因延遲帶來的不公平性。
vruntime虛擬實時
在 CFS 中,給每一個進程安排了一個虛擬時鍾vruntime(virtual runtime),這個變量並非直接等於他的絕對運行時間,而是根據運行時間放大或者縮小一個比例,CFS使用這個vruntime 來代表一個進程的運行時間。如果一個進程得以執行,那么他的vruntime將不斷增大,直到它沒有執行。沒有執行的進程的vruntime不變。調度器為了體現絕對的完全公平的調度原則,總是選擇vruntime最小的進程,讓其投入執行。他們被維護到一個以vruntime為順序的紅黑樹rbtree中,每次去取最小的vruntime的進程(最左側的葉子節點)來投入運行。實際運行時間到vruntime的計算公式為:
[ vruntime = 實際運行時間 * 1024 / 進程權重 ]
這里的1024代表nice值為0的進程權重。所有的進程都以nice為0的權重1024作為基准,計算自己的vruntime。
挑選的進程進行運行了,它運行多久?進程運行的時間是根據進程的權重進行分配。
[ 分配給進程的運行時間 = 調度周期 *(進程權重 / 所有進程權重之和) ]
虛擬運行時間是通過進程的實際運行時間和進程權重(weight)計算出來的。在CFS調度器中,將進程優先級這個概念弱化,而是強調進程的權重。一個進程的權重越大,則說明這個進程更需要運行,因此它的虛擬運行時間就越小,這樣被調度的機會就越大。
關於nice和進程權重以及vruntime之間的計算方式非常復雜。有興趣的可以在網上搜索或者看源碼。
總之,nice對時間片的作用不再是算數加權,而是幾何加權。
3.2 實時調度策略
實時調度策略分為兩種:SCHED_FIFO 和 SCHED_RR。
這兩種實時進程都比任何普通進程的優先級更高(SCHED_NORMAL),都會比他們更先得到調度。
SCHED_FIFO:一個這種類型的進程出於可執行的狀態,就會一直執行,直到它自己被阻塞或者主動放棄 CPU;它不基於時間片,可以一直執行下去,只有更高優先級的SCHED_FIFO或者SCHED_RR才能搶占它的任務,如果有兩個同樣優先級的SCHED_FIFO任務,它們會輪流執行,其他低優先級的只有等它們變為不可執行狀態,才有機會執行。
SCHED_RR:與SCHED_FIFO大致相同,只是SCHED_RR級的進程在耗盡事先分配給它的時間后就不能再執行了。所以SCHED_RR是帶有時間片的SCHED_FIFO:一種實時輪流調度(Realtime Robin)算法。
上述兩種實時算法實現的都是靜態優先級。內核不為實時進程計算動態優先級,保證給定的優先級的實時進程總能夠搶占比他優先級低的進程。
4、調度的實現
進程調度的主要入口點是函數schedule(),即實現進程切換的功能:選擇哪個進程可以運行,何時投入運行。
該函數的核心是for()循環,它以優先級為序,從最高的優先級調度類開始,遍歷所有的調度類。
進程狀態可以分為可執行和不可執行,分別放入不同的結構中。可執行的進程放在紅黑樹中,而不可執行的放在等待隊列。
一個進程可能在兩種結構中不斷移動。
比如讀文件操作,在執行工作時,處在紅黑樹中,當讀完時可能需要等待磁盤,這時會把自己標記成休眠狀態,從紅黑樹中移出,放入等待隊列,然后調用schedule()選擇和執行一個其他進程。而當磁盤作業完成時,又會被喚醒,進程再次設置為可執行狀態,然后從等待隊列中移到紅黑樹中。
4.1 搶占與上下文切換
上下文切換,就是從一個可執行進程切換到另一個可執行進程,由context_switch()函數處理。每一個新的進程被選出來准備投入運行的時候,schedule()就會調用該函數。
自願切換意味着進程需要等待某種資源,強制切換則與搶占(Preemption)有關。
搶占(Preemption)是指內核強行切換正在CPU上運行的進程,在搶占的過程中並不需要得到進程的配合,在隨后的某個時刻被搶占的進程還可以恢復運行。發生搶占的原因主要有:進程的時間片用完了,或者優先級更高的進程來爭奪CPU了。
搶占的過程分兩步,第一步觸發搶占,第二步執行搶占,這兩步中間不一定是連續的,有些特殊情況下甚至會間隔相當長的時間:
- 觸發搶占:給正在CPU上運行的當前進程設置一個請求重新調度的標志(TIF_NEED_RESCHED),僅此而已,此時進程並沒有切換。
- 執行搶占:在隨后的某個時刻,內核會檢查TIF_NEED_RESCHED標志並調用schedule()執行搶占。
搶占只在某些特定的時機發生,這是內核的代碼決定的。
(1)觸發搶占的時機
每個進程都包含一個TIF_NEED_RESCHED標志,內核根據這個標志判斷該進程是否應該被搶占,設置TIF_NEED_RESCHED標志就意味着觸發搶占。
直接設置TIF_NEED_RESCHED標志的函數是set_tsk_need_resched();
觸發搶占的函數是resched_task()。
TIF_NEED_RESCHED標志什么時候被設置呢?在以下時刻:
-
周期性的時鍾中斷
時鍾中斷處理函數會調用scheduler_tick(),這是調度器核心層(scheduler core)的函數,它通過調度類(scheduling class)的task_tick方法檢查進程的時間片是否耗盡,如果耗盡則觸發搶占。
-
喚醒進程的時候
當進程被喚醒的時候,如果優先級高於CPU上的當前進程,就會觸發搶占。相應的內核代碼中,try_to_wake_up()最終通過check_preempt_curr()檢查是否觸發搶占。
-
新進程創建的時候
如果新進程的優先級高於CPU上的當前進程,會觸發搶占。相應的調度器核心層代碼是sched_fork(),它再通過調度類的task_fork方法觸發搶占。
-
進程修改nice值的時候
如果進程修改nice值導致優先級高於CPU上的當前進程,也會觸發搶占。內核代碼參見 set_user_nice()。
-
進行負載均衡的時候
在多CPU的系統上,進程調度器盡量使各個CPU之間的負載保持均衡,而負載均衡操作可能會需要觸發搶占。
不同的調度類有不同的負載均衡算法,涉及的核心代碼也不一樣,比如CFS類在load_balance()中觸發搶占;RT類的負載均衡基於overload,如果當前運行隊列中的RT進程超過一個,就調用push_rt_task()把進程推給別的CPU,在這里會觸發搶占。
(2)執行搶占的時機
觸發搶占通過設置進程的TIF_NEED_RESCHED標志告訴調度器需要進行搶占操作了,但是真正執行搶占還要等內核代碼發現這個標志才行,而內核代碼只在設定的幾個點上檢查TIF_NEED_RESCHED標志,這也就是執行搶占的時機。
搶占如果發生在進程處於用戶態的時候,稱為User Preemption(用戶態搶占);如果發生在進程處於內核態的時候,則稱為Kernel Preemption(內核態搶占)。
執行User Preemption(用戶態搶占)的時機
- 從系統調用(syscall)返回用戶態時;
- 從中斷處理程序返回用戶態時;
執行Kernel Preemption(內核態搶占)的時機
Linux在2.6版本之后就支持內核搶占了,但是請注意,具體取決於內核編譯時的選項:
-
CONFIG_PREEMPT_NONE=y
不允許內核搶占。這是SLES的默認選項。
-
CONFIG_PREEMPT_VOLUNTARY=y
在一些耗時較長的內核代碼中主動調用cond_resched()讓出CPU。這是RHEL的默認選項。
-
CONFIG_PREEMPT=y
允許完全內核搶占。
在 CONFIG_PREEMPT=y 的前提下,內核態搶占的時機是:
- 中斷處理程序返回內核空間之前會檢查TIF_NEED_RESCHED標志,如果置位則調用preempt_schedule_irq()執行搶占。preempt_schedule_irq()是對schedule()的包裝。
- 當內核從non-preemptible(禁止搶占)狀態變成preemptible(允許搶占)的時候;在preempt_enable()中,會最終調用preempt_schedule()來執行搶占。preempt_schedule()是對schedule()的包裝。
“搶占”這一部分來自網上,條理比書上更清晰,但是和書上也稍有差別,大體一致,不影響整體理解。
參考資料:
《Linux內核設計與實現》原書第三版