contributed by < steven1lung >
Repo : https://github.com/steven1lung/cfs-visualizer-react
Website : https://steven1lung.github.io/cfs-visualizer-react/
我覺得 Linus Torvalds 解釋的作業系統很精準:「作業系統唯一的任務是要幫助程式運行,作業系統一直都在等著程式向它要求資源。」作業系統可以看成是硬體跟使用者的仲介,工作包含管理硬體資源跟應用程式,而應用程式會再與使用者進行互動。而作業系統核心可以執行特權指令,讓每個任務都可以安全地被執行。
作業系統核心會在開機的時候被初始化,並且透過 boot loader 載入到記憶體中,核心從開機載入後就會一直待在記憶體中。記憶體儲存核心重要程式碼的位置也會跟一般的程式分開,防止應用程式或是其他作業系統程式對重要部分進行存取。
核心的任務是要管理硬體資源,像是操作 CPU、記憶體階層、I/O 裝置等等。為了達成這些任務,核心要有權限對所有的資源進行存取。所以將 kernel code 跟 user application code 分開。實作的方式為:
存核心的區塊稱為 kernel space,而存使用者資料的區塊稱為 user space。
因為有這個機制,user space 會被高權限的系統管理。每個行程可以運行在 user mode 或是 kernel mode。User mode 下運行的行程可以使用「System Call」來使用高權限的功能,可以將 system call 視為使用者跟核心之間的 API。當一個行程呼叫 system call:
舉例來說,x86 架構的兩位元 code selector (cs) 會顯示現在的權限等級 (current privilege level) 。0 代表 kernel mode,3 代表 user mode,每個系統呼叫都會暫時更改 cs 裡面的值。
Linux 核心是一塊非常特別的軟體,其中一個特性是核心自己沒有 error checking,因為核心非常信任自己,核心裡的函式都會被當成是沒有錯誤的。因為錯誤偵測跟處理需要花費不少心力,所以核心裡都是使用 assertion 來確認硬體跟軟體的一致性,一旦出現錯誤核心就會暫停或 panic()
。但是程式錯誤或是硬體錯誤還會有機會在核心出現的,例如:在 user space 操作 null 指標會產生一個 segmentaion fault,而在 kernel space 操作 null 指標或產生一個 oops。核心產生 oops 之後,情況嚴重點還會 panic()
,這下這個核心就不能被信任跟繼續執行,所以通常都會重啟,以免出現破壞記憶體的問題。
核心有不同的設計方式,以光譜兩端為例:一邊會是 monolithic kernel,而一邊是 microkernel。在 monolithic 的設計裡,每個核心的服務都位在核心裡,而相反的,microkernel 目的是要將核心的大小縮小,盡量將大部分的服務移到 user space,只在 kernel space 留下最重要的部分。
Linux 的設計偏向 monolithic kernel,所以許多程式都在 kernel space。
現在也有 hybrid 處在兩種概念之間的設計,像是 macOS。
Microkernel 會這樣做的原因不外乎就是將受到信任的程式碼減少,因為信任的程式碼減少,所以需要做的 assertion 就會減少,不像是 monolithic 需要考慮到許多因素。另一個原因是將服務移到 user space 比較有維護性,可以做到直接在核心上開發測試,直接將要測試的服務編譯完後重啟就好了。
但是 Monolithic 還會一直被留著是有原因的,因為 microkernel 的效率太差。Monolithic 可以直接跟底層硬體溝通,而 microkernel 因為將服務移到 user space,所以會有許多行程之間的溝通,造成這個問題有兩個主因:
Linux 核心試著解決這種可擴充性的問題是透過 kernel modules,可以在執行期間進行 insmod
跟 rmmod
載入跟移除核心模組。但因為模組是運行在 kernel space,所以在開發的時候會比較困難。
但是 kernel modules 也不能完全達到 microkernel 的功能,像是要在執行期間替換一個排程器是不允許的,除非有多個排程器可以在 kernel code 進行切換。
所以 modules 並不是要實作核心的程式,而是當一個擴充額外功能的工具。
當初這個排程器被設計出來並不是為了應付多處理器的架構,那時候使用了最簡單的設計。當初只使用了單一佇列存放任務,並迭代這個佇列來選取任務。在那時這個方法可行,是因當初有限制行程數量 (NR_TASKS
最初被設為 64)。
運作方法如下:
goodness
。越「好」的行程優先權越高。
好人不長命,禍害遺千年的概念 xD。好的行程一下就被挑出來執行掉了。
會取名
並且 O(n) 排程器也在 SMP 系統有些困難:
造成行程被分配到不同處理器時會有大量的 cache miss (affinity)。
執行效率會因為一個 global 的 lock 而拖慢。
在 pre-2.6 linux 版本,搶佔是不被允許的。造成一些低優先權行正在執行時,有許多高優先權行程在等待。
在
MAX_PRIO
(140) 個住列),所以要使用為元表達 140 個優先權,一個 word 32 位元,所以 BITMAP_SIZE
就為 5。Bitmap 作用是要指出哪些優先權是空的(1 為有任務,0 為空的住列),來達到快速找出任務的效果。
優先權的計算在這裡會依據任務的表現,一個行程的優先權是由 static priority 跟 bonus priority 去計算的。Static 對應到 nice 值,從 -20 到 +19。Bonus 則是從 -5 到 +5,並且是被任務的行為決定的:如果行程一直都在睡覺,那 bonus 會被設為 -5 提高優先權,反之亦然。
但是
還有問題是 starvation,低優先權的任務要等到所有高優先權任務執行完,才有機會被執行,導致一些即時行程會因為遲遲等不到處理器而緊張(priority inversion)。
這邊要先知道 RSDL 排程器,可以去看老師的書 2.2.4 章,簡潔明瞭。
CFS 跟 RSDL 不同的地方在於 CFS 並沒有使用固定的 timeslice,也沒有使用任何 heuristic 函示來計算優先權,而是試著去在硬體上製作一個理想的多工 CPU。假設我們有一個很小很小的 timeslice,那我們就可以很理想地分給每個行程
但是真正的處理器會在這種排程下做非常多的 context switch,所以現實世界不太可能會有這種完全平等的演算法。退而求其次的方法是使用 virtual runtime(vruntime
),代表每個行程在理想情況下應該執行的時間。CFS 就是透過 vruntime 來選擇下一個任務,目標是維持每個任務的 vruntime ,讓每個 vruntime 都差不多大。所以 CFS 會挑選 vruntime 最小的任務來執行。
執行完的任務會將 vruntime 加上
參考資料:
跟上面
紅黑樹的特性這邊就不做介紹了,網路上很多。
排程器會使用到一些參數,這些參數通常都會存在 task_struct
裡面的 sched_enitity
中,sched_enitity
裡面會有 load_weight
、
struct rb_node run_node
、vruntime
等等的重要資訊。
計時器會在每個 tick period 透過 do_timer()
跟 update_process_times()
去更新系統的 uptime 還有各個行程的資訊。
在 update_process_times()
裡會呼叫到 scheduler_tick()
,就是排程器每次更新 task 資訊的地方。scheduler_tick()
有幾個重要的呼叫:
sched_clock_tick()
更新 per_CPU
sched_clock_data
。
update_rq_clock(rq)
更新 runqueue 裡的
clock_task
,會在update_curr()
使用到clock_task
。
task_tick(rq, curr, 0)
更新每個 task 的資料。
task_tick
會依照在 sched_class
所定義好的函示執行對應的動作,這邊就以老師的書裡的例子做使用,就會是 .task_tick = task_tick_fair
(p.71),所以會執行 task_tick_fair
。
task_tick_fair
會從傳入的 rq
拿到 sched_entity
,並且透過一個 for
來對每一個 entity 進行更新。
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued);
}
真正去更變更一個 enitity 的 vruntime
是 enitity_tick()
會呼叫到的第一個函式,update_curr()
。更新完 vruntime
就會檢查現在的 enitity 有沒有用完分配的時間或是有沒有其他 entity 需要被執行。
一個 enitity 的 vruntime
的更新會發生在 update_curr()
中,所執行的運算如下:
其中的 delta_exec
是目前這個 enitity 所執行的時間,透過 curr->exec_start
減去現在的時間得到。而 weight
計算的公式是:
分配給一個任務的 timeslice
是在 entitiy_tick()
最後幾行計算的,透過呼叫 check_preempt_tick()
,而 check_preempt_tick()
會呼叫 sched_slice(cfs_rq, curr)
去計算要分配給任務的 timeslice
。
這邊要注意的是要計算出 timeslice
跟 vruntime
所呼叫的函式都是 calc_delta()
因為這兩個都差別只有傳進去的參數不同,運算是一樣的。target_latency
是每個任務執行一次所需要的最小時間。
sum_exec_runtime
會紀錄任務目前所執行的時間
這邊會列出一些等等會使用到的變數:
排程器的週期
一個任務最少會被執行到的時間
剛醒來的任務的
vruntime
如果大於現在任務的vruntime
,照理來說會選擇原本的任務繼續執行,但如果兩個任務vruntime
之間的差距沒有超過 sched_wakeup_granularity 就會讓醒來的任務執行。
確保讓每個任務都執行到一次,所以會取
。
這邊的 nice 值是在說明對系統友善的程度,nice 值越大,priority 越低。
之後會將建立的任務加入紅黑樹中,加入紅黑樹的動作是在 nr_running
被增加之前完成的,詳細程式碼在 kernel/sched/fair.c 中的 enqueue_task_fair。
vruntime
最小的任務,也就是在紅黑樹最左邊的節點。更新被執行的時間 sum_exec_runtime += delta_exec
delta_exec
就是現在的時間減去任務開始執行的時間:delta_exec = now - curr->exec_start
。
更新 vruntime
vruntime += delta_exec/task_weight * 1024
從上方的運算式可以看出來如果一個任務的 weight
比較大,那這個任務 vruntime 的增加速度就會比較慢,讓排程器更容易挑選到這個任務。
輸入:
3 11
A 1 3 0
B 2 4 -2
C 2 3 2
第一行的參數分別為任務數量跟總執行時間,接下來的每一行就是任務的名稱、開始的時間、需要執行的時間長度、nice 值。
A 會在第一個單位到達,並且總共要執行 3 個單位,nice 值是 0。以此類推…
Scheduler 開始,上述開頭有一些變數我們先假設為下列值:
任務 A 被加入排程器(burst time = 3, nice = 0),A 的 timeslice:
這時候的 vruntime
是 0,因為根本沒有執行時間。
timeslice
= 6,vruntime
= 0
所以任務 A 會被選中,並且從紅黑樹中移除。
任務 B(burst time = 4, nice = -2)、C(burst time = 3, nice = 2)被加入排程器。
A 的 timeslice:
B 的 timeslice:
C 的 timeslice: = 1.2
A 的 timeslice 跑完,但是 A 還沒有完成,所以會被加回紅黑樹中,新的 vruntime:
排程器會挑選任務 B 執行,給予 timeslice = 2.91。
任務 B 還在 timeslice 內,也沒有新的任務被加入。
B 的 timeslice 跑完,且 B 還沒完成,所以會加回紅黑樹中,新的 vruntime:
排程器會挑選任務 C 並且將其從紅黑樹移除,給予 timeslice = 1.2。
任務 C 還在 timeslice 內,也沒有新的任務被加入。
C 的 timeslcie 跑完,且 C 還沒完成,所以會加回紅黑樹中,新的 vruntime:
A: vruntime = 2
B: vruntime = 1.91
C: vruntime = 3.12
排成器會挑選任務 B(vruntime = 1.91 最小),並給予 timeslice = 2.91。
B 執行完成。
A 的 timeslice:
C 的 timeslice:
A: vruntime = 2
C: vruntime = 3.12
排程器挑選任務 A,給予 timeslice = 3.66。
A 執行完成。
C 的 timeslice:
排程器挑選任務 C,給予 timeslice = 6。
C 執行完成。
使用 reactjs 來實作,詳細程式碼在這裡。目前將排程邏輯輸出在 console,可以透過 f12 看目前狀態。
struct rb_node {
unsigned long __rb_parent_color;
struct rb_node *rb_right;
struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
看了
rb_node
跟list_head
之後,發現 Linux 風格的資料結構都不會包含 data,而是會將rb_node
或是list_head
這些資料結構包在一個更大的結構體裡面。
一個紅黑樹節點包含:父節點、左節點、右節點、顏色。而在 Linux 宣告的程式碼中,很巧妙地將父節點跟顏色一起宣告,使用 unsigned long __rb_parent_color
將指向父節點的指標跟自己的顏色合併。
透過 __attribute__((aligned(sizeof(long))))
讓編譯器知道 struct rb_node
會對齊 sizeof(long)
,這樣就會讓指標最右兩個位元是沒有使用到的,就可以把顏色的資料放進去其中一個位元中。
舉例來說,一個節點指標如果指向 0xF724315C
,這個位址轉成二進制會變成 ...1100
,最右邊兩個位元會是 0,所以開發者就把這其中一個沒用到的位元拿來儲存顏色。
更詳細的內容可以去看我的紅黑樹筆記
用 javascript 實作紅黑樹裡的插入跟刪除功能,節點的架構如下:
class Node {
constructor(key, value) {
this.key = key;
this.value = value;
this.left = null;
this.right = null;
this.color = RED;
this.parent = null;
}
}
紅黑樹的需要考慮到的實作有:插入、刪除、平衡,插入是我花比較多時間的地方。
原本考慮想說找找看網路上有沒有現成的套件,但還是覺得自己寫會對這個資料結構更熟悉。
function click_next_clock(){
schedule_tick();
add_tasks();
update_new_tasks();
schedule();
}
架構簡單來看會像上面那樣,每次點 next clock 按鈕意思是進入下一個 clock。每一個 clock 都會去呼叫 sched_tick()
更新 schedule entity 裡的一些資訊。如果有超過 timeslice 或是任務完成的話,就會呼叫 schedule()
選擇下一個任務。 如果有新的任務被加進 readyqueue
也會呼叫排程器,重新計算所有任務的 timeslice
。如果有任務執行完 timeslice
但還沒有完成,就會更新 vruntime
並加回紅黑樹。
會執行的動作:
schedule_tick
sum_exec_runtime
。如果執行完成或是用完 timeslice 的情況會在這邊判斷等等要不要 schedule()
。用完 timeslice 的任務也會需要重新計算 vruntime
並且加回紅黑樹中。add_tasks
update_new_tasks
schedule
vruntime
的任務執行,並從紅黑樹中移除。普通的 CFS 會去從任務的角度去分配執行時間,比如說:使用者 A 有 49 跟任務,使用者 B 有 1 個任務,那這樣 CFS 會分配這 50 個任務各 1/50 的執行時間。但是這樣從使用者的角度來看,A 被分配到快所有的時間,這樣對 B 不公平。
這個章節介紹了 groups 的概念,可以把任務分到一個群組,對一個群組去做排程,達到從使用者角度的公平。
Linux 在 v2.6.24 加入 group scheduling,當核心被設定 CONFIG_FAIR_GROUP_SCHED
就會被使用。
在看老師的書 Demystifying the Linux CPU Scheduler 的時候,突然好奇說每一個任務的 loading 是如何運算的,畢竟排程器上的任務如此多,計算出 load 然後依據每個 CPU 的狀態去做 task migration 應該是很重要的課題。所以我就跑去 pelt.c 看了一下實際的運算是如何做到的。
pelt 全名是 Per Entity Load Tracking
在看程式碼的時候,發現有個地方的中括號打反了變成 [1002..1024[
,我就想說試試看發人生第一個 patch。
結果自己在摸索如何發 patch 花了好多時間…,轉眼間就早上了。所以就記錄了一下自己發 patch 的過程來紀念逝去的時間。雖然這個 patch 最後是我誤會中括號的用法,所以並沒有被採納,但跟這些厲害的人交流讓人有點興奮><
結果發現反過來的中括號是法文的用法,是我自己搞錯 xD
我發的 patch 在這裡。
記錄我發 patch 的過程可以點這裡,就不放在排程器筆記這裡了,佔空間。