摘要:本文將會從最基礎的調度算法提及,逐個分析各類主流調度算法的原理,帶你們一塊兒探索CPU調度的奧祕。
本文分享自華爲雲社區《探索CPU的調度原理》,做者:元閏子。算法
前言
軟件工程師們總習慣把OS(Operating System,操做系統)當成是一個很是值得信賴的管家,咱們只管把程序託管到OS上運行,卻不多深刻了解操做系統的運行原理。確實,OS做爲一個通用的軟件系統,在大多數的場景下都表現得足夠的優秀。但仍會有一些特殊的場景,須要咱們對OS進行各項調優,才能讓業務系統更高效地完成任務。這就要求咱們必須深刻了解OS的原理,不只僅只會使喚這個管家,還能懂得如何讓管家作得更好。緩存
OS是一個很是龐大的軟件系統,本文主要探索其中的冰山一角:CPU的調度原理。數據結構
提及CPU的調度原理,不少人的第一反應是基於時間片的調度,也即每一個進程都有佔用CPU運行的時間片,時間片用完以後,就讓出CPU給其餘進程。至於OS是如何判斷一個時間片是否用完的、如何切換到另外一個進程等等更深層的原理,瞭解的人彷佛並很少。學習
其實,基於時間片的調度只是衆多CPU的調度算法的一類,本文將會從最基礎的調度算法提及,逐個分析各類主流調度算法的原理,帶你們一塊兒探索CPU調度的奧祕。優化
CPU的上下文切換
在探索CPU調度原理以前,咱們先了解一下CPU的上下文切換,它是CPU調度的基礎。url
現在的OS幾乎都支持"同時"運行遠大於CPU數量的任務,OS會將CPU輪流分配給它們使用。這就要求OS必須知道從哪裏加載任務,以及加載後從哪裏開始運行,而這些信息都保存在CPU的寄存器中,其中即將執行的下一條指令的地址被保存在程序計數器(PC)這一特殊寄存器上。咱們將寄存器的這些信息稱爲CPU的上下文,也叫硬件上下文。spa
OS在切換運行任務時,將上一任務的上下文保存下來,並將即將運行的任務的上下文加載到CPU寄存器上的這一動做,被稱爲CPU上下文切換。操作系統
CPU上下文屬於進程上下文的一部分,咱們常說的進程上下文由以下兩部分組成:.net
- 用戶級上下文:包含進程的運行時堆棧、數據塊、代碼塊等信息。
- 系統級上下文:包含進程標識信息、進程現場信息(CPU上下文)、進程控制信息等信息。
這涉及到兩個問題:(1)上一任務的CPU上下文如何保存下來?(2)何時執行上下文切換?3d
問題1: 上一任務的CPU上下文如何保存下來?
CPU上下文會被保存在進程的內核空間(kernel space)上。OS在給每一個進程分配虛擬內存空間時,會分配一個內核空間,這部份內存只能由內核代碼訪問。OS在切換CPU上下文前,會先將當前CPU的通用寄存器、PC等進程現場信息保存在進程的內核空間上,待下次切換時,再取出從新裝載到CPU上,以恢復任務的運行。
問題2: 何時執行上下文切換?
OS要想進行任務上下文切換,必須佔用CPU來執行切換邏輯。然而,用戶程序運行的過程當中,CPU已經被用戶程序所佔用,也即OS在此刻並未處於運行狀態,天然也沒法執行上下文切換。針對該問題,有兩種解決策略,協做式策略與搶佔式策略。
協做式策略依賴用戶程序主動讓出CPU,好比執行系統調用(System Call)或者出現除零等異常。但該策略並不靠譜,若是用戶程序沒有主動讓出CPU,甚至是惡意死循環,那麼該程序將會一直佔用CPU,惟一的恢復手段就是重啓系統了。
搶佔式策略則依賴硬件的定時中斷機制(Timer Interrupt),OS會在初始化時向硬件註冊中斷處理回調(Interrupt Handler)。當硬件產生中斷時,硬件會將CPU的處理權交給來OS,OS就能夠在中斷回調上實現CPU上下文的切換。
調度的衡量指標
對於一種CPU調度算法的好壞,通常都經過以下兩個指標來進行衡量:
- 週轉時間(turnaround time),指從任務到達至任務完成之間的時間,即T_{turnaround}=T_{completiong}-T_{arrival}Tturnaround=Tcompletiong−Tarrival
- 響應時間(response time),指從任務到達至任務首次被調度的時間,即T_{response}=T_{firstrun}-T_{arrival}Tresponse=Tfirstrun−Tarrival
兩個指標從某種程度上是對立的,要求高的平均週轉時間,必然會下降平均響應時間。具體追求哪一種指標與任務類型有關,好比程序編譯類的任務,要求週轉時間要小,儘量快的完成編譯;用戶交互類的任務,則要求響應時間要小,避免影響用戶體驗。
工做負載假設
OS上的工做負載(也即各種任務運行的情況)老是變幻無窮的,爲了更好的理解各種CPU調度算法原理,咱們先對工做負載進行來以下幾種假設:
- 假設1:全部任務都運行時長都相同。
- 假設2:全部任務的開始時間都是相同的
- 假設3:一旦任務開始,就會一直運行,直至任務完成。
- 假設4:全部任務只使用CPU資源(好比不產生I/O操做)。
- 假設5:預先知道全部任務的運行時長。
準備工做已經作好,下面咱們開始進入CPU調度算法的奇妙世界。
FIFO:先進先出
FIFO(First In First Out,先進先出)調度算法以原理簡單,容易實現著稱,它先調度首先到達的任務直至結束,而後再調度下一個任務,以此類推。若是有多個任務同時到達,則隨機選一個。
在咱們假設的工做負載情況下,FIFO效率良好。好比有A、B、C三個任務知足上述全部負載假設,每一個任務運行時長爲10s,在t=0時刻到達,那麼任務調度狀況是這樣的:
根據FIFO的調度原理,A、B、C分別在十、20、30時刻完成任務,平均週轉時間爲20s( \frac {10+20+30}{3}310+20+30),效果很好。
然而現實老是殘酷的,若是假設1被打破,好比A的運行時間變成100s,B和C的仍是10s,那麼調度狀況是這樣的:
根據FIFO的調度原理,因爲A的運行時間過長,B和C長時間得不到調度,致使平均週轉時間惡化爲110( \frac {100+110+120}{3}3100+110+120)。
所以,FIFO調度策略在任務運行時間差別較大的場景下,容易出現任務餓死的問題!
針對這個問題,若是運行時間較短的B和C先被調度,問題就能夠解決了,這正是SJF調度算法的思想。
SJF:最短任務優先
SJF(Shortest Job First,最短任務優先)從相同到達時間的多個任務中選取運行時長最短的一個任務進行調度,接着再調度第二短的任務,以此類推。
針對上一節的工做負載,使用SJF進行調度的狀況以下,週轉時間變成了50s( \frac {10+20+120}{3}310+20+120),相比FIFO的110s,有了2倍多的提高。
讓咱們繼續打破假設2,A在t=0時刻,B和C則在t=10時刻到達,那麼調度狀況會變成這樣:
由於任務B和C比A後到,它們不得不一直等待A運行結束後纔有機會調度,即便A須要長時間運行。週轉時間惡化爲103.33s(\frac {100+(110-10)+(120-10)}{3}3100+(110−10)+(120−10)),再次出現任務餓死的問題!
STCF:最短期完成優先
爲了解決SJF的任務餓死問題,咱們須要打破假設3,也即任務在運行過程當中是容許被打斷的。若是B和C在到達時就當即被調度,問題就解決了。這屬於搶佔式調度,原理就是CPU上下文切換一節提到的,在中判定時器到達以後,OS完成任務A和B的上下文切換。
咱們在協做式調度的SJF算法的基礎上,加上搶佔式調度算法,就演變成了STCF算法(Shortest Time-to-Completion First,最短期完成優先),調度原理是當運行時長較短的任務到達時,中斷當前的任務,優先調度運行時長較短的任務。
使用STCF算法對該工做負載進行調度的狀況以下,週轉時間優化爲50s(\frac {120+(20-10)+(30-10)}{3}3120+(20−10)+(30−10)),再次解決了任務餓死問題!
到目前爲止,咱們只關心了週轉時間這一衡量指標,那麼FIFO、SJF和STCF調度算法的響應時間又是多長呢?
不妨假設A、B、C三個任務都在t=0時刻到達,運行時長都是5s,那麼這三個算法的調度狀況以下,平均響應時長爲5s(\frac {0+(5-0)+(10-0)}{3}30+(5−0)+(10−0)):
更糟糕的是,隨着任務運行時長的增加,平均響應時長也隨之增加,這對於交互類任務來講將會是災難性的,嚴重影響用戶體驗。該問題的根源在於,當任務都同時到達且運行時長相同時,最後一個任務必須等待其餘任務所有完成以後纔開始調度。
爲了優化響應時間,咱們熟悉的基於時間片的調度出現了。
RR:基於時間片的輪詢調度
RR(Round Robin,輪訓)算法給每一個任務分配一個時間片,當任務的時間片用完以後,調度器會中斷當前任務,切換到下一個任務,以此類推。
須要注意的是,時間片的長度設置必須是中判定時器的整數倍,好比中判定時器時長爲2ms,那麼任務的時間片能夠設置爲2ms、4ms、6ms … 不然即便任務的時間片用完以後,定時中斷沒發生,OS也沒法切換任務。
如今,使用RR進行調度,給A、B、C分配一個1s的時間片,那麼調度狀況以下,平均響應時長爲1s(\frac {0+(1-0)+(2-0)}{3}30+(1−0)+(2−0)):
從RR的調度原理能夠發現,把時間片設置得越小,平均響應時間也越小。但隨着時間片的變小,任務切換的次數也隨之上升,也就是上下文切換的消耗會變大。所以,時間片大小的設置是一個trade-off的過程,不能一味追求響應時間而忽略CPU上下文切換帶來的消耗。
CPU上下文切換的消耗,不僅是保存和恢復寄存器所帶來的消耗。程序在運行過程當中,會逐漸在CPU各級緩存、TLB、分支預測器等硬件上創建屬於本身的緩存數據。當任務被切換後,就意味着又得重來一遍緩存預熱,這會帶來巨大的消耗。
另外,RR調度算法的週轉時間爲14s(\frac {(13-0)+(14-0)+(15-0)}{3}3(13−0)+(14−0)+(15−0)),相比於FIFO、SJF和STCF的10s(\frac {(5-0)+(10-0)+(15-0)}{3}3(5−0)+(10−0)+(15−0))差了很多。這也驗證了以前所說的,週轉時間和響應時間在某種程度上是對立的,若是想要優化週轉時間,建議使用SJF和STCF;若是想要優化響應時間,則建議使用RR。
I/O操做對調度的影響
到目前爲止,咱們並未考慮任何的I/O操做。咱們知道,當觸發I/O操做時,進程並不會佔用CPU,而是阻塞等待I/O操做的完成。如今讓咱們打破假設4,考慮任務A和B都在t=0時刻到達,運行時長都是50ms,但A每隔10ms執行一次阻塞10ms的I/O操做,而B沒有I/O。
若是使用STCF進行調度,調度的狀況是這樣的:
從上圖看出,任務A和B的調度總時長達到了140ms,比實際A和B運行時長總和100ms要大。並且A阻塞在I/O操做期間,調度器並無切換到B,致使了CPU的空轉!
要解決該問題,只需使用RR的調度算法,給任務A和B分配10ms的時間片,這樣當A阻塞在I/O操做時,就能夠調度B,而B用完時間片後,剛好A也從I/O阻塞中返回,以此類推,調度總時長優化至100ms。
該調度方案是創建在假設5之上的,也即要求調度器預先知道A和B的運行時長、I/O操做時間長等信息,才能如此充分地利用CPU。然而,實際的狀況遠比這複雜,I/O阻塞時長不會每次都同樣,調度器也沒法準確知道A和B的運行信息。當假設5也被打破時,調度器又該如何實現才能最大程度保證CPU利用率,以及調度的合理性呢?
接下來,咱們將介紹一個可以在全部工做負載假設被打破的狀況下依然表現良好,被許多現代操做系統採用的CPU調度算法,MLFQ。
MLFQ:多級反饋隊列
MLFQ(Multi-Level Feedback Queue,多級反饋隊列)調度算法的目標以下:
- 優化週轉時間。
- 下降交互類任務的響應時間,提高用戶體驗。
從前面分析咱們知道,要優化週轉時間,能夠優先調度運行時長短的任務(像SJF和STCF的作法);要優化響應時間,則採用相似RR的基於時間片的調度。然而,這兩個目標看起來是矛盾的,要下降響應時間,必然會增長週轉時間。
那麼對MLFQ來講,就須要解決以下兩個問題:
- 在不預先清楚任務的運行信息(包括運行時長、I/O操做等)的前提下,如何權衡週轉時間和響應時間?
- 如何從歷史調度中學習,以便將來作出更好的決策?
劃分任務的優先級
MLFQ與前文介紹的幾種調度算法最顯著的特色就是新增了優先級隊列存放不一樣優先級的任務,並定下了以下兩個規則:
- 規則1:若是Priority(A) > Priority(B),則調度A
- 規則2:若是Priority(A) = Priority(B),則按照RR算法調度A和B
優先級的變化
MLFQ必須考慮改變任務的優先級,不然根據 規則1 和 規則2 ,對於上圖中的任務C,在A和B運行結束以前,C都不會得到運行的機會,致使C的響應時間很長。所以,能夠定下了以下幾個優先級變化規則:
- 規則3:當一個新的任務到達時,將它放到最高優先級隊列中
- 規則4a:若是任務A運行了一個時間片都沒有主動讓出CPU(好比I/O操做),則優先級下降一級
- 規則4b:若是任務A在時間片用完以前,有主動讓出CPU,則優先級保持不變
規則3主要考慮到讓新加入的任務都能獲得調度機會,避免出現任務餓死的問題
規則4a和4b主要考慮到,交互類任務大都是short-running的,而且會頻繁讓出CPU,所以爲了保證響應時間,須要保持現有的優先級;而CPU密集型任務,每每不會太關注響應時間,所以能夠下降優先級。
按照上述規則,當一個long-running任務A到達時,調度狀況是這樣的:
若是在任務A運行到t=100時,short-time任務B到達,調度狀況是這樣的:
從上述調度狀況能夠看出,MLFQ具有了STCF的優勢,便可以優先完成short-running任務的調度,縮短了週轉時間。
若是任務A運行到t=100時,交互類任務C到達,那麼調度狀況是這樣的:
MLFQ會在任務處於阻塞時按照優先級選擇其餘任務運行,避免CPU空轉。所以,在上圖中,當任務C處於I/O阻塞狀態時,任務A獲得了運行時間片,當任務C從I/O阻塞上返回時,A再次掛起,以此類推。另外,由於任務C在時間片以內出現主動讓出CPU的行爲,C的優先級一直保持不變,這對於交互類任務而言,有效提高了用戶體驗。
CPU密集型任務餓死問題
到目前爲止,MLFQ彷佛可以同時兼顧週轉時間,以及交互類任務的響應時間,它真的完美了嗎?
考慮以下場景,任務A運行到t=100時,交互類任務C和D同時到達,那麼調度狀況會是這樣的:
因而可知,若是當前系統上存在不少交互類任務時,CPU密集型任務將會存在餓死的可能!
爲了解決該問題,能夠設立了以下規則:
- 規則5:系統運行S時長以後,將全部任務放到最高優先級隊列上(Priority Boost)
加上該規則以後,假設設置S爲50ms,那麼調度狀況是這樣的,餓死問題獲得解決!
惡意任務問題
考慮以下一個惡意任務E,爲了長時間佔用CPU,任務E在時間片還剩1%時故意執行I/O操做,並很快返回。根據規則4b,E將會維持在原來的最高優先級隊列上,所以下次調度時仍然得到調度優先權:
爲了解決該問題,咱們須要將規則4調整爲以下規則:
- 規則4:給每一個優先級分配一個時間片,當任務用完該優先級的時間片後,優先級降一級
應用新的規則4後,相同的工做負載,調度狀況變成了以下所述,再也不出現惡意任務E佔用大量CPU的問題。
到目前爲止,MLFQ的基本原理已經介紹完,最後,咱們總結下MLFQ最關鍵的5項規則:
- 規則1:若是Priority(A) > Priority(B),則調度A
- 規則2:若是Priority(A) = Priority(B),則按照RR算法調度A和B
- 規則3:當一個新的任務到達時,將它放到最高優先級隊列中
- 規則4:給每一個優先級分配一個時間片,當任務用完該優先級的時間片後,優先級降一級
- 規則5:系統運行S時長以後,將全部任務放到最高優先級隊列上(Priority Boost)
如今,再回到本節開始時提出的兩個問題:
一、在不預先清楚任務的運行信息(包括運行時長、I/O操做等)的前提下,MLFQ如何權衡週轉時間和響應時間?
在預先不清楚任務究竟是long-running或short-running的狀況下,MLFQ會先假設任務屬於shrot-running任務,若是假設正確,任務就會很快完成,週轉時間和響應時間都獲得優化;即便假設錯誤,任務的優先級也能逐漸下降,把更多的調度機會讓給其餘short-running任務。
二、MLFQ如何從歷史調度中學習,以便將來作出更好的決策?
MLFQ主要根據任務是否有主動讓出CPU的行爲來判斷其是不是交互類任務,若是是,則維持在當前的優先級,保證該任務的調度優先權,提高交互類任務的響應性。
固然,MLFQ並不是完美的調度算法,它也存在着各類問題,其中最讓人困擾的就是MLFQ各項參數的設定,好比優先級隊列的數量,時間片的長度、Priority Boost的間隔等。這些參數並無完美的參考值,只能根據不一樣的工做負載來進行設置。
好比,咱們能夠將低優先級隊列上任務的時間片設置長一些,由於低優先級的任務每每是CPU密集型任務,它們不太關心響應時間,較長的時間片長可以減小上下文切換帶來的消耗。
CFS:Linux的徹底公平調度
本節咱們將介紹一個平時打交道最多的調度算法,Linux系統下的CFS(Completely Fair Scheduler,徹底公平調度)。與上一節介紹的MLFQ不一樣,CFS並不是以優化週轉時間和響應時間爲目標,而是但願將CPU公平地均分給每一個任務。
固然,CFS也提供了給進程設置優先級的功能,讓用戶/管理員決定哪些進程須要得到更多的調度時間。
基本原理
大部分調度算法都是基於固定時間片來進行調度,而CFS另闢蹊徑,採用基於計數的調度方法,該技術被稱爲virtual runtime。
CFS給每一個任務都維護一個vruntime值,每當任務被調度以後,就累加它的vruntime。好比,當任務A運行了5ms的時間片以後,則更新爲vruntime += 5ms。CFS在下次調度時,選擇vruntime值最小的任務來調度,好比:
那CFS應該何時進行任務切換呢?切換得頻繁些,任務的調度會更加的公平,可是上下文切換帶來的消耗也越大。所以,CFS給用戶提供了個可配參數sched_latency,讓用戶來決定切換的時機。CFS將每一個任務分到的時間片設置爲 time_slice = sched_latency / n(n爲當前的任務數) ,以確保在sched_latency週期內,各任務可以均分CPU,保證公平性。
好比將sched_latency設置爲48ms,當前有4個任務A、B、C和D,那麼每一個任務分到的時間片爲12ms;後面C和D結束以後,A和B分到的時間片也更新爲24ms:
從上述原理上看,在sched_latency 不變的狀況下,隨着系統任務數的增長,每一個任務分到的時間片也隨之減小,任務切換所帶來的消耗也會增大。爲了不過多的任務切換消耗,CFS提供了可配參數min_granularity來設置任務的最小時間片。好比sched_latency設置爲48ms,min_granularity設置爲 6ms,那麼即便當前任務數有12,每一個任務數分到的時間片也是6ms,而不是4ms。
給任務分配權重
有時候,咱們但願給系統中某個重要的業務進程多分配些時間片,而其餘不重要的進程則少分配些時間片。但按照上一節介紹的基本原理,使用CFS調度時,每一個任務都是均分CPU的,有沒有辦法能夠作到這一點呢?
能夠給任務分配權重,讓權重高的任務更多的CPU!
加上權重機制後,任務時間片的計算方式變成了這樣:
好比,sched_latency仍是設置爲48ms,現有A和B兩個任務,A的權重設置爲1024,B的權重設置爲3072,按照上述的公式,A的時間片是12ms,B的時間片是36ms。
從上一節可知,CFS每次選取vruntime值最小的任務來調度,而每次調度完成後,vruntime的計算規則爲vruntime += runtime,所以僅僅改變時間片的計算規則不會生效,還需將vruntime的計算規則調整爲:
仍是前面的例子,假設A和B都沒有I/O操做,更新vruntime計算規則後,調度狀況以下,任務B比任務A可以分得更多的CPU了。
使用紅黑樹提高vruntime查找效率
CFS每次切換任務時,都會選取vruntime值最小的任務來調度,所以須要它有個數據結構來存儲各個任務及其vruntime信息。
最直觀的固然就是選取一個有序列表來存儲這些信息,列表按照vruntime排序。這樣在切換任務時,CFS只需獲取列表頭的任務便可,時間複雜度爲O(1)。好比當前有10個任務,vruntime保存爲有序鏈表[1, 5, 9, 10, 14, 18, 17, 21, 22, 24],可是每次插入或刪除任務時,時間複雜度會是O(N),並且耗時隨着任務數的增多而線性增加!
爲了兼顧查詢、插入、刪除的效率,CFS使用紅黑樹來保存任務和vruntime信息,這樣,查詢、插入、刪除操做的複雜度變成了log(N),並不會隨着任務數的增多而線性增加,極大提高了效率。
另外,爲了提高存儲效率,CFS在紅黑樹中只保存了處於Running狀態的任務的信息。
應對I/O與休眠
每次都選取vruntime值最小的任務來調度這種策略,也會存在任務餓死的問題。考慮有A和B兩個任務,時間片爲1s,起初A和B均分CPU輪流運行,在某次調度後,B進入了休眠,假設休眠了10s。等B醒來後,vruntime_{B}vruntimeB就會比vruntime_{A}vruntimeA小10s,在接下來的10s中,B將會一直被調度,從而任務A出現了餓死現象。
爲了解決該問題,CFS規定當任務從休眠或I/O中返回時,該任務的vruntime會被設置爲當前紅黑樹中的最小vruntime值。上述例子,B從休眠中醒來後,vruntime_{B}vruntimeB會被設置爲11,所以也就不會餓死任務A了。
這種作法其實也存在瑕疵,若是任務的休眠時間很短,那麼它醒來後依舊是優先調度,這對於其餘任務來講是不公平的。
寫在最後
本文花了很長的篇幅講解了幾種常見CPU調度算法的原理,每種算法都有各自的優缺點,並不存在一種完美的調度策略。在應用中,咱們須要根據實際的工做負載,選取合適的調度算法,配置合理的調度參數,權衡週轉時間和響應時間、任務公平和切換消耗。這些都應驗了《Fundamentals of Software Architecture》中的那句名言:Everything in software architecture is a trade-off.
本文中描述的調度算法都是基於單核處理器進行分析的,而多核處理器上的調度算法要比這複雜不少,好比須要考慮處理器之間共享數據同步、緩存親和性等,但本質原理依然離不開本文所描述的幾種基礎調度算法。
參考
- Operating Systems: Three Easy Pieces, Remzi H Arpaci-Dusseau / Andrea C Arpaci-Dusseau
- 計算機系統基礎(三):異常、中斷和輸入/輸出, 袁春風 南京大學