核心本身也是並行的
在階梯上你學過使用者執行緒如何避免互相踩到——一段臨界區間由一把互斥鎖或一個號誌守著,好讓競爭條件永遠不會弄壞共享資料。我們很容易以為核心本身不知怎地超然於這一切之外:那位無所不能的大樓管理員從不會跟任何人撞在一起。事實恰恰相反。核心握著整台機器裡共享得最兇的資料——就緒佇列、分頁表、開啟檔案表、每個裝置的緩衝區——而許多東西看起來都同時在碰這些資料。核心是系統上承受最大並行壓力的程式,它必須以同樣的小心來同步自己,外加幾個使用者程式碼從不會遇到的危險。
並行從哪裡來?來自三個源頭,而且它們層層疊加。第一,在多核心機器上,好幾顆 CPU 真的在同一瞬間執行核心程式碼——這是真正的平行,不只是交錯。第二,即使只有一顆核心,一個中斷(前一篇導覽裡那個門鈴)也可能在核心更新一個串列更新到一半時觸發,而處理常式可能正想去碰那同一個串列。第三,在現代核心裡,排程器能在核心自己操作到一半時把它暫停下來,去跑一個更緊急的工作。每個源頭都是「兩段核心程式碼在尷尬時刻碰到同一份資料」的不同方式,而每一個都需要它自己的防禦。
為什麼核心不能說睡就睡
當一個使用者執行緒拿不到鎖時,最仁慈的做法是把它放去睡覺、讓排程器去跑別人——這正是你已經見過的那種阻塞號誌。但有些核心程式碼是不准睡的,而光是這一條規則就重塑了一切。經典的例子是中斷情境:當門鈴響起,處理常式必須執行、完成、然後迅速退出,好讓下一個中斷能被服務。如果一個處理常式試圖阻塞去等一把鎖,根本沒有什麼合理的東西可以切換過去,整台機器就可能卡死。所以在那些情境裡,核心承擔不起一把阻塞鎖。它需要一把「等待者保持清醒、單純空轉」的鎖。
那把鎖就是自旋鎖,而在核心裡,它恰恰在「於使用者空間會很浪費」的地方賺回了身價。自旋鎖的等待者做忙碌等待——它一圈又一圈地檢查鎖,全程燒著一顆 CPU 核心。在使用者程式碼裡這通常是罪過。但一把核心自旋鎖只守著寥寥幾個指令(更新一個計數器、把一個節點接進串列),而且在奈秒之內就會釋放,所以短暫空轉反而比睡覺要付的那兩次上下文切換更便宜。這條經驗法則殘酷又清楚:持有自旋鎖只能撐幾個指令、握著它時絕不做任何可能睡覺的事,而在單一 CPU 上,自旋鎖本身幾乎什麼都不做——它真正的工作是把兩顆不同的核心擋在同一段臨界區間之外。
但光靠自旋鎖並不能解決單核心上的中斷危險。想像一下:一顆 CPU 取得自旋鎖去編輯一個串列,還沒做完,一個中斷就觸發了,而處理常式試圖去取那同一把自旋鎖。處理常式空轉著,等一把那段被中斷的程式碼永遠無法釋放的鎖——因為那段程式碼被凍住了,正在等處理常式返回。這是一個自找的死結,標準的修法是:在這把鎖被持有的短暫片刻裡,把這顆 CPU 的中斷關掉。所以真正的核心慣用法是:關中斷、抓自旋鎖、做那一小段臨界區間、放自旋鎖、再開中斷。門鈴只被靜音「剛好夠寫完一張紙條」那麼一小段時間。
可搶佔:當核心從自己腳下被抽走
這裡藏著一個更深的設計抉擇,而它正是一個老核心與一個現代核心之間的差別。問題是:當一條執行緒正在核心裡執行時(比方說正做到一個系統呼叫的一半),排程器是否被允許把它停下來、去跑一條更緊急的執行緒?這就是核心可搶佔,而它正是你在行程身上見過的那個可搶佔排程想法,現在轉過頭來對準核心自己。一個不可搶佔的核心說不行:一旦你進入核心,你就會一直跑到你完成、或自願讓出 CPU 為止。一個可搶佔的核心說可以:即使是核心程式碼,也能在幾乎任何安全的點被暫停。
為什麼要費這個事?為了反應性。想像一條媒體播放執行緒,它必須每隔幾毫秒就把音訊緩衝區補滿,否則你就會聽到爆音。如果一個背景行程在一個不可搶佔的核心裡晃進了一個很長的系統呼叫,那條音訊執行緒就無法執行,直到那個呼叫結束為止——不管它多緊急,聽得到的結果就是一聲卡頓。從「決定要切換」到「真的跑起新執行緒」之間的延遲,就是分派延遲,而一個可搶佔的核心存在的目的,就是把它做得又小又可預測。這對即時作業系統極其重要,在那裡,錯過一個期限不是卡頓,而是失敗。
可搶佔是一份會自己開出帳單的禮物。如果核心能在操作到一半時被暫停,那麼即使在單核心上,兩條執行緒也能在核心裡交錯,使多核心早已有的那些競爭死灰復燃。所以一個可搶佔的核心必須四處灑上我們一直在討論的那種同步,標出那些「被搶佔不安全」的地方。方便的是,自旋鎖慣用法已經做到了這件事:持有一把自旋鎖會關掉那顆 CPU 上的搶佔,所以那個把其他核心擋在外面的同一個原語,也擋住排程器在你臨界區間中途插進來。「取得鎖」與「現在別搶佔我」原來是同一枚硬幣的兩面。
幾乎從不上鎖的讀取:RCU 與上半/下半的拆分
當資料被不斷讀取、卻很少被寫入時,鎖就變得昂貴——想想一張路由表,每秒有成千上萬個封包查詢它,但它只在網路重新設定時才改變。為了保護那罕見的情況,而強迫每個讀取者光是看一眼都要上鎖,會把常見的情況扼住喉嚨。核心優雅的答案是讀取—複製—更新(RCU):讀取者完全不上鎖、幾乎不付出代價,而寫入者從不原地編輯資料。寫入者改為複製整個結構、修改那份副本,然後原子地交換單一個指標,好讓新的讀取者看到新版本。舊的讀取者繼續安全地用著舊版本;只有當其中最後一個也用完之後,舊副本才被回收。讀取者之所以快,正是因為寫入者扛起了所有的苦工。
還有一塊把這一切接回中斷。回想一下中斷那篇導覽裡的上半部/下半部拆分:上半部是那個微小而緊急的處理常式,在中斷關閉的情況下執行、必須一閃即完;下半部則是被排定稍後、在中斷重新開啟後才跑的可延後工作。這個拆分本身就是一種同步策略。把「中斷關閉」的窗口縮到微乎其微,核心就把它靜音門鈴的時間壓到最短;而把粗重的工作推到一個被允許睡覺的情境,就讓那份工作能用普通的阻塞鎖。在你選擇任何同步原語之前,「我的程式碼跑在哪個情境裡——它能不能睡」這個問題,是頭一個、也是最重要的問題。
可載入模組:擴充一個已經在運行的核心
現在把焦點從「核心如何協調」轉到「核心如何成長」。一個核心必須理解世上存在的每一種裝置、檔案系統與協定——但把這一切全烤進一個巨大的二進位檔,會讓它臃腫不堪,還逼得你每接一個新玩意兒就得重新編譯。答案是可載入核心模組:一塊核心程式碼,最常見的就是一個裝置驅動程式,它能被分開編譯、依需求插進一個運行中的核心,不需要時再被移除。插上一台 USB 相機,相應的模組就自己載入了;核心長出一項新能力,連一次重開機都不必。這就像大樓管理員為了一個下午雇了一位專業承包商,而不是把每個工種都養成永久編制。
這裡有個初學者常漏掉、而且必須誠實說清楚的部分:可載入模組不是一個被沙箱關起來的外掛。一旦載入,它就在完整的核心模式下執行,擁有與核心其餘部分一模一樣的全權——它與核心之間沒有任何保護邊界。模組裡的臭蟲就是核心裡的臭蟲:一個壞掉的指標能弄壞任何東西,而一個模組崩潰能把整個系統一起拖垮。這裡的模組化講的是「建置時與載入時的方便」,不是「隔離」。這正是最後一篇導覽要探討的那股張力:像 Linux 這樣的單體核心為了速度,把驅動程式當成可信任的核心內模組來跑;而微核心則為了強健,把驅動程式推到外面、變成各自隔離的行程,並以通訊成本來換取那份安全。
insert a module: user runs `insmod camera.ko`
| module's init function runs in kernel mode
| it registers itself (e.g. adds a driver to a table)
v kernel now has the new capability -- no reboot
...module handles its device, sharing all kernel locks/RCU...
| user runs `rmmod camera` (only if nobody is using it)
v module's exit function unregisters + frees, then unloads把一切串起來:撐住這一切的紀律
退一步看,整幅圖景其實是同一個主題穿著許多套戲服。不論是兩顆核心、一個中斷、一次搶佔、還是一個剛載入的模組,危險永遠是同一個:兩段程式碼在尷尬的時刻碰到了共享的核心資料。那些防禦構成了一個小而誠實的工具箱,而在它們之間做選擇,才是真正的本領。每一次,那個決定性的問題都是情境:這段程式碼能不能睡?
- 這段程式碼能睡嗎?如果它跑在中斷情境裡、或正握著一把自旋鎖,答案就是不行——在那裡絕不呼叫任何可能阻塞的東西。
- 一段不能睡、又很短的臨界區間?用自旋鎖——而如果某個中斷處理常式也會碰這份資料,就在你握著它的時候把這顆 CPU 的中斷關掉。
- 一段較長、可能會睡的工作?用一把阻塞鎖(核心的互斥鎖或號誌),並確定你身處一個被允許睡覺的情境——通常是下半部或系統呼叫路徑。
- 以讀取為主、很少寫入的資料?拿出 RCU,讓讀取者不付代價;讓寫入者去複製、交換一個指標、並延後回收舊版本。
而同步那一階梯講過的同一份殘酷誠實,如今依然適用,賭注還更高。一把核心鎖只有在每一條路徑——每個驅動程式、每個模組、每個中斷處理常式——都正確使用它時,才能保護資料;只要一個粗心的模組跳過了鎖,就替整個系統重新打開了那道競爭之門。以不一致的順序持有鎖,你就招來死結;讓一個低優先權的持有者擋住一個高優先權的等待者,你就得到優先權反轉,正是那個差點葬送火星拓荒者號任務的臭蟲。核心是機器上最有權力的程式,而那份權力正是為什麼它的紀律必須一絲不苟。接下來,最後一篇導覽會一路退後,去問一個問題:這一切究竟該不該全擠在一個大核心裡。