JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

即時作業系統、即時排程,與決定性

當一個超級迴圈再也沒辦法把每件事都準時兼顧時,你會伸手去拿一個小小的作業系統,讓它跑好幾個任務,再配一個排程器來保證最緊急的那些任務都趕得上它們的期限。這篇談的是「即時」到底是什麼意思、以優先權為基礎的排程如何把它送到手上,以及那些決定一個系統究竟是不是真正具有決定性的銳利邊角——優先權反轉、中斷延遲、最壞情況的時序。

當超級迴圈再也擠不下

第一篇留給你的是超級迴圈(super-loop):讀、決定、驅動輸出、永遠重複。對數量極為龐大的裝置來說它真的就夠了,你也該先伸手去拿它。但想像一個做三件工作的迴圈——每 1 毫秒取樣一次感測器、每 500 毫秒閃一次狀態 LED、再從一個慢速的序列埠解碼一串位元組。如果那個解碼位元組的步驟偶爾要花 4 毫秒,那麼每次發生時,你那個 1 毫秒的感測器取樣就遲到了 3 毫秒。迴圈只有單一一條控制流,所以任何地方一個慢步驟,都會延遲它之後的每一件事。你可以手動微調順序撐一陣子,但隨著工作越來越多,超級迴圈就變成一道脆弱的謎題:「誰被允許花多久」。

出路是別再寫一長串的序列,而是改寫好幾個獨立的任務(task),每一個看起來都像它自己的小超級迴圈,再把「選哪一個來跑」這份工作交給一小塊軟體:一個即時作業系統(real-time operating system,RTOS)。一個 RTOS 不是把 Linux 縮小——它沒有虛擬記憶體、沒有行程、常常沒有檔案系統,而且可以只是幾 KiB 的程式碼。它給你的,正是裸超級迴圈所缺的那一樣寶貴東西:一個能夠暫停一個任務、改跑另一個的排程器,於是一份慢工作再也擋不住一份緊急工作。每個任務都是一個有著自己堆疊的 C 函式,寫成「做我的事,然後等到下次需要我時」。

「即時」到底承諾了什麼

即時(real-time)這個詞是整個主題裡最被誤解的術語,所以讓我把它講精確,因為「即時就是快」這句口號根本是錯的。即時的意思是可預測:系統保證在一個已知的時間界限內做出回應,每一次都是,連最壞情況也是。一個總是在 50 毫秒內回答的慢系統是即時的;一個通常 1 微秒就回答、卻偶爾卡上 100 毫秒的飛快系統,不是。即時的貨幣不是平均速度,而是期限(deadline)——一個結果必須在它之前送到的時間點——而那個承諾講的是可靠地趕上它,不是講快。

它有兩種口味,把它們搞混就會設計出錯的系統。在硬即時(hard real-time)裡,錯過一個期限就是一次失敗,有時是災難性的:安全氣囊必須在撞擊後幾毫秒內引爆、馬達的換相脈衝必須準時落下否則馬達就失速、飛控必須在飛機翻覆之前更新。錯過單單一個期限就是一個臭蟲,沒得商量。在軟即時(soft real-time)裡,期限該被趕上、遲到要付出代價,但偶爾錯過一次只會讓品質下降、而不是讓系統壞掉:一張影格晚 5 毫秒到,造成一個微小的頓挫,不是當機。這個區別由硬即時與軟即時捕捉住,而它牽動了下游的一切——你選哪個排程器、留多少餘裕、必須多嚴格地分析最壞情況。

以優先權為基礎的排程如何趕上期限

排程器到底是怎麼保證緊急任務準時跑的?幾乎每個 RTOS 都用固定優先權的搶占式排程(fixed-priority preemptive scheduling),規則講起來很簡單:在每一個瞬間,跑那個準備好要跑的、優先權最高的任務;而一旦有一個更高優先權的任務變成準備好,立刻暫停目前這個、切換過去。那個「暫停再切換」就是搶占(preemption)——就是作業系統那幾級裡的搶占式排程這個想法,現在被用來服務期限。因為最緊急的、準備好的任務永遠贏得 CPU,一份在解碼位元組的低優先權工作,永遠不可能讓你那個高優先權的感測器任務錯過它 1 毫秒的節拍:感測器搶占它、做完自己的工作,而解碼器再從它被暫停的確切位置繼續。

哪個任務該拿到哪個優先權?對週期性的任務,有一個漂亮、可證明的答案,叫做單調速率排程(rate-monotonic scheduling):依速率指派優先權——跑得最頻繁(週期最短)的任務拿到最高的優先權。違反直覺地,緊急程度跟隨的是頻率,不是重要性。單調速率排程附帶一個真正的定理:如果總 CPU 使用率(每個任務的執行時間除以它的週期,再全部加起來)對許多任務維持在大約 69% 這個界限以下,那麼每一個期限都可被證明地趕上。那個界限對它的代價很誠實——你也許得讓大約三分之一的 CPU 閒置,才能保證準時,那是一個證明、而不是一個希望,所要付的價。

Rate-monotonic test (3 periodic tasks):

  task   exec C   period T   utilization C/T
  ----   ------   --------   ---------------
  A      0.2 ms    1 ms        0.20
  B      1.0 ms    5 ms        0.20
  C      3.0 ms   20 ms        0.15
                           total U = 0.55

  RM bound for n=3 :  3 * (2^(1/3) - 1)  ~=  0.78
  U = 0.55  <  0.78   ->  all deadlines provably met
  priorities by rate:  A (1ms) > B (5ms) > C (20ms)
一個算過的單調速率檢查。把每個任務的使用率 C/T 加總;如果總和維持在那個任務數所對應的界限以下,這個固定優先權的排程就可被證明地趕上每一個期限。優先權給的是重複得最快的任務,而不是最「重要」的那一個。

單調速率並不是唯一的方案。最早期限優先(earliest-deadline-first)採取一個不同的、動態的觀點:在每一個瞬間,跑那個準備好的、期限最近的任務,並隨著期限逼近而重新計算。最早期限優先(EDF)在理論上更強大——它可以把每一個期限都趕上、一路到 100% 的使用率,不浪費任何閒置餘裕——但那份強大要你付代價:優先權在執行期改變,所以排程器更複雜、在過載下的行為更難推理、而單單一次超時就可能連鎖崩塌。許多出貨的系統仍然偏好單調速率,正是因為它的固定優先權讓最壞情況容易看見、也容易信任。誠實的總結:EDF 榨出更多 CPU;固定優先權則更容易分析、也更容易在凌晨三點除錯。

優先權反轉:當排程器對自己說了謊

這裡有一個簡單的優先權規則本身並不防得了的臭蟲——它之所以出名,是因為它差點葬送了 1997 年的火星拓荒者號任務。想像三個任務:高、中、低。低任務取得一把互斥鎖去碰一個共享資源。當它持著鎖時,高任務醒來、想取同一把互斥鎖、於是必須阻塞——這很公道,它得等低任務釋放。但這時候任務醒了。它不需要那把鎖,所以排程器看到它的優先權比低任務高,就跑它。中任務想跑多久就跑多久,而在它跑的時候,低任務從來拿不到 CPU 去做完並釋放那把鎖——於是任務,系統裡最重要的那一個,就卡在那兒等著任務。優先權被默默地反轉了。

這就是優先權反轉(priority inversion),它是即時系統裡真正微妙的危險之一——你不會用隨手一測就抓到它,因為它需要那個恰好的三方時序才會現身。優先權反轉是你在同步那一級遇過的優先權反轉危險的近親,而標準的解藥是優先權繼承(priority inheritance):當一個低優先權任務持著一把高優先權任務正在等的鎖時,核心暫時把低任務的優先權拉高到和那個等待者一樣。現在中任務再也搶占不了它;低任務做完它的臨界區、釋放鎖、降回它本來的優先權,高任務就得以前進。一個好的 RTOS 會提供具優先權繼承的互斥鎖;用不用它,就是「在實驗室能動的系統」和「在火星上神祕當機的系統」之間的差別。

決定性在哪裡被贏得、又在哪裡被輸掉

一個排程器要趕上期限,唯一的前提是它能預測每一步要花多久——而預測最深的敵人,是平均情況和最壞情況之間的落差。對一個硬期限來說真正要緊的數字,是最壞情況執行時間(worst-case execution time,WCET):一個任務在所有輸入、所有路徑下,可能花的最長時間。WCET 是真的很難釘死的,而且它令人謙卑,因為那些讓程式碼平均跑得快的硬體特性,恰恰讓它的最壞情況變得模糊:一次快取失誤可以讓某一次函式執行比另一次慢上十倍,分支預測失誤添了抖動,而在更大的晶片上,MMU和分頁錯誤又添了更多。硬即時系統的設計者常常關掉快取、或把程式碼釘進快速記憶體裡,刻意拿平均速度去換一個緊湊、可知的最壞情況——這正是定義了即時本身的那個「可預測勝過速度」的交易。

時序預算裡還有一塊是排程器管不到的:晶片對外界反應有多快。當一個硬體事件觸發一個中斷,在它的 ISR 真正開始跑之前,有一段無可避免的延遲——做完目前那條指令、存好暫存器、查向量、進入處理常式。那段延遲就是中斷延遲(interrupt latency),而一個硬即時期限必須把最壞情況的延遲算進去,不是把典型的那個算進去。低中斷延遲最大的敵人,就是關閉中斷:每一次你用關掉中斷來保護一段臨界區,你都把所有東西的最壞情況延遲,延長了它們被關掉的那段時間。中斷延遲正是為什麼嵌入式工程師會把 ISR 和「中斷被關閉的區段」都壓得無情地短——你在中斷被遮蔽的狀態下花掉的每一微秒,都是加到系統裡每一個期限的最壞情況上的一微秒。

關於堆積,最後一個誠實的提醒。在有主機作業系統的系統上,你想都不想就呼叫 malloc(),但一個通用配置器的時間不是決定性的——單單一次 malloc() 可能走過一條長度未知的空閒串列,而隨著時間過去,碎裂會讓它越來越慢、甚至徹底失敗。在一個硬即時任務裡,你幾乎從不在熱路徑上呼叫 malloc();你把所有東西都預先配置好,或者用一個固定大小的記憶體池,它的每一個操作都花同樣有界限的時間。這一整級反覆出現的教訓,最後一次回來了:在即時嵌入式工作裡,你拿方便的和平均的,去換有界限的和可知的——因為一個你無法證明的保證,根本就不是保證。