---
tags: LINUX KERNEL, LKI
---
# Linux 核心搶佔
> 資料整理: [jserv](https://wiki.csie.ncku.edu.tw/User/jserv)
## 何謂搶佔 (preemptive)?

在以優先權為主的排程策略中,當新的行程 (上圖的 Task$_2$) 進入「可執行」(runnable) 狀態,作業系統核心的排程器會檢查其優先權。若該行程的優先權高於目前正在執行的行程 (上圖的 Task$_1$),核心便觸發搶佔 ([preempt](https://www.dictionary.com/browse/preempt)),中斷目前行程的執行,讓更高優先權的行程取而代之。
> 1. to occupy (land) in order to establish a prior right to buy.
> 2. to acquire or appropriate before someone else; take for oneself; arrogate:
> a political issue preempted by the opposition party.
> 3. to take the place of because of priorities, reconsideration, rescheduling, etc.; supplant:
> The special newscast preempted the usual television program.
> :notebook: 漢語「[搶佔](https://lib.ctcn.edu.tw/chtdict/content.aspx?TermId=22670)」沒有顯著「優先權」的意涵
早期 Linux 核心 (v2.4 及更早) 不支援核心搶佔。當時 Linux 定位為伺服器與工作站作業系統,設計目標偏重吞吐量 (throughput) 而非回應延遲 (latency),不可搶佔的核心簡化同步設計,核心程式碼執行期間不會被其他行程打斷,因而降低單 CPU、process context 之間的重入風險。然而這不代表可完全省略同步機制,在 SMP 與 interrupt context 下,許多臨界區域 (critical section) 仍需額外的 lock 保護。隨著 Linux 擴充至桌面、嵌入式與多媒體領域,不可搶佔核心引發的問題日益明顯:
* 行程從使用者模式進入核心模式後,其他行程須等待該行程退出核心模式才有機會執行。若核心路徑耗時較長 (如檔案系統操作、記憶體回收),高優先權的互動式行程只能等待,導致使用者可感知的延遲
* 若低優先權行程正在執行臨界區域,中途被中斷喚醒高優先權行程,後者因需要進入同一臨界區域而被迫等待,造成優先權反轉 (priority inversion)
推動 Linux 核心認真支援搶佔的關鍵因素:
* 桌面與多媒體需求:2000 年代初期,Linux 桌面化加速,音訊播放中斷 (audio skip)、滑鼠卡頓等延遲問題成為使用者體驗的痛點。Robert Love 在 2002 年提出的核心搶佔修補 (preemptible kernel patch) 於 Linux 2.5.4-pre6 併入主線,成為 v2.6 的 `CONFIG_PREEMPT` 選項
* 嵌入式與即時應用:工業控制、機器人、車載系統等對確定性延遲有嚴格要求。Ingo Molnar 與 Thomas Gleixner 自 2004 年起推動 `PREEMPT_RT` 修補,相關工作多年來逐步併入主線;到 Linux v6.12,`PREEMPT_RT` 已可在主要架構上正式啟用,成為 mainline RT 支援的重要節點
* 延遲與吞吐量的取捨:搶佔並非無代價,每次搶佔點的檢查、context switch 的 cache/pipeline 影響,以及更複雜的同步機制都會降低吞吐量。核心因此提供多種搶佔模式,讓使用者依情境選擇
搶佔的存在是為了讓高優先權任務獲得更低的延遲 (latency),提高系統的即時 (real-time) 能力。
> 延伸閱讀: [Linux 核心設計: PREEMPT_RT 作為邁向硬即時作業系統的機制](https://hackmd.io/@sysprog/preempt-rt)
本文以 Linux v6.14 原始程式碼為主要探討標的,程式碼片段與函式名稱皆以該版本為準。涉及 v6.14 之後的發展 (如 v6.15 RCU 整合、v6.16 Arm64 lazy preemption) 會在相應段落標註版本。分成四個子議題:
* 搶佔模式的演進,討論 `PREEMPT_NONE`, `PREEMPT`, `PREEMPT_RT`, `PREEMPT_DYNAMIC` 與 `PREEMPT_LAZY` 的差異
* 搶佔旗標何時被設定,分析排程器何時判定該切換任務
* 搶佔何時真正發生,分析控制流何時走到 `schedule()`
* `preempt_count`、中斷與除錯工具,說明核心何時能切換,及問題排除
## 搶佔模式的演進
Linux 核心歷經數個搶佔模式的演變:
| 模式 | 行為 | 適用情境 |
|---|---|---|
| `PREEMPT_NONE` (自 v2.6) | 僅在系統呼叫返回及中斷返回使用者空間時搶佔,透過散布於核心各處的 `cond_resched()` 提供自願讓出點 | 伺服器、高吞吐量運算 |
| `PREEMPT_VOLUNTARY` (自 v2.6) | 在 `PREEMPT_NONE` 基礎上增加更多 `cond_resched()` 呼叫點,降低延遲但仍非完全搶佔 | 桌面系統 (折衷) |
| `PREEMPT` (full) (自 v2.6) | 核心模式下只要 `preempt_count` 為 0 且 `TIF_NEED_RESCHED` 被設定,即可搶佔 | 桌面、嵌入式、低延遲需求 |
| `PREEMPT_RT` (自 v6.12) | 將多數 `spinlock_t` 轉為可休眠、具 priority inheritance 的 rt_mutex 型態鎖,讓大部分核心路徑可被搶佔;<br>`raw_spinlock_t` 與部分低階路徑仍為例外;Linux v6.12 是 `PREEMPT_RT` 能在主要架構上正式啟用的重要 mainline 里程碑 | 硬即時系統 |
| `PREEMPT_DYNAMIC` (自 v5.12) | 透過架構相關的執行期修補機制 (如 static call 或 static key),允許單一核心映像檔在開機時選擇搶佔模式 (`preempt=none\|voluntary\|full`) | 通用映像檔 |
| `PREEMPT_LAZY` (自 v6.13) | 對 `SCHED_NORMAL` 行程採延遲搶佔,僅對 RT/DL 行程立即搶佔,兼顧吞吐量與延遲 | 介於 `PREEMPT_VOLUNTARY` 與 full preemption 之間的折衷 |
下表從搶佔旗標設定、檢查點、lazy 旗標與 `preempt_count` 四個面向,比較各模式的行為差異:
| 面向 | `PREEMPT_NONE` / `VOLUNTARY` | `PREEMPT` / `DYNAMIC=full` | `PREEMPT_LAZY` | `PREEMPT_RT` |
|---|---|---|---|---|
| 誰設定搶佔旗標 | 排程器 (`resched_curr()`);`cond_resched()` 提供自願讓出點,本身不是旗標來源 | 排程器 (`resched_curr()`) | 排程器 (`resched_curr()` / `resched_curr_lazy()`) | 排程器,另搭配 rt_mutex / priority inheritance |
| 典型檢查點 | 返回使用者空間;`cond_resched()` | 返回使用者空間;中斷退出;`preempt_enable()`;`spin_unlock()` | 返回使用者空間一定看 lazy;核心內部搶佔點通常只看一般旗標 | 大部分核心路徑可被搶佔,仍受 `raw_spinlock_t` 等低階例外限制 |
| 是否檢查 lazy 旗標 | 否 | 否 | 是,但主要在返回使用者空間的路徑 | 視實際組態與搶佔模型而定 |
| 是否受 `preempt_count` 影響 | 是 | 是 | 是 | 是,但 lock 語義與非 RT 組態不同 |
### PREEMPT_DYNAMIC
Linux v5.12 (2021 年) 引入 `PREEMPT_DYNAMIC`,依架構以 static call、static key 等機制在開機時修補搶佔相關的呼叫點。啟用此機制後,`preempt_schedule()`、`irqentry_exit_cond_resched()`、`cond_resched()` 等路徑可在執行期切換為實際實作或空操作,使發行版得以用單一核心映像檔支援多種搶佔模式。
開機參數 `preempt=` 可選值:
* `none`: 等同 `PREEMPT_NONE`,`cond_resched()` 為有效呼叫
* `voluntary`: 等同 `PREEMPT_VOLUNTARY`
* `full`: 等同 `PREEMPT`,`cond_resched()` 被替換為 NOP
* `lazy`: Linux v6.13 起新增,等同 `PREEMPT_LAZY`
架構支援:x86 (5.12)、Arm64 (5.17)、RISC-V、s390、LoongArch、PowerPC 相繼跟進。
查詢目前搶佔模式 (以 Arm64 主機為例):
```shell
$ uname -a
Linux arm-server 6.14.0-37-generic #37-Ubuntu SMP PREEMPT_DYNAMIC \
Fri Nov 14 23:05:04 UTC 2025 aarch64 aarch64 aarch64 GNU/Linux
$ grep PREEMPT /boot/config-$(uname -r)
# CONFIG_PREEMPT_NONE is not set
CONFIG_PREEMPT_VOLUNTARY=y
# CONFIG_PREEMPT is not set
CONFIG_PREEMPT_DYNAMIC=y
...
# PREEMPT_DYNAMIC 啟用時,查詢執行期選用的模式
# 需先掛載 debugfs (mount -t debugfs none /sys/kernel/debug)
$ sudo cat /sys/kernel/debug/sched/preempt
none (voluntary) full
# 括號內為目前生效的模式
# 此 Arm64 6.14 範例尚未提供 lazy 選項,與後文提到的 6.16 支援時程一致
# 量測排程延遲 (需安裝 rt-tests 套件)
$ sudo cyclictest -t1 -p 80 -i 10000 -l 5000 -h 200 -m -q
# ...
# Min Latencies: 00006
# Avg Latencies: 00010
# Max Latencies: 00017
```
以下為上述 cyclictest 在同一台 Arm64 主機上的實測直方圖 (5000 次取樣,interval=10 ms,FIFO priority=80):

延遲集中在 7-13 $\mu$s 區間,最大值 17 $\mu$s,無超過 200 $\mu$s 的 overflow。此結果反映 `PREEMPT_DYNAMIC` 在 `voluntary` 模式下的實測排程延遲特性。若切換為 `full` 或 `lazy` 模式,延遲分布可能改善,但仍需以同一台機器、相同負載與相同量測條件做對照,才能得出可靠結論。
### `PREEMPT_LAZY` 與 `TIF_NEED_RESCHED_LAZY`
Peter Zijlstra 提出的 `PREEMPT_LAZY` 於 Linux v6.13 採納 (2025-01 發布)。此機制將搶佔旗標拆分為二:
| 旗標 | 設定時機 | 觸發搶佔的位置 |
|---|---|---|
| `TIF_NEED_RESCHED` | RT/DL/FIFO 行程喚醒、計時器到期、明確呼叫 `resched_curr()` | 返回使用者空間、中斷退出時的核心搶佔、`preempt_schedule()` |
| `TIF_NEED_RESCHED_LAZY` | `SCHED_NORMAL` 行程的喚醒搶佔 (透過 `resched_curr_lazy()`) | 僅在返回使用者空間時;計時器 tick 會將此旗標提升為 `TIF_NEED_RESCHED` |
要點:
* 核心內部搶佔點 (如中斷退出路徑的 `irqentry_exit_cond_resched()`) 僅檢查 `TIF_NEED_RESCHED`,不檢查 lazy 旗標
* `TIF_NEED_RESCHED_LAZY` 主要在返回使用者空間時消化;若遇到計時器 tick,也可能被提升為一般旗標
* 設計目的不是追求最低延遲,而是在接近 `PREEMPT_VOLUNTARY` 的吞吐量下,保留比 voluntary 更積極、但比 full preemption 更保守的搶佔能力
lazy 旗標在計時器 tick 時可能被提升為 `TIF_NEED_RESCHED`,因此其延遲常以一個 tick 週期的量級來理解 (以 `HZ=250` 為例約 4 ms)。不過實際延遲仍受 `NO_HZ`、是否很快返回使用者空間、是否遇到明確 resched 點等因素影響,不能機械地視為固定上限。
此設計的關鍵考量是 latency 與 throughput 的折衷。full preemption 雖降低延遲,但會更頻繁搶佔持有 lock 的行程,增加 lock contention 並損害吞吐量。在資料庫 (如 PostgreSQL) 或網頁伺服器等高並行負載中,頻繁的核心搶佔會導致 CPU cache 汙染與 lock 爭用加劇,吞吐量可顯著下降。`PREEMPT_LAZY` 允許 `SCHED_NORMAL` 行程完成其時間片段或離開核心空間後才被搶佔,減少在核心態持有 lock 時被強制切換的機率。
若想實際觀察 lazy preemption,可從 tracepoint 著手。例如以 ftrace 觀察 `sched:sched_wakeup`、`sched:sched_switch` 與 `irq:irq_handler_exit`,再對照一般 `TIF_NEED_RESCHED` 與 lazy 旗標最終於何處被消化。對教學而言,不必一開始就追完整 call graph,只要先看出「發生喚醒發生,但未必立刻在核心內搶佔」這個現象即可。
最小觀察範例如下:
```shell
# 啟用相關 tracepoint
echo 0 | sudo tee /sys/kernel/tracing/tracing_on
sudo sh -c 'echo > /sys/kernel/tracing/trace'
echo 1 | sudo tee /sys/kernel/tracing/events/sched/sched_wakeup/enable
echo 1 | sudo tee /sys/kernel/tracing/events/sched/sched_switch/enable
echo 1 | sudo tee /sys/kernel/tracing/events/irq/irq_handler_exit/enable
echo 1 | sudo tee /sys/kernel/tracing/tracing_on
# 在另一個終端製造 CPU 壓力或喚醒事件後,擷取 trace
sudo cat /sys/kernel/tracing/trace | less
# 觀察完畢後關閉 tracing
echo 0 | sudo tee /sys/kernel/tracing/tracing_on
echo 0 | sudo tee /sys/kernel/tracing/events/sched/sched_wakeup/enable
echo 0 | sudo tee /sys/kernel/tracing/events/sched/sched_switch/enable
echo 0 | sudo tee /sys/kernel/tracing/events/irq/irq_handler_exit/enable
```
不用逐行還原整個 call graph,只要比對 `sched_wakeup` 與後續 `sched_switch` 的相對位置:若喚醒事件發生後沒有立即在核心內切換,而是延後到返回使用者空間附近才出現 `sched_switch`,通常就是 lazy preemption 的典型跡象。
#### `PREEMPT_LAZY` 的版本演進
`PREEMPT_LAZY` 的架構支援逐版擴充:
| 版本 | 日期 | 架構 |
|---|---|---|
| v6.13 | 2025-01 | x86、LoongArch、RISC-V |
| v6.14 | 2025-03 | PowerPC |
| v6.15 | 2025-05 | RCU 整合:獨立 `PREEMPT_LAZY` 組態使用 `PREEMPT_RCU=n` (偏重吞吐量),搭配 `PREEMPT_DYNAMIC` 時仍使用 `PREEMPT_RCU=y` |
| v6.16 | 2025-07 | Arm64、PowerPC dynamic preemption |
> 延伸閱讀: [The long road to lazy preemption (LWN, 2024)](https://lwn.net/Articles/994322/)、[Lazy preempt changes for v6.15 (LWN)](https://lwn.net/Articles/1011770/)
需特別注意,`PREEMPT_LAZY` 在 Linux v6.14 的脈絡下仍應視為新增的搶佔模型,而非已全面取代 `PREEMPT_NONE` 或 `PREEMPT_VOLUNTARY`。它代表核心演進方向,但不同架構、不同版本與不同發行版組態的收斂程度並不一致。閱讀 `Kconfig.preempt` 與發行系統設定時,仍需以當下實際程式碼為準。
## 搶佔何時發生?
在 Linux 核心中,使用者層級的搶佔由被搶佔行程 A 與搶佔行程 B 協作完成。示意如下:

> 行程 B 有較行程 A 更高的優先權,因此稱 Process B preempts A
搶佔並非由行程 B 單方面奪走 CPU 使用權,而是行程 B 在確認具有搶佔資格後,透過設定行程 A 的 `TIF_NEED_RESCHED` 旗標 (或在 `PREEMPT_LAZY` 模式下設定 `TIF_NEED_RESCHED_LAZY`),告知行程 A 需讓出 CPU。待行程 A 執行到搶佔點時檢查該旗標,若已設定則觸發重新排程 (reschedule),讓排程器選擇更高優先權的行程。流程如下:
1. 行程 B 進入可執行狀態
2. 核心檢查行程 B 的優先權
3. 若優先權高於正在執行的行程 A,設定行程 A 的搶佔旗標
4. 行程 A 執行到搶佔點時檢查旗標;若為核心層級,還需確認 `preempt_count` 為 0
核心搶佔機制可拆分為兩個層次:
1. 何時設定 `TIF_NEED_RESCHED` 或 `TIF_NEED_RESCHED_LAZY`
2. 何時真正進入 `schedule()`
前者偏向排程器決策,後者偏向控制流何時走到搶佔點。搶佔判斷邏輯可歸納如下表:
| 問題 | 典型答案 |
|---|---|
| 誰決定該不該換人? | 排程器設定 `TIF_NEED_RESCHED` 或 `TIF_NEED_RESCHED_LAZY` |
| 誰決定現在能不能換人? | 搶佔點本身;若在核心內,還要看 `preempt_count` 是否為 0 |
| 什麼地方會檢查 lazy 旗標? | 返回使用者空間的路徑 |
| 什麼地方通常只看一般旗標? | 核心內部的搶佔點,例如中斷退出時 |
### 何時設定 `TIF_NEED_RESCHED`?
#### 1. 目前行程的時間配額耗盡
最常見的情況是目前行程把時間配額用完。此時由計時器 tick 推動排程器更新執行狀態,必要時要求重新排程。處理函式如下:
> 以下程式碼為簡化後的概念碼,用來說明 v6.14 時 `sched_tick()` 與 `rq->curr` 的關係;若對照 Proxy Execution 後的版本,欄位名稱與排程對象可能改為 `rq->donor` 等形式。
```c
void sched_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
...
curr->sched_class->task_tick(rq, curr, 0);
...
}
```
`sched_tick()` 取出目前 CPU 的 run queue 與正在執行的行程,呼叫對應排程類別的 `task_tick()` 重新評估該行程是否仍應繼續執行。其呼叫鏈如下:
> `task_tick()` $\to$ `entity_tick()` $\to$ `update_curr()`
`update_curr()` 更新行程的 `vruntime`,隨後 `entity_tick()` 呼叫 `check_preempt_tick()` 觸發搶佔。
在 `PREEMPT_LAZY` 模式下,`sched_tick()` 在呼叫 `task_tick()` 前會檢查 `TIF_NEED_RESCHED_LAZY` 是否已設定,若是則呼叫 `resched_curr()` 將其提升為 `TIF_NEED_RESCHED`,確保延遲搶佔最終生效。
#### 2. 新喚醒的行程優先權高於目前行程
另一種常見情況是有更值得立刻執行的行程被喚醒。例如 I/O 完成後,互動式行程重新變成 runnable,排程器就要判斷是否該讓它插隊。
核心提供三個函式喚醒行程:
* `wake_up_new_task()`: 喚醒新建的行程 (如 `fork` 產生的子行程)
* `wake_up_process()`: 以 `TASK_NORMAL` 作為 wake mask,喚醒一般 sleep 狀態的行程
* `wake_up_state()`: 喚醒指定狀態的行程
其中後二者呼叫 `try_to_wake_up()`,其呼叫鏈:
> `try_to_wake_up()` $\to$ `ttwu_queue()` $\to$ `ttwu_do_activate()` $\to$ `ttwu_do_wakeup()`
在 `wake_up_new_task()` 及 `ttwu_do_wakeup()` 中,皆呼叫 `wakeup_preempt()`:
> 以下程式碼為簡化後的概念碼,用來說明 `wakeup_preempt()` 的判斷方向;由於排程器核心資料結構仍在演進,欄位名稱與比較標的可能隨版本調整。
```c
void wakeup_preempt(struct rq *rq, struct task_struct *p, int flags)
{
if (p->sched_class == rq->curr->sched_class) {
rq->curr->sched_class->wakeup_preempt(rq, p, flags);
} else if (sched_class_above(p->sched_class, rq->curr->sched_class)) {
resched_curr(rq);
}
...
}
```
> 自 Linux v6.7 起 (Ingo Molnar, commit `e23edc86b09d`),外層函式與排程類別回呼皆由 `check_preempt_curr` 更名為 `wakeup_preempt`,使語義更明確。在 v6.14 中,比較標的為 `rq->curr`;若新喚醒行程的排程類別優先權較高,便直接要求重新排程。
> Linux v6.17 引入 Proxy Execution 後,比較標的改為 `rq->donor`。在該架構下,高優先權任務因 mutex 被阻塞時,會將執行資格捐贈給持鎖者,使後者以捐贈者的優先權執行並儘快釋放 lock。這將傳統的排程 task 轉變為排程執行 context,是解決優先權反轉的另一種方向。
`wakeup_preempt()` 的職責是在行程被喚醒的當下,立刻判斷它是否應搶佔目前正在執行的行程。
若搶佔行程與 `rq->curr` 屬同一排程類別,呼叫該類別的 `.wakeup_preempt()` 決定是否搶佔;若搶佔行程的排程類別優先權較高 (`sched_class_above()` 為真),則直接呼叫 `resched_curr()` 設定 `TIF_NEED_RESCHED`。換句話說,同一排程類別交給 class callback 判斷,跨排程類別則直接要求 reschedule。
以 fair 排程類別為例,`check_preempt_wakeup_fair()` 負責判斷是否搶佔。這裡需要區分歷史機制與當前機制,避免不同版本的程式碼與文字對不上。
歷史機制,也就是 CFS 時期:
* 比較二個行程的 `vruntime` 差值是否超過 wakeup granularity (`gran`),並以 `wakeup_preempt_entity()` 輔助函式實作。若在舊版文章或舊版核心程式碼中看到這個函式,屬於 CFS 的脈絡。
目前的機制亦即 EEVDF (Earliest Eligible Virtual Deadline First):
* 自 Linux v6.6 起,fair 排程器改以 EEVDF 為主。搶佔判斷不再以單純的 `vruntime` 差值為主,而是基於 lag 與 virtual deadline 兩個概念。須注意,這不代表所有舊有的 wakeup tunable 或保護機制都已完全消失,而是整體決策邏輯已由 EEVDF 主導。
* lag: 行程「應得 CPU 時間」與「實際 CPU 時間」之差。lag $\ge 0$ 表示行程尚未用完其公平份額,具備被排程的資格 (eligible)。
* virtual deadline: 行程下一個時間片段的虛擬截止期限。截止日期越早,越應優先執行。
目前 EEVDF 的搶佔條件可歸納為:
1. 目前行程為 IDLE 排程策略,且搶佔行程非 IDLE 排程策略,此時無條件搶佔
2. 喚醒行程具備資格 (eligible,即 lag $\ge 0$) 且其虛擬截止日期早於目前行程,透過 `__pick_eevdf()` 比較截止日期決定搶佔
最終呼叫 `resched_curr_lazy()` 設定搶佔旗標。在 `PREEMPT_LAZY` 模式下,此函式對 `SCHED_NORMAL` 行程設定 `TIF_NEED_RESCHED_LAZY`;非 lazy 模式下等同 `resched_curr()` 設定 `TIF_NEED_RESCHED`。
### 何時設定 `preempt_count`?
`TIF_NEED_RESCHED` 回答的是想不想切換,`preempt_count` 回答的是現在能不能切換。以下若未特別註明,先以非 `PREEMPT_RT` 組態理解。此時只要核心正處在不可搶佔區段,例如持有 spinlock、處於 hardirq,或正在執行 softirq,`preempt_count` 就不會是 0。
`preempt_count` 是一個 per-thread 計數器,其位元欄位編碼多種 context 資訊:
| 位元範圍 | 欄位 | 意義 |
|---|---|---|
| 0-7 | preemption count | `preempt_disable()` 的巢狀深度 |
| 8-15 | softirq count | 軟體中斷的巢狀深度 |
| 16-19 | hardirq count | 硬體中斷的巢狀深度 |
| 20-23 | NMI count | 不可遮罩中斷的巢狀深度 |
只要任何欄位不為 0,`preempt_count` 整體就不為 0,代表目前處於某種不可搶佔的 context。核心據此提供一系列 context 判斷巨集:
* `in_hardirq()`: 硬體中斷 context
* `in_serving_softirq()`: 軟體中斷 context
* `in_nmi()`: NMI context
* `in_task()`: 行程 context (非以上任何一種)
* `in_atomic()`: `preempt_count != 0`,泛指所有不可搶佔的狀態。在 `PREEMPT_RT` 下須特別注意:因為 `spinlock_t` 不再停用搶佔,且 softirq 也改於 thread context 執行,持有一般 `spinlock_t` 或處理 softirq 工作時,`in_atomic()` 都未必如非 RT 組態那樣成立,這是開發 RT 驅動程式時常見的陷阱
在部分架構上,`preempt_count` 與 `need_resched` 的記憶體佈局經過最佳化,以降低搶佔判斷的成本。x86 將兩者放在相鄰的記憶體位置,使得搶佔計數器為零且需要重新排程這兩個條件可用單一指令完成判斷。Arm64 則將兩者包裝為 64 位元 union (little-endian 下,較低 32 個位元為 `count`,較高 32 個位元為 `need_resched`),以 `preempt_count == 0` 一次比較同時確認兩個條件。
核心提供以下巨集操作 `preempt_count`:
```c
#define preempt_count_add(val) // preempt_count 增加 val
#define preempt_count_sub(val) // preempt_count 減少 val
#define preempt_count_inc() // preempt_count 遞增
#define preempt_count_dec() // preempt_count 遞減
#define preempt_disable() // 停用搶佔且 preempt_count 遞增
#define preempt_enable() // 啟用搶佔且 preempt_count 遞減
#ifdef CONFIG_PREEMPTION
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
```
`preempt_enable()` 透過 `preempt_count_dec_and_test()` 檢查計數器是否歸零。若歸零,代表先前禁止搶佔的條件已解除,便呼叫 `__preempt_schedule()` 觸發排程。
核心程式碼較少直接使用 `preempt_enable()` / `preempt_disable()`,更常透過 lock 間接操作。在非 `PREEMPT_RT` 組態下,spinlock 的 `spin_lock()` 內含 `preempt_disable()`,`spin_unlock()` 內含 `preempt_enable()`。換言之,每次釋放 spinlock 時隱含一次搶佔檢查。
spinlock 持有期間停用搶佔的原因:若允許搶佔,低優先權行程持有 spinlock 時被搶佔,高優先權行程若嘗試取得同一 spinlock 便會 busy-wait,而低優先權行程因被搶佔無法及時釋放 lock,造成優先權反轉,並可能在單一 CPU 上形成無法前進的空轉等待。須注意 `preempt_disable()` 僅停用目前 CPU 的搶佔;在 SMP 系統上,其他 CPU 仍可並行執行核心程式碼,因此涉及跨 CPU 共享資料時仍須使用 spinlock 等同步機制。
> 在 `PREEMPT_RT` 組態下,上述行為截然不同:多數 `spinlock_t` 被轉換為可休眠的 rt_mutex,持有期間不再停用搶佔,也不會 busy-wait。這使得幾乎所有核心路徑皆可被搶佔,但同時也意味著 lock 持有者可能被排程出去,因此 `PREEMPT_RT` 引入 priority inheritance 機制,確保持有 lock 的行程繼承等待者中的最高優先權,避免優先權反轉。僅 `raw_spinlock_t` 仍維持傳統的停用搶佔行為,保留給極少數不可休眠的低階路徑 (如排程器本身、中斷控制器操作)。
直接使用 `preempt_disable()` 的典型情境是存取 per-CPU 資料:
```c
int cpu = smp_processor_id(); /* 取得目前 CPU 編號 */
per_cpu_data[cpu] = value;
/* 若此處發生搶佔,行程可能被遷移至其他 CPU,
後續的 smp_processor_id() 將回傳不同值 */
something = per_cpu_data[smp_processor_id()];
```
在一般可搶佔核心中,以 `preempt_disable()` / `preempt_enable()` 包裹這段程式碼,可確保行程不會在存取期間被遷移。
但在 `PREEMPT_RT` 組態下,這種寫法不宜視為通用解法。若需求只是暫時固定在目前 CPU 上,較合適的工具是 `migrate_disable()` / `migrate_enable()`;若要保護 per-CPU 資料結構,則通常應改用 `local_lock_t` 等 RT 友善機制,而不是單靠 `preempt_disable()`。
在 `PREEMPT_DYNAMIC` 啟用時,`preempt_count` 無條件維護 (即使選擇 `preempt=none` 模式),額外負擔在現代硬體上可忽略,且能確保執行期切換搶佔模式的一致性。
## 中斷處理與搶佔的關係
核心層級搶佔的多個時機與中斷處理流程密切相關,以下先整理中斷處理的基本架構。
Linux 中,許多中斷處理拆分為二部分:
* top-half: 由硬體觸發,須儘快完成執行
* bottom-half (簡稱 `bh`): 由 top-half 延後處理的後半段工作。典型情況下,hard IRQ handler 結束後會儘快處理 softirq;若系統負載較高,也可能延後交由 `ksoftirqd` 等 thread context 處理
以時間順序來看:

以處理層級來看:

Bottom-half 在 Linux 核心對應的機制:
* softirq: 即 software interrupt。非 `PREEMPT_RT` 時通常在 interrupt context 內執行,數量固定且靜態註冊;在 `PREEMPT_RT` 上則多改由 thread context 執行
* tasklet: 建構在 softirq 之上的動態機制,確保同一 tasklet 在同一時間只會在一個 CPU core 上執行。目前逐步被 `WQ_BH` workqueue 取代
* workqueue: 在 process context 內執行排入的工作,容許休眠操作,較 tasklet 有彈性
* `WQ_BH` workqueue: 建構在 workqueue 基礎設施上的 bottom-half 介面,用來提供接近 tasklet 的使用方式;雖沿用 workqueue API,但其執行語義仍屬 bottom-half 脈絡,不能把它等同為一般可休眠的 process context workqueue
詳見: [Linux 核心設計: 中斷處理和現代架構考量](https://hackmd.io/@sysprog/linux-interrupt)
## 何時執行搶佔?
若先只看最常遇到的入口,可先記住這 3 類:
* 返回使用者空間前,`exit_to_user_mode_loop()` 同時檢查一般與 lazy 旗標
* 中斷發生於核心模式時,`irqentry_exit_cond_resched()` 檢查 `preempt_count` 與一般旗標
* 離開不可搶佔區段後,例如 `preempt_enable()` 或 `spin_unlock()`,核心可能立即補做搶佔檢查
### 使用者層級搶佔
本節說明核心在返回使用者模式之前的搶佔檢查路徑。不論先前是經由系統呼叫還是中斷進入核心,最後都會在 exit-to-user 路徑檢查搶佔旗標,同時檢查 `TIF_NEED_RESCHED` 與 `TIF_NEED_RESCHED_LAZY`。
#### 1. 系統呼叫結束
系統呼叫返回使用者模式時檢查搶佔旗標。以 x86 架構為例:
> `do_syscall_64()` $\to$ `syscall_exit_to_user_mode()` $\to$ `exit_to_user_mode_prepare()` $\to$ `exit_to_user_mode_loop()`
`exit_to_user_mode_loop()` 內容如下 (Linux v6.13+ 版本):
```c
static unsigned long exit_to_user_mode_loop(struct pt_regs *regs,
unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
local_irq_enable_exit_to_user(ti_work);
if (ti_work & (_TIF_NEED_RESCHED | _TIF_NEED_RESCHED_LAZY))
schedule();
...
}
```
返回使用者空間的路徑同時檢查 `TIF_NEED_RESCHED` 及 `TIF_NEED_RESCHED_LAZY`,兩者任一被設定即觸發排程。
#### 2. 中斷結束
若中斷發生在使用者模式,返回路徑與系統呼叫相同,最終仍會走到 exit-to-user 檢查點。因此,對 user space 來說,可把「系統呼叫返回」與「中斷返回」視為同一類搶佔時機。
### 搶佔判定流程
以下流程圖為概念化整理,用來幫助建立整體控制流印象;實際路徑仍需依架構、核心版本與組態對照。
以下流程圖總結從硬體中斷到搶佔執行的判定邏輯:
```
Interrupt Occurs
│
▼
ISR (top-half)
│
▼
irqentry_exit()
│
├─ 中斷發生在使用者模式?
│ YES ──▶ exit_to_user_mode_loop()
│ │
│ ▼
│ TIF_NEED_RESCHED 或
│ TIF_NEED_RESCHED_LAZY 被設定?
│ │
│ YES ──▶ schedule()
│
└─ 中斷發生在核心模式?
YES ──▶ irqentry_exit_cond_resched()
│
▼
preempt_count == 0?
│
NO ──▶ 不搶佔 (持有 lock 或處於 atomic context)
│
YES
│
▼
TIF_NEED_RESCHED 被設定?
(不檢查 lazy 旗標)
│
YES ──▶ preempt_schedule_irq()
```
### 核心層級搶佔
除返回使用者空間外,核心亦可在維持核心模式執行期間觸發搶佔。以下針對非 `PREEMPT_RT` 的核心組態說明。
#### 1. 中斷退出時
若中斷發生在核心模式,退出時由 `irqentry_exit()` 路徑處理,並在 `irqentry_exit_cond_resched()` 檢查是否可搶佔:
> `irqentry_exit()` $\to$ `irqentry_exit_cond_resched()`
核心實作:
```c
/* v6.14 raw 實作,PREEMPT_DYNAMIC 透過執行期修補包裝 */
void raw_irqentry_exit_cond_resched(void)
{
if (!preempt_count()) {
rcu_irq_exit_check_preempt();
if (IS_ENABLED(CONFIG_DEBUG_ENTRY))
WARN_ON_ONCE(!on_thread_stack());
if (need_resched())
preempt_schedule_irq();
}
}
```
> 後續版本新增 `arch_irqentry_exit_need_resched()` 作為額外的架構層級條件檢查。
若 `preempt_count` 為 0 且 `need_resched()` 為真,呼叫 `preempt_schedule_irq()`。在 `PREEMPT_DYNAMIC` 下,外層 `irqentry_exit_cond_resched()` 會依所選模式切換實作;`preempt=none` 或 `preempt=voluntary` 時可視為關閉此核心搶佔點。在 `PREEMPT_LAZY` 模式下,`need_resched()` 僅檢查 `TIF_NEED_RESCHED` (不含 lazy 旗標),因此一般 `SCHED_NORMAL` 行程不會因 lazy 旗標而在此處被核心搶佔。
#### 2. softirq 執行完畢後
以非 `PREEMPT_RT` 為例,softirq 執行期間不允許任意插入核心搶佔,因此執行前會先調整 `preempt_count`。等 softirq 跑完,再檢查是否需要補做排程:
```c
void __local_bh_enable_ip(unsigned long ip, unsigned int cnt)
{
...
/*
* Keep preemption disabled until we are done with
* softirq processing:
*/
__preempt_count_sub(cnt - 1);
if (unlikely(!in_interrupt() && local_softirq_pending())) {
/*
* Run softirq if any pending. And do it in its own stack
* as we may be calling this deep in a task call stack already.
*/
do_softirq();
}
preempt_count_dec();
...
preempt_check_resched();
}
```
#### 3. 臨界區域結束後
臨界區域也可以用同樣的方式理解:進入時先暫時禁止搶佔,離開時再檢查現在能不能切換。對常見的 spinlock 而言,這個檢查通常就發生在 `spin_unlock()` 之後。
## 排除搶佔問題
自 Linux v6.12 起,`sched_ext` (SCX) 允許透過 BPF 程式動態載入排程策略,不過應當留意,即便 CPU 排程策略可抽換,核心搶佔仍受 `TIF_NEED_RESCHED` / `preempt_count` 等基本框架約束。下方若干 Linux 核心組態能協助開發者縮減問題排除的範圍。
### `CONFIG_DEBUG_PREEMPT`
啟用此核心組態選項後,核心會在 `preempt_count` 操作時加入額外的合理性檢查,包括偵測計數器 underflow (遞減至負值)、在搶佔未停用的 context 中使用 `smp_processor_id()` (preemption-unsafe),以及 scheduling-while-atomic (在不可排程的 context 中呼叫 `schedule()`) 等問題。違規時核心會印出警告訊息與 call stack,協助定位問題來源。
### `might_sleep()`
`might_sleep()` 是核心提供的除錯巨集,用於標記此處可能會休眠。啟用 `CONFIG_DEBUG_ATOMIC_SLEEP` 後,`might_sleep()` 會依序呼叫 `__might_sleep()` 與 `might_resched()`:
```c
/* include/linux/kernel.h (簡化) */
#ifdef CONFIG_DEBUG_ATOMIC_SLEEP
#define might_sleep() do { \
__might_sleep(__FILE__, __LINE__); \
might_resched(); \
} while (0)
#endif
/* kernel/sched/core.c */
void __might_sleep(const char *file, int line)
{
...
__might_resched(file, line, 0);
}
void __might_resched(const char *file, int line, unsigned int offsets)
{
...
if (preempt_count() || irqs_disabled()) /* atomic context 檢查 */
/* 印出警告與 call stack */
...
}
```
實際的 atomic context 檢查 (`preempt_count() != 0` 或 `irqs_disabled()`) 由 `__might_resched()` 執行。`preempt_count() != 0` 代表持有 spinlock 或已明確停用搶佔,`irqs_disabled()` 代表中斷已關閉。這兩種狀態下呼叫可能休眠的函式 (如 `kmalloc(GFP_KERNEL)`、`mutex_lock()`、`copy_from_user()`) 會導致 deadlock 或排程器狀態不一致。`might_sleep()` 被廣泛植入這類函式的入口,使得 bug 在開發階段即被揭露,而非等到實際觸發休眠才產生難以重現的問題。此外,`might_resched()` 在 `CONFIG_PREEMPT_VOLUNTARY` 下等同於 `cond_resched()`,提供額外的自願讓出點。
> 常見的錯誤樣態是在 `spin_lock()` 保護的臨界區域內呼叫 `kmalloc(GFP_KERNEL)`,正確做法是改用 `GFP_ATOMIC` 或將記憶體配置移到 lock 外部。
## 延伸閱讀
* [Revisiting the kernel's preemption models](https://lwn.net/Articles/944686/) (2023 年)
* [The long road to lazy preemption](https://lwn.net/Articles/994322/) (2024 年)
* [Lazy preempt changes for v6.15](https://lwn.net/Articles/1011770/)
* [A proxy-execution baby step](https://lwn.net/Articles/1030842/) (2025 年)
* [Linux kernel preemption and the latency-throughput tradeoff](https://www.codeblueprint.co.uk/2019/12/23/linux-preemption-latency-throughput.html)