程式是作業系統虛擬出來的概念,用來組織計算機中的任務。但隨著程式被賦予越來越多的任務,程式好像有了真實的生命,它從誕生就隨著CPU時間執行,直到最終消失。不過,程式的生命都得到了作業系統核心的關照。就好像疲於照顧幾個孩子的母親核心必須做出決定,如何在程式間分配有限的計算資源,最終讓使用者獲得最佳的使用體驗。核心中安排程式執行的模組稱為排程器(scheduler)。這裡將介紹排程器的工作方式。
程式狀態
排程器可以切換程式狀態(process state)。一個Linux程式從被建立到死亡,可能會經過很多種狀態,比如執行、暫停、可中斷睡眠、不可中斷睡眠、退出等。我們可以把Linux下繁多的程式狀態,歸納為三種基本狀態。
- 就緒(Ready): 程式已經獲得了CPU以外的所有必要資源,如程式空間、網路連線等。就緒狀態下的程式等到CPU,便可立即執行。
- 執行(Running):程式獲得CPU,執行程式。
- 阻塞(Blocked):當程式由於等待某個事件而無法執行時,便放棄CPU,處於阻塞狀態。
圖1 程式的基本狀態
程式建立後,就自動變成了就緒狀態。如果核心把CPU時間分配給該程式,那麼程式就從就緒狀態變成了執行狀態。在執行狀態下,程式執行指令,最為活躍。正在執行的程式可以主動進入阻塞狀態,比如這個程式需要將一部分硬碟中的資料讀取到記憶體中。在這段讀取時間裡,程式不需要使用CPU,可以主動進入阻塞狀態,讓出CPU。當讀取結束時,計算機硬體發出訊號,程式再從阻塞狀態恢復為就緒狀態。程式也可以被迫進入阻塞狀態,比如接收到SIGSTOP訊號。
排程器是CPU時間的管理員。Linux排程器需要負責做兩件事:一件事是選擇某些就緒的程式來執行;另一件事是打斷某些執行中的程式,讓它們變回就緒狀態。不過,並不是所有的排程器都有第二個功能。有的排程器的狀態切換是單向的,只能讓就緒程式變成執行狀態,不能把正在執行中的程式變回就緒狀態。支援雙向狀態切換的排程器被稱為搶佔式(pre-emptive)排程器。
排程器在讓一個程式變回就緒時,就會立即讓另一個就緒的程式開始執行。多個程式接替使用CPU,從而最大效率地利用CPU時間。當然,如果執行中程式主動進入阻塞狀態,那麼排程器也會選擇另一個就緒程式來消費CPU時間。所謂的上下文切換(context switch)就是指程式在CPU中切換執行的過程。核心承擔了上下文切換的任務,負責儲存和重建程式被切換掉之前的CPU狀態,從而讓程式感覺不到自己的執行被中斷。應用程式的開發者在編寫計算機程式時,就不用專門寫程式碼處理上下文切換了。
程式的優先順序
排程器分配CPU時間的基本依據,就是程式的優先順序。根據程式任務性質的不同,程式可以有不同的執行優先順序。根據優先順序特點,我們可以把程式分為兩種類別。
- 實時程式(Real-Time Process):優先順序高、需要儘快被執行的程式。它們一定不能被普通程式所阻擋,例如視訊播放、各種監測系統。
- 普通程式(Normal Process):優先順序低、更長執行時間的程式。例如文字編譯器、批處理一段文件、圖形渲染。
普通程式根據行為的不同,還可以被分成互動程式(interactive process)和批處理程式(batch process)。互動程式的例子有圖形介面,它們可能處在長時間的等待狀態,例如等待使用者的輸入。一旦特定事件發生,互動程式需要儘快被啟用。一般來說,圖形介面的反應時間是50到100毫秒。批處理程式沒有與使用者互動的,往往在後臺被默默地執行。
實時程式由Linux作業系統創造,普通使用者只能建立普通程式。兩種程式的優先順序不同,實時程式的優先順序永遠高於普通程式。程式的優先順序是一個0到139的整數。數字越小,優先順序越高。其中,優先順序0到99留給實時程式,100到139留給普通程式。
一個普通程式的預設優先順序是120。我們可以用命令nice來修改一個程式的預設優先順序。例如有一個可執行程式叫app,執行命令:
$nice -n -20 ./app
命令中的-20指的是從預設優先順序上減去20。通過這個命令執行app程式,核心會將app程式的預設優先順序設定成100,也就是普通程式的最高優先順序。命令中的-20可以被換成-20至19中任何一個整數,包括-20 和 19。預設優先順序將會變成執行時的靜態優先順序(static priority)。排程器最終使用的優先順序根據的是程式的動態優先順序:
動態優先順序 = 靜態優先順序 – Bonus + 5
如果這個公式的計算結果小於100或大於139,將會取100到139範圍內最接近計算結果的數字作為實際的動態優先順序。公式中的Bonus是一個估計值,這個數字越大,代表著它可能越需要被優先執行。如果核心發現這個程式需要經常跟使用者互動,將會把Bonus值設定成大於5的數字。如果程式不經常跟使用者互動,核心將會把程式的Bonus設定成小於5的數。
O(n)和O(1)排程器
下面介紹Linux的排程策略。最原始的排程策略是按照優先順序排列好程式,等到一個程式執行完了再執行優先順序較低的一個,但這種策略完全無法發揮多工系統的優勢。因此,隨著時間推移,作業系統的排程器也多次進化。
先來看Linux 2.4核心推出的O(n)排程器。O(n)這個名字,來源於演算法複雜度的大O表示法。大O符號代表這個演算法在最壞情況下的複雜度。字母n在這裡代表作業系統中的活躍程式數量。O(n)表示這個排程器的時間複雜度和活躍程式的數量成正比。
O(n)排程器把時間分成大量的微小時間片(Epoch)。在每個時間片開始的時候,排程器會檢查所有處在就緒狀態的程式。排程器計算每個程式的優先順序,然後選擇優先順序最高的程式來執行。一旦被排程器切換到執行,程式可以不被打擾地用盡這個時間片。如果程式沒有用盡時間片,那麼該時間片的剩餘時間會增加到下一個時間片中。
O(n)排程器在每次使用時間片前都要檢查所有就緒程式的優先順序。這個檢查時間和程式中程式數目n成正比,這也正是該排程器複雜度為O(n)的原因。當計算機中有大量程式在執行時,這個排程器的效能將會被大大降低。也就是說,O(n)排程器沒有很好的可擴充性。O(n)排程器是Linux 2.6之前使用的程式排程器。當Java語言逐漸流行後,由於Java虛擬機器會建立大量程式,排程器的效能問題變得更加明顯。
為了解決O(n)排程器的效能問題,O(1)排程器被髮明瞭出來,並從Linux 2.6核心開始使用。顧名思義,O(1)排程器是指排程器每次選擇要執行的程式的時間都是1個單位的常數,和系統中的程式數量無關。這樣,就算系統中有大量的程式,排程器的效能也不會下降。O(1)排程器的創新之處在於,它會把程式按照優先順序排好,放入特定的資料結構中。在選擇下一個要執行的程式時,排程器不用遍歷程式,就可以直接選擇優先順序最高的程式。
和O(n)排程器類似,O(1)也是把時間片分配給程式。優先順序為120以下的程式時間片為:
(140–priority)×20毫秒
優先順序120及以上的程式時間片為:
(140–priority)×5 毫秒
O(1)排程器會用兩個佇列來存放程式。一個佇列稱為活躍佇列,用於儲存那些待分配時間片的程式。另一個佇列稱為過期佇列,用於儲存那些已經享用過時間片的程式。O(1)排程器把時間片從活躍佇列中調出一個程式。這個程式用盡時間片,就會轉移到過期佇列。當活躍佇列的所有程式都被執行過後,排程器就會把活躍佇列和過期佇列對調,用同樣的方式繼續執行這些程式。
上面的描述沒有考慮優先順序。加入優先順序後,情況會變得複雜一些。作業系統會建立140個活躍佇列和過期佇列,對應優先順序0到139的程式。一開始,所有程式都會放在活躍佇列中。然後作業系統會從優先順序最高的活躍佇列開始依次選擇程式來執行,如果兩個程式的優先順序相同,他們有相同的概率被選中。執行一次後,這個程式會被從活躍佇列中剔除。如果這個程式在這次時間片中沒有徹底完成,它會被加入優先順序相同的過期佇列中。當140個活躍佇列的所有程式都被執行完後,過期佇列中將會有很多程式。排程器將對調優先順序相同的活躍佇列和過期佇列繼續執行下去。過期佇列和活躍佇列,如圖2所示。
圖2 過期佇列和活躍佇列(需要替換)
我們下面看一個例子,有五個程式,如表1所示。
表1 程式
Linux作業系統中的程式佇列(run queue),如表2所示。
表2 程式佇列
那麼在一個執行週期,被選中的程式依次是先A,然後B和C,隨後是D,最後是E。
注意,普通程式的執行策略並沒有保證優先順序為100的程式會先被執行完進入結束狀態,再執行優先順序為101的程式,而是在每個對調活躍和過期佇列的週期中都有機會被執行,這種設計是為了避免程式飢餓(starvation)。所謂的程式飢餓,就是優先順序低的程式很久都沒有機會被執行。
我們看到,O(1)排程器在挑選下一個要執行的程式時很簡單,不需要遍歷所有程式。但是它依然有一些缺點。程式的執行順序和時間片長度極度依賴於優先順序。比如,計算優先順序為100、110、120、130和139這幾個程式的時間片長度,如表3所示。
表3 程式的時間片長度
從表格中你會發現,優先順序為110和120的程式的時間片長度差距比120和130之間的大了10倍。也就是說,程式時間片長度的計算存在很大的隨機性。O(1)排程器會根據平均休眠時間來調整程式優先順序。該排程器假設那些休眠時間長的程式是在等待使用者互動。這些互動類的程式應該獲得更高的優先順序,以便給使用者更好的體驗。一旦這個假設不成立,O(1)排程器對CPU的調配就會出現問題。
完全公平排程器
從2007年釋出的Linux 2.6.23版本起,完全公平排程器(CFS,Completely Fair Scheduler)取代了O(1)排程器。CFS排程器不對程式進行任何形式的估計和猜測。這一點和O(1)區分互動和非互動程式的做法完全不同。
CFS排程器增加了一個虛擬執行時(virtual runtime)的概念。每次一個程式在CPU中被執行了一段時間,就會增加它虛擬執行時的記錄。在每次選擇要執行的程式時,不是選擇優先順序最高的程式,而是選擇虛擬執行時最少的程式。完全公平排程器用一種叫紅黑樹的資料結構取代了O(1)排程器的140個佇列。紅黑樹可以高效地找到虛擬執行最小的程式。
我們先通過例子來看CFS排程器。假如一臺執行的計算機中本來擁有A、B、C、D四個程式。核心記錄著每個程式的虛擬執行時,如表4所示。
表4 每個程式的虛擬執行時
系統增加一個新的程式E。新建立程式的虛擬執行時不會被設定成0,而會被設定成當前所有程式最小的虛擬執行時。這能保證該程式被較快地執行。在原來的程式中,最小虛擬執行時是程式A的1 000納秒,因此E的初始虛擬執行時會被設定為1 000納秒。新的程式列表如表5所示。
表5 新的程式列表
假如排程器需要選擇下一個執行的程式,程式A會被選中執行。程式A會執行一個排程器決定的時間片。假如程式A執行了250納秒,那它的虛擬執行時增加。而其他的程式沒有執行,所以虛擬執行時不變。在A消耗完時間片後,更新後的程式列表,如表6所示。
表6 更新後的程式列表
可以看到,程式A的排序下降到了第三位,下一個將要被執行的程式是程式E。從本質上看,虛擬執行時代表了該程式已經消耗了多少CPU時間。如果它消耗得少,那麼理應優先獲得計算資源。
按照上述的基本設計理念,CFS排程器能讓所有程式公平地使用CPU。聽起來,這讓程式的優先順序變得毫無意義。CFS排程器也考慮到了這一點。CFS排程器會根據程式的優先順序來計算一個時間片因子。同樣是增加250納秒的虛擬執行時,優先順序低的程式實際獲得的可能只有200納秒,而優先順序高的程式實際獲得可能有300納秒。這樣,優先順序高的程式就獲得了更多的計算資源。
以上就是排程器的基本原理,以及Linux用過的幾種排程策略。排程器可以更加合理地把CPU時間分配給程式。現代計算機都是多工系統,排程器在多工系統中起著頂樑柱的作用。
歡迎閱讀“騎著企鵝採樹莓”系列文章