Understanding the linux 2.6.8.1 scheduler

深入理解Linux 2.6.8.1 CPU 調度器

1. 簡介 3

1.1 文件綜述 3

1.2 Linux內核著作 3

1.3 排版約定 4

1.4 關於該文檔 4

1.5 隨書CD 4

2. Linux內核源碼 5

2.1 獲取源碼 5

2.2 內核版本管理 5

2.3 代碼組織 5

3. 進程與線程綜述 6

3.1 程序和進程 6

3.2 線程 6

3.3 調度 6

3.4 CPUIO受限的線程 7

3.5 上下文切換 7

3.6 Linux進程與線程 8

4. Linux調度目標 9

4.1 Linux的目標市場及他們在調度器上所作出的努力 9

4.2 效率 9

4.3 交互性 9

4.4 公平性與飢餓預防 10

4.5 SMP調度 10

4.6 SMT調度 10

4.7 NUMA調度 11

4.8 軟實時調度 11

4.9 調度性能透視 11

5. Linux 2.6.8.1調度器 13

5.1 起源及O(1)算法的重要性 13

5.2 運行隊列 13

5.3 優先級數組 15

5.4 運算優先級及時間片 17

5.5 休眠眠與喚醒任務 19

5.6 主調度函數 21

5.7 負載均衡 22

5.8 軟實時調度 23

5.9 NUMA調度 24

5.10 調度器調整 25

6. Linux 2.4.x調度器 27

6.1 算法 27

6.2 優勢 28

6.3 劣勢 28

7. Linux調度器的未來 30

7.1 調整實現 vs. 算法變換 30

8. 附錄 31

8.1 致謝 31

8.2 關於作者 31

8.3 遵循法規(GNU FDL 31

引用 32

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

1. 簡介

1.1 文件綜述

大量與複雜的代碼源使得近年來linux的發展相對迅速。這是由於被大量的業餘愛好者,家庭用戶,商業用戶及教育協會所接受。Linux 內核郵件列表(內核開發者的郵件列表)在2004年的夏天觀察到,幾乎平均每天都有50到100個開發者發出300份消息。這個數字不包括那些專注於體系結構的論壇,那些論壇的消息是另外統計的。在2003年八月到2004年八月的一年中,超過16000份不同大小的更新被提交給了官方linux內核進行審批。高速的發展導致非常少的內核重要部件在應用時能夠被文檔化。

文檔的匱乏導致內核開發者,學生,研究者,甚至是內核老手都難以及時理解內核實現的變化。對所有這些人來說,實現級別的文檔給他們帶來許多好處。很顯然的,那些希望能夠對內核開發做出貢獻的人來說,他們必須對現有的內核實現有個比較全面的瞭解。但是,爲什麼學生和研究者也應該瞭解現有內核實現呢?難道是瞭解內核理論與內核大致工作機理還不夠嗎?這是由於,Linux的內核的開發重點是其實用性,而不是其理論的合理性,所以,許多關於linux內核改變的決斷都是基於其使用的真實效果。這意味着,大多情況下,linux的實現會從理論基礎中分離出來。通常這種情況發生時,都是出於一些很重要的原因。在實踐的過程中,理解被應用的算法,以及理論與實際產生分歧的推理過程,理解理論的缺陷,這是未來算法發展的幾大要素。

由於上述的種種原因,linux需要針對其內核實現的專門性文檔,而不僅僅是開發者在一時間設計決斷的基礎理論。這篇描述linux 2.6.8.1調度器的文章是受到了 Mel Gorman 所提出的關於linux虛擬內存系統的論文的啓發,該論文是其領域內被最多linux虛擬內存系統的開發者所引用,受到最高評價的文章。

這篇文章的目的是提供一份深入的關於linux 2.6.8.1調度器的文檔。希望這篇文章能對衆多希望瞭解調度器的實現的內核開發者和學生及研究員提供些許幫助。同時也希望這篇文章能夠減少讀者深入瞭解調度器所需要花的時間。同樣 Mr.Gorman 關於linux 2.4.20虛擬內存系統的文章對理解2.6.x系列內核的虛擬內存系統仍很有幫助,希望這篇文章也能對2.6.8.1之前版本的linux內核起到重要作用。

1.2 Linux內核著作

雖然linux內核缺乏及時更新的代碼級的文檔,但是仍有許多有用的高質量引導性文檔。如果你希望能夠深入理解linux內核的構建,所有下述的這些文章都值得一看。

《linux kernel development》 Robert Love

這本書涵蓋了整個linux 2.6.x系列內核,直到2004年的秋天,恐怕這是唯一一本做到涵蓋整個2.6.x系列內核的書籍。本書給出了關於每個linux組件的概述,同時描述了他們如何組織在一起。其中,包括寫的非常好的linux2.6.x調度器部分。

上述這本書可能是唯一一本涵蓋整個linux2.6.x內核的書籍,但是仍然有許多其他關於linux2.4.x內核的書籍,同樣對理解linux2.6.x內核組件有很大的幫助。這些書籍包括:

《understand the linux kernel》 Daniel Bovet and Macro Cesati

《linux device driver》 Alessando Rubini and Jonathan

《IA-64 linux kernel》 David and Stephane

《understanding the linux virtual memory manager》 Mel Gorman

 

Linux文檔工程是另外一個非常好的文檔源。提供的許多文檔涵蓋了linux各派別的許多方面內容以及linux內核的相關內容。

過去在官方LKML的討論成果在許多網站上都可以找到。簡單的在搜索引擎(例如google)上輸入LKML就可以找到你想要的內容。

同時,隨內核源碼發佈的文檔或多或少的也會給我們提供些許幫助。可以在Documentation/directory下被找到。

不幸的是Linux2.6.x內核附帶的文檔對本文所要描述的調度器的描述提供不了多大的幫助,因爲調度器在2.4.x系列內核與2.6.x系列內核之間被修改的太多了。

1.3 排版約定

新概念與URL被排版爲斜體。二進制碼,命令與包名被排版爲粗體。代碼,宏與文件路徑是等寬字體。包含文件的路徑兩端會加上尖括號,這些文件能夠在linux源碼的include目錄下找到。所有文件的默認根路徑爲linux內核源碼的根目錄。結構體中的字段在被使用時,會從結構體引出一個箭頭指向該字段。

1.4 關於該文檔

下載地址:http://josh.trancesoftware.com/linux

1.5 隨書CD

這份文檔的附帶CD包含linux2.6.8.1內核源碼,一個給調度器代碼打註釋的升級程序,以及一份該文檔電子版文檔。該CD是ISO-9660格式的CD,可以用於所有現用的操作系統。爲了使用註釋補丁,需要將補丁包移動到linux源碼的kernel目錄下,運行如下命令:

Patch -p0 < sched_comments.patch

 

2. Linux內核源碼

2.1 獲取源碼

Linux的內核源碼永遠是學習linux內核最好的資源。在嘗試完全瞭解linux內核的旅途中,沒有什麼文檔是可以完全代替內核源碼本身的。這份文檔會在很大程度上引用內核源碼。你可以在Linux Kernel Achives上找到內核源碼。(http://www.kernel.org)主頁上列出了各個版本內核的最新發布版,其中包括內核源碼,升級補丁以及修改日誌。所有linux內核的發佈版可以在他的FTP站點上找到。(ftp://ftp.kernel.org/)

2.2 內核版本管理

Linux內核版本號的格式爲:W.X.Y.ZW位置上的數字很少會發生更改,當且僅當內核發生了非常重大改變,以至於使用在第一個版本的linux上的大多數軟件都無法運行的情況下,他纔會發生變化。這種情況在linux的歷史上只發生過一次。(這就是本文檔所關注的版本號以2開頭的原因)

X位置的數字表示linux內核系列。該位置上的數字若是偶數則代表該系列是一個穩定的發佈系列,若是奇數則代表該系列是個發展中的系列。根據歷史,系列號每兩年改變一次。舊版本的系列仍然在開發,只要仍有對他感興趣的人存在。

Y位置的數字代表版本號。版本號在每個發佈版發佈時都會加1。通常情況下,他是內核版本的最後一個位置,但偶爾也會遇到一個發佈版本由於一些重大錯誤而需要改正的情況。在上述這種情況下,Z位置就出現了。Z位置的數字第一次出現是在2.6.8.1版本發佈的時候。2.6.8內核在NFS的實現上存在一個非常嚴重的錯誤。這個錯誤在2.6.8版本發佈後被迅速發現,這樣在修復了該錯誤後,2.6.8.1版本就被髮布了,該版本除修復了上述錯誤外,與2.6.8版本基本無區別。

2.3 代碼組織

在每個linux源碼包下都包含有許多子目錄。閱讀本文檔時,瞭解下面這些子目錄會給你帶來很大的幫助:

Documentation:包含許多很好的內核相關文檔及開發過程文檔;

Arch:包含與計算機體系結構相關的代碼,每個體系結構都有其所對應的子目錄;

Include:包含頭文件;

Kernel:包含主要的內核代碼;

Mm:包含內核的內存管理代碼。

3. 進程與線程綜述

在學習調度器之前,對進程與線程兩大概念有個比較好的認識是非常重要的。詳細描述進程與線程並不是本文檔的主要目的,所有這裏只提供一些我們必須知道的相關知識。我們強烈建議本文的讀者能夠從其他相關的材料中深入學習進程與線程的相關知識,以加深對本文的體會。在參考書目中我們提供了一些非常好的書目[2345]

3.1 程序和進程

程序的執行,就是一系列指令與數據交織在一起完成一項工作。一個進程就是一個程序的實例。以此類推,程序就像是C++JAVA中的類,而進程就像是對象。進程就是一個被創建用以具體表現程序運行過程中所經歷狀態的抽象概念。這意味着進程需要記錄與線程或線程執行相關的數據。這些數據包括,變量,硬件狀態以及一段地址空間的內容。

3.2 線程

一個進程可以擁有許多執行的線程一起工作來完成其目的。這些線程被適宜的命名爲線程。內核必須記錄每一個線程的堆棧以及硬件狀態,同時無論是否需要,內核仍必須記錄進程內部的執行溢出。通常情況下,線程共享進程的地址空間,但這並不是必須的。我們必須瞭解的是,在每一個CPU時間片內,只有一個線程能夠得到執行,這就是內核需要CPU調度器的原因。我們可以在大多數瀏覽器內找到一個進程擁有多線程這種情況。通常會有一個線程來處理用戶接口事件,一個線程來處理網絡事務,一個線程來生成網頁。

3.3 調度

多任務內核宏觀上允許在同一時間有多個進程存在,並且每個進程能夠運行地就像系統中只有其一個進程在運行一般。進程運行時是沒有必要了解其他進程的運行情況的,除非他們從一開始就被設計爲需要相互瞭解。這使得進程更容易開發,運行及維護。儘管從微觀的角度,CPU在一個時間片內只能執行一個進程的一個線程,但在宏觀上,許多進程的線程是同時在被運行的。這是因爲每個線程執行完一個非常短的時間片後,另外的線程會得到運行下一個時間片的機會。內核的調度器就是用以執行線程調度策略的,該策略內容包括在何時,在何地,線程會被運行多久等。正常情況下,調度器工作在其獨自的由時鐘中斷喚醒的線程內。另外,他是通過系統調用或另一個希望放棄CPU的內核線程被調用的。每個線程都會被允許運行一個固定長的時間,然後CPU會通過上下文切換,切換至調度器線程,緊接着CPU再次通過上下文切換,切換至調度器選中的線程,如此往復。這樣,一個關於CPU用法的策略就被提出了。

3.4 CPUIO受限的線程

被執行的線程通常不是CPU受限就是IO受限。一些進程在使用CPU進行計算上消耗了大量的時間,而另外一些進程則在等待相對較慢的IO操作上消耗了大量的時間。例如,一個進行DNA排序的線程就是CPU受限的;一個由字處理程序獲得輸入的線程就是IO受限的,因爲他在等待用戶輸入上消耗了大量的時間。一個線程是CPU受限還是IO受限,這通常並不是很明確的。調度器能做的就只是猜測。許多調度器的確對一個線程是否是CPU受限或是IO受限相當在意,對這些調度器而言,線程分類技術是其重要的組成部分。

調度器趨向於給與那些IO受限的進程更高的優先級去使用CPU。接受用戶輸入的程序通常都是IO受限的。即使是最快的打字員在每次敲擊鍵盤之間都需要有一段思考的時間,在這段時間之內,程序只能等待。給予那些與人進行交互的程序以更高的優先級是非常重要的,因爲當一個人希望獲得計算機立即的響應時比一個人在等待大量的工作被完成時更容易察覺到計算機在速度與響應上的缺陷。

給予IO受限的程序更高的優先級對整個系統來說都是有益的,這並不單純因爲用戶輸入。因爲IO操作通常都需要花上相當長的時間,讓他們儘早開始是很有益處的。例如,一個需要從硬盤獲取一小段數據的程序在獲得其所需要的數據前要等上相當長一段時間。儘快地開始數據請求能夠解放CPU,使其能在數據請求的這段時間內做一些另外的工作,同時能夠使得提交數據請求的程序儘早的繼續執行下去。本質上講,這樣更有效地平行了系統資源。硬盤驅動器在搜索數據的同時,CPU可以工作於其他的事,所以使兩個資源儘可能儘早執行是大有益處的。CPU在硬盤驅動器獲取數據的這段時間內可以做許多操作。

3.5 上下文切換

上下文切換就是從一個運行中的線程切換到另外一個線程的過程。這個過程包括,保存CPU寄存器的狀態,加載新的狀態,刷新高速緩衝存儲器,以及改變當前的虛擬內存映射。在大多數體系結構中,上下文切換是一個代價高昂的操作,所以在這些體系結構中,他們的執行需要被儘可能的避免。在執行上下文切換所需要的這段時間中,計算機可以完成許多實際的工作。如何控制上下文切換的執行要根據體系結構而定,同時這並非內核調度器的一個部分,儘管如此,他的實現在很大程度上影響了調度器的設計工作。在linux的源碼中,上下文切換的代碼在以下兩文件中被定義:

Include/asm-[arch]/mmu_context.h

Include/asm-[arch]/system.h

3.6 Linux進程與線程

Linux使用了唯一的一種方法來實現進程與線程的抽象。在linux中,所有的線程可以被簡單的看作是進程,只不過他們可以共享一些特定的資源。在linux中,任一進程可以被簡單的看做是一組線程,但又不僅僅只是一組線程,因爲這些線程共享着線程組ID以及其所必須的資源。爲了表示進程與線程的區別,以免混淆,從此處開始,我們使用“任務”這個詞來表示線程,值得一提的是,其在POSIX中並不代表線程這個意思。在下文中,進程與線程只有在真正需要區別他們兩的情況下才會被使用到。在linux任務結構體task_struct中,TGID被存在tgid字段中。Linux會給每個線程分配一個PID([task_struct]->pid),但是人們通常說的PID是指任務的TGID。值得一提的是,這個模型與牛分叉算法的結合使得linux中進程與線程的生成更加迅速且有效,然而在許多其他的操作系統中,產生進程的代價要比產生線程的代價要高昂的多。

可惜的是,關於linux進程與線程的更多實現細節已經超出本文所要講述的內容範圍。我們現在只需要知道的是,在linux中,進程僅僅是被當做一組線程的集合來看待。進程與線程之間並沒有什麼非常嚴格的界限。正因如此,linux只針對線程進行調度,本質上忽略了各線程所歸屬的進程的差別。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

4. Linux調度目標

4.1 Linux的目標市場及他們在調度器上所作出的努力

一個操作系統的調度算法在很大程度是是由該操作系統的目標市場所決定的,反之亦然。瞭解一個操作系統的目標市場對理解其調度器的調度目的和調度算法有很大的幫助。

Linux一開始的時候是由Linus Torvalds開發,用於其個人PC機上的。然而,無論linux的緣由如何,由於他在服務器上的優秀表現,現在已經被作爲服務器操作系統被廣泛接受。造成這種情況的原因各種各樣,但絕不僅僅是因爲以下這個事實:在linux內核之上運行的程序大部分都是爲那些具有相對專業技巧的人士準備的。這個事實導致了linux聲名狼藉的複雜性,以及未經提煉的圖形用戶接口選項,還有其後他服務器領域的歸屬。Linux在服務器市場的出現促進了他自身的發展,及其在服務器行業各方面的進步。Linux作爲服務器操作系統的超凡本領在當今只有爲數不多的幾個操作系統能夠比及,其中包括SUN公司的SloarisIBMAIX。但是,linux在費用與法律上的便利使得許多公司也開始棄用SloarisAIX,轉而使用linux

儘管linux的名號在服務器操作系統領域已經相當出名,但是許多使用者和開發者認爲,linux同樣能在普通桌面PC機上去的成功。在過去的幾年中,有股巨大的推動力在促使linux優化其內核,從而能夠更快的進軍桌面PC機市場。在這個過程中,可能最重要的一步要屬Ingo Molnar2.6.x版本的內核重寫了調度器。Molnar瞭解桌面PC機市場與服務器市場的不同需求,在此基礎上,他設計了他自己的調度器,結果該調度器使得基於2.6.x內核的linux發佈版在桌面PC機上的表現得到大幅提升。Linux將目標同時瞄準桌面PC機市場及服務器市場給內核的調度器提出了更高的要求,這樣,linux內核調度器成爲了一個學習如何平衡來自不同市場的需求的學習用例。

4.2 效率

Linux調度器的重要目標就是效率。這意味着,調度器必須在抑制其他請求的同時,嘗試完成竟可能多的任務。例如,由於上下文切換的代價高昂,允許任務運行一段較長的時間片就能夠提高效率。同樣,由於調度器的代碼經常被運行,其自身的運行速度也是調度效率的關鍵因素。作出調度決定的代碼必須能運行的儘可能快。效率也會受到其他一些因素的影響,例如出於某些目的的交互,因爲交互從本質上就代表了更多的上下文切換。然而,在將所有的請求置於一起考慮時,保證全局效率就是調度器最重要的目標。

4.3 交互性

保證交互性是linux調度器的一個重要目標,尤其是在linux正着力優化其在桌面系統的表現時。交互性處在效率的表層,但儘管如此他仍非常重要。一次按鍵,一次鼠標點擊都可以是交互性的例子。這樣的事件需要進行立即的處理,因爲如果計算機不進行立即的處理,用戶能夠很明顯的察覺到,併爲每次計算機無法立即響應其操作而厭煩不已。但是,在編譯程序或生成高分辨率的圖片時,用戶並不奢求馬上獲得響應。他們並不可能察覺到計算機在編譯linux內核時是否多花了20秒鐘的時間。用於交互性計算的調度器應該被設計成能夠在規定時間內對用戶交互作出響應。理想情況下,該規定的時間應該是用戶無法感知到的時間間隔,從而使用戶覺得計算機是立即作出響應的。

4.4 公平性與飢餓預防

CPU對待各不同的任務時保持一定程度的公平是很重要的,這裏所謂的公平就包括不讓任何一個線程捱餓的約定。當一個線程由於不斷有其他比他更高優先級的進程在運行而長時間無法得到運行時,飢餓現象就發生了。飢餓現象是不允許發生的,儘管由於用戶定義或觸發器設置,一些線程的優先級就是比另外一些的高。無論以何種方式,我們都必須在一些進程即將進入飢餓狀態時,給予他們優先級的提升或立即的CPU搶佔權。公平並不是意味着每個線程針對使用CPU時間都有相同的所有權及優先級,而是意味着不應該有線程進入到飢餓狀態,或者不應該有線程能夠欺騙CPU以獲得更高的優先級或更多的CPU處理時間。

4.5 SMP調度

由於linux內核支持多處理器機制,他的調度器必須能夠在多CPU的情況下還能正確地調度任務進行執行。這意味着,調度器必須清楚地瞭解什麼任務在哪個CPU內部執行,確保同一時刻不會有相同的任務在不同的CPU內部被執行,並且能在大體上組織所有的任務在多CPU內有效地被調度。由於所有的CPU通常有權訪問相同的內存及系統資源,調度器首要要考慮的是如何更好的利用處理器時間。在選擇一個任務應在哪個CPU內被調度時,我們幾乎沒有理由認爲某個CPU比其他CPU會更好。最常見的考慮因素就是超高速緩存區。如果儘可能在同一個CPU上調度一個任務,那麼CPU對該任務進行高速緩存的可能性就大幅提升。

4.6 SMT調度

Linux支持在一塊對稱多線程(SMT)芯片上進行多線程調度。雖然SMT這個概念我們早已知曉,但是Intel公司的HT技術才使得SMT技術成爲業界主流。本質上,每塊SMT可以擁有多個共享固定資源的虛擬處理器。由於那些被共享的資源,虛擬處理器不應該被當做普通的處理器來對待。

4.7 NUMA調度

Linux內核支持非一致內存訪問(Non-Uniform Memory Access),這意味着如果硬件允許,他能夠在多個節點上跑一個單獨的系統鏡像。從硬件層次上講,一個節點就好比是一臺傳統的單處理機或多處理機,他擁有他自己的CPU及內存。然而,NUMA系統將各節點視作一個有單一系統鏡像的系統的一部分。這通常利用某種高速內聯技術來實現,這種技術更像是在主板層面上連接各點,而非網絡層面。這意味着,所有的CPU都有權利執行任何一個線程,所有節點的內存都可以通過同一個內存空間進行訪問。NUMA幫助包括瞭解與SMP調度中類似的高速緩存問題,但同樣也包括內存位置問題(當CPU正在執行的線程需要獲取內存空間時,在系統內部移動線程的效率是很低的,因爲內存操作需要花更多的時間去完成)。或許支持NUMA的調度器需要處理的最大的問題在於,系統內可能存在比大多SMP系統要多的多的CPU。普通的SMP系統大致會擁有28CPU,但NUMA系統可能擁有成百上千個CPU。在這篇文章還未定稿時,SGI正運行着擁有512個處理器的NUMA系統。這是有史以來,在單一的linux系統鏡像下能運行的處理器的最大數目。這也是2.6.8.1內核調度器的處理極限。

4.8 軟實時調度

Linux調度器支持軟實時調度。這意味着他能夠有效地調度具有嚴格時間要求的任務。然而,雖然linux2.6.x內核有能力使那些對時間有嚴格要求的任務按期完成,但他也並不能保證所有的任務都能按期完成。實時任務被分配以特殊的調度模式,同時調度器給予他們比其他普通任務更高的優先級。實時調度模式包括先進先出模式,該模式基於先來先服務,允許實時任務一直被運行直至完成;同時實時調度模式還包括循環調度模式,該模式循環調度實時任務,從本質上其忽略系統中的非實時任務。

4.9 調度性能透視

從調度器的角度而言,沒有一個唯一的可以滿足所有人要求的調度器性能的定義;也就是說,linux調度器沒有一個唯一的性能指標。對好的調度器性能的定義經常導致一種相互妥協的情況出現,在某個方面提高其性能就會導致其其他方面的性能下降。一些linux調度器的改進能夠提高其各方面的性能,但是這樣的改進越來越少發生了。一個很好的關於性能妥協問題的例子就是,臺式機,服務器及高性能計算時的性能平衡。

對於臺式機用戶而言,最重要的性能指標就是感知性能,就是機器對用戶請求能多快作出響應,例如鼠標點擊或按鍵事件。如果一個用戶正在後臺編譯內核代碼,在前臺使用字處理器,該用戶將很難感知到內核編譯是否由於CPU需要對字處理程序按鍵的中斷進行處理而多花了些許時間。對用戶而言,此時最重要的是,當其按下一個按鍵時,計算機是否能夠儘快的插入並顯示出其希望輸入的字符。這就要求CPU在用戶按下按鍵時能夠迅速地進行上下文切換,處理用戶的請求。爲了能夠實現這些操作,要麼當前運行的線程必須在其所用的時間片用完之前主動放棄處理器,要麼CPU的時間片必須足夠的短,以使得用戶按下按鍵到當前時間片用完之間的這段延時不被用戶所感知。由於上下文切換的代價高昂,上下文切換必須被最少化,然而還必須保證其能夠足夠頻繁地進行,從而爲用戶提供良好的感知性能。更少的上下文切換意味着更高的效率,因爲這樣,更多的CPU時間被用於完成真正的工作,更少的時間被用於任務切換。更多的上下文切換意味着系統對用戶輸入作出更多的迴應。在交互式的桌面系統中,我們希望調度器做到的是,讓上下文切換足夠頻繁的進行以使用戶能夠在某種程度上獲得計算機立即的響應,同時上下文切換的頻率必須控制在一定的程度以防止系統變得效率低下。

服務器系統通常會比桌面系統更少的關注感知性能。他們更加關注的是系統的實際性能,也就是,減少一組工作所需要消耗的時間總和。由於用戶通常能夠容忍一個更長時間的響應延時,更多的重點被放在通過減少上下文切換從而提高全局效率上。如果三個複雜的訪問一個被加載進內存的數據庫的請求同時發生,最好是能讓他們所花的總體時間最少,從而減少平均響應時間,而不是爲了讓結果同時返回而降低執行效率。向計算機提交數據庫查詢請求的個人或程序通常比往字處理程序中輸入字符的個人對計算機響應時間的期望要低。但是,如果用戶從FTP服務器上請求兩個非常大的文件時,計算機在第一個文件傳輸完成後纔開始傳輸第二個文件,這樣通常會令人難以接受。因此,針對服務器系統而言,雖然人們對其的響應時間要求並不如臺式機那麼嚴格,但是其也同樣被期待能儘可能地達到人們的響應期望。

HPC系統通常在處理需要幾天幾夜去解決的大型問題時要求最少的立即響應次數。當系統被授予了一組工作,完成這組工作的全局效率是最重要的因素,這意味着出於響應目的的上下文切換必須被最小化。HPC系統的響應時間期望是所有系統中最低的,這代表着他的性能期望與桌面計算機系統的性能期望完全背道而馳。而服務器系統處在兩者之間。

上述的比喻闡明瞭一個觀點:對於調度器性能而言,沒有普遍的理想情況。對於臺式機而言非常優秀的調度器,對於跑HPC應用程序的機器而言就是噩夢。Linux調度器努力地改進以適應任何情況,儘管他不可能對於所有情況來說都是完美的。在臺式機用戶不斷要求計算機響應能夠滿足他們要求的同時,HPC用戶正不斷推進對他們而言的性能理想狀態的優化。

 

 

 

 

 

 

 

5. Linux 2.6.8.1調度器

5.1 起源及O(1)算法的重要性

5.1.1 linux 2.6.8.1調度器的起源

Linux2.5.x開發的過程中,出現了一個新的調度器算法,他是對內核的最重要的改進之一。儘管2.4.x的內核被廣泛應用,他很穩定同時在許多方面也表現良好,但是他仍有許多不受人歡迎的特性。那些不受歡迎的特性與其最原始的設計息息相關,所以當Ingo Molnar想要修復那些特性的時候,他編寫了一個全新的調度器,而非在原有的老版本基礎上做任何修改。Linux2.4.x內核調度算法包括O(n)算法這個事實可以說是其最大的瑕疵,而隨後新調度器使用的O(1)算法成爲了其最受歡迎的改進。

 

5.1.2 什麼是O(1)算法

一個算法會對系統輸入進行操作,而輸入的規模則影響其運行時間。O符號是用以表示算法的執行時間隨輸入規模變大的增長率。例如,O(n)算法的執行時間隨着輸入規模n的增大而線性增大,O(n^2)算法是平方增長的。如果有辦法獲得算法運行的常量上限範圍,那就是O(1)。也就是說O(1)算法能夠在一個固定的時間段內完成,無論輸入的規模有多大。

 

5.1.3 什麼使得linux2.6.8.1的調度器能夠實現O(1)時間運行

Linux2.6.8.1調度器並不包含任何比O(1)算法更耗時的算法。也就是說,無論系統內有多少任務同時運行,調度器的任何部分都必須能在常量的時間內運行完成。這允許linux內核能有效地處理大量任務而不會使其隨着任務數目的增多而增加額外開銷。在linux2.6.8.1調度器有兩個重要的結構體能夠使其在O(1)時間內完成職責,調度器的設計就是圍繞着他們進行設計的,這兩個結構體分別是:運行隊列,優先級數組。

5.2 運行隊列

5.2.1 概述

運行隊列數據結構是linux2.6.8.1調度器中最基礎的數據結構;他是整個算法建立起來的基礎。本質上,一個運行隊列記錄着所有被分配到特定CPU上的可運行的任務。正因如此,系統中的每個CPU都創建並維護着自己的運行隊列。每個運行隊列都包含兩個優先級數組。在CPU中的所有任務都會從一個優先級數組中開始,即活動優先級數組,而後,隨着他們用完了他們的時間片,他們被移動到過期優先級數組。在移動的過程中,會計算新的時間片並分配給相應任務。當活動優先級數組內已經沒有可運行的任務時,內核簡單地將活動優先級數組與過期優先級數組進行交換(僅在指針上進行更新就可以完成操作)。運行隊列的工作就是記錄CPU特殊線程的信息,同時控制他的兩個優先級數組。

5.2.2 數據結構

運行隊列數據結構在 kernel/sched.c 中定義爲一個結構。他沒被定義在 kernel/sched.h 是因爲從調度器的公共接口抽象其內部工作是一項重要的體系結構目標。運行隊列結構包含以下變量:

 

spin_lock_t lock

這是保護運行隊列的鎖。任何時候只有一個任務可以對特定的運行隊列進行修改。

 

unsigned long nr_running

運行隊列上可運行的任務數。

 

unsigned long cpu_load

運行隊列表現出的CPU負載。無論何時,只要rebalance_tick()函數被調用,負載就要被重新計算。重新計算出的負載是舊負載與現有負載(nr_running * SHCED_LOAD_SCALE)的平均值。

 

unsigned long long  nr_switches

自運行隊列創建起的上下文切換次數。這個值在內核中並無什麼用處,他僅在proc文件系統中用作統計數據。

 

unsigned long expired_timestamp

自上次兩數組切換至當前的時間。

 

unsigned long nr_uninterruptible

在運行隊列中不可中斷的任務的個數。

 

unsigned long long timestamp_last_tick

上次調度器滴答的時間戳。主要用在task_hot宏內,該宏用於判定一個任務是否用到了高速緩存存儲器。

 

task_t *curr

指向當前工作任務的指針。

 

task_t *idle

指向CPU理想任務的指針。

 

struct mm_struct *prev_mm

指向先前已經運行過的任務的虛擬內存映像的指針,這用於在高速緩存存儲器方面高效地處理虛擬內存映像。

 

prio_array_t *active

活動優先級數組指針。該數組包含那些仍然擁有時間片的任務。

 

prio_array_t *expired

過期優先級數組指針。該數組包含那些時間片用完了的任務。

 

prio_array_t arrays[2]

兩優先級數組的數組。活動與過期數組指針在其間進行交換。

 

int best_expired_prio

過期的所有任務中的最高優先級。用在EXPIRED_STARVING宏中以確定是否有比當前運行任務更高優先級的任務已過期。

 

atomic_t nr_iowait

運行隊列中等待IO操作的任務數目。用於判定內核狀態。

 

struct sched_domain *sd

運行隊列所屬的調度器域。本質上說,這是一組相互間可以共享任務的CPU

 

int active_balance

移動線程用以判斷運行隊列是否需要被平衡的標誌。

 

int push_cpu

當被平衡時,運行隊列應將任務轉移向的CPU

 

task_t *migration_thread

CPU的移動線程。移動線程是尋找任務轉移問題的線程。

 

struct list_head migration_queue

需要被轉移到其他CPU的任務列表。

 

5.2.3 上鎖

任何時刻,只有一個任務能修改特定CPU的運行隊列,因此,其他想要修改運行隊列的任務必須首先獲得運行隊列鎖。獲取多個運行隊列鎖必須按照運行隊列升序的順序來完成,以防止死鎖情況發生。一個方便地獲取兩個運行隊列鎖的函數是double_rq_lock(rq1,rq2),該函數自己處理上鎖順序。相反的,函數double_rq_unlock(rq1,rq2)爲兩運行隊列解鎖。給某任務所在隊列上鎖可以利用以下函數:task_rq_lock(task,&flag)

5.3 優先級數組

5.3.1 概述

該數據結構是大多數linux2.6.8.1調度器有益操作的基礎,尤其是其常量時間性能。Linux2.6.8.1調度器總是調度系統中最高優先級的任務,同時,如果有多個任務處在同一個優先級上,則他們將被循環調度。優先級數組能夠讓尋找系統中最高優先級任務的工作成爲一個時間恆定的操作。而且,同時使用兩個優先級數組使得新時間點過度成爲時間恆定的操作。新時間點是指所有的任務都擁有新時間片時,或所有的任務都用完時其間片時。

 

5.3.2 數據結構

unsigned int nr_active

在優先級數組中可活動任務的數目。

 

unsigned long bitmap[BITMAP_SIZE]

位圖顯示出存在在優先級數組中的活動任務的屬性。例如,有三個活動任務,兩個優先級爲0,一個優先級爲5,那麼在位圖中,比特05應該被置位。這使得在優先級數組中尋找可運行的最高優先級的任務只需要簡單地調用恆時函數__ffs(),該函數是個在程序字中尋找最高次序比特的被高度優化過的函數。

 

struct list_head queue[MAX_PRIO]

一個連接列表數組。每個優先級在數組中都擁有一個列表。這些列表包含任務,無論何時當其中某個列表的大小大於0時,在優先級數組位圖中的對應優先級的比特位將被置位。當一個任務被加入到優先級數組,他將被根據他的優先級被分配到指定的數組隊列中。在優先級數組中的最高優先級的任務總是被最先被調度,同時處於同一優先級的任務將被循環調度。

 

5.3.3 如何使用優先級數組

在所有仍有時間片剩餘的任務中,調度器總是最先調度優先級最高的任務。優先級數組使得調度器算法能夠在常量時間內找到最高優先級的任務。

優先級數組是一組鏈接的列表,每個優先級擁有一個列表。當一個任務被加入優先級數組時,他被加入到與其優先級相符的列表中。長度爲MAX_PRIO+1的位圖的比特位將爲擁有活動任務的優先級置位。爲了找到優先級數組中最高優先級的任務,我們只需要找到位圖中第一個被置位的比特位。同個優先級的任務被循環調度;一個時間片運行完成後,任務將被置於其優先級列表的末端。因爲在一個有限長度的位圖中尋找第一個被置位的比特位與在一個有限長度的列表中尋找第一個元素都是在有限空間內的操作,所以,這個部份的調度器算法的時間性能是常量的,也就是用了O(1)時間。

當一個任務的時間片被用完,他將從活動優先級數組中被移出,同時被放置進過期優先級數組。在移動過程中,會計算一個新的時間片。當活動優先級數組中已經沒有活動任務時,我們只將指向活動優先級數組與指向過期優先級數組的指針簡單地進行交換就完成了兩優先級數組間的交換操作。由於每當一個任務在用完時間片時都會被重新計算並移出活動數組,所以當兩數組交換後,任務就無需再計算時間片。也就是說,內核將統一爲每個任務重新計算時間片的迭代過程分解爲一個個常量時間的操作。交換兩數組的操作也是常量時間操作,簡單的交換指針避免了將一個數組中的元素移動到另一個數組中的O(n)時間的操作。

因爲所有在優先級數組上發生的操作都是常量時間O(1)的操作,linux調度器表現地挺好。無論系統中有多少任務存在,linux2.6.8.1調度器都會在相同的極少的時間內完成其任務。

5.4 運算優先級及時間片

5.4.1 靜態任務優先級及nice()系統調用

所有的任務都有一個靜態的優先級,經常被稱作nice值。在linuxnice值的範圍是從-2019,越大的數字代表越低的優先級。默認情況下,任務一開始時,其靜態優先級爲0,但是該優先級是可以通過nice()系統調用進行改變的。除了系統對任務優先級進行初始化時賦值的0及通過nice()系統調用對靜態優先級進行的修改,調度器從不在除此之外的情況修改任務的靜態優先級。修改靜態優先級就是用戶修改任務優先級的途徑,而調度器則會選擇尊重用戶的輸入。

任務的靜態優先級被存於其staic_prio變量中。當p是一個任務時,p->static_prio就是其靜態優先級。

 

5.4.2 動態任務優先級

Linux2.6.8.1調度器利用增加靜態優先級的方式獎勵IO受限的任務,利用降低靜態優先級的方式懲罰CPU受限的任務。被調整的優先級被稱爲任務的動態優先級,同時他可以通過任務的prio變量獲得。如果一個任務是交互式任務,他的優先級就會被提高。如果一個任務極其佔用CPU,他就會受到懲罰。在linux2.6.8.1調度器中,最大的優先級獎勵爲5,最大的優先級懲罰也是5。由於調度器使用獎勵與懲罰制度,對任務的靜態優先級進行調整受到了重視。一個輕微佔用CPUnice值爲-2的任務可能擁有值爲0的動態優先級,就像其他許多非大量佔用CPUIO的任務一樣。如果用戶利用nice()系統調用調整了兩個任務中任意一個的靜態優先級,系統會對這兩個任務進行相應的調整。

 

5.4.3 I/O受限 vs. CPU受限 啓發式教學

動態優先級獎勵與懲罰機制是基於交互性啓發法。這種啓發法是利用記錄任務運行與休眠的時間來實現的。IO受限的任務趨向於在大多時間內進行休眠,然而CPU受限的任務由於他們很少被IO阻塞,所以他們很少休眠。大多情況下,任務都處於上述兩者之間,任務並非完全CPU受限,也並非完全IO受限。這樣的情況使得啓發法產生範圍這一概念來代替簡單的比特標誌(標誌任務爲IO受限或CPU受限)。在linux2.6.8.1調度器中,當一個任務從休眠中醒來,其全部的休眠時間會被加入到sleep_avg變量中。當一個任務自願地或非自願地放棄CPU,系統或從其sleep_avg變量中減去任務運行的時間。任務的sleep_avg變量越大,任務的動態優先級就會變得越大。由於這種啓發法同時記錄了任務運行與休眠的時間,他也顯得相對精確。由於休眠很長時間的任務也有可能會用完其時間片,所以,必須阻止休眠很長時間後大量佔用CPU時間的任務獲取大量的交互性獎勵。Linux2.6.8.1調度器的交互性啓發法已防止了此類形象的發生,因爲很長的運行時間會抵消很長的休眠時間。

 

5.4.4 effective_prio()函數

該函數用以計算任務的動態優先級。他被線程與進程的喚醒調用函數recalc_task_prio()scheduler_tick()函數調用。該函數會在任務的sleep_avg變量被改變後被調用,因爲sleep_avg是任務動態優先級最主要的啓發法。

Effective_prio函數做的第一件事就是,如果任務是實時任務,則返回任務的當前優先級。該函數不會對實時任務進行獎勵或懲罰。以下的兩行是關鍵:

 

bonus = CURRENT_BONUS(p) - MAX_BONUS / 2

prio = p->static_prio - bonus

 

CURRENT_BONUS是被如下定義的:

#define CURRENT_BONUS(p) /

NS_TO_JIFFIES((p)->sleep_avg)*MAX_BONUS/ AX_SLEEP_AVG)

 

本質上,CURRENT_BONUS將任務的休眠平均值映射到0-10的區間上,MAX_BONUS就是10。如果一個任務的sleep_avg很高,那麼CURRENT_BONUS的返回值就會很高,反之亦然。由於MAX_BONUS是任務優先級可增減的最大範圍的兩倍,他被除以2,同時被從前者CURRENT_BONUS(p)中減去。如果一個任務有非常高的sleep_avg,那麼此時CURRENT_BONUS(p)返回10,所以bonus就會是5。接着任務的靜態優先級會被減去5,該值是一個任務能獲得的最大獎勵了。如果一個任務的sleep_avg0,那麼此時CURRENT_BONUS(p)返回0。在這種情況下,bonus會是-5,任務靜態優先級會被減去-5,也就是加上5。靜態優先級加上5是對那些長期佔用CPU而不休眠的任務的最大的懲罰。

一旦一個新的動態優先級被計算出來,effective_prio函數能做的最好一件事就是確保任務優先級處在非實時優先級範圍內。例如,當一個高度交互的任務擁有-20的靜態優先級,他就無法再獲得5的獎勵,因爲他已經擁有最高的非實時優先級。

 

5.4.5 計算時間片

我們只要簡單地在時間片範圍內映射一個任務的靜態優先級就可以計算出時間片,同時必須保證該時間片處在確定的最大與最小值之間。任務的靜態優先級越高,他就會獲得越多的時間片。Task_timeslice()函數只是一個對BASE_TIMESLICE宏的簡單調用,該宏被定義如下:

 

#define BASE_TIMESLICE(p) (MIN_TIMESLICE + /

((MAX_TIMESLICE - MIN_TIMESLICE) * /

(MAX_PRIO-1 - (p)->static_prio) / (MAX_USER_PRIO - 1)))

 

實際上,這就是最小的時間片加上任務的靜態優先級映射進時間片範圍的值,時間片範圍是(MAX_TIMESLICE - MIN_TIMESLICE)

還有一關鍵點在於,一個交互性任務的時間片有可能基於調度器的TIMESLICE_GRANULARITY值被分解成一塊一塊。Schedular_tick()函數是用以驗證是否當前運行的任務從其他相同動態優先級的任務手中搶佔CPU太長的時間。若一個任務運行了TIMESLICE_GARANULARITY的時間,並且存在其他相同優先級的任務時,那麼就會在其他相同優先級的任務中進行一次循環轉換。

 

5.4.6 生成新任務時的公平性

生成新任務時,wake_up_forked_thread()wake_up_forked_process()兩函數會減少父子任務的sleep_avg變量。這是爲了防止高交互性任務產生其他高交互性的任務。如果沒有這兩個函數,那麼高交互性任務可以不斷產生高交互性的任務用以佔用CPU。有了這個兩個函數,sleep_avg變量以及隨後的優先級都會被降低,這樣就增加了父子任務被更高優先級進程搶佔CPU的可能性。我們還需要知道的是,父子任務的時間片並不會減少,因爲時間片是基於靜態優先級,而非基於被sleep_avg變量影響的動態優先級的。

 

5.4.7 重新插入交互性任務

每隔一毫秒,一個時鐘中斷就會調用schedular_tick()函數。如果一個任務時間片已經被用完,通常情況下,他會被給予另外一個新的時間片並被置於其所在運行隊列的過期優先級數組中。然而,schedular_tick()函數會將獲得新時間片的交互性任務重新插入到活動優先級數組中,只要過期優先級數組中沒有出現飢餓現象。通過阻止有非交互性任務能成功用完其時間片而交互性任務卻處在過期優先級數組中這種情況發生,有助於交互性任務的運行。

 

5.4.8 互動性評分

互動性評分幫助控制任務交互性狀態的上升與下降比率。事實上,當任務休眠長時間後,其會獲得一分的互動性分數;當任務運行長時間後,其會失去一分的互動性分數。一個任務的交互性評分分數被存儲在其interactive_credit變量中。如果一個任務擁有超過100分的互動性評分,我們認爲他有很高的互動性評分。如果一個任務擁有低於-100分的互動性評分,我們認爲他有很低的互動性評分。

5.5 休眠眠與喚醒任務

5.5.1 爲什麼要休眠?

任務並不總是在運行,當他們不運行時,他們就休眠。任務由於許多原因會進入休眠狀態;但是總歸是一個原因,他們都在等待某些事件發生。有些時候,任務進行休眠是因爲他需要等待從設備上讀入數據,有些時候,任務進行休眠是因爲他在等待從程序的另外一個片段發來的信號,有些時候他就是要休眠一個固定的時間。

休眠是一個很特殊的狀態,在這個狀態下,任務不能被調度,也不能被運行。這很重要,因爲如果休眠的任務能夠被調度或執行,那麼程序執行會在不該繼續的時候繼續,這使得休眠必須被實現爲忙循環。例如,如果一個任務在他請求設備傳輸數據後可以立即繼續執行,而此時無法確認設備數據是否傳輸完畢,那麼任務就必須不斷地通過循環檢測來確定數據傳輸是否完成。

 

5.5.2 可中斷與不可中斷的狀態

當一個任務進入休眠狀態,他仍會處於兩種情況:可中斷與不可中斷。在可中斷的休眠狀態下的任務可以比預期更早地醒來來對發給他的信號進行響應,而處於不可中斷的睡眠狀態下的任務則不能如此。例如,一個用戶會使用kill命令來結束一個任務,kill命令會嘗試向任務發送SIGTERM信號來完成他的工作。如果任務此時處於不可中斷的狀態,他會簡單地忽略發送來的信號,直到他所等待的事件發生。然而處於可中斷狀態的任務就會立即對傳給他的信號作出響應。

 

5.5.3 等待隊列

一個等待隊列實際上就是等待一些事件或情況發生的任務列表。當該事件發生,該事件的控制代碼會將該等待隊列中的所有任務全部喚醒。這就相當於是個事件通告發布的集中地。準備要休眠的任務會在休眠之前加入到一個等待隊列當中,以便在其等待的事件發生時能夠得到通知。

 

5.5.4 休眠

任務通過系統調用來進入休眠狀態,這些系統調用通常會利用以下幾步來保證任務進入安全和成功的休眠期:

1) 通過DECLARE_WAITQUEUE()來創建一個等待隊列;

2) 通過add_wait_queue()函數將任務加入到等待隊列。該等待隊列在其等待的事件發生時會喚醒所有處於該隊列中的任務。無論如何,事件發生時,事件代碼必須在合適的時機對等待隊列調用wake_up()函數;

3) 利用TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE來標示任務進入休眠狀態;

4) 開啓一個循環來調用schedule()函數及一個測試來判斷狀態是否爲真。如果其初始就爲真,則不需要調用schedule()函數,因爲在這種情況下,任務不需要進行休眠。否則,任務需要調用schedule()函數來放棄CPU佔有權。由於該任務已經被標記爲休眠,他就不會再被CPU進行調度。

5) 當任務被喚醒,循環的狀態檢測會被再次執行。這樣會防止可能發生的虛假喚醒。如果等待的事件真的發生了,循環會跳出。否則該循環會繼續進行,並調用schedule()函數。

6) 一旦事件發生,用TASK_RUNNING來標記任務,並通過remove_wait_queue()函數將其從等待隊列中移除。

 

 

5.5.5 喚醒

try_to_wake_up()函數負責喚醒任務。當一個等待隊列被告知要喚醒,在該等待隊列中的所有任務都會執行try_to_wake_up()函數,同時任務會從等待隊列中被移除。任務會被標記爲TASK_RUNNING,並被加入回合適的運行隊列等待被調度。

5.6 主調度函數

5.6.1 概述

schedule()函數就是主調度函數。他的工作就是選擇一個應該被調度運行的任務,並將CPU切換到運行該任務。當一個任務自願地放棄CPU時,該函數就會被調用。同時如果scheduler_tick()函數因爲任務用完其時間片而設置了他的TIF_NEED_RESCHED標誌,那麼當優先權被重新激活時,schedule()函數也會得到調用。scheduler_tick()是一個通過時鐘中斷在每個系統時鐘滴答都會被調用的函數。他檢查正在運行的任務及在CPU運行隊列中的其他任務來判斷調度與負載均衡是否需要被執行。

 

5.6.2 schedule()函數

該函數做的第一件事就是確認他不是在不該被調用的情況下被調用的。在那之後,他關閉優先權搶佔操作,並判斷該被調度出CPU的任務運行的時間長度。如果該任務有個高的交互性評分,那麼這個時間長度會被剪短,因爲一個經常等待IO的任務不希望由於其一次長時間使用CPU而丟失交互性狀態。接下來,如果該函數進入了內核優先級的搶佔,等待一個信號的可中斷任務會得到TASK_RUNNING狀態,不可中斷任務會從運行隊列中被移除。這是因爲,如果一個任務可中斷同時他在等待一個信號,他需要處理該信號。不可中斷的任務不應該存在在運行隊列中。

到達這裏之後,就需要尋找下一個應該被執行的任務了。如果在運行隊列中沒有可以被運行的程序,系統會進行一次負載均衡。如果負載均衡沒有帶來任何可運行的工作,那麼系統會向理想任務進行上下文切換。如果在運行隊列中存在可運行任務,當他們並不在活動優先級數組中,那麼活動優先級數組與過期優先級數組就會進行對換。

到了這步,活動優先級數組中就會存在可運行任務。那麼,系統就會檢查活動優先級數組的位圖來找出所有可運行任務優先級中最高的優先級。在那之後,在SMT CPU上的從屬休眠任務有可能得到運行的機會。如果存在一個從屬的休眠任務,當前的CPU會被切換至理想任務從而使從屬的休眠任務可以醒來完成他應該完成的工作。

如果此時由於種種原因沒有執行到理想任務的切換,那麼系統就會執行一次檢查來判斷下個被選中執行的程序是否是非實時且已被喚醒的。如果該任務是非實時且已被喚醒,他會被給予一個稍高的sleep_avg值,同時其動態優先級會被重新計算。這是給休眠任務另一種微小獎勵的方法。一旦該檢查被執行並且獎勵被髮放,喚醒標誌就會被清除。

現在,schedule()函數已經準備好進行真正的上下文切換。此時,算法會進行目標跳轉,不論被下一個變量指向的任務是什麼,都會執行到該目標任務的上下文切換。之前要調度理想任務的決定也只是簡單地將next變量指向理想任務,並跳至該點。在這裏,先前的任務將其TIF_NEED_RESCHED標誌清除,上下文切換的統計數據變量被更新,先前的任務從其sleep_avg變量中扣除運行時間。同樣,如果任務的sleep_avg變量低於0且其交互評分不高也不低,那麼任務就會扣除一個交互性評分。這是因爲,如果任務的sleep_avg變量小於0則他一定不常休眠。這步完成後,只要先前的任務與新任務不是同個任務,那麼就會進行上下文切換。在上下文切換後,調度算法中被停用的優先級搶佔被重新激活。schedule()函數的最後一步是檢查優先級搶佔在調度算法執行過程中是否被請求,如果有則重新調度。

5.7 負載均衡

5.7.1 爲什麼要進行負載均衡?

任務經常性地會處於CPU的一些特定部分。這是爲了更好地利用高速緩衝存儲器及內存。但是,有些時候,一個CPU處理的任務要比系統中其他CPU要多。例如,在一個dual處理器系統中,有可能存在這樣的情況:所有的任務都被分配給一個CPU,而另外一個CPU則一直處於理想狀態。很顯然,這並非最好的狀態。這個問題的解決辦法就是將一部分任務從擁有很多任務的CPU移到另外一個CPU來平衡系統。負載均衡是任何控制多CPU的內核的一個重要組成部分。

 

5.7.2 調度器域

每個在系統中的節點都有調度器域,該調度器域指向其父調度器域。一個節點可以是一個單處理器系統,一個SMP系統,或者一個NUMA系統。對於NUMA系統的情況,一個節點的域的父調度器域包括在系統中的所有CPU

每個調度器域將CPU分成不同的組。在一個單處理器系統或一個SMP系統中,每個CPU就形成一個組。包含系統中所有CPU的最高級別的調度器域會將每個節點分成一組,該組包含節點上的所有CPU。所有組被作爲環形連接表進行維護,而所有組的組合等價於域。沒有CPU可以同時處在兩個組中。

一個域的負載只能在該域中被平衡。只有在域中組的負載變得不平衡時,任務纔有可能在組間進行移動。組的負載是組中所有CPU負載的綜合。

 

5.7.3 CPU負載

由於每個可運行的CPU都只存在一個運行隊列,所以利用該結構體進行CPU負載的記錄是合理的。每個運行隊列維護一個叫做cpu_load的變量,該變量反應出CPU負載的狀況。當運行隊列被初始化時,該變量被設置爲0,同時他在每次系統調用rebalance_tick()時被更新。上述函數在scheduler_tick()的末尾被調用,如果CPU處於理想狀態,則他會被調用的更早些。在rebalance_tick()函數中,當前運行隊列的cpu_load變量被設置爲當前負載與舊負載的平均值。當前負載是由當前運行隊列中獲得任務的數目與SCHED_LOAD_SCALE的乘積決定的。上述宏是個大數128,他僅僅是用以增大負載計算的結果。

 

5.7.4 均衡邏輯

負載均衡是通過rebalance_tick()函數被調用的,其在scheduler_tick()函數中被調用。Rebalance_tick()首先更新CPU的負載變量,然後上升到CPU的調度域層嘗試重新進行負載均衡。如果CPU的調度器域在比其均衡間隔還長的時間段內沒有進行過負載均衡,則上述函數只嘗試均衡調度器域。這點非常重要,因爲所有的CPU共享頂級調度器域,我們不希望在每次CPU時鐘滴答發生時就對頂級域進行負載均衡。我們可以想象,如果不這麼做的話,在擁有512個處理器的NUMA系統中,對頂級域的負載均衡會多麼經常發生。

如果rebalance_tick()函數判斷出一個調度器域需要進行負載均衡,那麼他會對該調度器域調用load_balance()函數。load_balance()函數會尋找域中最繁忙的組,如果沒有最繁忙的組,他會退出。如果有最繁忙的組,他會判斷是否該最忙的組包含當前CPU,如果包含他同樣會退出。load_balance()函數會將任務拉進低負荷的組,而非將任務從高負荷的組中推出。一旦最繁忙的組被選出,load_balance()函數會嘗試通過move_task()函數將任務從最繁忙的組的運行隊列移動到當前CPU的運行隊列中。load_balanc()剩餘部分的工作致力於根據負載均衡是否成功來更新啓發法,並且清理鎖。

move_task()函數嘗試將一定數量的任務從最繁忙的組中移動到當前組中。該函數會嘗試先移走目標運行隊列中過期優先級數組中的任務,在過期優先級數組中,該函數會嘗試先移走最低優先級的任務。任務通過調用pull_task()被移走。pull_task()函數將任務從當前運行隊列中出隊,而後讓其入隊到目標運行隊列中。該操作簡短且簡單,是調度器設計簡潔的證明。

 

5.7.5 移動線程

每個CPU都擁有一個移動線程,該線程是一個高優先級的內核線程,他保證運行隊列的均衡性。該線程migration_thread()函數中的循環直到由於某些原因被叫停。如果有任務移動請求,那麼移動線程會發現該請求並處理他。

5.8 軟實時調度

Linux2.6.8.1調度器提供對軟實時調度的支持。“軟”這個形容詞來源於這樣一個事實:雖然調度器在使任務如期完成上做的很好,但他並不保證所有的任務都能如期完成。

 

5.8.1 優先的實時任務

實時任務擁有從099的優先級,而非實時任務優先級被映射到內部優先級範圍100-140。因爲實時任務有比非實時任務低的優先級值,實時任務總是會比非實時任務更容易優先執行。只要實時任務是可運行的,就沒有其他的任務可以運行,因爲實時任務利用兩種不同的調度策略在執行,分別爲SCHED_FIFOSCHED_RR。非實時任務被標識爲SCHED_NORMAL,遵循普通調度操作。

 

5.8.2 SCHED_FIFO調度

SCHED_FIFO任務根據先進先出進行調度。如果系統中有這樣的任務,那麼他會一直佔用CPU運行他想要的時間長度,沒有任何任務可以搶佔CPU。這樣的任務沒有時間片。多個SCHED_FIFO任務的情況下,高優先級的任務會搶先於低優先級的任務進行執行。

 

5.8.3 SCHED_RR調度

SCHED_RR任務與SCHED_FIFO任務相似,但他擁有自己的時間片,並且總是被SCHED_FIFO搶先執行。SCHED_RR任務根據優先級來確定執行順序,在同一優先級內他們會被循環調度。每個在固定優先級下的SCHED_RR任務運行完其指定的時間片後會回到其優先級數組列表的末端。

5.9 NUMA調度

5.9.1 調度器域/組管理

調度器域系統是linux2.6.8.1 NUMA支持中的一個重要組件。NUMA體系結構區別於單處理機系統與SMP系統的地方在於NUMA系統可以包含多節點。最典型的特徵就是,系統中每個節點都有本地內存庫及某些其他資源,這些資源能夠很好地被物理上近鄰的CPU利用。例如,雖然NUMA系統中的某個CPU可以利用系統中任何節點上的內存,但訪問本地內存總比訪問物理上20碼遠或好幾個NUMA鏈接遠的節點上的內存要快的多。簡而言之,由於NUMA系統物理上可以非常的大,且其節點間的連接也並不是非常理想,所有臨近資源就成爲一個問題。相鄰問題使得將資源組織成組的形式變得非常重要,這也就是調度器域系統所要做的事情了。

在一個NUMA系統上,頂級調度器域包括系統中所有的CPU。頂級調度器域對每個節點都分有一個組;組的CPU包括所有在節點上的CPU。頂級域爲每個節點分有子調度器域,每個子域可以有一組CPU。該調度器域結構是由調度器中的特殊域初始化函數進行設置的,這些函數只有在CONFIG_NUMA爲真的時候纔會被編譯。

 

5.9.2 NUMA任務移動

scheduler_tick()運行時,他會判斷在當前CPU基礎域中的組是否平衡。如果不平衡,他會對域中的組進行負載均衡處理。一旦域進入平衡狀態,其父域就平衡了。這意味着,NUMA系統中每個節點的基礎調度器域允許節點保有任務,這由於資源臨近性等原因而受到歡迎。由於調度器在調度器域的組之間進行平衡以及非必要的獨立CPU,當頂級域平衡時,只有任一節點負載過重纔會在節點間進行任務移動。NUMA系統中的獨立CPU在頂級域平衡操作時並不被考慮在內。一旦任務成爲新節點的一個部分,他會持續呆在節點內部直到該節點負載過重。這些平衡操作等級不鼓勵非必要的節點接任務移動。

5.10 調度器調整

5.10.1 調整的原因

擁有一些基礎linux開發能力的linux用戶總希望能夠優化調度器以滿足某個方面的特殊要求。這樣的人可能包括那些希望能夠犧牲整體效率換取系統響應時間的臺式機用戶,或者包括那些爲了系統整體效率而犧牲響應時間的系統管理員。

 

5.10.2 調度器調整可能性

在文件kernel/sched.c的頂端,有一系列以MIN_TIMESLICE開頭的宏,這些宏的定義可以被調整以滿足用戶特殊需求。這些值可以被合理地進行修改,同時調度器會穩定地工作。在改變了需要的宏定義後,就如一般情況一樣,用戶需要重新編譯內核。在已被編譯的內核中修改這些值是沒有任何意義的,並且運行中系統的這些值是不能修改的。另外一些可以被調整的值我們會在5.10.3 - 5.10.6中進行討論。

我們重點要明白的是,調度器代碼及調度器可以處理的工作擁有太多的變量,以至於對調度器做出的修改沒有任何保證。最好的進行調度器調試的方式是利用調度器會處理的工作來進行實驗和排錯。

 

5.10.3 MIN_TIMESLICEMAX_TIMESLICE

MIN_TIMESLICE是調度器可得的最小的時間片。MAX_TIMESLICE是調度器可得的最大的時間片。平均時間片大小是由上述兩宏的平均值決定的,所以增加上述兩值中任意一值都會增加通用時間片長度。增加時間片長度就會增加全局效率,因爲更長的時間片長度意味着更少的上下文切換,但是這會減少系統響應次數。然而,由於IO受限任務通常都比CPU受限任務擁有更高的動態優先級,交互性任務比其他任務更可能搶佔CPU,無論該任務的時間片有多長;這意味着,交互性受來自長時間片的影響會更少些。如果在系統中有很多的任務,例如在一個高端服務器上,更長的時間片會導致低優先級的任務要花更多時間等待。如果大多數任務都處在相同的優先級上,響應時間會受到更大的影響,因爲沒有任何一個任務可以搶佔其他任務的CPU以嘗試給用戶提供更多的響應次數。

 

5.10.4 PRIO_BONUS_RATIO

這是在動態優先級計算中可作爲獎勵或懲罰被任務所得到的優先級上升或下降範圍的比例值。默認情況下,該值爲25,所以任務可以從0值上移或下降25%。由於在0上下分別有20個優先級,所以默認情況下,任務可以獲得5個優先級的獎勵或懲罰。

事實上,這個值控制着靜態用戶定義的優先級有效的程度。當這個值很高時,利用nice()系統調用將一個任務設置爲高優先級的效果就並不明顯,因爲此時動態優先級在進行計算時允許更多的彈性。當這個值很低時,靜態優先級就更起作用。

 

5.10.5 MAX_SLEEP_AVG

MAX_SLEEP_AVG的值越大,一個任務被作爲活動任務前所需要進行休眠的時間就越長。增加該值會降低系統交互性,但是對於非交互性的工作而言,所有任務的平等性纔是他想要的。全局效率此時會得到提升,因爲動態優先級更少的增加意味着更少的CPU搶佔及上下文切換。

 

5.10.6 STARVATION_LIMIT

交互性任務在用光時間片時會被重新插入到活動優先級數組中,但這會引起其他任務出現飢餓狀態。如果另一個任務沒有運行夠比STARVATION_LIMIT更長的時間,那麼交互性任務會暫停運行以讓飢餓的任務獲得CPU時間。降低該值會影響交互性,因爲這樣會導致交互性任務經常性地被強迫放棄CPU,轉而讓飢餓任務進行執行。但是這麼做,系統公平性就得到提高。增加該值會提高交互性性能,非交互性任務需爲此付出代價。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

6. Linux 2.4.x調度器

6.1 算法

linux2.4.x調度器有一個基本的瞭解會對我們理解在2.6.x內核中對調度器作出的改進有很大幫助。

Linux2.4.x調度器算法將時間分成一個個時間點,這些時間點是一個個時間週期,在這些時間週期中,任一個任務都允許用完其時間片。在時間點開始時,每個任務都會被給予一個時間片,這意味着對時間片計算的調度器算法的執行效率是O(n),因爲他必須對每個任務進行迭代。

每個任務有個基本時間片,其決定於任務的默認或由用戶分配的nice值。Nice值被映射爲調度器滴答,nice值爲0的任務對應獲得的時間片爲200ms。當計算一個任務真正的時間片時,基本時間片基於任務IO受限的程度得到修改。每個任務有個counter值,該值包含任務被分配的時間片內的調度器滴答數。在時間點的末尾,任務有可能由於等待IO的原因而無法用完其時間片。任務的新時間片會在時間點的末尾用如下的方式進行重新計算:

p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice)

剩餘的調度器滴答計數值向右移動一位,並被加入到基礎時間片中。這樣就使得那些在上個時間點中由於IO受限原因而無法用完時間片的任務在下個時間點內擁有了更長的時間片。如果一個任務突然成爲了CPU受限任務,同時用完其時間片,那麼他在下個時間點只能擁有基本時間片。然而,這越來越難實現,因爲連續的低時間片利用的時間點會將任務的時間片越壘越長。

當一個任務生成一個新的任務時,父任務的時間片會與其子任務進行分割。這就使得任務無法不斷產生子任務而霸佔CPU

Schedule()函數迭代判斷所有的可運行任務並調用goodness()函數,從而選擇出下一個要運行的任務。引起goodness()函數返回最高值的任務將被選中進行運行。Goodness通常由進程counter值加上其nice值來決定,但是對於實時任務而言,該結果還要加上1000,以使得實時任務總是先於非實時任務被選中。在goodness()函數中有個有趣的優化,如果一個任務與前一個運行的任務共享某些地址空間,那麼在計算時他將由於可以利用現有高速緩存頁而得到goodness值的輕微提升。Goodness算法事實上是如下執行的:

If(p->policy != SCHED_NORMAL)

Return 1000 + p->tr_priority;

If(p->counter == 0)

Return 0;

If(p->mm == prev->mm)

Return p->counter + p->priority + 1;

Return p->counter + p->priority;

6.2 優勢

Linux2.4.x調度器算法表現不錯,但是他相對平凡,他的優勢也僅僅侷限於在普通的領域內。

 

6.2.1 實用

儘管在技術上很含糊,但是linux2.4.x調度器很實用的事實不應該被打折扣。人們對linux調度器的要求很高;linux2.4.x跑在許多非常重要的系統中,從Fortune5000服務器到NASA超級計算機,他都運行地很好。Linux2.4.x調度器已經足夠強健及有效,這使得linux成爲計算世界的一個重要選手,這些成就都是以往的調度器所無法企及的。

 

6.2.2 相對簡單的邏輯

Linux2.4.x中的kernel/sched.c文件大約是linux2.6.x中該文件的三分之一。該算法是相當簡單的,儘管其內在行爲和影響都有些無法預測。在linux2.4.x中,調整調度器的行爲是相對簡單的,然而想不通過徹底檢查而改進他就相對困難。

6.3 劣勢

在《Understanding the linux kernel》一書中,作者解釋了四個liunx2.4.x調度器的缺點:低拓展性,過大的平均時間片,非最優的IO受阻任務的優先級升級策略,弱的實時應用支持。

 

6.3.1 可拓展性

Linux2.4.x調度器執行時間效率爲O(n),這意味着在一個擁有非常多任務的系統中進行調度是非常可怕的。在每個調用schedule()函數的期間,任一個任務至少被迭代一次以使得調度器能夠完成其任務。這暗示着,系統中存在一段很長的無真正任務被執行的時間區段。交互性感知性能將因此受到重大影響。這個問題在linux2.6.x調度器中已經通過使用效率爲O(1)的算法得到解決。Linux2.6.x調度器特別地在每個任務用完其時間片時就重新計算時間片。Linux2.4.x調度器在所有任務都用完時間片時才統一一次性爲所有任務重新計算時間片。另外,linux2.6.x調度器的優先級數組使得尋找系統中最高優先級的進程就如在位圖中尋找第一個被置位的比特位那麼簡單。而linux2.4.x調度器卻迭代所有的進程去尋找那個最高優先級的進程。

 

6.3.2 過大的平均時間片

linux2.4.x調度器分配的平均時間片大致爲210ms。這個時間片相對較長,同時,按BovertCesati的說法:“顯然對擁有非常高系統負載希望的高端機來說,這個值太大了。”這是因爲,這麼長的時間片會導致低優先級任務執行時間變得過大。例如,當有100個線程不中斷地執行他們各自的210ms長度的時間片時,最低優先級的任務需要等待超過20秒的時間纔有可能得到運行。這個問題不會因爲執行飢餓檢查或在計算時間片時將系統負載考慮在內而得到緩解,這些根本提供不了任何幫助。在重新計算時間片時,只有進程數據字段被使用到:

p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);

這個問題在linux2.6.x調度器中通過降低平均時間片長度得到緩解,但並沒有完全得到解決。事實上,在linux2.6.x中,系統負載需要多出一倍纔會產生上述問題。我們需要重點記住的是,儘管如此,高優先級任務可以搶佔其他長時間片的任務,以此來維護可接受的交互性。這並不能幫助那些非交互性的或處於隊列末端的,但無法等待超長時間而得不到執行的任務。一個很好的例子就是一個已經從IO源獲取到數據,並等待生成HTTP回覆的web服務器,用以生成回覆信息的長時間等待可能導致客戶連接的超時。

 

6.3.3 IO受限任務的優先級升級

Linux2.4.x調度器對IO受限任務的偏好有一些很明顯的缺陷。第一,IO受限的非交互性任務會得到優先級升級,即使他們並不需要。同時,這樣對交互但CPU受限的任務不公平,因爲由於交互性得到的優先級提高與由於CPU受限而得到的處罰會相互抵消。由於linux2.4.x調度器給任務所分配的時間片是基於任務在上個時間片剩餘的時間及一個基於用戶nice值的附加值,CPU受阻任務的前者值會很低,並且如果接下來任務碰巧是交互性的,那麼他只會得到很小的獎勵。

這兩個問題都無法得到很好的解決,直到找到比休眠時間更好的度量交互性的標準。由於我們的基本邏輯是,常休眠的任務是交互性的,不常休眠的任務是非交互性的,融合對立的特性總是困難的。針對之前的問題,linux2.6.x將休眠時間過長的任務分類爲懶惰任務,以如下的方式給他們分配平均休眠時間:

If(...sleep_time > INTERACTIVE_SLEEP(p)){

p->sleep_avg=JIFFIES_TO_NS(MAX_SLEEP_AVG - AVG_TIMESLICE);

}

這阻止了給予過度休眠的任務大量的獎勵。這並不是問題的解決方法,但是這至少限制了問題拓展的範圍。

 

6.3.4 實時應用支持

Linux2.4.x內核是不可搶佔的,所以對實時任務的支持就很弱。中斷和異常會導致短暫的內核模式執行,在該執行期間,可運行的實時任務無法立即繼續執行。這對於有強時間限制的實時任務是不可接受的。內核搶佔性給內核代碼增加了很高的複雜性,尤其是考慮到加鎖問題,而這已被抵觸許久。Linux2.6.x是一個可搶佔的內核,所有對實時任務的支持會更好。然而在有些情況下,linux2.6.x內核也是無法被搶佔的,所以其對實時任務的支持也非完美。還有許多關於實時任務支持的問題,但這些已經超出本文所要敘述的範圍。

7. Linux調度器的未來

7.1 調整實現 vs. 算法變換

Linux2.6.x調度器是立體的。在近期他不可能會有大的改動,因爲更深入的立體性能提高是很難控制的。大量的不同工作情況讓調度器望而卻步,這意味着針對一種工作情況作出的調度器調整很可能會影響到調度器在其他工作情況下的表現。

儘管linux2.6.8.1調度器的基本算法與數據結構不大可能會發生大的變化,但事件處理方式仍然會不斷得到改進。這不會在算法級上影響性能,這反而會全面地改善調度器性能。有可能會增加一些特性,但總體的調度器結構不會發生重大改變。

 

7.1.1 調度器模式及可交換調度器

兩個未來調度器的有趣的發展方向是調度器模式與可交換的調度器。

調度器模式意味着將調度器工作進行分類,同時允許根用戶動態選擇系統的調度器行爲。例如,有可能有兩種模式,服務器模式與臺式機模式,此時系統管理員可以通過系統調用讓機器進入其中一種模式。臺式機模式會更加註重用戶交互性,而服務器模式則更加註重效率。實際上的調度器模式可能沒有這麼簡單,但是即使只有這麼簡單的設置也會給許多人帶來便利。實際上,簡單性更可能帶來愉悅地用戶體驗及促進系統改進。通過將可調整的宏變成運行時可調整的變量,或維護兩組用於不同設置的變量的方式,可以簡答地實現調度器模式。雖然這是個有趣的想法,但是這並不大可能會實現。

可交換調度器允許用戶選擇自己特定的調度器來處理任務。一個基礎的內核調度器會在用戶間循環切換,同時允許一個用戶選擇調度器來選擇任務進行執行。這樣,交互性用戶可以選擇更適用於處理交互性任務的調度器,而非交互性用戶也可以選擇適用於他們所處情況的調度器。這是一個對可交換調度器的非常簡單的描述,但是這也將其主要思想闡述清楚了。現在已有一些關於擁有可交換性調度器的內核的不同類型的例子,或許最顯著的就是GNU HURD內核。

7.1.2 共享的運行隊列

近期對linux調度器中SMT支持的添加並非完美的。在一片ARS TECH的報道中,Robert Love介紹了一個擁有四個虛擬處理器的工作站的例子。如果三個虛擬處理器都是懶惰的,第四個處理器有兩個工作,此時調度器應該嘗試將其中一個任務移動到另外一個物理CPU上,而非將該任務移動到同一芯片內部的另外一個虛擬處理器上。現在,這並沒有發生。Love介紹的解決方案是在SMT體系結構下共享運行隊列。如果進行了運行隊列的共享,負載均衡會現在所有物理CPU之間進行均衡負載,而非先在虛擬處理器之間進行。這樣的特性很可能會被加進linux調度器。

8. 附錄

8.1 致謝

作者希望能夠感謝下述的人和組織,作者從他們那得到了許多幫助,積極的影響及鼓勵:

Professor Libby Shoop

Professor Richard K. Molnar

Jeff Carr

Free Software/OSS Community

Silicon Graphics

8.2 關於作者

Josh Aas是一位美國聖保羅市麥卡利斯特學院的學生,他將獲得計算機科學及英語文學的雙學位。此前他從未想過會與這兩個科目結緣。在寫這篇文章時,他正受聘于Silcon Graphic公司,工作在linux系統軟件組,從事基於linux的高性能服務器等方面的工作。他空餘的大部分時間都工作於開源軟件工程,尤其是Mozilla.org。他其他的愛好包括旅遊,閱讀,跑步等。

譯者:WInScar Daemon Huang(西安電子科技大學)

8.3 遵循法規GNU FDL

允許在遵循GNU開源文檔協議的1.1版本或更高版本的前提下,進行該文檔的複製,發佈與修改。該協議的副本可以在下述地址找到:

http://www.gnu.org/licenses/fdl.txt

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

引用

[1] Curt Schimmel, UNIX r Systems for Modern Architectures - Symmetric

Multiprocessing and Caching for Kernel Programmers. Addison-Wesleym

1994.

 

[2] Andrew S. Woodhull, Andrew S. Tanenbaum. Operating Systems Design

and Implementation, 2nd Edition. Prentice-Hall, 1997.

 

[3] Andrew S. Tanenbaum. Modern Operating Systems, Second Edition. Prentice

Hall, 2001.

 

[4] Robert Love. Linux Kernel Development. Sams, 2004.

 

[5] Daniele Bovet, Marco Cesati. Understanding the Linux Kernel (2nd Edition).

O’Reilly, 2003.

 

[6] Mel Gorman. Understanding The Linux Virtual Memory Manager.  Unpublished,2004.

 

[7] Bitkeeper source management system, Linux project <linux.bkbits.net>

 

[8] Con Kolivas, Linux Kernel CPU Scheduler Contributor, IRC conversations,

no transcript. December 2004.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章