Linux 程式排程淺析

發表於2016-09-28

作業系統要實現多程式,程式排程必不可少。程式排程是對TASK_RUNNING狀態的程式進行排程(參見《linux程式狀態淺析》)。如果程式不可執行(正在睡眠或其他),那麼它跟程式排程沒多大關係。

所以,如果你的系統負載非常低,盼星星盼月亮才出現一個可執行狀態的程式。那麼程式排程也就不會太重要。哪個程式可執行,就讓它執行去,沒有什麼需要多考慮的。

反之,如果系統負載非常高,時時刻刻都有N多個程式處於可執行狀態,等待被排程執行。那麼程式排程程式為了協調這N個程式的執行,必定得做很多工作。協調得不好,系統的效能就會大打折扣。這個時候,程式排程就是非常重要的。

儘管我們平常接觸的很多計算機(如桌面系統、網路伺服器、等)負載都比較低,但是linux作為一個通用作業系統,不能假設系統負載低,必須為應付高負載下的程式排程做精心的設計。

當然,這些設計對於低負載(且沒有什麼實時性要求)的環境,沒多大用。極端情況下,如果CPU的負載始終保持0或1(永遠都只有一個程式或沒有程式需要在CPU上執行),那麼這些設計基本上都是徒勞的。

優先順序

現在的作業系統為了協調多個程式的“同時”執行,最基本的手段就是給程式定義優先順序。定義了程式的優先順序,如果有多個程式同時處於可執行狀態,那麼誰優先順序高誰就去執行,沒有什麼好糾結的了。

那麼,程式的優先順序該如何確定呢?有兩種方式:由使用者程式指定、由核心的排程程式動態調整。(下面會說到)

linux核心將程式分成兩個級別:普通程式和實時程式。實時程式的優先順序都高於普通程式,除此之外,它們的排程策略也有所不同。

實時程式的排程

實時,原本的涵義是“給定的操作一定要在確定的時間內完成”。重點並不在於操作一定要處理得多快,而是時間要可控(在最壞情況下也不能突破給定的時間)。

這樣的“實時”稱為“硬實時”,多用於很精密的系統之中(比如什麼火箭、導彈之類的)。一般來說,硬實時的系統是相對比較專用的。

像linux這樣的通用作業系統顯然沒法滿足這樣的要求,中斷處理、虛擬記憶體、等機制的存在給處理時間帶來了很大的不確定性。硬體的cache、磁碟尋道、匯流排爭用、也會帶來不確定性。

比如考慮“i++;”這麼一句C程式碼。絕大多數情況下,它執行得很快。但是極端情況下還是有這樣的可能:

1、i的記憶體空間未分配,CPU觸發缺頁異常。而linux在缺頁異常的處理程式碼中試圖分配記憶體時,又可能由於系統記憶體緊缺而分配失敗,導致程式進入睡眠;
2、程式碼執行過程中硬體產生中斷,linux進入中斷處理程式而擱置當前程式。而中斷處理程式的處理過程中又可能發生新的硬體中斷,中斷永遠巢狀不止……;等等……

而像linux這樣號稱實現了“實時”的通用作業系統,其實只是實現了“軟實時”,即儘可能地滿足程式的實時需求。

如果一個程式有實時需求(它是一個實時程式),則只要它是可執行狀態的,核心就一直讓它執行,以儘可能地滿足它對CPU的需要,直到它完成所需要做的事情,然後睡眠或退出(變為非可執行狀態)。

而如果有多個實時程式都處於可執行狀態,則核心會先滿足優先順序最高的實時程式對CPU的需要,直到它變為非可執行狀態。於是,只要高優先順序的實時程式一直處於可執行狀態,低優先順序的實時程式就一直不能得到CPU;只要一直有實時程式處於可執行狀態,普通程式就一直不能得到CPU。

(後來,核心新增了/proc/sys/kernel/sched_rt_runtime_us和/proc/sys/kernel/sched_rt_period_us兩個引數,限定了在以sched_rt_period_us為週期的時間內,實時程式最多隻能執行sched_rt_runtime_us這麼多時間。這樣就在一直有實時程式處於可執行狀態的情況下,給普通程式留了一點點能夠得到執行的機會。參閱《linux組排程淺析》。)

那麼,如果多個相同優先順序的實時程式都處於可執行狀態呢?這時就有兩種排程策略可供選擇:

1、SCHED_FIFO:先進先出。直到先被執行的程式變為非可執行狀態,後來的程式才被排程執行。在這種策略下,先來的程式可以行sched_yield系統呼叫,自願放棄CPU,以讓權給後來的程式;

2、SCHED_RR:輪轉排程。核心為實時程式分配時間片,在時間片用完時,讓下一個程式使用CPU;

強調一下,這兩種排程策略僅僅針對於相同優先順序的多個實時程式同時處於可執行狀態的情況。

在linux下,使用者程式可以通過sched_setscheduler系統呼叫來設定程式的排程策略以及相關排程引數;sched_setparam系統呼叫則只用於設定排程引數。這兩個系統呼叫要求使用者程式具有設定程式優先順序的能力(CAP_SYS_NICE,一般來說需要root許可權)(參閱capability相關的文章)。

通過將程式的策略設為SCHED_FIFO或SCHED_RR,使得程式變為實時程式。而程式的優先順序則是通過以上兩個系統呼叫在設定排程引數時指定的。

對於實時程式,核心不會試圖調整其優先順序。因為程式實時與否?有多實時?這些問題都是跟使用者程式的應用場景相關,只有使用者能夠回答,核心不能臆斷。

綜上所述,實時程式的排程是非常簡單的。程式的優先順序和排程策略都由使用者定死了,核心只需要總是選擇優先順序最高的實時程式來排程執行即可。唯一稍微麻煩一點的只是在選擇具有相同優先順序的實時程式時,要考慮兩種排程策略。

普通程式的排程

實時程式排程的中心思想是,讓處於可執行狀態的最高優先順序的實時程式儘可能地佔有CPU,因為它有實時需求;而普通程式則被認為是沒有實時需求的程式,於是排程程式力圖讓各個處於可執行狀態的普通程式和平共處地分享CPU,從而讓使用者覺得這些程式是同時執行的。

與實時程式相比,普通程式的排程要複雜得多。核心需要考慮兩件麻煩事:

一、動態調整程式的優先順序

按程式的行為特徵,可以將程式分為“互動式程式”和“批處理程式”:

互動式程式(如桌面程式、伺服器、等)主要的任務是與外界互動。這樣的程式應該具有較高的優先順序,它們總是睡眠等待外界的輸入。而在輸入到來,核心將其喚醒時,它們又應該很快被排程執行,以做出響應。比如一個桌面程式,如果滑鼠點選後半秒種還沒反應,使用者就會感覺系統“卡”了;

批處理程式(如編譯程式)主要的任務是做持續的運算,因而它們會持續處於可執行狀態。這樣的程式一般不需要高優先順序,比如編譯程式多執行了幾秒種,使用者多半不會太在意;

如果使用者能夠明確知道程式應該有怎樣的優先順序,可以通過nicesetpriority(非實時程式優先順序的設定)系統呼叫來對優先順序進行設定。(如果要提高程式的優先順序,要求使用者程式具有CAP_SYS_NICE能力。

然而應用程式未必就像桌面程式、編譯程式這樣典型。程式的行為可能五花八門,可能一會兒像互動式程式,一會兒又像批處理程式。以致於使用者難以給它設定一個合適的優先順序。再者,即使使用者明確知道一個程式是互動式還是批處理,也多半礙於許可權或因為偷懶而不去設定程式的優先順序。(你又是否為某個程式設定過優先順序呢?)

於是,最終,區分互動式程式和批處理程式的重任就落到了核心的排程程式上。

排程程式關注程式近一段時間內的表現(主要是檢查其睡眠時間和執行時間),根據一些經驗性的公式,判斷它現在是互動式的還是批處理的?程度如何?最後決定給它的優先順序做一定的調整。

程式的優先順序被動態調整後,就出現了兩個優先順序:

1、使用者程式設定的優先順序(如果未設定,則使用預設值),稱為靜態優先順序。這是程式優先順序的基準,在程式執行的過程中往往是不改變的;
2、優先順序動態調整後,實際生效的優先順序。這個值是可能時時刻刻都在變化的;

二、排程的公平性

在支援多程式的系統中,理想情況下,各個程式應該是根據其優先順序公平地佔有CPU。而不會出現“誰運氣好誰佔得多”這樣的不可控的情況。

linux實現公平排程基本上是兩種思路:

1、給處於可執行狀態的程式分配時間片(按照優先順序),用完時間片的程式被放到“過期佇列”中。等可執行狀態的程式都過期了,再重新分配時間片;
2、動態調整程式的優先順序。隨著程式在CPU上執行,其優先順序被不斷調低,以便其他優先順序較低的程式得到執行機會;
後一種方式有更小的排程粒度,並且將“公平性”與“動態調整優先順序”兩件事情合而為一,大大簡化了核心排程程式的程式碼。因此,這種方式也成為核心排程程式的新寵。

強調一下,以上兩點都是僅針對普通程式的。而對於實時程式,核心既不能自作多情地去動態調整優先順序,也沒有什麼公平性可言。

普通程式具體的排程演算法非常複雜,並且隨linux核心版本的演變也在不斷更替(不僅僅是簡單的調整),所以本文就不繼續深入了。有興趣的朋友可以參考下面的連結:《Linux 排程器發展簡述

排程程式的效率

“優先順序”明確了哪個程式應該被排程執行,而排程程式還必須要關心效率問題。排程程式跟核心中的很多過程一樣會頻繁被執行,如果效率不濟就會浪費很多CPU時間,導致系統效能下降。

在linux 2.4時,可執行狀態的程式被掛在一個連結串列中。每次排程,排程程式需要掃描整個連結串列,以找出最優的那個程式來執行。複雜度為O(n);

在linux 2.6早期,可執行狀態的程式被掛在N(N=140)個連結串列中,每一個連結串列代表一個優先順序,系統中支援多少個優先順序就有多少個連結串列。每次排程,排程程式只需要從第一個不為空的連結串列中取出位於連結串列頭的程式即可。這樣就大大提高了排程程式的效率,複雜度為O(1);

在linux 2.6近期的版本中,可執行狀態的程式按照優先順序順序被掛在一個紅黑樹(可以想象成平衡二叉樹)中。每次排程,排程程式需要從樹中找出優先順序最高的程式。複雜度為O(logN)。

那麼,為什麼從linux 2.6早期到近期linux 2.6版本,排程程式選擇程式時的複雜度反而增加了呢?

這是因為,與此同時,排程程式對公平性的實現從上面提到的第一種思路改變為第二種思路(通過動態調整優先順序實現)。而O(1)的演算法是基於一組數目不大的連結串列來實現的,按我的理解,這使得優先順序的取值範圍很小(區分度很低),不能滿足公平性的需求。而使用紅黑樹則對優先順序的取值沒有限制(可以用32位、64位、或更多位來表示優先順序的值),並且O(logN)的複雜度也還是很高效的。

排程觸發的時機

排程的觸發主要有如下幾種情況:

1、當前程式(正在CPU上執行的程式)狀態變為非可執行狀態。

程式執行系統呼叫主動變為非可執行狀態。比如執行nanosleep進入睡眠、執行exit退出、等等;

程式請求的資源得不到滿足而被迫進入睡眠狀態。比如執行read系統呼叫時,磁碟快取記憶體裡沒有所需要的資料,從而睡眠等待磁碟IO;

程式響應訊號而變為非可執行狀態。比如響應SIGSTOP進入暫停狀態、響應SIGKILL退出、等等;

2、搶佔。程式執行時,非預期地被剝奪CPU的使用權。這又分兩種情況:程式用完了時間片、或出現了優先順序更高的程式。

優先順序更高的程式受正在CPU上執行的程式的影響而被喚醒。如傳送訊號主動喚醒,或因為釋放互斥物件(如釋放鎖)而被喚醒;

核心在響應時鐘中斷的過程中,發現當前程式的時間片用完;

核心在響應中斷的過程中,發現優先順序更高的程式所等待的外部資源的變為可用,從而將其喚醒。比如CPU收到網路卡中斷,核心處理該中斷,發現某個socket可讀,於是喚醒正在等待讀這個socket的程式;再比如核心在處理時鐘中斷的過程中,觸發了定時器,從而喚醒對應的正在nanosleep系統呼叫中睡眠的程式;

其他問題

1、核心搶佔

理想情況下,只要滿足“出現了優先順序更高的程式”這個條件,當前程式就應該被立刻搶佔。但是,就像多執行緒程式需要用鎖來保護臨界區資源一樣,核心中也存在很多這樣的臨界區,不大可能隨時隨地都能接收搶佔。

linux 2.4時的設計就非常簡單,核心不支援搶佔。程式執行在核心態時(比如正在執行系統呼叫、正處於異常處理函式中),是不允許搶佔的。必須等到返回使用者態時才會觸發排程(確切的說,是在返回使用者態之前,核心會專門檢查一下是否需要排程);

linux 2.6則實現了核心搶佔,但是在很多地方還是為了保護臨界區資源而需要臨時性的禁用核心搶佔。

也有一些地方是出於效率考慮而禁用搶佔,比較典型的是spin_lock。spin_lock是這樣一種鎖,如果請求加鎖得不到滿足(鎖已被別的程式佔有),則當前程式在一個死迴圈中不斷檢測鎖的狀態,直到鎖被釋放。

為什麼要這樣忙等待呢?因為臨界區很小,比如只保護“i+=j++;”這麼一句。如果因為加鎖失敗而形成“睡眠-喚醒”這麼個過程,就有些得不償失了。那麼既然當前程式忙等待(不睡眠),誰又來釋放鎖呢?其實已得到鎖的程式是執行在另一個CPU上的,並且是禁用了核心搶佔的。這個程式不會被其他程式搶佔,所以等待鎖的程式只有可能執行在別的CPU上。(如果只有一個CPU呢?那麼就不可能存在等待鎖的程式了。)

而如果不禁用核心搶佔呢?那麼得到鎖的程式將可能被搶佔,於是可能很久都不會釋放鎖。於是,等待鎖的程式可能就不知何年何月得償所望了。

對於一些實時性要求更高的系統,則不能容忍spin_lock這樣的東西。寧可改用更費勁的“睡眠-喚醒”過程,也不能因為禁用搶佔而讓更高優先順序的程式等待。比如,嵌入式實時linux montavista就是這麼幹的。
由此可見,實時並不代表高效。很多時候為了實現“實時”,還是需要對效能做一定讓步的。

2、多處理器下的負載均衡

前面我們並沒有專門討論多處理器對排程程式的影響,其實也沒有什麼特別的,就是在同一時刻能有多個程式並行地執行而已。那麼,為什麼會有“多處理器負載均衡”這個事情呢?

如果系統中只有一個可執行佇列,哪個CPU空閒了就去佇列中找一個最合適的程式來執行。這樣不是很好很均衡嗎?

的確如此,但是多處理器共用一個可執行佇列會有一些問題。顯然,每個CPU在執行排程程式時都需要把佇列鎖起來,這會使得排程程式難以並行,可能導致系統效能下降。而如果每個CPU對應一個可執行佇列則不存在這樣的問題。

另外,多個可執行佇列還有一個好處。這使得一個程式在一段時間內總是在同一個CPU上執行,那麼很可能這個CPU的各級cache中都快取著這個程式的資料,很有利於系統效能的提升。

所以,在linux下,每個CPU都有著對應的可執行佇列,而一個可執行狀態的程式在同一時刻只能處於一個可執行佇列中。

於是,“多處理器負載均衡”這個麻煩事情就來了。核心需要關注各個CPU可執行佇列中的程式數目,在數目不均衡時做出適當調整。什麼時候需要調整,以多大力度程式調整,這些都是核心需要關心的。當然,儘量不要調整最好,畢竟調整起來又要耗CPU、又要鎖可執行佇列,代價還是不小的。

另外,核心還得關心各個CPU的關係。兩個CPU之間,可能是相互獨立的、可能是共享cache的、甚至可能是由同一個物理CPU通過超執行緒技術虛擬出來的……CPU之間的關係也是實現負載均衡的重要依據。關係越緊密,程式在它們之間遷移的代價就越小。參見《linux核心SMP負載均衡淺析》。

3、優先順序繼承

由於互斥,一個程式(設為A)可能因為等待進入臨界區而睡眠。直到正在佔有相應資源的程式(設為B)退出臨界區,程式A才被喚醒。

可能存在這樣的情況:A的優先順序非常高,B的優先順序非常低。B進入了臨界區,但是卻被其他優先順序較高的程式(設為C)搶佔了,而得不到執行,也就無法退出臨界區。於是A也就無法被喚醒。

A有著很高的優先順序,但是現在卻淪落到跟B一起,被優先順序並不太高的C搶佔,導致執行被推遲。這種現象就叫做優先順序反轉。

出現這種現象是很不合理的。較好的應對措施是:當A開始等待B退出臨界區時,B臨時得到A的優先順序(還是假設A的優先順序高於B),以便順利完成處理過程,退出臨界區。之後B的優先順序恢復。這就是優先順序繼承的方法。
為了實現優先順序繼承,核心又得做很多事情。更細節的東西可以參考一下關於“優先順序反轉”或“優先順序繼承”的文章。

4、中斷處理執行緒化

在linux下,中斷處理程式執行於一個不可排程的上下文中。從CPU響應硬體中斷自動跳轉到核心設定的中斷處理程式去執行,到中斷處理程式退出,整個過程是不能被搶佔的。

一個程式如果被搶佔了,可以通過儲存在它的程式控制塊(task_struct)中的資訊,在之後的某個時間恢復它的執行。而中斷上下文則沒有task_struct,被搶佔了就沒法恢復了。

中斷處理程式不能被搶佔,也就意味著中斷處理程式的“優先順序”比任何程式都高(必須等中斷處理程式完成了,程式才能被執行)。但是在實際的應用場景中,可能某些實時程式應該得到比中斷處理程式更高的優先順序。

於是,一些實時性要求更高的系統就給中斷處理程式賦予了task_struct以及優先順序,使得它們在必要的時候能夠被高優先順序的程式搶佔。但是顯然,做這些工作是會給系統造成一定開銷的,這也是為了實現“實時”而對效能做出的一種讓步。

更多細節可以參考一下關於“中斷執行緒化”的文章。

相關文章