1. 程式人生 > >【讀書筆記】《Linux核心設計與實現》程序管理與程序排程

【讀書筆記】《Linux核心設計與實現》程序管理與程序排程

大學跟老師做嵌入式專案,寫過I2C的裝置驅動,但對Linux核心的瞭解也僅限於此。Android系統許多導致root的漏洞都是核心中的,研究起來很有趣,但看相關的分析文章總感覺隔著一層窗戶紙,不能完全理會。所以打算系統的學習一下Linux核心。買了兩本書《Linux核心設計與實現(第3版)》和《深入理解Linux核心(第3版)》

0x00 一些廢話

  • 面向物件思想。

Linux核心雖然是C和組合語言寫的,沒有使用面向物件的語言,但裡面卻包含了大量面向物件的設計。比如可以把核心中的程序看作一個物件,裡面即有用於表示程序狀態的各種變數,又有用於表示程序所有可執行操作的ops函式指標結構體。

  • 學習原理、思想和框架。

Linux核心程式碼相當龐大,一個表示程序的tast_struct結構都有1.7K大小(核心2.6-32位),如果我們關注的是其中某一個成員變數,很容易迷失在細節當中,所以學習原理和思想更重要,舉一反三才能剖析起核心原始碼來遊刃有餘。

0x01 Linux核心中,程序長啥樣

核心把程序存放在叫做任務佇列(task list)的雙向迴圈連結串列中。連結串列中每一項都是型別為task_struct,稱為程序描述符的結構。

核心中的程序也稱任務。

注意到程序描述符中包含的資料能完整地描述一個正在執行的程式:它開啟的檔案,程序的地址空間,掛起的訊號,程序的狀態,還有其他更多資訊。

2.6以前的核心中,task_struct是放在程序核心棧的尾部,這樣像x86這種暫存器較少的平臺中,可以通過棧指標快速計算出其位置。但2.6核心中,task_struct的空間是通過slab分配器進行動態分配的,因此現在程序核心棧的尾部只存了一個叫thread_info的結構體,其成員*task指標指向task_struct結構。

在(f:\linux-2.6.32.67\arch\x86\include\asm\thread_info.h)檔案可以檢視x86架構下thread_info結構定義:

struct thread_info {
        struct task_struct  *task;          /* main task structure */
        struct exec_domain  *exec_domain;   /* execution domain */
        __u32           flags;          /* low level flags */
        __u32           status;         /* thread synchronous flags */
        __u32           cpu;            /* current CPU */
        int             preempt_count;  /* 0 => preemptable, <0 => BUG */
        mm_segment_t    addr_limit;
        struct restart_block    restart_block;
        void __user     *sysenter_return;
#ifdef CONFIG_X86_32
        unsigned long   previous_esp;   /* ESP of the previous stack in case of nested (IRQ) stacks */
        __u8            supervisor_stack[0];
#endif
        int    uaccess_err;
};

核心中,大部分處理程序的程式碼都是通過直接操作的task_struct結構進行的,因此快速定位task_struct位置很重要,這直接影響作業系統執行速度。像PowerPC這類處理器(RISC)暫存器很多,task_struct的地址直接儲存在暫存器中即可。而在x86當中,只能在核心棧的尾端建立thread_info結構,通過計算偏移間接查詢task_struct結構,在x86系統上,是把棧指標的後13個有效位遮蔽掉,用來計算thread_info的偏移。該操作通過current_thread_info()函式完成,其彙編如下:

movl $-8192, %eax
andl %esp, %eax

這裡假定棧大小為8K。最後通過取task域獲得task_struct地址:

current_thread_info()->task;

程序描述符中的state域描述了程序的當前狀態。系統中每個程序必然處於下面5種程序狀態中的一種:

  • TASK_RUNNING(執行)——程序是可執行的,它或者正在執行,或者在執行佇列中等待執行。

  • TASK_INTERRUPTIBLE(可中斷)——程序正在睡眠(也就是說它被阻塞),待某些條件的達成。

  • TASK_UNINTERRUPTIBLE(不可中斷)——接收訊號也不會被喚醒。其它跟TASK_INTERRUPTIBLE相同

  • __TASK_TRACED——被其它程序跟蹤的程序,例如通過ptrace()對除錯程式進行跟蹤。

  • __TASK_STOPPED(停止)——程序停止執行。通過這種狀態發生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等訊號的時候。

核心可以使用set_task_state(task, state)函式來設定程序狀態:

set_task_state(task, state); /* 將任務task的狀態設定為state */

關於程序上下文

可執行程式程式碼是程序的重要組成部分。這些程式碼從一個可執行檔案載入到程序的地址空間執行。一般程式在使用者空間執行。當一個程式執行了系統呼叫或者觸發了某個異常,它就陷入核心空間。此時,我們稱核心“代表程序執行”並處於程序上下文。除非在此期間有更高優先順序的程序需要執行並由排程器做出相應調整,否則核心退出時,程式恢復在使用者空間繼續執行。

程序描述符中,還存放了程序間的關係。每個task_struct都包含一個指向其父程序task_struct的parent指標,還包含一個稱為children的子程序連結串列。所以對當前程序,可以通過下面程式碼獲取其父程序的程序描述符:

struct task_struct *my_parent = current->parent;

同樣可以用下面方式訪問子程序:

struct task_struct *task;
struct list_head *list;
list_for_each(list, &amp;current->children) {
        task = list_entry(list, struct task_struct, sibling);
        /* task現在指向當前程序的某個子程序 */
}

init程序的程序描述符是作為init_task靜態分配的。下面程式碼很好的展示了所有程序間的關係:

struct task_strcut *task;
for (task = current; task != &amp;init_task; task = task->parent)
        ; 
/* task現在指向init */

這樣我們就很空間從系統中任意一個程序出發,遍歷所有程序。因為佇列本身就是一個雙向連結串列,所以還可以這樣做。

獲取連結串列的下一個程序

list_entry(task->tasks.next, struct task_struct, tasks);

獲取前一個程序

list_entry(task->tasks.prev, struct task_struct, tasks);

這兩段程式碼分別通過next_task(task)巨集和prev_task(task)巨集實現。而實際上for_each_process(task)巨集提供了訪問整個佇列的能力。每次訪問,任務指標都指向連結串列中的下一個元素:

struct task_struct *task;

for_each_process(task) {

        /* 列印每個任務名稱與PID */

        printk("%s[%d]\n", task->comm, task->pid);

}

 

0x02 fork()幹了什麼

接下來看一下,核心中程序建立和終止的過程。

程序的產生過程是,在新的地址空間建立程序,讀入可執行檔案,開始執行。Linux把這個過程放到了兩個函式中實現:fork()和exec()。首先fork通過拷貝當前程序建立一個子程序,子程序只在PID,PPID和某些資源和統計量(例如:掛起的訊號,它沒有必要被繼承)上與父程序有區別。exec負責讀取可執行檔案並載入地址空間執行。

Linux的fork()有一個特點,就是寫時拷貝(copy-on-write)。也就是說通過fork建立的新程序,並不馬上拷貝父程序的地址空間,只在需要寫入的時候,資料才會被複制。而其它一些情況,比如fork完以後接著呼叫exec函式,並不需要拷貝父程序資源,這大大提高了fork的效率。Linux強調程序的快速執行能力,這個特性很重要。

fork()根據自己的引數標誌呼叫clone(),然後由clone()呼叫do_fork()。

do_fork()完成程序建立的大部分工作,它定義在kernel/fork.c檔案中。該函式會呼叫copy_process()函式,然後讓程序開始執行。copy_process()函式主要實現下面功能:

  1. 呼叫dup_task_struct()為新程序建立一個核心棧、thread_info結構和task_struct結構。此時子程序和父程序的描述符是完全相同的。

  2. 檢查確認程序數未超過系統限制。

  3. 子程序著手與父程序區別開來。程序描述符中許多引數被清0或設為初始化值,這些不是繼承而來的描述符成員,主要是統計資訊。task_struct中的大多數資料都依然未被改變。

  4. 子程序狀態被設定為TASK_UNINTERRUPTIBLE以保證它不會投入執行。

  5. copy_process()呼叫copy_flags()來更新task_struct的flags成員。表明程序是否擁有超級使用者許可權的PF_SUPERPRIV標誌被清0。表明程序還沒有呼叫exec()的標誌PF_FORKNOEXEC標誌被設定。

  6. 呼叫alloc_pid()為新程序分配一個有效PID

  7. 根據傳遞給clone()的引數標誌,copy_process()拷貝或共享開啟的檔案、檔案系統資訊、資訊處理函式、程序地址空間和名稱空間等。

  8. 最後,copy_process()做掃尾工作並返回一個指向子程序的指標。

再回到do_fork()函式,如果copy_process()函式成功返回,新建立的子程序被喚醒並讓其投入執行。核心會優先選擇執行新程序,因為一般子程序都會馬上呼叫exec()函式,這樣可以避免寫時拷貝的額外開銷。

在程序終止時,核心必須釋放它所佔有的資源並通知其父程序,這主要為了父程序能收集子程序退出的資訊。一般情況程序的退出,都是顯式或隱式的呼叫了exit()引起的。該功能由do_exit()實現,它定義在kernel/exit.c函式中,主要做下面的工作:

  1. 將task_struct中的標誌成員設定為PF_EXITING

  2. 呼叫del_timer_sync()刪除任一核心定時器。根據返回結果,它確保沒有定時器在排隊,也沒有定時器處理程式在執行。

  3. 如果BSD的程序記帳功能是開啟的,do_exit()呼叫acct_update_integrals()來輸出記帳資訊。

  4. 然後呼叫exit_mm()函式釋放程序佔用的mm_struct,如果沒有程序使用它們(也就是說這個地址空間沒有被共享),就徹底釋放它們。

  5. 接下來呼叫sem_exit()函式。如果程序排隊等候IPC訊號,則離開佇列。

  6. 呼叫exit_files()和exit_fs(),分別遞減檔案描述符和檔案系統資料的引用計數。如果其中某個引用計數為0,則釋放之。

  7. 使用exit()的退出碼設定task_struct結構的exit_code成員。以供父程序檢索,查詢子程序退出狀態。

  8. 呼叫exit_notify()向父程序傳送信,給子程序重新找養父,養父為執行緒組中的其它成員或init程序,並把程序狀態(task_struct的exit_state成員)設成EXIT_ZOMBIE

  9. do_exit()呼叫schedule()切換到新的程序。因為處於EXIT_ZOMBIE狀態的程序不會再被排程,所以這是程序執行的最後一段程式碼。do_exit()永不返回。

至此,與程序相關的所有資源都被釋放掉,它僅佔用的只有核心棧、thread_info結構和task_struct結構,此時它存在的唯一目的是向父程序提供資訊。父程序檢索到資訊或通知核心那是無關資訊後,由程序持有的剩餘記憶體全部返還給系統。

父程序通過wait這一族函式來接收子程序的退出通知,它們都最終呼叫wait4()這一系統呼叫來實現。刪除程序描述符通過release_task()由父程序實現,它負責刪除程序描述符並給孤兒程序(退出程序的子程序)找養父。

0x03 從Linux核心看執行緒與程序

包括《UNIX程式設計藝術》和一些前輩都說,Linux下不要使用執行緒。因為Linux下的程序與Windows等系統的不同,它相當輕量,並且多程序系統在某個程序掛掉時並不影響整個系統,Master程序可以重啟Worker程序。而多執行緒系統中,一個執行緒掛掉整個系統會崩潰。現在有機會從核心角度看一下Linux執行緒的實現。結果很意外,Linux下程序與執行緒並無不同,包括結構都是task_struct來描述。執行緒只被看作一個與其他程序共享某些資源的程序,在核心看來,並沒有執行緒這一概念。;(

0x04 Linux核心中程序排程的演化史

一個處理器,同一時刻只能被一個程序佔用,需要程序排程的原因很簡單。如果我們來設計程序排程演算法,最容易想到和實現的就是,遍歷整個程序列表,每個活動程序給固定時間的執行週期比如10ms。周而復始。這是最簡陋的時間片輪轉的程序排程演算法。Linux 2.5版本核心之前的程序排程演算法都是如此簡陋,它難以勝任大量程序存在的情況和多處理器情況。

Linux 2.5的核心對程序排程做了大手術,引入了O(1)排程程式,它通過靜態時間片演算法和針對每一個處理器的執行佇列,幫助擺脫子先前排程程式的限制。但它對時間敏感的程序有先天不足,所謂時間敏感程序,是指存在大量使用者互動的程序,比如桌面程式,它需要快速的響應客戶操作。

在Linux 2.6核心中,引入了“完全公平排程演算法”,簡稱CFS。它吸收了佇列理論,並將公平排程的概念引入到Linux排程程式。

0x05 當前排程演算法為了解決什麼問題

CFS演算法的引入是為了解決O(1)排程演算法中,對時間敏感程序響應的先天不足。

0x06 CFS演算法原理分析

分析CFS演算法排程原理之前,首先要思考的是我們要遵循什麼樣的排程策略。

  • I/O密集型和計算密集型程序

一個文字編輯器屬於I/O密集型程序,因為它時刻在等待並處理使用者輸入。而一個視訊解碼器則屬於計算密集型乾,它的工作主要是大量的運算。排程策略通常要在兩個矛盾的目標中間尋找平衡:程序響應速度(響應時間短)和最大系統利用率(高吞吐量)。為了滿足這樣的要求,排程程式通常採用一套非常複雜的演算法來決定最值得執行的程序投入執行,但是它往往不能保證低優先順序的程序被公平對待。Linux系統為了保證互動式應用和桌面系統的響應效能,對程序的響應做了優化,更傾向於優先排程I/O密集型程序。

  • 程序優先順序

排程演算法中最基本的一類就是基於優先順序的排程演算法。通常做法是優先順序高的程序先執行,低的後執行,相同優先順序的輪轉執行。在有的系統上,高優先順序的程序還可能擁有更多時間片。

Linux採用了兩種不同的優先順序範圍。第一種是nice值,範圍是-20到19,預設為0,越大的nice值意味著越低的優先順序。可以通過ps -el命令檢視,標記NI的一列就是程序的nice值。

第二種是實時優先順序。其值是可配置的,預設變化範圍是0-99。與nice值意義相反,越高的實時優先順序資料意味著程序優先順序越高。

  • 時間片

時間片是一個數值,它表明程序在被搶佔前可以執行的時間。排程策略必須確定一個預設時間片,但這並不簡單。因為過長的時候片會導致系統反應遲鈍,而過短的時間片會明顯增大程序切換帶來的消耗。這同樣是前面看到的矛盾,I/O密集型程序需要更短的時間片來保證響應速度,而計算密集型程序需要更長的時間片來保證效率(比如這樣可以使它們在快取記憶體中命中率更高)。

程序優先順序和時間片是傳統程序排程兩個通用概念。

下面就可以看一下CFS演算法的工作原理。

假設這樣一個Linux系統,其上只運行了兩個程序,一個文字編輯程式和一個視訊解碼程式。CFS不再給檔案編輯程式分配給定的優先順序和時間片,而是分配一個處理器使用比。如果兩個程式具有相同的nice值,那其處理器比都是50%,它們平分了處理器時間。但很明顯檔案編輯器更多時間用來等待使用者輸入,因此它肯定不會用到處理器的50%,而視訊解碼器為了更快完成解碼任務,就有機會使用超過50%的處理器時間。CFS發現這種情況,為了兌現程序公平使用處理器的承諾,在文字編輯器被喚醒時,將會立刻搶佔視訊解碼程序,讓檔案編輯程式投入執行。

我們看到,CFS演算法的核心就是:“完全公平”。

CFS的出發點是基於完善的多工處理器模型,所謂完善多工處理器模型是這樣的:我們能在10ms內同時執行兩個程序,它們各自使用處理器一半的能力。當然,這種模型並非現實,因為一個處理器不能同時執行多個程序。CFS允許每個程序執行一段時間,迴圈輪轉,選擇執行最少的程序作為下一個執行程序,而不再採用分配給每個程序時間片的做法了,CFS在所有可執行程序總數基礎上計算出一個程序應該執行多久,而不是依靠nice來計算時間片。nice值被CFS拿來作為程序獲得處理器執行時間比的權重。

我們應該注意到,當可執行的任務趨於無限時,它們各自所獲得的處理器執行比和時間片都趨於0,這樣造成了不可接受的切換損耗。CFS為此引入了每個程序可獲得時間片的最小值,稱為最小粒度。預設這個值是1.這保證了即使趨向於無窮的可執行程序,每個也至少得到1ms的執行時間。也就是說在程序非常非常多的時候,CFS並不是完美的公平排程演算法,因為大家要輪轉執行;(

CFS主要在kernel/sched_fair.c中實現,需特別關注的是4個組成部分:

  • 時間記賬

CFS使用vruntime變數來記錄一個程式到底運行了多長時間以及它還應該再執行多久。它的計算經過了所有可執行程序的標準化(或者說被加權的),並以ns為單位,因此它跟定時器節拍不再相關。

  • 程序選擇

CFS會挑選一個最小vruntime的程序來執行(經過nice加權過了,與優先順序已經無關),這就是CFS排程演算法的核心:選擇具有最小vruntime的任務。那麼剩下的工作就是如何選擇vruntime最小的任務了,CFS使用紅黑樹來組織可執行程序佇列,其中節點的鍵值便是可執行程式的vruntime.最左側的葉子節點就是我們要選擇執行的下一個程序。因此所有操作,都歸納到紅黑樹的增,刪,平衡上面來了。

  • 排程器入口

排程器入口函式是schedule(),定義在kernel/sched.c中。它是核心其它部分呼叫程序排程器的入口:選擇哪個程序可以執行,何時將其投入執行。schedule()首先通過pick_next_task()找到合適排程類。然後問一下排程類,哪個程式該運行了,其實現就這麼簡單。

  • 睡眠和喚醒

休眠(被阻塞)的程序處於一種特殊的不可執行狀態。程序休眠有多種原因,但肯定都是為了等待某一個事件。事件可能是一段趕時間從檔案I/O讀更多資料,或者某個硬體事件。一個程序還有可能嘗試獲取一個已被佔用的訊號量被迫進入休眠。無論何種情況引起的休眠,核心處理操作都是相同的:程序把自己標記為休眠狀態,從可執行紅黑樹中移除,放入等待佇列,然後呼叫schedule()選擇和執行一個其他程序。喚醒過程正好相反:程序被設定為可執行狀態,然後再從等待佇列移到可執行紅黑樹。