---
tags: LINUX KERNEL, LKI
---
# [Linux 核心設計](https://hackmd.io/@sysprog/linux-kernel-internal): 不只是執行單元的 process
Copyright (慣 C) 2018, 2026 [宅色夫](https://wiki.csie.ncku.edu.tw/User/jserv)
==[直播錄影](https://youtu.be/MJaqeSwYIsA)==
## 點題

Linux 核心以單一 `task_struct` 結構體同時表示行程 (process) 與執行緒 (thread),同一條 `clone()` 路徑搭配不同 flag 組合,既能產生獨立行程、共享資源的執行緒,也能組成容器隔離環境。這個統一表示並非最初設計,而是長期演化的結果,也是本文的主軸。
全文依下列脈絡展開:
- 歷史演化:從 1960 年代 CTSS 和 Multics 的行程雛形、Mach 的 task/thread 分離、POSIX 標準化,到 Linux 從 LinuxThreads 到 NPTL 的改造,說明 `task_struct` 為何演化為如今的樣貌
- 資料結構:`task_struct` 的子系統指標、核心 stack 與 `pt_regs`、`clone()` 與 `copy_mm()` 的實作、Copy-on-Write (CoW) 的效率、fork/exec 分離的設計哲學
- 生命週期:行程狀態機 (`TASK_RUNNING`、`INTERRUPTIBLE`、`UNINTERRUPTIBLE`、`ZOMBIE` 等)、`do_exit()` 的資源清理、zombie 與 orphan 行程的處理
- 核心啟動:PID 0 的歷史淵源 (UNIX V6/V7 的 `sched()`、BSD/Linux 的 `swapper`)、`start_kernel()` → `rest_init()` → `kernel_init`/`kthreadd` 的啟動流程、多核 (multicore) 系統中的 idle task
- 排程實體:`sched_entity` 與 `task_struct` 的脫勾,支撐近年排程器變革:EEVDF (v6.6)、sched_ext (v6.12)、PREEMPT_RT (v6.12)、Lazy Preemption (v6.13)、Proxy Execution (v6.18 仍持續演化)
- PID 體系:四層階層 (thread/process/process group/session)、PID namespace 隔離、pidfd (v5.1 起分階段引入,v5.3 補上 `pidfd_open()`) 解決 PID 重用的 race condition
- fork 的風險:多執行緒程式中的死結陷阱、ASLR 與 fd 洩漏隱憂、`posix_spawn` 作為替代方案
- context switch:stack 切換與 `ret` 指令達成執行流轉移 (而非明確儲存 RIP)、搶佔模型、PCID/ASID 與 lazy TLB、cache pollution 對成本的放大
- 同步處理與記憶體管理:futex 和 workqueue 的角色、user/kernel address space 分隔、exception table 的安全存取機制
搭配的多項實驗在 Arm64/Linux 環境以 `ps`, `/proc`, `strace`, Ftrace, bpftrace 動手驗證上述機制,涵蓋 PID 階層觀察、context switch 成本量測、fork vs pthread 比較、排程器 wakeup 路徑追蹤、核心執行緒與一般行程的差異等面向。
## 執行緒是後來出現的概念
理解 Linux 的行程模型,得先認清一個事實:執行緒在資訊科技領域,並非一開始就存在的概念,而是作業系統經過二十年演化才逐漸成形。行程與執行緒的關係,本質上是先有執行單元,後來才逐步拆解為資源容器和執行單元。
### 1960 年代:一個行程就是一切
1960 年代的 time-sharing 系統 ([CTSS](https://en.wikipedia.org/wiki/Compatible_Time-Sharing_System)、[Multics](https://en.wikipedia.org/wiki/Multics)) 建立最初的行程概念:一個行程同時扮演資源容器和執行單元,有一個 address space 和一條控制流程。1963 年 Melvin Conway 系統化描述 fork-join 平行執行模型,1970 年代早期 UNIX 將 `fork()` 發展成行程建立的基本介面。當時的假設很單純:一個程式執行,就是一條控制流。
fork-join 模型把平行工作分成分叉與匯合二個結構化事件,後來可在 Cilk、Java Fork/Join Framework 等語言或函式庫中看到類似樣貌。不過 UNIX 的 `fork()` 不是 fork-join 的直接語言層級復刻:它建立的是新的行程與 address space,是否等待、何時 `exec()`、是否共享外部資源,皆由呼叫者自行安排。這個差異很重要,因為 UNIX `fork()` 的彈性來自作業系統資源模型,而不是來自結構化平行程式語意。
詳情見〈[UNIX 作業系統 fork/exec 系統呼叫的前世今生](https://hackmd.io/@sysprog/unix-fork-exec)〉。
### 1970-1980 年代:行程太重,需要更輕量的執行單元
隨著系統複雜度上升,單一行程模型開始捉襟見肘:圖形化人機互動應用程式需要同時處理使用者輸入和背景 I/O、伺服器需要同時服務多個連線。用行程來做這些事有二個根本問題:
* context switch 代價高: 需要切換整個 address space (page table、TLB flush)
* 記憶體浪費: `fork()` 即使有 CoW,仍需複製 page table 和各種核心資料結構
1970 年代末到 1980 年代,各種輕量化方案紛紛出現:[coroutine](https://en.wikipedia.org/wiki/Coroutine)、使用者層級執行緒、[lightweight process](https://en.wikipedia.org/wiki/Light-weight_process) (LWP)。但這些方案各有局限,且缺乏統一的語意模型。
這些輕量化方案的共同目標是降低執行單元的建立、切換與溝通成本,但語意層級不同。coroutine 強調協作式控制流轉移;使用者層級執行緒把排程放在函式庫;LWP 則讓核心看見較少量的可排程實體。它們不像 fork-join 那樣強調結構化的分叉與匯合,而是為了 GUI、伺服器和多處理器工作負載的實務需求發展出來。因此各家系統會出現不同 API、排程模型與 signal 語意,後續 POSIX 標準化的目標也不是指定單一實作,而是定義應用程式可依賴的行為契約。
### 1980 年代:Mach 普及並明確化執行緒模型
關鍵轉折來自微核心 (microkernel) 對作業系統設計的衝擊。在 Mach 之前,已有系統探索類似的分離:[Thoth](https://en.wikipedia.org/wiki/Thoth_(operating_system)) (1979 年) 讓多個輕量行程共享同一 address space,[V 作業系統](https://en.wikipedia.org/wiki/V_(operating_system)) 以 team/process 的兩層結構區分資源容器和執行單元。但真正將這個概念普及並明確化為作業系統設計典範的,是 [Mach](https://en.wikipedia.org/wiki/Mach_(kernel)) (發音 [mʌk])。
Mach 由卡內基美隆大學 (Carnegie Mellon University) 在 1980 年代開發,走的是先拆解、再定義、後實作的路線:將系統分解為少數幾個正交的元件,釐清彼此的互動方式,然後才動手。最終成果是五個清晰的構成要素:ports、messages、tasks、threads,以及 virtual memory。
Mach 明確地將資源容器 (task) 和執行單元 (thread) 定義為二個各自獨立的 first-class 實體:
| 概念 | 定義 |
|---|---|
| task | 資源容器:持有 address space、port namespace 和一組執行緒 |
| thread | 執行單元:排程器操作的對象,擁有獨立的 register context 和 stack |
一個 task 本身不能執行任何指令;一個沒有執行緒的 task 是完全合法的。這種分離讓多執行緒和 SMP 支援從一開始就到位:同一 task 內執行緒之間的 context switch 不需要切換 address space,而行程層級資源與執行緒層級執行狀態也有清楚邊界。
先想清楚再動手的紀律帶來持久的技術突破,尤其是 CoW 虛擬記憶體與 port-based IPC,二者後來都被 BSD 和 Linux 吸收。但 Mach 也示範精緻分層設計的代價:IPC 模型所需的間接層成為揮之不去的效能瓶頸。
儘管 [Linux 不屬於微核心設計](https://en.wikipedia.org/wiki/Tanenbaum%E2%80%93Torvalds_debate),微核心開創的許多想法已逐步融入 Linux 核心。更完整的歷史脈絡,參見 [Microkernels: The veterans of OS design](http://www.slideshare.net/JakubJermar/microkernels-the-veterans-of-os-design) 和〈[微核心設計](https://hackmd.io/@sysprog/microkernel-design)〉。
### 標準化前夜:碎片化的 1980 年代
到 1980 年代後期,SMP 開始普及、GUI (X Window System) 需要並行 (concurrency)、伺服器 (RPC、資料庫、network daemon) 也需要並行處理,執行緒幾乎不可避免。但每家廠商都有自己的執行緒 API:
| 系統 | 多執行緒模型 |
|---|---|
| Solaris (Sun Microsystems) | M:N (user thread + LWP) |
| DEC OSF/1 (Digital Equipment Corporation) | M:N (scheduler activations) |
| IRIX (Silicon Graphics) | M:N (process scope) |
| Mach (卡內基美隆大學) | 原生執行緒 |
| Windows NT (Microsoft) | 1:1 kernel threads |
API、語意、同步模型全部不相容,應用程式移植極為困難。
### 1988-1995:POSIX 標準化執行緒語意
面對碎片化的現實,UNIX 陣營的主要廠商 (IBM、Sun Microsystems、Hewlett-Packard、Digital Equipment Corporation) 透過 IEEE POSIX 工作群組推動標準化。共同動機是保護 UNIX 生態系統、降低應用程式移植成本,以及在多執行緒領域與 Windows NT 抗衡。
[POSIX.1c](https://en.wikipedia.org/wiki/POSIX_Threads) (IEEE Std 1003.1c-1995) 正式定案,標準化 `pthread_create`、`pthread_mutex_lock` 等 API。POSIX 執行緒標準的關鍵貢獻不在於規定實作方式,而在於定義語意 (semantics):
* 執行緒生命週期 (建立、終結、join、detach)
* 同步原語 (mutex、condition variable、rwlock)
* signal 在多執行緒環境中的傳遞行為
* `fork()` 與執行緒的互動
POSIX 刻意不限制底層多執行緒模型,允許 1:1、M:N 或使用者層級實作。這讓各廠商保有實作自由,但也種下日後分裂的根源。
### 標準化時的設計爭議
POSIX 標準化過程中有幾個深刻的設計取捨:
signal 語意是最棘手的問題。一個行程有多個執行緒,signal 該送給誰?POSIX 區分 process-directed signal (如 `SIGTERM`,送給整個行程) 和 thread-directed signal (如 `pthread_kill` 指定的執行緒),但幾乎所有作業系統在實作時都踩過坑,Linux 尤其嚴重 (LinuxThreads 的 signal 行為是 POSIX 不相容的主要來源)。
`fork()` 與執行緒的互動同樣困難。POSIX 規定 `fork()` 後子行程只保留呼叫者那個執行緒,其餘執行緒消失。這是為了維持與 UNIX 行程模型的相容性,代價是 `fork()` 在多執行緒程式中幾乎無法安全使用 (見後文〈fork 的風險與替代方案〉)。
同步原語 (primitives) 的選擇也有爭議。POSIX 同時提供 mutex、condition variable、semaphore、rwlock 等介面;其中 pthread 程式最常以 mutex 搭配 condition variable 建構 monitor-style 同步。這裡牽涉一個可追溯到 1970 年代的設計分歧:[Hoare semantics](https://en.wikipedia.org/wiki/Monitor_(synchronization)#Blocking_condition_variables) 與 [Mesa semantics](https://en.wikipedia.org/wiki/Monitor_(synchronization)#Nonblocking_condition_variables) 對 condition variable signal 行為的不同規定。
[C. A. R. Hoare](https://en.wikipedia.org/wiki/Tony_Hoare) 在 1974 年的論文 "[Monitors: An Operating System Structuring Concept](https://doi.org/10.1145/355620.361161)" 中定義 monitor 時,採用較強的交接語意:signaler 喚醒 waiter 後,控制權立即轉交給 waiter,因此 waiter 重新執行時可依賴先前等待的條件仍成立。1980 年 Xerox PARC 的 [Butler Lampson](https://en.wikipedia.org/wiki/Butler_Lampson) 和 David Redell 在 "[Experience with Processes and Monitors in Mesa](https://doi.org/10.1145/358818.358824)" 中描述的 Mesa 系統採用較弱但較容易實作的語意:signal 只讓 waiter 變成可執行,signaler 可繼續執行,waiter 之後還要重新競爭 lock。
兩種語意的差異可概括為:
| 特性 | Hoare semantics | Mesa semantics |
|---|---|---|
| signal 後的控制流 | signaler 立即讓出 monitor | signaler 繼續執行 |
| waiter 醒來時的保證 | 條件保證為真 | 條件不保證為真 |
| 程式碼模式 | `if (!cond) wait()` | `while (!cond) wait()` |
| context switch 次數 | signal 觸發一次額外的 context switch | 不觸發額外的 context switch |
| 推理難度 | 較低 (條件不變式強) | 較高 (需防禦性檢查) |
| 實作難度 | 高 (需即時排程切換) | 低 (僅操作 ready queue) |
POSIX `pthread_cond_wait()` 的契約接近 Mesa 風格,而不是 Hoare 風格。POSIX 明確要求每個 condition wait 都對應一個由共享變數組成的 predicate;`pthread_cond_wait()` 返回不保證 predicate 已經成立,而且可能發生 spurious wakeup,因此應在返回後重新檢查 predicate。正確寫法是:
```c
pthread_mutex_lock(&mutex);
while (!condition)
pthread_cond_wait(&cond, &mutex);
/* condition is true while mutex is held */
pthread_mutex_unlock(&mutex);
```
這個 `while` 不是防禦性裝飾,而是 POSIX 語意的一部分。即使沒有 spurious wakeup,另一個執行緒也可能在 waiter 重新取得 mutex 前改變共享狀態;因此用 `if` 檢查一次是不可靠的。這也解釋了 POSIX 為何把語意定義在「predicate」而不是「signal 一定交給某個 waiter」上:condition variable 本身不是狀態,真正的狀態必須由 mutex 保護的共享資料表示。
### 各系統的因應與收斂
POSIX 標準發布後,各系統的因應方式反映出不同的工程取捨:
| 系統 | 多執行緒模型 | 演化 |
|---|---|---|
| Solaris | M:N (user thread ↔ LWP ↔ kernel thread) | Solaris 9 (2002 年) 放棄 M:N,改用 1:1 |
| Linux | 初期 LinuxThreads (不符合 POSIX),2002 年 NPTL (1:1) | 見下節「從 LinuxThreads 到 NPTL」 |
| FreeBSD | M:N (libkse) | 7.0 (2008 年) 將 1:1 的 libthr 設為預設 |
| Windows NT | 1:1 kernel thread (從第一天開始) | 沒有歷史包袱,Win32 thread API 與 POSIX 語意相近但 API 不同 |
| macOS / Darwin | Mach thread (first-class) + BSD layer | pthread 是 Mach thread 的包裝層 |
2000 年代後,主流通用作業系統大多收斂到 1:1 模型。原因很直接:核心已經夠快 (context switch、futex 降低同步開銷)、M:N 的複雜度難以控制 (兩層排程器的協調、signal 處理、debugger 支援),而多核排程器需要核心掌握每個執行單元。M:N 在理論上有效能優勢,但工程實務上常被同步、阻塞與除錯成本抵銷。最終勝出的設計是最簡單的那個:一個執行緒就是一個 kernel schedulable entity。
### RTOS 的特殊視角
在經典的 RTOS (VxWorks、QNX、FreeRTOS 等) 中,task 本身等同於執行緒,行程概念很弱或不存在,畢竟維護行程帶來的成本會影響 RTOS 著重的 deterministic 特性。過去 RTOS 的 API 分歧很大,POSIX 對 RTOS 的影響主要體現在相容層:廠商提供 pthread 相容層,將 pthread 呼叫轉接至原生 task API,但這種轉接會增加程式碼體積和執行時間開銷。RTOS 通常不支援 `fork()`,signal 語意也大幅簡化。POSIX 中的 `pthread_mutexattr_setprotocol` 和 `PTHREAD_PRIO_INHERIT` 就是為了 RTOS 的 real-time 需求而加入標準的。
近年出現的 RTOS 開始以 POSIX pthread 作為原生 API,而非額外的轉接層:
* [PX5 RTOS](https://en.wikipedia.org/wiki/PX5_RTOS) (2023 年,William Lamie 開發,他也是 ThreadX 和 Nucleus PLUS 的作者):以 pthread 作為主要原生 API,不經過一般相容層再轉接至另一套執行緒 API;官方文件宣稱其 pthread 服務覆蓋 mutex、condition variable、semaphore、signal、message queue 等介面,並以低記憶體佔用與縮短 API 路徑為設計目標。參見 "[New PX5 RTOS boasts native support for POSIX pthreads](https://px5rtos.com/blog/new-px5-rtos-boasts-native-support-for-posix-pthreads/)"
* [Zephyr RTOS](https://www.zephyrproject.org/) (Linux Foundation 專案,核心源自 Wind River 貢獻的 Rocket kernel,更早可追溯到 Virtuoso DSP RTOS):文件以 POSIX [PSE51/PSE52/PSE53](https://docs.zephyrproject.org/latest/services/portability/posix/index.html) 應用環境規範整理支援狀態,涵蓋 pthread、mutex、condition variable、semaphore 等介面;實際可用範圍受 Kconfig、硬體平台與 profile 限制,不能解讀為所有 POSIX profile 皆完整實作
這個趨勢反映出 POSIX pthread 從 UNIX 世界的相容層逐漸成為嵌入式系統的共通 API。在 RTOS 中 pthread 幾乎等於原生執行緒;在 UNIX 系統中 pthread 則是核心設計之上的相容層。兩端正在收斂。
不同的多執行緒模型各有取捨:
| 模型 | 說明 | 代表 |
|---|---|---|
| 1:1 | 每個 user 執行緒對應一個核心執行緒 | NPTL、modern Solaris、FreeBSD libthr |
| M:N | M 個 user 執行緒多工於 N 個核心執行緒 | 早期 Solaris、NGPT、FreeBSD libkse |
| 使用者層級 | 核心完全不知道執行緒的存在 | GNU Pth、green threads |
### Linux 的特殊路徑:執行緒不是設計出來的,是組合出來的
Linux 走的是完全不同的路。Linus Torvalds 在 1991 年發展 Linux 之際,核心只支援單一執行緒、單處理器,根本沒有 "thread" 這個概念,也沒有 SMP。五年後,Torvalds 在 1996 年的 [LKML 郵件](https://www.evanjones.ca/software/threading-linus-msg.html)闡述該選擇背後的考量:
> There is NO reason to think that "threads" and "processes" are separate entities. That's how it's traditionally done, but I personally think it's a major mistake to think that way. The way Linux thinks about it (and the way I want things to work) is that there is no such thing as a "process" or a "thread". There is only the totality of the COE (Context of Execution).
Linux 核心只有一個同時用來表示行程和執行緒的結構:`task_struct`。行程和執行緒的差異,僅在於 `clone()` 時選擇共享哪些資源 (CPU 狀態、MMU 狀態、credentials、開啟的檔案)。這是徹底的務實主義:一套資料結構、一條排程路徑、一組 signal 傳遞機制。Linux 的執行緒不是「設計出來」的獨立概念,而是透過 `clone()` flag 的組合「湧現」(emerge) 的。
三階段概念演化:
| 階段 | 時期 | 模型 | 代表 |
|---|---|---|---|
| 行程 = 一切 | 1960 年代 | `process = resource + execution` | UNIX、Multics |
| 資源與執行分離 | 1980 年代 | `process = resource container` + `thread = execution unit` | Thoth、V、Mach |
| 統一表示 | 1990 年代起 | `task_struct = everything`,`thread = clone(flags)` | Linux |
Mach vs. Linux:兩條路的對比:
| 特性 | Mach (微核心哲學:先想後做) | Linux (monolithic 實務:先做再修) |
|---|---|---|
| 核心概念 | 將資源容器 (task) 與執行單元 (thread) 分成不同核心物件 | 以單一 `task_struct` 統一表示所有執行個體 |
| 設計演化 | 早期就定義 task/thread 物件模型與 SMP 支援 | 早期僅有行程,執行緒語意是後來補上的 |
| 建立方式 | 在既有 task 中配置新的 thread 物件 | 以 `clone()` 系統呼叫選擇性共享親代行程資源 |
| 排程單位 | 原生支援 thread 排程,task 不具執行能力 | `task_struct` 即為排程單位,thread 僅是特殊的 task |
| POSIX 相容性 | 執行緒模型與 POSIX 語意天然匹配 | 歷經 LinuxThreads 到 NPTL 的十年改造才達成相容 |
| IPC 開銷 | 依賴 Message Passing,邊界清晰但效能瓶頸明顯 | 共享記憶體與 Futex 讓執行緒間通訊極其快速 |
| 現代定位 | 影響 macOS (Darwin)、GNU Hurd 等系統設計 | 成為伺服器、Android 與嵌入式 Linux 的主流核心模型 |
### 「先做再修」的代價,以及如何修正
Linux 的務實讓它在早期快速成長,但在需要符合 POSIX 執行緒語意時,付出不小的代價。
在 [NPTL](https://en.wikipedia.org/wiki/Native_POSIX_Thread_Library) 出現之前,Linux 仰賴 [LinuxThreads](https://en.wikipedia.org/wiki/LinuxThreads),用 `clone()` 建立核心 task 來充當 POSIX 執行緒。但核心缺乏 thread group 的概念,每個 "thread" 在核心眼中就是一個獨立的行程,導致 `getpid()` 在不同執行緒中回傳不同值、signal 無法正確傳遞給整個行程等一系列 POSIX 不相容問題 (詳見下節)。根源在於:Mach 從一開始就區分 task 和 thread,POSIX 執行緒語意水到渠成;Linux 從一開始就只有行程,核心根本沒有「多個執行緒構成一個行程」的概念。
結果論來說,Mach 顯示先把概念釐清的價值:執行緒語意不需要事後修補。Linux 則顯示務實路線的力量:儘管花費多年才補上 thread group、signal 語意和 TLS,修正後仍保有單一 `task_struct` 路徑的簡潔性。少一層行程/執行緒物件轉接,讓核心能以較直接的方式處理排程、signal 和資源共享。
Android 是個有趣的對照:底層跑的是 Linux 核心,應用框架卻把 process、thread、activity、service 等概念包裝成另一層生命週期模型;行程間通訊主要依賴 [binder IPC/RPC](https://en.wikipedia.org/wiki/OpenBinder),而不是傳統 UNIX pipe/socket 作為唯一機制。
早期 Linux 的行程和執行緒效能與其他作業系統的客觀比較,可參照 "[An Overview of the Singularity Project](https://www.microsoft.com/en-us/research/wp-content/uploads/2005/10/tr-2005-135.pdf)" (Microsoft Research, 2005 年) 第 31 頁。
## 從 LinuxThreads 到 NPTL
### LinuxThreads 的作法與嚴重限制
[LinuxThreads](https://en.wikipedia.org/wiki/LinuxThreads) 由 Xavier Leroy 於 1996 年發表,隨後由 glibc 接手維護。在當時的 Linux 核心 (v2.0/v2.2) 沒有 thread group 概念的情況下,LinuxThreads 把 POSIX 執行緒語意完全攤在 user space:
- 每個 pthread 以 `clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND)` 建立獨立的核心 task,核心視為獨立行程
- 另建立一個專責的 manager 執行緒 (privileged signal handler),負責序列化 pthread 建立、終結、signal 派送、thread-specific data 清理等操作。所有 user 執行緒透過 pipe 與 manager 通訊
- `pthread_t` 以 thread descriptor 結構體的 stack 位址編碼,靠 stack pointer 對齊推算目前執行緒
這套設計可讓 pthread 程式在 Linux 上編譯執行,但付出的代價遠超乎預期。相較同期 Solaris、DEC OSF/1、IRIX 等商業 UNIX,LinuxThreads 的問題幾乎涵蓋 POSIX 的每個面向:
- PID 與 TGID 混淆:`getpid()` 在同一行程的不同執行緒中回傳不同值;`ps` 與 `top` 將同一行程的多個執行緒列為獨立行程,使用者無法辨識
- signal 語意破損:process-directed signal (如 `SIGTERM`) 僅送達發起 `kill()` 時 PID 對應的執行緒,無法正確送達整個行程;`SIGSTOP` 無法一次暫停所有執行緒
- credentials 不共享:一個執行緒呼叫 `setuid()` 不會影響其他執行緒,違反 POSIX 規定的行程層級 credentials 共享
- `exec()` 與 `exit()` 不遵守 POSIX:`_exit()` 只終結發起呼叫的執行緒,而非整個行程;`exec()` 只置換呼叫者的映像,其他執行緒繼續存在
- reparent 問題:親代終結時,子執行緒可能被 `init` 收養,PPID 變為 1,造成 wait 語意混亂
- 資源限制錯置:`setrlimit()` 僅作用於呼叫者執行緒,不影響同一行程的其他執行緒
- debugger 困擾:gdb 看到的是多個獨立行程,thread-aware 除錯需要大量 workaround
- manager 執行緒的效能瓶頸:每次 `pthread_create()`、`pthread_exit()`、signal 派送都要繞經 manager,在 SMP 系統上形成嚴重的序列化熱點
- core dump 僅涵蓋單一執行緒狀態,難以診斷多執行緒程式崩潰
Ulrich Drepper 和 Ingo Molnar 在 NPTL 白皮書中直言,LinuxThreads 的效能問題與語意錯亂,使得不少移植自商業 UNIX 的多執行緒程式在 Linux 上要不是無法執行,就是效能低落到不可用,這是 1990 年代末到 2000 年代初 Linux 在企業伺服器領域被詬病的主因之一。
一個殘酷的案例是 Borland 在 2001-2002 年推出的 [Kylix](https://en.wikipedia.org/wiki/Kylix_(software)) (Delphi/C++Builder 的 Linux 版本,由 Danny Thorpe 主導)。Kylix 的 CLX 執行時期 (runtime) 以 `TThread` 包裝 `pthread_create`,但一路踩中 LinuxThreads 的每個陷阱:Kylix 1 在 CLX 應用程式中捕捉 `SIGSEGV`/`SIGFPE` 等硬體異常會造成整個程式凍結無回應,須升級至 Kylix 2 才修正;`TThread.Suspend`/`Resume` 在 Linux 沒有對應的 `thr_suspend`,Borland 只能在目標執行緒上安裝 signal handler 配合 `sigsuspend` 自行模擬;同步原語因 glibc 2.2 不提供 process-shared 版本和 named semaphore,只能限縮到 unnamed semaphore 和 process-local mutex。最著名的 workaround 是:glibc 2.3 改以 NPTL 為預設後,Kylix 的執行時期因為依賴 LinuxThreads 的 signal 號碼與 `getpid()` 行為而完全失效,使用者必須設 `LD_ASSUME_KERNEL=2.4.1` 強迫載入舊版 `libpthread`。這些繞道反映出,在 Linux 核心缺乏 thread group 與 process-directed signal 的年代,軟體公司即便投入相當資源,也只能把 POSIX 語意缺口吸收到自家執行時期裡。
修正的方向明確,也就是 Linux 核心必須原生支援 thread group,把 POSIX 語意落實於核心層級,而非僅在使用者層級修補。以下說明這些修正如何落實。
### 核心層面的準備
Linux v2.5.x 系列引入一系列關鍵的核心變更,為 POSIX 語意相容奠定基礎:
* `CLONE_THREAD`:將新建立的 task 加入與親代相同的 thread group,共享 `tgid`。這是修正 `getpid()` 行為的關鍵 flag
* `CLONE_SIGHAND`:共享 signal handler 表 (自 Linux v2.5.35 起,`CLONE_THREAD` 隱含要求 `CLONE_SIGHAND`;自 Linux v2.6.0 起,`CLONE_SIGHAND` 隱含要求 `CLONE_VM`)
* `CLONE_PARENT_SETTID` / `CLONE_CHILD_SETTID` / `CLONE_CHILD_CLEARTID`:在執行緒建立時將 TID 寫入指定位置,並在執行緒結束時清除 child TID,讓 user space 可搭配 futex 等待執行緒終結
* `CLONE_SETTLS`:在執行緒建立時設定 TLS (thread-local storage)
* thread group signal 語意:`SIGSTOP` 現在能作用於整個行程,job control 和 debugger 能正確暫停 thread group 內的所有執行緒
* [Futex](https://lwn.net/Articles/360699/) (Fast Userspace muTEX):早期由 Hubertus Franke、Matthew Kirkwood、Rusty Russell 等人推動,在 Linux v2.5 開發週期成熟。只有在 contention 時才需要系統呼叫,大幅降低同步原語的開銷
* TLS 支援:Ingo Molnar 的 32-bit x86 實作使用 GDT entry 為每個執行緒提供 thread-local storage,核心在 context switch 時切換相關 TLS entry;x86_64 則主要依賴 FS/GS base
參照 [Making Linux safe for pthreads](https://lwn.net/Articles/7577/)
### NPTL 的誕生
[NPTL](https://lwn.net/Articles/10465/) (Native POSIX Thread Library) 由 Red Hat 的 Ulrich Drepper (時任 glibc 維護者) 和 Ingo Molnar (核心開發者) 於 2002 年 7 月開始開發,9 月即發表 0.1 版。整個開發歷程不到二個月。NPTL 需要 Linux v2.5.36 以上、GCC 3.2 以上 (支援 `__thread` TLS 關鍵字) 和 glibc 2.3。
NPTL 的設計白皮書:[The Design of the New GNU Thread Library](https://www.akkadia.org/drepper/nptl-design.pdf) (Drepper & Molnar)
NPTL 實作 1:1 多執行緒模型,每個 POSIX 執行緒對應恰好一個核心 task。效能上,Ingo Molnar 展示在雙 450 MHz IA-32 系統上,2 秒內建立並終結 100,000 個執行緒。同樣的測試在 Linux v2.5.31 核心需要約 15 分鐘。系統在 100,000 個執行緒 idle 的狀態下仍然可正常使用。
參照 [Native POSIX Thread Library 0.1](https://lwn.net/Articles/10465/)、Ingo Molnar 的 [LKML 公告](https://lkml.org/lkml/2002/9/20/42)
### 1:1 vs. M:N 多執行緒模型
NPTL 選擇 1:1 多執行緒模型 (每個 user 執行緒對應一個核心執行緒) 而非 M:N (M 個 user 執行緒多工於 N 個核心執行緒)。這個選擇在當時引發激烈討論。
IBM 主導的 [NGPT](https://lwn.net/Articles/10477/) (Next Generation POSIX Threading) 專案實作 M:N 模型。IBM 在 1980 年代末推動 POSIX 執行緒標準化時,自家 AIX 已採用 M:N 多執行緒;NGPT 延續同一路線,將 AIX 的 M:N 經驗移植到 Linux。NGPT 在執行緒建立測試中比 LinuxThreads 快二倍,看似證明 M:N 的優勢。然而 [NPTL 在同一測試中比 NGPT 快四倍](https://lwn.net/Articles/10741/),即使該測試是刻意設計來偏好 M:N 模型的。
Ulrich Drepper 和 Ingo Molnar 選擇 1:1 的理由:
* 與其用 user-space 排程器來繞過核心開銷,不如直接修正核心。Molnar 讓 `clone()`、context switch 和 futex 快到 user-space 排程器毫無優勢
* M:N 需要二個排程器 (核心 + user-space) 的協調,priority inversion 是根本性的問題
* M:N 的 signal 處理很棘手:當目標 user 執行緒沒有排程到任何核心執行緒上時,process-directed signal 該送給誰?
* M:N 中一個 blocking system call 可能會卡住多工於同一核心執行緒的所有 user 執行緒。防止此問題需要複雜的 non-blocking wrapper 或 scheduler activation
如前節所述,Solaris 和 FreeBSD 都經歷相同的 M:N → 1:1 收斂。NGPT 於 2003 年中止開發;但 IBM Linux Technology Center 的同期 kernel-side 貢獻 (futex 由 IBM 的 Hubertus Franke、Matthew Kirkwood 與 Rusty Russell、Ingo Molnar 合作於 v2.5.7 合併) 被 NPTL 採用並延續至今。
參照 [The Native POSIX Thread Library](https://lwn.net/Articles/10710/) 和 [On the NPTL process](https://lwn.net/Articles/10747/)
### signal 處理:從 Linux v0.01 到多執行緒的核心議題
signal 是 Linux v0.01 就存在的核心機制,而非後補的機能,不過當時只算是 UNIX 傳統的簡化版而非完整的 POSIX 子系統。v0.01 尚未出現 `kernel/signal.c`:`sys_signal()` 定義於 [`kernel/sched.c`](https://kernel.googlesource.com/pub/scm/linux/kernel/git/nico/archive/+/refs/tags/v0.01/kernel/sched.c),返回 user space 前的 signal delivery 寫在 [`kernel/system_call.s`](https://kernel.googlesource.com/pub/scm/linux/kernel/git/nico/archive/+/refs/tags/v0.01/kernel/system_call.s) 的 `ret_from_sys_call` 組合語言段落。`task_struct` ([`include/linux/sched.h`](https://kernel.googlesource.com/pub/scm/linux/kernel/git/nico/archive/+/refs/tags/v0.01/include/linux/sched.h)) 直接內嵌 `long signal` (pending bitmask)、`fn_ptr sig_restorer` 與 `fn_ptr sig_fn[32]` 的 handler 陣列;`include/signal.h` 明確註記 sigaction 此時尚未實作。這套設計在單執行緒的年代相當稱職:一個 task 即為一個 process,`kill(pid, sig)` 直接送到該 task,返回路徑再檢查 `signal` bitmask 並呼叫對應的 `sig_fn[]` slot。
多執行緒打破這個簡潔模型。POSIX 將 signal 拆解為三類細部狀態:
- signal disposition (handler + 幾個 flag) 是 process-wide 屬性,同一 process 的所有執行緒共用
- signal mask 是 per-thread,每個執行緒可各自 block/unblock 不同 signal
- pending signal 分為 process-directed (如 `SIGTERM`、`SIGINT`,由 `kill()` 送到整個 process) 與 thread-directed (如 `pthread_kill`、`tgkill` 指定的執行緒,以及 `SIGSEGV`/`SIGFPE` 等同步硬體例外);前者可由 thread group 中任一未 block 該 signal 的執行緒處理,後者僅送給目標執行緒
在 LinuxThreads 時代,該層語意還攤在 user space:每個 pthread 是獨立的核心 task,核心不知道 thread group 的存在,`kill(pid, SIGTERM)` 只送到發起 `kill()` 時 PID 對應的那個執行緒,`SIGSTOP` 無法一次暫停所有執行緒;manager 執行緒必須以 pipe 在 user space 替核心轉送 signal,成為 SMP 下的序列化熱點。這也解釋為何當年的 debugger 難以在多執行緒 Linux 程式上穩定運作。
Linux v2.5.x 的改造把 signal 基礎設施拆成三層,對應 `task_struct` 的三個欄位:
- `task_struct->sighand` 指向 `struct sighand_struct` (定義於 [`include/linux/sched/signal.h`](https://github.com/torvalds/linux/blob/v6.18/include/linux/sched/signal.h)),存放 thread group 共用的 handler table,由 `CLONE_SIGHAND` 決定是否共享
- `task_struct->signal` 指向 `struct signal_struct`,存放 thread group 共享的 `shared_pending` queue、`group_stop_count`、`flags` 與資源使用統計等資訊,在 `CLONE_THREAD` 之下由整個 thread group 共用
- `task_struct->pending` 保留每個 task 獨立的 sigpending queue,承載 thread-directed signal;`task_struct->jobctl` 則是 per-task 的 job control 位元
queue 的選擇其實發生在 [`__send_signal_locked()`](https://github.com/torvalds/linux/blob/v6.18/kernel/signal.c):`PIDTYPE_PID` 代表 thread-directed,放進 `t->pending`;其餘 `PIDTYPE_*` 代表 process-directed,放進 `t->signal->shared_pending`。接著 `complete_signal()` 對 process-directed signal 在 thread group 中挑選可接收者,對 thread-directed signal 則直接針對目標 task 決定是否喚醒;若 signal 屬於 fatal 類型,還會觸發 group-exit 流程。job control signal (`SIGSTOP`/`SIGCONT` 等) 由 `prepare_signal()` 配合 `signal_struct` 的 group-stop 狀態與各 task 的 `jobctl` 位元協同作用,不是單靠 `shared_pending`。這個分層讓「同一 `task_struct` 路徑既表示 process 也表示 thread」的設計得以延續,不必為 signal 另闢獨立的 thread 物件。
signal 也牽動行程生命週期的其他實作細節:`copy_process()` 依 `CLONE_THREAD`、`CLONE_SIGHAND` 決定 `sighand`/`signal` 是共享還是複製;`__exit_signal()` 於 `do_exit()` 路徑回收 pending signal 與 job control 狀態;`ptrace` 以 signal stop 介入 debugger 流程;`execve()` 在 `begin_new_exec()` 中透過 `de_thread()` 終結非呼叫者的執行緒,才能將 signal handler 重置為單執行緒預期的狀態。從 v0.01 的單一 task bitmask 到今日的 thread group 共享模型,signal 處理始終是 Linux 支援多執行緒時最需要小心照料的議題之一。
## task_struct:行程與執行緒的共同載體
### task_struct 與 mm_struct
在 Linux 中,行程和執行緒皆以 task 表示,每一個 task 都用一個 `task_struct` 儲存其資訊。核心啟動時,`fork_init()` ([`kernel/fork.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/fork.c)) 以 `kmem_cache_create_usercopy()` 建立 `task_struct` 的 [SLAB](https://en.wikipedia.org/wiki/Slab_allocation) 快取;其對齊值取 `L1_CACHE_BYTES`、`ARCH_MIN_TASKALIGN` 和結構自然對齊需求中的最大值,避免 `task_struct` 的起始位址落在不利於 cache 存取的位置。`fork_init()` 同時以 `FUTEX_TID_MASK` (`0x3fffffff`,約 10 億) 和可用記憶體估算系統允許的最大執行緒數量。在 x86_64 上,`task_struct` 的大小會隨 `CONFIG_SMP`、`CONFIG_SCHED_CLASS_EXT`、`CONFIG_FAIR_GROUP_SCHED`、`CONFIG_SCHED_INFO` 等組態變動,常見設定下可達數 KB 到十餘 KB,佔據大量 cache line。
`task_struct` 透過指標連到各子系統的資料結構,每個指標對應一種可被 `clone()` flag 選擇性共享的資源。下方表格挑選若干有代表性的欄位,予以簡介:
| 類別 | 代表欄位 | 作用 |
|---|---|---|
| 識別 | `pid`, `tgid`, `group_leader` | `pid` 是核心內部的 task ID,也就是 Linux 特有的 TID;`tgid` 是 thread group ID,也是 user space `getpid()` 看到的行程 ID;`group_leader` 指向 thread group leader |
| 親緣關係 | `real_parent`, `parent`, `children`, `sibling` | `real_parent` 保留實際建立者;`parent` 是 `SIGCHLD` 和 `wait4()` 報告的接收者;`children` 與 `sibling` 把行程接成樹狀結構 |
| 狀態 | `__state`, `exit_state`, `flags` | `__state` 描述可執行、可中斷睡眠、不可中斷睡眠等執行狀態;`exit_state` 描述 zombie 或已死亡;`flags` 儲存 `PF_EXITING` 等 task 旗標 |
| 排程 | `prio`, `static_prio`, `normal_prio`, `rt_priority`, `se`, `rt`, `dl`, `scx` | 優先權欄位供排程器排序;`se`、`rt`、`dl`、`scx` 分別服務 fair、real-time、deadline 與 sched_ext 排程類別 |
| 記憶體 | `mm`, `active_mm` | `mm` 指向 `mm_struct`,描述 user address space;核心執行緒的 `mm` 為 `NULL`,切入時沿用前一個 task 已持有的 `active_mm` |
| 檔案系統 | `fs`, `files` | `fs` 儲存 root、cwd、umask 等檔案系統 context;`files` 指向開啟的檔案描述子 (file descriptor) 表格 |
| 身份與權限 | `cred`, `real_cred` | `cred` 是目前 task 用來執行權限檢查的 subjective credentials;`real_cred` 表示物件身分,也就是其他 task 檢查此 task 時看到的 credentials |
| 訊號 | `signal`, `sighand`, `blocked`, `pending` | `signal` 儲存 thread group 共用狀態;`sighand` 儲存 signal handler 表;`blocked` 與 `pending` 則描述 per-task 的遮罩與待處理 signal |
| 統計 | `utime`, `stime`, `nvcsw`, `nivcsw`, `start_time` | 記錄 user/kernel 執行時間、自願與非自願 context switch 次數,以及 task 啟動時間 |
| 隔離與帳務 | `nsproxy`, `cgroups`, `audit_context` | 分別連到 namespace、cgroup 與安全稽核狀態 |
Linux 的 `pid` 並非 POSIX 語意的行程 ID,而是每個 task 的識別碼;`TASK_RUNNING` 也不保證正在 CPU 上執行,而是可能正在 runqueue 中等待排程;核心執行緒沒有自己的 user address space,因此用 `mm == NULL` 區分,比從名稱或 PPID 推斷可靠。
其中一個關鍵欄位為 `mm_struct` 指標:
```c
struct task_struct {
...
struct mm_struct *mm;
struct mm_struct *active_mm;
...
};
```
`mm_struct` 儲存行程虛擬記憶體的資訊。在 v6.18 中,VMA 以 [maple tree](https://lwn.net/Articles/866573/) 管理 (v6.1 起取代紅黑樹和鏈結串列),對應欄位為 `mm_mt`。`mm_struct` 維護兩個引用計數:`mm_users` 記錄共享此 `mm_struct` 的 user space task 數量 (即 thread group 內的執行緒總數),透過 `mmget()`/`mmput()` 操作;`mm_count` 則記錄整體引用次數,包含 `mm_users` 貢獻的一次以及核心執行緒透過 `active_mm` 借用的次數。只有 `mm_count` 降為 0 時 `mm_struct` 才會真正釋放,`mm_users` 歸零僅觸發 address space 的清理而不回收 `mm_struct` 本身。這個分層設計讓核心執行緒可安全沿用前一個 task 的 `active_mm`,而不必擔心該 `mm_struct` 被過早回收。
每塊虛擬記憶體區段以 `vm_area_struct` 表示,記錄起訖位址 (`vm_start`/`vm_end`)、存取權限 (`vm_page_prot`/`vm_flags`)、對應的檔案 (`vm_file`) 或 anonymous mapping (`anon_vma`) 等資訊。v6.18 的 `vm_area_struct` 不再包含 linked list 節點和 red-black tree 節點,VMA 的走訪改由 maple tree 迭代器完成。
關於 `mm_struct`、`vm_area_struct`、maple tree 的完整欄位和操作,參見〈[Linux 核心的記憶體管理](https://hackmd.io/@sysprog/linux-memory)〉。
## credentials 與執行統計
`task_struct` 不只描述可否被排程,也描述這個 task 以什麼身分執行,以及核心如何對其統計管理。身份資訊主要透過 `struct cred` 表示:
```c
struct task_struct {
...
const struct cred __rcu *real_cred;
const struct cred __rcu *cred;
...
};
```
`real_cred` 是 objective credentials,表示別的 task 檢查此 task 時看到的身分;`cred` 是 subjective credentials,表示此 task 主動操作檔案、IPC 物件或網
路資源時使用的身分。多數一般情況下二者相同,但核心仍區分把它們,讓 override credentials、keyring、檔案系統操作等路徑能精確描述「誰在操作」與「誰被操作」。
`struct cred` 內含幾組常見 ID:
- `uid` / `gid`:real user/group ID,通常表示誰啟動這個行程
- `euid` / `egid`:effective user/group ID,許多權限檢查實際比較這組值
- `suid` / `sgid`:saved ID,支援 set-user-ID 程式在權限升降之間切換
- `fsuid` / `fsgid`:檔案系統存取使用的 ID,VFS 權限檢查會參考
Linux 另以 capabilities 拆分傳統 root 權限。`cap_permitted` 表示 task 最多可取得哪些 capability,`cap_effective` 表示目前實際生效的 capability,`cap_inheritable` 與 `cap_ambient` 則影響 `execve()` 前後的繼承規則。這讓系統可以授予 `CAP_NET_BIND_SERVICE`、`CAP_SYS_TIME` 等細粒度能力,而不必把整個 root
權限交給程式。
執行統計則分散在 task 與 signal 層級的欄位中。常見欄位包括:
- `utime`:task 在 user mode 消耗的 CPU 時間
- `stime`:task 在 kernel mode 消耗的 CPU 時間
- `nvcsw`:自願 context switch 次數,通常來自等待 I/O、futex、sleep 等主動讓出 CPU 的路徑
- `nivcsw`:非自願 context switch 次數,通常表示 timeslice 用盡或被更高優先權 task 搶佔
- `start_time`:task 啟動時間,可用於 `/proc`、`ps` 與資源統計
這些數值不是單純給 `/proc` 顯示用,而是排程、資源限制、統計與效能診斷共同依賴的基礎。當 `nivcsw` 過高時,通常代表 CPU 競爭激烈;當 `utime` 遠高於 `stime` 時,工作負載偏向 user space 計算;反過來若 `stime` 很高,則可能需要檢查系統呼叫、I/O 或核心鎖競爭。
### 為何 Linux 不特別區分執行緒與行程
前述的設計哲學在實作中體現為:執行緒與行程共用同一條 `clone()` 程式碼路徑,差異僅在於一組 flag 的選擇:
* `CLONE_VM`:共享 address space (即共用 `mm_struct`)
* `CLONE_THREAD`:加入同一 thread group (共用 TGID)
* `CLONE_SIGHAND`:共享 signal handler 表
* `CLONE_FILES`:共享檔案描述子表格
* `CLONE_FS`:共享檔案系統資訊 (root、cwd、umask)
* `CLONE_NEWPID`、`CLONE_NEWNS`、`CLONE_NEWNET` 等:建立新的 namespace (容器的基礎)
同一個 `clone()` 系統呼叫,搭配不同 flag 組合,涵蓋從執行緒 (`CLONE_VM | CLONE_THREAD | ...`) 到行程 (`fork`,不帶共享資源的 flag,只指定 `SIGCHLD` 等終結通知) 到容器 (`CLONE_NEWPID | CLONE_NEWNS | ...`) 的完整頻譜。glibc 的 `pthread_create` 在呼叫 `clone()` 時同時帶入前五項 flag (以及 `CLONE_SYSVSEM`、`CLONE_SETTLS`、`CLONE_PARENT_SETTID`、`CLONE_CHILD_CLEARTID`),使得同一行程內的執行緒共享記憶體、signal handler、檔案描述子和檔案系統上下文。`fork()` 不帶 `CLONE_VM`、`CLONE_THREAD`、`CLONE_FILES` 等共享資源 flag,因此子行程獲得獨立的副本。
無論如何建立,每個 task 都擁有獨立的:
* `task_struct` (含獨立的 scheduling entity)
* 核心 stack (x86_64 上通常 16 KB,4 個 page)
* CPU 暫存器 context (儲存於核心 stack 上)
* `thread_info` / `thread_struct`
除了核心 stack 之外,每個 user-space 執行緒也有各自的 user stack。行程的主執行緒使用定址空間頂端的 stack 區段 (由 `RLIMIT_STACK` 控制上限,通常 8 MB)
。`pthread_create` 建立的執行緒則由 NPTL 透過 `mmap(PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK)` 在 memory mapping 區域配置獨立的 stack 空間,預設大小同樣約 8 MB (可經 `pthread_attr_setstacksize()` 調整),底端附帶 guard page 偵測 stack overflow。因此大量執行緒的 user stack 會佔用
可觀的虛擬定址空間。GCC 的 `__thread` 關鍵字 (C11 為 `_Thread_local`) 宣告的變數會為每個執行緒配置獨立副本,儲存在各自的 TLS 區段,達成不需 mutex 的 per-thread 全域狀態。
### 核心 stack 與 pt_regs
每個執行緒都有自己的核心 stack,在 `fork()` 時由 `alloc_thread_stack_node()` 配置。核心 stack 的大小因架構、page size 和核心組態而異;x86_64 常見設定為 16 KB (4 個 4 KB page),Arm64 也常見 16 KB,但不可只用單一組態推論所有系統。以下以 x86_64 為例,核心 stack 的主要配置如下 (由高位址往低位址):
* 最頂端附近:`pt_regs` (trap frame),在系統呼叫或中斷進入核心時由硬體和 entry code 儲存
* 中間:核心函式的 call frame,stack 向下成長
* stack 邊界外:guard page (`CONFIG_VMAP_STACK`,x86_64 自 v4.9 起預設啟用)
`pt_regs` 定義於 [`arch/x86/include/asm/ptrace.h`](https://github.com/torvalds/linux/blob/v6.18/arch/x86/include/asm/ptrace.h),儲存從 user space 進入核心時,低階 entry path 需要儲存的暫存器狀態:
```c
struct pt_regs {
unsigned long r15, r14, r13, r12;
unsigned long bp, bx;
unsigned long r11, r10, r9, r8;
unsigned long ax, cx, dx, si, di;
unsigned long orig_ax; /* syscall number */
/* Pushed by CPU hardware on trap/interrupt: */
unsigned long ip, cs, flags, sp, ss;
};
```
末端五個欄位 (`ip`, `cs`, `flags`, `sp`, `ss`) 在一般 trap/interrupt 進入時由 CPU 硬體 push;系統呼叫快速路徑則由核心 entry code 建構相容的 `pt_regs` 佈局。其餘欄位由 [`arch/x86/entry/entry_64.S`](https://github.com/torvalds/linux/blob/v6.18/arch/x86/entry/entry_64.S) 的 entry code 儲存。`orig_ax` 用於儲存 syscall number;非系統呼叫路徑通常填入特殊值。
x86_64 除了每個 task 的核心 stack 之外,還有 [Interrupt Stack Table](https://www.kernel.org/doc/Documentation/x86/kernel-stacks) (IST) 機制:TSS 可定義最多 7 組專用 stack,供特定異常 (double fault、NMI、MCE 等) 使用。當這些異常發生時,CPU 硬體自動切換到 IST 指定的 stack,不依賴目前 task 的核心 stack。這在核心 stack overflow 觸發 double fault 時尤為關鍵:若沒有 IST,double fault handler 會嘗試使用已溢出的 stack 而造成 triple fault (系統重啟)。
核心 stack overflow 的防護機制有三層:
* `CONFIG_VMAP_STACK`:stack 從 vmalloc 區域配置,鄰近的 guard page 未映射。stack overflow 觸及 guard page 時立即產生 page fault,x86 會切換到 Double Fault IST stack 並進入 panic 路徑。這是最可靠的防護,因為硬體強制執行
* `CONFIG_STACKPROTECTOR`:GCC `-fstack-protector`,在函式返回時檢查 canary 值
* `CONFIG_SCHED_STACK_END_CHECK`:在 stack 底部放置 magic value (`STACK_END_MAGIC`,`0x57AC6E9D`),每次 context switch 時驗證
### thread_info 與 current 巨集
`thread_info` 在 x86_64 上內嵌於 `task_struct` 的起始位置 (自 v4.9 起):
```c
struct thread_info {
unsigned long flags; /* TIF_NEED_RESCHED, TIF_SIGPENDING, ... */
...
};
```
* `TIF_NEED_RESCHED`:在從核心返回 user space 時檢查,若已設定則觸發重新排程
* `TIF_SIGPENDING`:有待處理的 signal
`current` 巨集用於取得目前 CPU 上正在執行的 task 的 `task_struct` 指標。在現代 x86_64 上,`current` 是 per-CPU 變數,透過 GS segment 的單次記憶體載入完成:
```c
DECLARE_PER_CPU(struct task_struct *, current_task);
#define current get_current() /* reads per-CPU variable */
```
早期 Linux 是從 stack 指標推算 `thread_info` 的位址 (將 SP 對齊到 stack 大小的邊界),但這個做法在 stack 大小可變和安全性考量下已被淘汰。
### clone() 與 copy_mm()
若使用 `clone()` 搭配 `CLONE_VM` 作為參數,新建立的 task 的 `mm` 會指向與其親代相同的 `mm_struct` 實體,使其 address space 與親代共享。
使用者空間呼叫 `fork()` 時,glibc wrapper 在多數 Linux 架構上並非直接發出舊式 `fork` 系統呼叫,而是呼叫 `clone` 並帶入 `CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD` 等 flag (可用 `strace` 或 bpftrace 觀察到 `clone_flags = 0x1200011`,見實驗 11)。glibc 內部最終經由架構對應的 syscall wrapper 將
系統呼叫號與參數載入暫存器,再以 trap 指令 (x86-64 為 `syscall`、arm64 為 `svc #0`、arm 為 `swi 0x0`) 觸發 CPU 切換至核心模式。核心的系統呼叫 dispatcher 依系統呼叫號進入架構對應的 `clone` / `clone3` / `fork` 入口;這些入口在核心內部會收斂到 `kernel_clone()`。在 v5.9 之前,相關路徑的共同 helper 為 `_do_fork()`;[Christian Brauner 在 v5.10 將其重構為 `kernel_clone()`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=cad6967ac10843a70842cd39c7b53412901dd21f),統一 `fork`、`vfork`、`clone`、`clone3` 四條路徑的進入點,並以 `struct kernel_clone_args` 取代散落的參數。
以下圖示呈現 `fork()` 與 `pthread_create()` 從使用者空間到核心的呼叫路徑:
```graphviz
digraph clone_call_path {
rankdir=TB;
node [fontname="Courier", fontsize=10];
edge [fontname="Helvetica", fontsize=9];
subgraph cluster_user {
label="User space"; style=dashed; color=gray60;
labeljust=l; fontname="Helvetica"; fontsize=10;
fork_api [label="fork()", shape=box, style=filled, fillcolor="#B3D9FF"];
pthread [label="pthread_create()", shape=box, style=filled, fillcolor="#B3D9FF"];
fork_wrapper [label="glibc fork wrapper", shape=box, style=filled, fillcolor="#CCE5FF"];
do_clone [label="glibc: do_clone()\nsyscall wrapper", shape=box, style=filled, fillcolor="#CCE5FF"];
}
subgraph cluster_kernel {
label="Kernel space"; style=dashed; color=gray40;
labeljust=l; fontname="Helvetica"; fontsize=10;
sys_fork [label="kernel fork entry\nflags = SIGCHLD", shape=box, style=filled, fillcolor="#FFD9B3"];
sys_clone [label="kernel clone entry\nflags = CLONE_VM | CLONE_THREAD\n| CLONE_SIGHAND | CLONE_FILES\n| CLONE_FS | ...", shape=box, style=filled, fillcolor="#FFD9B3"];
kernel_clone [label="kernel_clone()", shape=box, style=filled, fillcolor="#FFE0B2"];
copy_process [label="copy_process()", shape=box, style=filled, fillcolor="#FFCCBC"];
dup_task [label="dup_task_struct()", shape=box, style=filled, fillcolor="#FFF9C4"];
copy_files [label="copy_files()", shape=box, style=filled, fillcolor="#FFF9C4"];
copy_fs [label="copy_fs()", shape=box, style=filled, fillcolor="#FFF9C4"];
copy_mm [label="copy_mm()", shape=box, style=filled, fillcolor="#FFF9C4"];
copy_thread [label="copy_thread()", shape=box, style=filled, fillcolor="#FFF9C4"];
wake [label="wake_up_new_task()", shape=box, style=filled, fillcolor="#C8E6C9"];
}
fork_api -> fork_wrapper;
fork_wrapper -> sys_clone [label="glibc 常見路徑"];
fork_api -> sys_fork [label="直接系統呼叫或特定架構", style=dashed];
pthread -> do_clone;
do_clone -> sys_clone;
sys_fork -> kernel_clone;
sys_clone -> kernel_clone;
kernel_clone -> copy_process;
copy_process -> dup_task;
copy_process -> copy_files;
copy_process -> copy_fs;
copy_process -> copy_mm;
copy_process -> copy_thread;
kernel_clone -> wake;
}
```
`clone()` 系統呼叫由 `kernel_clone()` 啟動,定義於 [`kernel/fork.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/fork.c):
```c
pid_t kernel_clone(struct kernel_clone_args *args)
{
...
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
...
wake_up_new_task(p);
...
}
```
`kernel_clone()` 先呼叫 `copy_process()` 以親代為範本建立新的 `task_struct`,再呼叫 `wake_up_new_task()` 將新 task 加入 runqueue,等待排程器排程。
`copy_process()` 的第一步是 `dup_task_struct(current)`。它從 SLAB 快取配置一塊新的 `task_struct` 記憶體,再以結構體整體指派 (`*dst = *src`) 將親代的 `task_struct` 淺複製到新物件。此時新 task 的各子系統指標 (`mm`, `files`, `fs`, `sighand` 等) 仍指向親代的資料結構,尚未獨立。接下來的 `copy_*` 系列函式逐一處理每個子系統:
`copy_process()` 內部的主要呼叫順序為 `sched_fork()` (初始化排程狀態) → `perf_event_init_task()` → `audit_alloc()` → `security_task_alloc()` → `copy_semundo()` → `copy_files()` → `copy_fs()` → `copy_sighand()` → `copy_signal()` → `copy_mm()` → `copy_namespaces()` → `copy_io()` → `copy_thread()` (設定核心 stack 使子 task 從 `fork()` 返回 0)。每一步都有對應的 `bad_fork_cleanup_*` 標籤,以 goto 展開錯誤回收。
每個 `copy_*` 函式遵循統一的模式:檢查對應的 `CLONE_*` flag,若設定則遞增親代資料結構的引用計數並直接沿用 (共享);否則配置新的記憶體並複製一份獨立副本。以 `copy_files()` 為例:若帶有 `CLONE_FILES`,新 task 與親代共用同一份 `files_struct` (遞增 `count`);否則呼叫 `dup_fd()` 從 SLAB 快取配置新的 `files_struct` 並複製檔案描述子表格。`copy_fs()` 對 `CLONE_FS` 和 `fs_struct` 亦如此。
`copy_mm()` 的關鍵邏輯:若帶有 `CLONE_VM`,新 task 的 `mm` 直接沿用親代的 `mm` (遞增 `mm_users`);否則透過 `dup_mm()` 建立獨立副本 (搭配 CoW)。`clone3` (v5.3) 使 `kernel_clone_args.flags` 具備 64 位元寬度 ([Reworking clone()](https://lwn.net/Articles/792628/));`copy_mm()` 等 `copy_*` helper 的 `clone_flags` 參數在後續核心才陸續改為接收 `u64`。自 v6.3 起引入的 `sched_mm_cid_fork()` 則負責初始化排程器所需的 per-mm concurrency ID。
`copy_process()` 在完成資源複製後,根據 `CLONE_THREAD` 決定 thread group 關聯:
```c
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->tgid = current->tgid;
p->group_leader = current->group_leader;
} else {
p->tgid = p->pid;
p->group_leader = p;
}
```
若帶有 `CLONE_THREAD`,新 task 的 `tgid` 沿用親代的 `tgid`,`group_leader` 指向親代的 `group_leader`,並以 `list_add_tail_rcu()` 將新 task 的 `thread_group` 節點串入 `group_leader->thread_group` 雙向鏈結串列,同時將 `thread_node` 串入 `signal->thread_head`。後者讓核心可透過 `for_each_thread()` 巨集走
訪同一行程內的所有執行緒;例如 signal 傳遞路徑中 `complete_signal()` 需要挑選可接收 process-directed signal 的執行緒時,就依賴這條鏈結串列。若不帶 `CLONE_THREAD` (即 `fork()`),新 task 自身成為 thread group leader,`tgid` 等於自己的 `pid`,`group_leader` 指向自己。
下圖展示 thread group 的內部鏈結。三個 `task_struct` 屬於同一行程:leader (行程本身) 和二個子執行緒。`group_leader` 指標讓任何執行緒可直接找到 leader;`thread_group` 雙向鏈結串列連接所有執行緒;`thread_node` 則經由 `signal_struct` 的 `thread_head` 串接,提供另一條走訪路徑。
```graphviz
digraph thread_group_linkage {
rankdir=TB;
node [shape=record, fontname="Courier", fontsize=9];
edge [fontname="Helvetica", fontsize=8];
subgraph cluster_tg {
label="Thread Group (同一行程)"; style=dashed; color=steelblue;
labeljust=l; fontname="Helvetica"; fontsize=10;
leader [label="{task_struct (leader)|group_leader = self|<tg> thread_group|pid|tgid|<sig> signal|<tn> thread_node}", style=filled, fillcolor="#B3D9FF"];
t1 [label="{task_struct (執行緒 1)|<gl> group_leader|<tg> thread_group|pid|tgid|<sig> signal|<tn> thread_node}", style=filled, fillcolor="#CCE5FF"];
t2 [label="{task_struct (執行緒 2)|<gl> group_leader|<tg> thread_group|pid|tgid|<sig> signal|<tn> thread_node}", style=filled, fillcolor="#CCE5FF"];
}
sig [label="{signal_struct|shared_pending|group_stop_count|<th> thread_head}", style=filled, fillcolor="#C8E6C9"];
/* group_leader 指標 */
t1:gl -> leader [label="group_leader", color=coral, style=bold];
t2:gl -> leader [label="group_leader", color=coral, style=bold];
/* thread_group 雙向鏈結串列 */
leader:tg -> t1:tg [label="thread_group", color=steelblue, dir=both];
t1:tg -> t2:tg [label="thread_group", color=steelblue, dir=both];
/* signal_struct 共享 */
leader:sig -> sig [style=dashed, color=gray50];
t1:sig -> sig [style=dashed, color=gray50];
t2:sig -> sig [style=dashed, color=gray50];
/* thread_node ↔ thread_head */
leader:tn -> sig:th [label="thread_node", color=forestgreen];
t1:tn -> sig:th [label="thread_node", color=forestgreen];
t2:tn -> sig:th [label="thread_node", color=forestgreen];
}
```
這段邏輯是 Linux「執行緒不過是共享資源的 task」設計的最終落實:同一份 `copy_process()` 程式碼路徑,僅因 `clone_flags` 的不同,就能產生獨立行程或同一 thread group 內的執行緒。判斷一個 task 該稱為行程還是執行緒,慣例上看它是否擁有獨立的定址空間 (`mm`):有則為行程,沒有 (與親代共享) 則為執行緒。核心執行
緒更極端:它們的 `mm` 為 `NULL`,共享核心定址空間,因此一律稱為核心執行緒,而非核心行程。
這個設計帶來的直觀結果是:建立執行緒 (`pthread_create`) 比建立行程 (`fork`) 成本低,因為執行緒共享 `mm_struct`、`files_struct`、`fs_struct` 和 `sighand_struct`,不需要配置和複製這些資料結構。執行緒之間的 context switch 也不需要切換定址空間 (不重載 CR3),省去 TLB flush 的代價。代價是共享定址空間帶來的
同步需求:任何執行緒對全域變數或 heap 的寫入都直接可見,若缺乏同步機制就會引發 data race。
| 資源 | `fork()` | `pthread_create()` (clone + 共享 flag) |
|---|---|---|
| `mm_struct` (定址空間) | `dup_mm()` 複製獨立副本 | 共享 (`CLONE_VM`) |
| `files_struct` (檔案描述子表格) | `dup_fd()` 複製獨立副本 | 共享 (`CLONE_FILES`) |
| `fs_struct` (root / cwd / umask) | `copy_fs_struct()` 複製 | 共享 (`CLONE_FS`) |
| `sighand_struct` (signal handler) | 複製 | 共享 (`CLONE_SIGHAND`) |
| `nsproxy` (namespace) | 預設共享 (除非帶 `CLONE_NEW*`) | 共享 |
| `task_struct` / 核心 stack / 暫存器 | 獨立 | 獨立 |
以下圖示對照執行緒 (共享資源) 與行程 (獨立副本) 的差異。執行緒建立時帶入 `CLONE_VM | CLONE_FILES | CLONE_FS` 等 flag,新 `task_struct` 的 `mm`、`files`、`fs` 指標直接沿用親代的資料結構 (遞增引用計數);行程建立時不帶這些 flag,`copy_mm()`、`copy_files()`、`copy_fs()` 各自配置新的資料結構並複製內容。
```graphviz
digraph resource_sharing {
rankdir=TB;
node [shape=record, fontname="Courier", fontsize=10];
edge [fontname="Helvetica", fontsize=9];
subgraph cluster_thread {
label="執行緒建立 (CLONE_VM | CLONE_FILES | CLONE_FS)";
labeljust=l; fontname="Helvetica"; fontsize=10;
style=dashed; color=steelblue;
ts_parent [label="{task_struct (親代)|{mm|files|fs|nsproxy}}", style=filled, fillcolor="#B3D9FF"];
ts_child [label="{task_struct (子執行緒)|{mm|files|fs|nsproxy}}", style=filled, fillcolor="#B3D9FF"];
mm_shared [label="mm_struct\n(共享)", shape=ellipse, style=filled, fillcolor="#C8E6C9"];
files_shared [label="files_struct\n(共享)", shape=ellipse, style=filled, fillcolor="#C8E6C9"];
fs_shared [label="fs_struct\n(共享)", shape=ellipse, style=filled, fillcolor="#C8E6C9"];
ns_shared [label="nsproxy\n(共享)", shape=ellipse, style=filled, fillcolor="#C8E6C9"];
ts_parent -> mm_shared;
ts_child -> mm_shared;
ts_parent -> files_shared;
ts_child -> files_shared;
ts_parent -> fs_shared;
ts_child -> fs_shared;
ts_parent -> ns_shared;
ts_child -> ns_shared;
{rank=same; ts_parent; ts_child;}
{rank=same; mm_shared; files_shared; fs_shared; ns_shared;}
}
subgraph cluster_process {
label="行程建立 (fork, 不帶共享 flag)";
labeljust=l; fontname="Helvetica"; fontsize=10;
style=dashed; color=firebrick;
ts_p2 [label="{task_struct (親代)|{mm|files|fs|nsproxy}}", style=filled, fillcolor="#FFD9B3"];
ts_c2 [label="{task_struct (子行程)|{mm|files|fs|nsproxy}}", style=filled, fillcolor="#FFD9B3"];
mm_p [label="mm_struct\n(親代)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
mm_c [label="mm_struct\n(子行程, CoW)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
files_p [label="files_struct\n(親代)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
files_c [label="files_struct\n(子行程)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
fs_p [label="fs_struct\n(親代)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
fs_c [label="fs_struct\n(子行程)", shape=ellipse, style=filled, fillcolor="#FFCCBC"];
ns_p [label="nsproxy\n(共享)", shape=ellipse, style=filled, fillcolor="#C8E6C9"];
ts_p2 -> mm_p;
ts_c2 -> mm_c;
ts_p2 -> files_p;
ts_c2 -> files_c;
ts_p2 -> fs_p;
ts_c2 -> fs_c;
ts_p2 -> ns_p;
ts_c2 -> ns_p;
{rank=same; ts_p2; ts_c2;}
}
}
```
`copy_process()` 在完成資源複製後,根據 `CLONE_THREAD` 決定 thread group 關聯。若帶有 `CLONE_THREAD`,新 task 的 `tgid` 沿用親代的 `tgid`,`group_leader` 指向親代的 `group_leader`,並以 `list_add_tail_rcu()` 將新 task 的 `thread_group` 節點串入 `group_leader->thread_group` 雙向鏈結串列,同時將 `thread_node` 串入 `signal->thread_head`。後者讓核心可透過 `for_each_thread()` 巨集走訪同一行程內的所有執行緒;例如 signal 傳遞路徑中 `complete_signal()` 需
要挑選可接收 process-directed signal 的執行緒時,就依賴這條鏈結串列。若不帶 `CLONE_THREAD` (即 `fork()`),新 task 自身成為 thread group leader,`tgid` 等於自己的 `pid`,`group_leader` 指向自身。
歷史背景參考:[Understanding the Linux Virtual Memory Manager](https://www.kernel.org/doc/gorman/html/understand/understand007.html)
### CoW 與 fork 的效率
`fork()` 不帶 `CLONE_VM`,因此 `copy_mm()` 呼叫 `dup_mm()` 複製整個 address space。但 Linux 不會立即複製所有實體記憶體頁面,而是使用 CoW:
1. `dup_mmap()` 走訪親代的 maple tree,為每個 VMA 建立子行程的副本
2. 對於可寫入的 private mapping,`copy_page_range()` 將親代和子行程的 page table entry 都設為唯讀 (清除 RW flag),並遞增每個 page frame 的 reference count
3. 當任一行程嘗試寫入這些頁面時,CPU 產生 protection fault
4. page fault handler 檢查 `vm_area_struct` 的 `vm_flags`,發現該區域允許寫入 (`VM_WRITE`),判定這是 CoW fault,呼叫 `do_wp_page()` 複製該頁面,將新頁面設為可寫入
因此 `fork()` 的成本主要與 VMA 和 page table 的規模相關,而非與已使用的實體記憶體總量成正比。唯讀頁面 (程式碼、共享函式庫) 通常不需要複製。`fork()` 後緊接 `exec()` 的常見模式幾乎不觸發任何 CoW,因為 `exec()` 會丟棄整個 address space。CoW 其一問題是 [GUP (get_user_pages) 與 CoW 的互動](https://lwn.net/Articles/849638/):當核心為 direct I/O 取得 user page 的實體位址後,若另一側觸發 CoW,可能導致 I/O 寫入錯誤的頁面。Linux 近年的 GUP、page fault 與 VMA locking 修正,都是為了縮小這類 race condition 的影響範圍;其中通用 per-VMA locking 在 v6.4 週期合併,用於降低 page fault 路徑對 `mmap_lock` 的競爭。
slab 配置器在 v6.18 引入 sheaves 機制,作為 slab cache 可選的 per-CPU 物件暫存層。這項變更主要降低高頻配置與釋放路徑上的鎖競爭;對 `fork()` 而言,收益通常來自 VMA、page table 或其他小物件配置路徑的間接改善,而不是保證所有 `task_struct` 配置都因此加速。
`vfork()` 更進一步:搭配 `CLONE_VFORK | CLONE_VM`,子行程直接共享親代的 address space (不複製 page table),親代在子行程呼叫 `exec()` 或 `_exit()` 之前被阻擋。子行程不得修改共享記憶體、不得從呼叫 `vfork()` 的函式返回 (會破壞親代的 stack frame)、不得呼叫可能修改全域狀態的函式庫。在 CoW 普及後,`vfork()` 的主要優勢是省去 page table 的複製,對大型行程仍有可量測的差異。
### fork/exec 分離的設計哲學
UNIX 在 fork 出現之前,採用 overlay 機制:shell 把命令的二進位映像載入自身記憶體,覆蓋 shell 程式碼,執行完畢後重新載入 shell。Dennis Ritchie 記述早期 PDP-7 Unix:
> Processes existed very early in PDP-7 Unix ... precisely two of them, one for each terminal. There was no fork, wait, or exec.
Ken Thompson 的突破在於一個反直覺的想法:與其從頭建構新行程,不如複製既有的。`fork()` 只做一件事 (複製),`exec()` 只做一件事 (置換映像),二者的分離讓 shell 可在 fork 和 exec 之間任意操作檔案描述子,不需要核心知道 I/O 重導向的細節。例如 `ls > out.txt` 的實作:
1. Shell 呼叫 `fork()` 建立子行程
2. 子行程呼叫 `open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644)` 取得 fd 3
3. 子行程呼叫 `dup2(3, STDOUT_FILENO)` 再 `close(3)`
4. 子行程呼叫 `execvp("ls", ...)`
5. `ls` 寫入 fd 1,輸出自動流向 `out.txt`,`ls` 本身完全不知道重導向的存在
Pipeline (`cmd1 | cmd2`) 的實作同理:shell 呼叫 `pipe()` 取得 read/write fd pair,分別 fork 二個子行程,各自 `dup2` 對應的 pipe 端,再 exec。核心不需要任何 pipeline 專用的 API。
這個設計的延伸性極佳:日後加入的 namespace、cgroup、seccomp 等機制,都可以在 fork 和 exec 之間以一般的系統呼叫設定,不需要修改 fork 或 exec 的介面。fork 的歷史淵源 (Conway 1963、Project Genie、Thompson 的轉化) 已在前文〈1960 年代:一個行程就是一切〉和〈[UNIX 作業系統 fork/exec 系統呼叫的前世今生](https://hackmd.io/@sysprog/unix-fork-exec)〉中詳述。
### copy_thread 與子行程的「返回」
`copy_process()` 的最後階段呼叫 `copy_thread()`,設定子行程的核心 stack,使其在首次被排程器選取時能正確「返回」。具體機制因架構而異:
x86-64 ([`arch/x86/kernel/process.c`](https://github.com/torvalds/linux/blob/v6.18/arch/x86/kernel/process.c)):
1. 將親代的 `pt_regs` 複製到子行程核心 stack 的頂端
2. 將子行程的 `pt_regs->ax` 設為 0 (這就是 `fork()` 在子行程中回傳 0 的原因)
3. 設定 stack frame,使首次 context switch 「返回」到 `ret_from_fork`
arm64 ([`arch/arm64/kernel/process.c`](https://github.com/torvalds/linux/blob/v6.18/arch/arm64/kernel/process.c)):
1. 將親代的 `pt_regs` 複製到子行程核心 stack 的頂端
2. 將子行程的 `pt_regs->regs[0]` (即 x0,arm64 的回傳值暫存器) 設為 0
3. 將 `cpu_context.pc` 設為 `ret_from_fork`,使首次 context switch 跳至該位址
首次切入子 task 時會從 `ret_from_fork` 開始,完成 fork 後處理 (如 `finish_task_switch`),再進入一般返回 user space 路徑。由於回傳值暫存器 (x86-64 的 `rax`、arm64 的 `x0`) 已設為 0,回到 user space 時 `fork()` 回傳 0;而親代的 `kernel_clone()` 直接回傳 `pid_vnr(pid)`,即子行程的 PID。這就是 fork「一次呼叫、兩次回傳」的機制本質:並非同一段程式碼回傳二次,而是二個不同的 task 各自從核心返回 user space,攜帶不同的回傳值。
## 行程狀態與生命週期
行程從建立到終結的完整生命週期:
```
Parent process
│
│ fork()/clone()
│ ┌─────────────────────────────┐
▼ ▼ │
┌──────────┐ execve() ┌────────┐ │
│ Child │─────────────►│ New │ │
│(copy of │ │program │ │
│ parent) │ │ image │ │
└────┬─────┘ └───┬────┘ │
│ │ │
│ exit() / signal │ │
▼ ▼ │
┌─────────┐ ┌─────────┐ │
│ ZOMBIE │ │ ZOMBIE │ │
└────┬────┘ └────┬────┘ │
│ parent calls wait() │ │
▼ ▼ │
(reaped) (reaped) │
│
Parent continues ◄───────────────────┘
```
### 狀態轉換
行程的執行狀態和退出狀態分別儲存於 `task_struct` 的不同欄位,定義於 [`include/linux/sched.h`](https://github.com/torvalds/linux/blob/v6.18/include/linux/sched.h)。
執行狀態 (`task_struct->__state`):
* `TASK_RUNNING` (值為 0):正在 CPU 上執行,或在 runqueue 中等待被排程。這個狀態涵蓋執行中和就緒兩種情況
* `TASK_INTERRUPTIBLE`:睡眠中,等待事件或 signal 喚醒
* `TASK_UNINTERRUPTIBLE`:睡眠中,僅等待事件喚醒,忽略 signal (即使 `kill -9` 也無效)。在 `ps` 輸出中顯示為 "D" 狀態。Linux 的 load average 將 `TASK_UNINTERRUPTIBLE` 計入 (不同於其他 UNIX),因此若 load average 飆高但 CPU 使用率不高,通常是大量行程阻塞在磁碟 I/O 或核心鎖上
* `TASK_KILLABLE`:類似 `TASK_UNINTERRUPTIBLE`,但會回應 fatal signal;此狀態自 v2.6.25 起引入,參見 [TASK_KILLABLE](https://lwn.net/Articles/288056/),用於可安全中斷的等待路徑,減少不可殺行程的累積
* `__TASK_STOPPED`:收到 `SIGSTOP`/`SIGTSTP` 後停止
* `__TASK_TRACED`:被 ptrace 追蹤 (例如 GDB) 而停止
退出狀態 (`task_struct->exit_state`):
* `EXIT_ZOMBIE`:已終結,等待親代呼叫 `wait()`
* `EXIT_DEAD`:最終狀態,親代已呼叫 `wait()`,正在被系統移除。從 `EXIT_ZOMBIE` 轉為 `EXIT_DEAD` 是為了避免多個執行緒同時對同一行程執行 `wait()` 時的 race condition
除 `TASK_RUNNING` 為 0 外,各狀態值以 2 的冪定義 (如 `0x0001`、`0x0002`),可用位元運算組合和測試。例如核心以 `state & (TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE)` 判斷 task 是否正在睡眠。
`ps -Lel` 的 `S` 欄顯示目前狀態:`R` (TASK_RUNNING,含在 CPU 上和在 runqueue 中)、`S` (TASK_INTERRUPTIBLE)、`D` (TASK_UNINTERRUPTIBLE)、`T` (stopped/traced)、`Z` (zombie)。
以下圖示呈現 Linux 行程的狀態轉換:
```
fork()/clone()
│
▼
┌─────────┐
│TASK_NEW │
└────┬────┘
│ wake_up_new_task()
▼
┌────────────────────────────────────┐
│ TASK_RUNNING │
│ ┌───────────┐ ┌────────────┐ │
│ │ ready │◄──► │ on CPU │ │
│ │(runqueue) │ │ │ │
│ └───────────┘ └────┬───────┘ │
└─────────────────────────┼──────────┘
▲ ▲ │ │
│ │ │ │
try_to_wake_up() SIGCONT wait for do_exit()
│ │ resource │
│ │ │ │
│ ┌──────┴──┐ │ ▼
│ │ STOPPED │ │ ┌───────────┐
│ └─────────┘ │ │EXIT_ZOMBIE│
│ ▼ └──────┬────┘
┌────┴──────────────────────┐ │ wait()
│ INTERRUPTIBLE / │ ▼
│ UNINTERRUPTIBLE / │ ┌──────────┐
│ KILLABLE │ │EXIT_DEAD │
└───────────────────────────┘ └──────────┘
│ release_task()
▼
(freed)
```
從 "on CPU" 到 "ready" 的轉換有兩種觸發方式:自願 (voluntary,如 `sched_yield()` 或主動呼叫 `schedule()`) 和非自願 (involuntary,timeslice 耗盡或更高 priority 的 task 被喚醒)。
### exec:置換行程映像
`execve()` 將目前行程的 address space 完全置換為新的程式。呼叫鏈:
`execve()` → `do_execve()` → `do_execveat_common()` → `bprm_execve()` → `exec_binprm()` → `search_binary_handler()` → `load_elf_binary()` (針對 ELF 格式)
`load_elf_binary()` 的關鍵步驟:
1. 驗證 ELF header
2. `begin_new_exec()`:point of no return,釋放舊的 address space
3. 映射 .text (read/execute)、.data (read/write)、.bss (zero-fill)
4. 設定動態連結器 (`ld-linux.so`)
5. `start_thread()`:修改 `pt_regs` 中的 IP 和 SP,使返回 user space 時跳到新程式的 `_start`
在多執行緒行程中呼叫 `execve()` 時,`begin_new_exec()` 內部呼叫 `de_thread()`,終結所有非呼叫者的執行緒,使 exec 成功後的行程回到單一執行緒。若呼叫者原本不是 thread group leader,核心會在 `de_thread()` 中調整 PID 關係,使呼叫者接手原 leader 的 TGID;這也是 `execve()` 路徑必須特別處理 thread group 的原因。
shebang (`#!`) 由核心處理,而非 shell。`search_binary_handler()` 走訪已註冊的 binary format handler:`binfmt_elf` 處理 ELF,`binfmt_script` 處理 `#!` 腳本 (解析直譯器路徑後重新進入 binary handler 流程)。核心會限制直譯器遞迴深度,避免腳本或 `binfmt_misc` 互相指向造成無限遞迴。參見 [How programs get run: ELF binaries](https://lwn.net/Articles/631631/)。
`fork`/`exec`/`clone` 系統呼叫的完整實作路徑,參見〈[Linux 核心的系統呼叫](https://hackmd.io/@sysprog/linux-syscall)〉。
### do_exit:行程的終結
`do_exit()` ([`kernel/exit.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/exit.c)) 執行有序的資源清理:
1. 設定 `PF_EXITING` flag (防止重入)
2. `exit_mm()`:釋放 address space
3. `exit_sem()`、`exit_shm()`:釋放 SysV IPC 資源
4. `exit_files()`:關閉開啟的檔案 (遞減 reference count)
5. `exit_fs()`:釋放檔案系統 context
6. `exit_task_namespaces()`、`exit_thread()`:釋放 namespace 和架構相關資源
7. `cgroup_exit()`:脫離 cgroup
8. `exit_notify()`:傳送 `SIGCHLD` 給親代,reparent 子行程,並根據條件決定 `exit_state` 為 `EXIT_ZOMBIE` 或 `EXIT_DEAD`
9. `do_task_dead()` → 呼叫 `__schedule()`,永不返回
`do_exit()` 釋放的與保留的資源有明確分界:
| 釋放 (`do_exit` 完成時) | 保留 (等待親代 `wait`) |
|---|---|
| address space (`mm_struct`) | `task_struct` 本身 |
| 檔案描述子表格 | exit code、resource usage 統計 |
| 檔案系統 context | PID slot |
| namespace、cgroup 參照 | 等待親代讀取的終結狀態 |
行程在 `do_exit()` 之後進入 `EXIT_ZOMBIE` 狀態。此時它不佔用 user space 記憶體,也不持有任何檔案或鎖,但 `task_struct` 仍未釋放;它存在的目的,是保留 exit code 和資源使用統計,等待親代呼叫 `wait()` 來讀取。在 `CONFIG_VMAP_STACK` 啟用的現代核心 (v4.9+) 上,核心 stack 可能在 `finish_task_switch()` 路徑中提前釋放;但 `task_struct` 本身和 PID slot 直到 `release_task()` 才會回收。
v5.17 對 `exit_mm()` 的重構簡化 exit 路徑中 `mm` 的釋放邏輯,移除若干歷史遺留的 race condition workaround。
### Zombie 行程
根據 [wait(2)](https://man7.org/linux/man-pages/man2/wait.2.html):子行程終結後,若親代尚未呼叫 `wait` 取得其 exit status,核心仍保留子行程的最少資訊 (termination status、resource usage statistics),此即 zombie 行程。Zombie 至少佔用一個 `task_struct`;在部分組態或時機下,核心 stack 也可能尚未釋放,但 address space、檔案描述子等一般資源已釋放。
根據 [exit(3)](https://man7.org/linux/man-pages/man3/exit.3.html),子行程終結時有三種情況:
* 若親代已設定 `SA_NOCLDWAIT`,或將 `SIGCHLD` handler 設為 `SIG_IGN`,核心直接丟棄 exit status,子行程立即消亡,不會成為 zombie
* 若親代已在 `wait` 中等待,子行程的 exit status 立即傳遞給親代,子行程立即消亡
* 否則,子行程成為 zombie,在行程表中保留一個 slot,直到親代呼叫 `waitpid()` 取得 termination status 後,zombie slot 才被釋放
親代呼叫 `wait()` 後,核心執行 `release_task()`:呼叫 `detach_pid()` 將任務從各 PID type 的結構中抽離,待該 PID 不再被任何任務參照後,核心回收對應的 PID 項目,最後釋放 `task_struct`。Zombie 無法被外部 kill (已不存在可接收 signal 的 context),只有親代或收養它的 subreaper / init 呼叫 `wait()` 才能清理。
產生 zombie 累積的常見原因包括:親代程式邏輯未呼叫 `wait`、裝置驅動程式錯誤導致親代行程停滯而無法回收子行程,或 subreaper/init 未及時呼叫 `wait` 清理已「收養」的子行程。
### Orphan 行程
Zombie 是子行程已終結、親代尚未回收,而 orphan 是親代已終結、子行程還活著。二者不同:orphan 是仍在執行的行程,只是失去親代。
根據 [wait(2)](https://man7.org/linux/man-pages/man2/wait.2.html):若親代行程終結,其 zombie children (以及仍在執行的 orphan children) 會被 `init` (PID 1) 或最近的 subreaper ([`prctl(2)` `PR_SET_CHILD_SUBREAPER`](https://man7.org/linux/man-pages/man2/prctl.2.html)) 收養。`init` 會自動呼叫 `wait` 來移除 zombie。在核心中,`find_new_reaper()` 依序尋找新的親代:
1. 同一 thread group 中的其他執行緒
2. 最近的 subreaper
3. `init` (PID 1)
以下程式示範 orphan 的產生 (引用自 [The Linux Programming Interface](https://man7.org/tlpi/code/online/diff/namespaces/orphan.c.html), Chapter 26):
```c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t ppid_orig = getpid();
pid_t pid = fork();
if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); }
if (pid != 0) { /* 親代 */
printf("Parent (PID=%ld) created child with PID %ld\n",
(long) getpid(), (long) pid);
printf("Parent (PID=%ld; PPID=%ld) terminating\n",
(long) getpid(), (long) getppid());
exit(EXIT_SUCCESS);
}
/* 子行程:等待被收養 */
do {
usleep(100000);
} while (getppid() == ppid_orig);
printf("\nChild (PID=%ld) now an orphan (parent PID=%ld)\n",
(long) getpid(), (long) getppid());
sleep(1);
printf("Child (PID=%ld) terminating\n", (long) getpid());
exit(EXIT_SUCCESS);
}
```
執行後可觀察到子行程的 PPID 從親代的 PID 變為 1 (或 subreaper 的 PID),證實 reparent 機制的運作。
## 從 PID 0 到 PID 1:核心的啟動
### PID 0 的歷史淵源
在 UNIX 及其相容作業系統中,PID 確實從 0 開始,但 UNIX 不提供系統呼叫來讓使用者操作 PID 0。`kill(0, sig)` 會將 signal 送給整個 process group 而非 PID 0;`fork()` 在子行程中回傳 0,直接把這個數值挪用;`sched_setscheduler(0, ...)` 操作的是呼叫者自身。
在 UNIX V6 (1975 年) 和 V7 (1979 年) 中,PID 0 執行 `sched()` 函式,負責在核心記憶體和磁碟之間搬移整個行程映像 (swapping)。當時 `struct proc` 尚無 `p_comm` 欄位,PID 0 沒有儲存的名稱字串,`sched` 嚴格來說是函式名而非行程名。在 PDP-7 和 PDP-11 主機上,硬體記憶體管理不具備完整的虛擬記憶體能力,此機制是必要的。UNIX System V 衍生的 Solaris 讓 `ps` 輸出顯示 PID 0 為 `sched`,確立 System V 系統的命名慣例。BSD 分支 (4.4BSD-Lite2 起明確設定) 和 Linux 則一致稱 PID 0 為 `swapper`。無論名稱為何,PID 0 的存在簡化排程的實作:不必處理沒有行程可執行的狀況,因為總有一個行程可執行。
1980 年代,UNIX System V 與 BSD 系統逐步以 demand paging 取代早期 swapping 模式,PID 0 的行程交換角色隨之淡化。隨著排程演算法和 CPU idle 機制趨於複雜,原本實作於 `sched()` 的排程和 idle 任務操作被拆分為獨立的程式碼片段。
### Linux 核心的啟動流程
核心啟動的完整流程如下圖所示,從 `start_kernel()` 到 user space 的 `init` 行程:
```
Boot loader
│
▼
start_kernel() ← PID 0 (init_task) runs here
│
├─ sched_init() ← init scheduler, mark init_task as idle
│
▼
rest_init()
│
├─ kernel_thread(kernel_init, ...) → PID 1
│ │
│ ├─ device drivers, mount rootfs
│ └─ run_init_process("/sbin/init")
│ └─ execve() → user space init/systemd
│
├─ kernel_thread(kthreadd, ...) → PID 2
│ └─ spawn kthreads on demand
│
└─ cpu_startup_entry()
└─ while (1) do_idle(); ← PID 0 becomes idle task
```

`init_task` 定義於 [`init/init_task.c`](https://github.com/torvalds/linux/blob/v6.18/init/init_task.c),是 Linux 核心所有行程和執行緒的 `task_struct` 雛形,也是引導 CPU 的初始 idle 執行緒,不是透過 `kernel_thread` 函式建立。在 `start_kernel()` 中,`sched_init()` 將 `init_task` 標記為引導 CPU 的 idle 執行緒:
```c
init_idle(current, smp_processor_id());
```
`start_kernel()` 結束前呼叫 `rest_init()`,後者產生二個關鍵任務:
```c
static noinline void __ref __noreturn rest_init(void)
{
...
pid = kernel_thread(kernel_init, NULL, NULL,
CLONE_FS | CLONE_FILES); /* 任務 1 */
...
pid = kernel_thread(kthreadd, NULL, NULL,
CLONE_FS | CLONE_FILES); /* 任務 2 */
...
complete(&kthreadd_done);
...
cpu_startup_entry(CPUHP_ONLINE); /* 進入 idle 迴圈 */
}
```
* 任務 1 (`kernel_init`):進行更多核心初始化 (裝置驅動程式、掛載 rootfs),最後呼叫 `run_init_process()` 執行 user space 的 init 程式 (`/sbin/init` 或 systemd),成為 PID 1
* 任務 2 (`kthreadd`):核心執行緒的背景程式,持續檢查 `kthread_create_list`,為所有後續的核心執行緒提供建立服務。因此幾乎所有核心執行緒的 PPID 都是 2
建立順序有其講究:`kernel_init` 必須先於 `kthreadd` 建立,才能取得 PID 1。但 `kernel_init` 在初始化過程中會呼叫 `kthread_create()` 建立核心執行緒,而 `kthread_create()` 依賴 `kthreadd` 已就緒。因此 `rest_init()` 在建立 `kthreadd` 後呼叫 `complete(&kthreadd_done)` 發出完成通知,讓阻塞在 `wait_for_completion(&kthreadd_done)` 的 `kernel_init` 得以繼續執行。
`rest_init()` 進入 `cpu_startup_entry()` 之前,將 `init_task` 的排程類別切換為 `idle_sched_class`。idle 排程類別的 priority 最低,位於排程類別鏈結的尾端 (stop > deadline > RT > fair > sched_ext > idle);只有當 runqueue 上沒有任何其他可執行的 task 時,排程器的 `pick_next_task()` 才會走到 `idle_sched_class`,回傳 `rq->idle`。換言之,idle task 不參與一般的 priority 排序,而是 runqueue 為空時的最終歸宿。
`cpu_startup_entry()` 最終進入 `cpu_idle_loop()` (定義於 [`kernel/sched/idle.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/sched/idle.c)) 的無窮迴圈。此迴圈的核心邏輯是:反覆檢查 `need_resched()` flag;若無待排程的 task,呼叫架構對應的低功耗指令 (x86 的 `hlt`/`mwait`、arm64 的 `wfi`) 讓 CPU 暫停執行並降低功耗,等待硬體中斷喚醒後再重新檢查。當 `need_resched()` 為真 (例如有 task 被喚醒或 timer 觸發),idle 迴圈離開內層 while,呼叫 `schedule_preempt_disabled()` 將 CPU 交給就緒的 task。此時 PID 0 就是引導 CPU core 的 idle task。
### 多核系統中的 idle task
在多核處理器系統中,`kernel_init` (任務 1) 首先呼叫 `smp_init()` 啟動所有非 bootstrap 的 CPU core。`smp_init()` 為每個 CPU core 呼叫 `fork_idle()`,後者透過 `copy_process()` 以 `init_task` 為範本建立新的 idle 執行緒。這裡有個關鍵例外:`copy_process()` 在建立 idle task 時跳過新 PID 的配置:
```c
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children,
args->set_tid, args->set_tid_size);
...
}
```
隨後 `fork_idle()` 呼叫 `init_idle_pids()`,使新 idle task 的所有識別碼與 `init_struct_pid` 匹配。因此每個 CPU core 的 idle task 都與 `init_task` 共享 PID 0。每個 CPU core 啟動後,進入各自的 `cpu_startup_entry()` → `do_idle()` 迴圈,名為 `swapper/N` (N 為 CPU 編號)。
可用 [Ftrace](https://www.kernel.org/doc/Documentation/trace/ftrace.txt) 觀察排程器在 idle task 和 user task 之間的切換:
```
$ cd /sys/kernel/debug/tracing
$ echo 1 > events/sched/enable ; sleep 2 ; echo 0 > events/sched/enable
$ cat per_cpu/cpu2/trace
```
參考輸出 (摘錄):
```
<idle>-0 [002] d..2. 43323.411061: sched_switch: prev_comm=swapper/2 prev_pid=0 ... ==>
next_comm=bash next_pid=31424
sleep-31424 [002] d..2. 43323.411311: sched_switch: prev_comm=sleep prev_pid=31424 ... ==>
next_comm=swapper/2 next_pid=0
```
不難見到 `swapper/2` (PID 0) 和 user task 之間的交替。當沒有可執行的 task 時,排程器切回 idle task。
### 核心執行緒概覽
核心執行緒完全在 kernel space 執行,`task_struct->mm` 為 `NULL` (沒有 user 定址空間),不會返回 user mode。之所以稱為「執行緒」而非「行程」,原因在於所有核心執行緒共享同一份核心定址空間:每個行程的 page table 上半部都映射相同的核心區域,核心執行緒不需要自己的 page table,只要沿用任一行程已建立的映射即可存取核心記憶體。這與 user 執行緒共享同一個 `mm_struct` 的邏輯類似,只不過核心執行緒共享的是核心定址空間而非 user 定址空間。
`mm == NULL` 並不表示核心執行緒無法進行位址轉換。MMU 仍需要有效的 page table 才能運作,因此核心在 `context_switch()` 中以 `active_mm` 處理這個需求:切入核心執行緒時,`switch_mm_irqs_off()` 偵測到 `next->mm == NULL`,便將前一個 task 已持有的 `active_mm` 指定給核心執行緒,CR3/TTBR0_EL1 維持不變,不觸發 TLB flush。這就是 lazy TLB 機制。核心執行緒只存取 page table 上半部的核心映射,不碰下半部的 user 映射,因此不論沿用哪個行程的 page table 都能正確運作。
在 `ps` 輸出中,核心執行緒通常以方括號標示;多數由 `kthreadd` 建立的核心執行緒 PPID 為 2,但 idle 執行緒、早期啟動任務和少數特殊路徑不適用這個簡化規則。常見的核心執行緒包括:
| 名稱 | 功能 |
|---|---|
| `kworker` | 執行 workqueue 中的工作項目,處理中斷下半部、I/O 等延遲操作 |
| `ksoftirqd` | 處理 softirq (軟體中斷),當 softirq 負載過高時從中斷 context 卸載至此 |
| `migration` | 在 CPU core 之間搬移 task 以平衡 runqueue 負載,每個 CPU core 一個實例 |
| `kswapd` | 背景 page reclaim,在記憶體不足時回收頁面 |
| `kcompactd` | 背景記憶體壓縮 (memory compaction),減少外部碎片化 |
| `khugepaged` | 將小頁面合併為 transparent huge page (THP) |
| `watchdogd` | 偵測 soft lockup (CPU 長時間不排程) 和 hard lockup (CPU 長時間不回應中斷) |
| `oom_reaper` | 在 OOM killer 選定犧牲者後,負責回收其記憶體 |
核心執行緒與一般行程都是 `task_struct`,排程器對二者一視同仁:核心執行緒擁有獨立的 `sched_entity`,被放進 runqueue、參與 timeslice 分配、可被搶佔,與任何 user 行程無異。`ps aux` 能列出核心執行緒 (方括號標示),`top -H` 能看到它們的 CPU 使用率,`pidstat -w -t` 能統計它們的 context switch 次數。差異全在 address space 和執行模式:
| | 一般行程 / user 執行緒 | 核心執行緒 |
|---|---|---|
| `task_struct->mm` | 指向自身或共享的 `mm_struct` | `NULL` |
| address space | 擁有 user + kernel 映射 | 無 user 映射,切入時沿用前一個 task 已持有的 `active_mm` (lazy TLB) |
| 執行模式 | user mode ↔ kernel mode | 永遠在 kernel mode,不返回 user space |
| 建立方式 | `fork()`/`clone()`/`pthread_create` | 多數由 `kthread_create()` 請 `kthreadd` 建立;idle 和早期啟動任務等例外 |
| 排程 | 同 | 同,排程器不區分 |
| `ps` 可見 | 同 | 多數可見,以方括號標示;idle/swapper 不顯示 |
| 可否存取 user 記憶體 | 可 (`copy_from_user` 等) | 通常不可 (`mm` 為 `NULL`);少數路徑可透過 `kthread_use_mm()` 暫時借用指定 `mm` |
| signal | 完整 POSIX signal 語意 | 多數核心執行緒不參與一般 POSIX signal 流程;是否允許特定 signal 由執行緒函式自行決定 |
換言之,排程器以 task 及其對應的 scheduling entity 為共同單位,不以 user 行程或核心執行緒作為根本分界;區分二者的是 `mm` 指標和執行模式。`mm == NULL` 這個條件在核心中被廣泛用來判斷目前 task 是否為核心執行緒,例如 `context_switch()` 中 `switch_mm_irqs_off()` 以此決定是否需要切換 address space。
完整的核心執行緒巡禮可參照 Shlomi Boutnaru 的〈[The Linux Process Journey](https://medium.com/@boutnaru)〉系列。
## 排程實體:sched_entity 與 task_struct 的脫勾
以 CPU 排程的觀點,排程器操作的對象並非 `task_struct`,而是內嵌於其中的 scheduling entity。在 v6.18 中,`task_struct` 包含多個 scheduling entity,分別對應不同的排程策略:
```c
struct task_struct {
...
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
struct sched_dl_entity *dl_server;
#ifdef CONFIG_SCHED_CLASS_EXT
struct sched_ext_entity scx;
#endif
/* Proxy Execution infrastructure */
struct mutex *blocked_on;
...
};
```
* `se`:fair scheduling 使用的排程實體。自 v6.6 起,Linux 的 fair class 改用 [EEVDF](https://lwn.net/Articles/925371/) (Earliest Eligible Virtual Deadline First) 選取下一個實體;它仍沿用 CFS 的許多基礎設施,但以 eligible time 和 virtual deadline 取代單純挑選最小 `vruntime`,用來改善延遲控制與 lag 補償
* `rt`:real-time 排程 (FIFO/RR) 使用的排程實體
* `dl`:deadline 排程 (EDF) 使用的排程實體
* `dl_server`:指向 fair server 使用的 deadline entity,用於在 RT throttling 與 fair task 頻寬保留之間建立較明確的排程邊界
* `scx`:啟用 `CONFIG_SCHED_CLASS_EXT` 時存在,[sched_ext](https://lwn.net/Articles/974387/) 框架使用的排程實體,允許透過 BPF 程式在 runtime 定義部分排程策略
### sched_ext 與實務上的 BPF 排程器
sched_ext 在 v6.12 合併之後,讓 BPF 程式可以介入 `pick_next_task` 等排程決策,等同將排程策略拆成一個可替換的模組。這對 workload-specific tuning 意義很大:資料中心、遊戲桌面與 real-time 管線都曾碰到 EEVDF 或 CFS 決策不夠理想的情境,但修改 mainline 排程器的代價極高。sched_ext 讓廠商和社群先在 user space + BPF 迭代,再把驗證過的想法回饋到核心。
[scx 官方套件庫](https://github.com/sched-ext/scx) 收錄數個具代表性的 BPF 排程器:
- `scx_rusty`:Meta 開發的 multi-domain BPF/user-space hybrid 排程器,預設以 per-LLC domain 為單位;user space Rust 負責 load balancing,BPF 負責 per-domain vtime 排序與 greedy work stealing
- `scx_layered`:把任務依 cgroup、`comm`/`pcomm`、nice、UID/GID、PID/TGID 或 runtime hint 分層,每層套用不同的 timeslice 與 CPU 子集,適合混合 batch 與 interactive workload
- `scx_lavd`:「Latency-criticality Aware Virtual Deadline」排程器,針對桌面與遊戲情境的 tail latency 最佳化
- `scx_bpfland`:源自 `scx_rustland` 的 BPF-only 排程器,鎖定互動型工作負載,README 將其標示為「ready for production use」
sched_ext 能接管 `SCHED_NORMAL`、`SCHED_BATCH`、`SCHED_IDLE` 與專用的 `SCHED_EXT` 策略;若 BPF 程式被解除、觸發 SysRq-S 或發生 runnable task stall,核心會自動把任務退回 fair class,用 EEVDF 收尾,避免無排程器可用的窘境。這套機制也讓學界設計如 [Shenango](https://www.usenix.org/conference/nsdi19/presentation/ousterhout)、[ghOSt](https://dl.acm.org/doi/10.1145/3477132.3483542) 之類的排程研究,更容易以 sched_ext 重新實作並在 Linux 平台反覆驗證。
### Proxy Execution
Proxy Execution 是 Linux 排程器為改善優先權反轉 (priority inversion) 而發展的機制,截至 v6.18 仍處於逐步整合階段 (部分基礎設施已合併,完整功能尚在 RFC 檢視和推進)。傳統的 mutex 繼承 (Priority Inheritance) 會暫時提升持鎖任務的 priority;Proxy Execution 則嘗試讓被阻塞任務的排程資格推動持鎖任務執行,使被依賴的工作更快完成。這不是取代所有 priority inheritance 的通用解法,而是針對特定鎖與排程路徑逐步整合的機制。
這種設計將誰被排程選取與 CPU 實際執行哪個 task 進一步分離。當高 priority 任務 A 因為任務 B 持有的鎖而阻塞時,排程器可沿著阻塞關係找到 B,實際執行 B 的程式碼,讓 A 所依賴的進度向前推進。實作上仍必須處理鎖鏈、CPU affinity、排程類別與帳務統計等細節,因此文獻和程式碼常把它描述為一組基礎設施,而不是單一演算法開關。參見 [Proxy Execution](https://lwn.net/Articles/934049/) (2023 年) 的設計討論。
排程器直接操作 `sched_entity`,而非 `task_struct`。這項設計的關鍵在於:`sched_entity` 不一定對應單一 task,也可以代表一整個 task group。當 `CONFIG_FAIR_GROUP_SCHED` 啟用時,`sched_entity` 具備階層結構,排程器得以用統一的演算法處理個別 task 與 task group。
在 EEVDF 中,排程器從根 `cfs_rq` 開始,逐層在 eligible entity 中偏好 virtual deadline 較早者,直到找到代表實際 task 的 entity 為止。同一 thread group 內的 user 執行緒共享同一個 `mm_struct` (每個執行緒的 `task_struct->mm` 指向相同實體)。但在排程層面,每個執行緒各自持有獨立的 `sched_entity`,排程器對其一視同仁。
關於 `sched_entity` 的完整欄位、EEVDF 的 vruntime/deadline 計算,以及 group scheduling 的階層結構,參見〈[Linux 核心的 CPU 排程器](https://hackmd.io/@sysprog/linux-scheduler)〉。
## PID 結構與查詢機制
### 四種 PID type 的階層關係
參考 [CPU Scheduling of the Linux Kernel](https://docs.google.com/presentation/d/1qDSFOfhGF-AX3O8CNzoAkQr_5ohr0nMVDlNfPnM9hOI/),Linux 核心的 PID 體系有四個層級:
* 每個執行緒擁有一個 PID
* 數個執行緒組成一個 thread group,該 group 擁有一個 TGID。其中 thread group leader 的 PID 等於 TGID,此 thread group 即為行程
* 數個行程組成一個 process group,該 group 擁有一個 PGID。process group leader 必為 thread group leader,其 PID 等於 PGID
* 數個 process group 組成一個 login session,對應 SID
這四個層級分別對應核心中的 `PIDTYPE_PID`、`PIDTYPE_TGID`、`PIDTYPE_PGID`、`PIDTYPE_SID`,即 `struct pid` 的 `tasks[type]` 陣列索引。
```graphviz
digraph pid_hierarchy {
rankdir=BT;
node [shape=record, fontname="Courier", fontsize=10];
edge [fontname="Helvetica", fontsize=9];
subgraph cluster_session {
label="Session (SID)"; style=dashed; color=gray60;
subgraph cluster_pg1 {
label="Process Group (PGID)"; style=dashed; color=gray40;
subgraph cluster_tg1 {
label="Thread Group / 行程 (TGID)"; style=solid; color=steelblue;
t1 [label="Thread 1\n(leader)\nPID=100"];
t2 [label="Thread 2\nPID=101"];
t3 [label="Thread 3\nPID=102"];
}
subgraph cluster_tg2 {
label="Thread Group / 行程 (TGID)"; style=solid; color=steelblue;
t4 [label="Thread 4\n(leader)\nPID=200"];
t5 [label="Thread 5\nPID=201"];
}
}
subgraph cluster_pg2 {
label="Process Group (PGID)"; style=dashed; color=gray40;
subgraph cluster_tg3 {
label="Thread Group / 行程 (TGID)"; style=solid; color=steelblue;
t6 [label="Thread 6\n(leader)\nPID=300"];
}
}
}
note [shape=note, label="TGID = leader 的 PID\nPGID = pg leader 的 PID\nSID = session leader 的 PID",
fontsize=9, fillcolor=lightyellow, style=filled];
}
```
### PID 資料結構的設計演化
理解 `struct pid` 的設計,從簡單到複雜逐步推導較為清晰。
若不考慮 namespace 和 ID 類型,一個 PID 只對應一個 `task_struct`,結構可以極其精簡:`task_struct` 持有 `pid_link`,指向 `struct pid`;`struct pid` 儲存 PID 數值 `nr` 和一條 hash chain (`pid_chain`),供 `pid_hash[]` 雜湊表索引。`pid_map` 位元圖追蹤哪些 PID 已被配置。
引入 ID 類型 (`PIDTYPE_PID`、`PIDTYPE_PGID`、`PIDTYPE_SID`) 後,`task_struct` 需要 `pid_link pids[PIDTYPE_MAX]` 陣列,每個元素指向對應類型的 `struct pid`。`struct pid` 的 `tasks[PIDTYPE_MAX]` 則是每種 ID 類型的 hash list head,串聯以該 PID 為 group leader 的所有 task。例如:行程 A 是行程 B 和 C 的 process group leader,則 A 的 `pid` 結構體的 `tasks[PIDTYPE_PGID]` 串聯 B 和 C 的 `pids[PIDTYPE_PGID].node`。
再加入 PID namespace 後,一個行程在每個可見的 namespace 中各有一個局部 ID,`struct pid` 因此增加 `level` 欄位和 `struct upid numbers[]` 可變長度陣列。
每個 `upid` 記錄一個 namespace 中的 `nr` 值與指向 `pid_namespace` 的指標。
```graphviz
digraph pid_design_evolution {
rankdir=TB;
node [shape=record, fontname="Courier", fontsize=10];
edge [fontname="Helvetica", fontsize=9];
newrank=true;
subgraph cluster_simple {
label="階段一:1 PID = 1 task (早期核心)"; style=rounded; color="#888888";
ts1 [label="task_struct|<pl> pid_link"];
pid1 [label="struct pid|nr|<pc> pid_chain"];
hash1 [label="pid_hash[]|...|<slot> slot|..."];
ts1:pl -> pid1 [label="pid"];
pid1:pc -> hash1:slot [style=dashed, label="hash 索引"];
}
subgraph cluster_typed {
label="階段二:加入 ID 類型 (v2.6,支援 process group / session)"; style=rounded; color="#888888";
ts2 [label="task_struct|<p0> pids[PID]|<p1> pids[PGID]|<p2> pids[SID]"];
pid2 [label="struct pid|<t0> tasks[PID]|<t1> tasks[PGID]|<t2> tasks[SID]|nr|pid_chain"];
ts2:p0 -> pid2:t0 [label="PID"];
ts2:p1 -> pid2:t1 [label="PGID"];
ts2:p2 -> pid2:t2 [label="SID"];
}
subgraph cluster_ns {
label="階段三:加入 PID namespace (v2.6.24+,支援容器隔離)"; style=rounded; color="#888888";
ts3 [label="task_struct|pid_links[PIDTYPE_MAX]|nsproxy"];
pid3 [label="struct pid|count|level|tasks[PIDTYPE_MAX]|<u0> numbers[0]|<u1> numbers[level]"];
upid0 [label="upid|nr=5|<ns0> ns"];
upid1 [label="upid|nr=100|<ns1> ns"];
ns0 [label="pid_namespace\n(root, level=0)"];
ns1 [label="pid_namespace\n(child, level=1)"];
ts3 -> pid3 [label="pid_links"];
pid3:u0 -> upid0;
pid3:u1 -> upid1;
upid0:ns0 -> ns0;
upid1:ns1 -> ns1;
ns1 -> ns0 [label="parent"];
}
}
```
### PID namespace 與 global/local ID
每個 PID namespace 擁有獨立的 PID 空間。同一個任務在不同 namespace 中可能擁有不同 PID。
Linux 的行程 ID 因此分為二類:
* global ID:在 initial PID namespace 中的 ID。`task_struct->pid` 標識個別 task/thread;`task_struct->tgid` 標識所屬 thread group leader 的 PID
* local ID:任務在特定 namespace 中的 ID,可能與 global ID 不同
每個 PID namespace 擁有獨立的 PID 配置空間。在 v6.18 中,每個 `pid_namespace` 持有一個 `struct idr`,從 namespace 內的 PID 數值對應至 `struct pid`。查詢時,`find_pid_ns()` 直接呼叫 `idr_find(&ns->idr, nr)`;task 與 `struct pid` 的關聯則仍透過 `struct pid` 內的 `tasks[PIDTYPE_*]` hlist 和 `task_struct::pid_links[]` 維護。
`struct pid` (定義於 [`include/linux/pid.h`](https://github.com/torvalds/linux/blob/v6.18/include/linux/pid.h)) 是核心中統一表示行程相關 ID 的資料結構
:
```c
struct pid {
refcount_t count;
unsigned int level;
struct hlist_head tasks[PIDTYPE_MAX];
struct rcu_head rcu;
struct upid numbers[];
};
```
`count` 是引用計數,同一個 `struct pid` 可同時代表 task PID、thread group TGID、process group PGID 或 session SID,每被一種用途引用就遞增一次。`level` 記錄此 PID 所在的 namespace 層級深度 (root namespace 為 0)。尾端的 `numbers[]` 是 flexible array member,配置時依 `level + 1` 的大小動態擴充,每個元素
為 `struct upid`:
```c
struct upid {
int nr;
struct pid_namespace *ns;
};
```
`numbers[0]` 存放 root namespace 中的 PID 數值,`numbers[level]` 存放最內層 namespace 中的 PID 數值。同一個 task 在不同層級的 namespace 中因此擁有不同
的 `nr`,核心需要轉換時以 `pid_vnr()` (取得 task 在呼叫者所屬 namespace 中的 PID) 或 `pid_nr_ns()` (取得指定 namespace 中的 PID) 完成。`tasks[PIDTYPE_MAX]` 是 per-type 的 hash list head,將同屬一個 PID 的 task 串聯起來:例如 `tasks[PIDTYPE_PGID]` 串聯同一 process group 中的所有 thread group leader。
下圖展示 PID namespace 的階層結構。親代 namespace 可看到子 namespace 的行程,反之不行。每個 namespace 內的 PID 獨立編號,因此同一行程在不同層級擁有不同的局部 ID:
```graphviz
digraph pid_namespace_hierarchy {
rankdir=TB;
node [shape=circle, fontname="Helvetica", fontsize=10, width=0.45, fixedsize=true];
edge [fontname="Helvetica", fontsize=9];
compound=true;
subgraph cluster_root {
label="Root namespace (level 0)"; labeljust=l;
style=rounded; color=steelblue; bgcolor="#e8f0fe";
r1 [label="1"]; r2 [label="2"]; r3 [label="3"]; r4 [label="4"];
r5 [label="5"]; r6 [label="6"]; r7 [label="7"]; r8 [label="8"];
r9 [label="9"]; r10 [label="10"];
}
subgraph cluster_child1 {
label="Child ns A (level 1)"; labeljust=l;
style=rounded; color=coral; bgcolor="#fde8e8";
c1a [label="1"]; c2a [label="2"]; c3a [label="3"];
}
subgraph cluster_child2 {
label="Child ns B (level 1)"; labeljust=l;
style=rounded; color=coral; bgcolor="#fde8e8";
c1b [label="1"]; c2b [label="2"]; c3b [label="3"];
}
subgraph cluster_grand {
label="Grandchild ns (level 2)"; labeljust=l;
style=rounded; color=forestgreen; bgcolor="#e8f5e8";
g1 [label="1"];
}
/* 同一行程在不同層級的 PID 對應 */
r5 -> c1a [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
r6 -> c2a [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
r7 -> c3a [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
r8 -> c1b [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
r9 -> c2b [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
r10 -> c3b [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
c1a -> g1 [style=dashed, color=gray60, arrowhead=vee, arrowsize=0.7];
legend [shape=note, fontsize=9, fontname="Helvetica",
style=filled, fillcolor=lightyellow,
label="同一行程在各層的局部 PID\l\lroot ns | child ns A | grandchild ns\l PID 5 | PID 1 | PID 1\l PID 6 | PID 2 | —\l PID 7 | PID 3 | —\l"];
}
```
`pid_namespace` 之間以 `parent` 指標串成鏈。root namespace 的 `level` 為 0,每建立一個子 namespace,`level` 遞增。核心藉 `level` 判斷行程在多少個 namespace 中可見,並據此配置 `struct pid` 的 `numbers[]` 陣列大小。
```graphviz
digraph pid_namespace_chain {
rankdir=LR;
node [shape=record, fontname="Courier", fontsize=10];
edge [fontname="Helvetica", fontsize=9];
ns0 [label="pid_namespace|level=0|idr|child_reaper\n(init 行程)|<p> parent=NULL"];
ns1a [label="pid_namespace|level=1|idr|child_reaper|<p> parent"];
ns1b [label="pid_namespace|level=1|idr|child_reaper|<p> parent"];
ns2 [label="pid_namespace|level=2|idr|child_reaper|<p> parent"];
ns1a:p -> ns0 [label="parent"];
ns1b:p -> ns0 [label="parent"];
ns2:p -> ns1a [label="parent"];
}
```
`struct pid` 的配置在 `copy_process()` 中由 `alloc_pid()` ([`kernel/pid.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/pid.c)) 完成。`alloc_pid()` 使用每個 `pid_namespace` 在建立時預備的 `kmem_cache`;該快取由 [`kernel/pid_namespace.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/pid_namespace.c) 的 `create_pid_cachep()` 建立,物件大小以 `struct_size_t(struct pid, numbers, level + 1)` 計算,確保 `numbers[]` 有足夠空間容納從 root 到
最內層 namespace 的所有 `upid`。配置後,`alloc_pid()` 由最內層 namespace 向外,在各層的 `idr` 中註冊對應的 PID 數值。idle task 的建立是唯一的例外:`copy_process()` 以 `pid != &init_struct_pid` 作為判斷條件,跳過 `alloc_pid()`,所有 CPU core 的 idle task 共享 `init_struct_pid`。
下圖展示多個 `task_struct` 透過 `pid_links[]` 連接至 `struct pid`,再透過 `numbers[]` 映射至各層 namespace 的完整關係。假設行程 A (PID 100) 是行程 B (PID 101) 和 C (PID 102) 的 process group leader:
```graphviz
digraph task_pid_relationship {
rankdir=LR;
node [shape=record, fontname="Courier", fontsize=9];
edge [fontname="Helvetica", fontsize=8];
tsA [label="<head>task_struct A|pid_t pid=100|pid_t tgid=100|<p0>pid_links[PID]|<p1>pid_links[PGID]|<p2>pid_links[SID]|group_leader=self"];
tsB [label="<head>task_struct B|pid_t pid=101|pid_t tgid=101|<p0>pid_links[PID]|<p1>pid_links[PGID]|<p2>pid_links[SID]|group_leader=self"];
tsC [label="<head>task_struct C|pid_t pid=102|pid_t tgid=102|<p0>pid_links[PID]|<p1>pid_links[PGID]|<p2>pid_links[SID]|group_leader=self"];
pidA [label="<head>struct pid (A)|count|level=0|<t0>tasks[PID]|<t1>tasks[PGID]|<t2>tasks[SID]|<u0>numbers[0]: nr=100, ns=root"];
pidB [label="<head>struct pid (B)|count|level=0|<t0>tasks[PID]|<u0>numbers[0]: nr=101, ns=root"];
pidC [label="<head>struct pid (C)|count|level=0|<t0>tasks[PID]|<u0>numbers[0]: nr=102, ns=root"];
tsA:p0 -> pidA:t0 [label="PID"];
tsA:p1 -> pidA:t1 [label="PGID"];
tsB:p0 -> pidB:t0 [label="PID"];
tsB:p1 -> pidA:t1 [label="PGID\n(同 group)", color=coral];
tsC:p0 -> pidC:t0 [label="PID"];
tsC:p1 -> pidA:t1 [label="PGID\n(同 group)", color=coral];
ns [label="pid_namespace\n(root, level=0)|idr"];
pidA:u0 -> ns [style=dashed, color=gray];
pidB:u0 -> ns [style=dashed, color=gray];
pidC:u0 -> ns [style=dashed, color=gray];
}
```
行程 B 和 C 的 `pid_links[PIDTYPE_PGID]` 都指向行程 A 的 `struct pid`,因為 A 是它們的 process group leader。`tasks[PIDTYPE_PGID]` 的 hash list 把 B 和 C 串聯起來,核心需要走訪整個 process group 時從此鏈結開始。TGID 的處理類似:同一 thread group 的所有執行緒共享 leader 的 `struct pid`。
這些鏈結在 `copy_process()` 尾段建立。對於 thread group leader (即建立行程而非執行緒),核心先以 `init_task_pid()` 將 `task_struct` 的 `pid_links[type]` 指向對應的 `struct pid`,再以 `attach_pid()` 將 `task_struct` 掛入 `pid->tasks[type]` 的 hash list:
```c
void attach_pid(struct task_struct *task, enum pid_type type)
{
struct pid *pid = *task_pid_ptr(task, type);
hlist_add_head_rcu(&task->pid_links[type],
&pid->tasks[type]);
}
```
leader 需要 `attach_pid()` 三種類型 (`PIDTYPE_TGID`、`PIDTYPE_PGID`、`PIDTYPE_SID`);非 leader 的執行緒因為共享 leader 的 TGID/PGID/SID,不需要呼叫 `attach_pid()`。
### PID 查詢函式
核心提供三層封裝,從 PID 數值查詢 `struct pid` 實體:
```graphviz
digraph pid_lookup {
rankdir=TB;
node [shape=box, fontname="Courier", fontsize=10, style=rounded];
edge [fontname="Helvetica", fontsize=9];
fgp [label="find_get_pid(nr)\n取得 pid 並遞增 refcount"];
fvp [label="find_vpid(nr)\n在 current 的 active PID namespace 中查詢"];
fpns [label="find_pid_ns(nr, ns)\nidr_find(&ns->idr, nr)"];
pt [label="pid_task(pid, type)\nhlist_entry(pid->tasks[type])"];
ts [label="struct task_struct *", shape=ellipse];
fgp -> fvp [label="呼叫"];
fvp -> fpns [label="呼叫\n(帶入 current ns)"];
fpns -> pt [label="取得 struct pid 後"];
pt -> ts [label="回傳"];
}
```
- `find_pid_ns(nr, ns)`:最底層函式,透過 `idr_find(&ns->idr, nr)` 在指定 namespace 的 IDR 中查詢。v6.18 已改用 IDR (基於 radix tree),早期核心 (v4.x) 使用 `pid_hash[]` 雜湊表搭配 `upid->pid_chain` 鏈結
- `find_vpid(nr)`:封裝 `find_pid_ns()`,自動帶入呼叫者目前所在的 active PID namespace (`task_active_pid_ns(current)`)。這和 `pid_ns_for_children` 不同;後者描述後續子行程將使用的 PID namespace
- `find_get_pid(nr)`:封裝 `find_vpid()`,額外在 RCU read-side critical section 內遞增 `struct pid` 的引用計數,確保回傳的指標在 RCU grace period 結束
後仍有效
從 `struct pid` 反向查詢 `task_struct`,核心提供 `pid_task(pid, type)`:根據 `type` 索引 `pid->tasks[type]` hash list,取出第一個 `task_struct`。搭配前述查詢函式,從 PID 數值找到 `task_struct` 的完整路徑為:
```c
struct task_struct *p = pid_task(find_vpid(nr), PIDTYPE_PID);
```
核心另提供便利封裝:`find_task_by_vpid(nr)` 和 `find_task_by_pid_ns(nr, ns)`。
取得特定 ID 類型的 `struct pid` 實體:
- `task_pid(task)`:`task->pid_links[PIDTYPE_PID].pid`,即 task 自身的 PID
- `task_tgid(task)`:`task->group_leader->pid_links[PIDTYPE_PID].pid`,即 thread group leader 的 PID (等同 TGID)
- `task_pgrp(task)`:`task->group_leader->pid_links[PIDTYPE_PGID].pid`
- `task_session(task)`:`task->group_leader->pid_links[PIDTYPE_SID].pid`
取得局部 PID 數值:`pid_nr_ns(pid, ns)` 檢查 `ns->level <= pid->level` 後回傳 `pid->numbers[ns->level].nr`。親代 namespace 能看到子 namespace 的行程,反之不行,因為子 namespace 的 `level` 高於親ㄉㄞ親代 namespace。
### getpid 回傳的是 TGID
[`kernel/sys.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/sys.c) 中 `getpid` 的註解直接揭示 PID/TGID 的語意分離:
```c
/**
* sys_getpid - return the thread group id of the current process
*
* Note, despite the name, this returns the tgid not the pid.
*/
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}
```
| 介面 | 回傳值 | 語意 |
|---|---|---|
| `task_struct->pid` | 每個執行緒獨有 | 核心內部的 TID |
| `task_struct->tgid` | thread group 共享 | 核心內部的 TGID = group leader 的 pid |
| `getpid()` (user space) | TGID | POSIX process ID |
| `gettid()` (Linux 特有) | 核心 PID | 每個執行緒各異,非 POSIX 標準 |
這個命名錯位源自歷史因素:Linux 最初沒有執行緒的概念,`pid` 就是行程的唯一識別碼;後來引入 thread group 時,為了維持 user space API 的相容性,`getpid()` 改為回傳 TGID。POSIX 的執行緒識別機制是 `pthread_t` (透過 `pthread_self()` 取得),與核心的 TID 分屬不同的識別體系。
### PID 走訪機制
核心提供 `do_each_pid_task` 巨集走訪特定 PID type 下的所有行程,`do_each_pid_thread` 則進一步走訪每個行程的所有執行緒。PID 配置的 wrap value 可透過 `/proc/sys/kernel/pid_max` 查詢與調整;當下一個 PID 達到此值時,核心會回到較小的可用 PID 繼續配置。實際值可能受發行版或 init 系統設定影響,64-bit 系統上限可達 $2^{22}$。
### pidfd:穩定的行程參照
傳統的 PID 是整數,會被核心回收並重新指定給新行程,造成 race condition:在取得 PID 和對其操作之間,該 PID 可能已指向不同行程。[pidfd](https://lwn.net/Articles/794707/) 機制分階段進入:`pidfd_send_signal()` 於 v5.1 先行合併,`CLONE_PIDFD` 於 v5.2 讓 `clone()` 建立行程時直接取得 pidfd,`pidfd_open()` 於 v5.3 讓既有行程也能取得 pidfd。pidfd 透過檔案描述子參照行程,解決 PID 重用問題:
* `pidfd_open(pid, 0)`:取得指向特定行程的 fd。若呼叫成功,後續 PID 重用不會讓該 fd 指向另一個行程;若目標已不存在,呼叫會以 `ESRCH` 失敗。Linux v6.9 起可用 `PIDFD_THREAD` 取得指向特定執行緒的 pidfd,未指定此 flag 時,`pid` 必須指向 thread group leader
* `pidfd_send_signal(pidfd, sig, ...)` (v5.1):透過 pidfd 傳送 signal,避免 PID 重用導致誤殺
* `pidfd_getfd(pidfd, targetfd, 0)` (v5.6):從另一行程複製檔案描述子,類似 `SCM_RIGHTS` 但不需要目標行程主動配合;呼叫者仍必須符合 `PTRACE_MODE_ATTACH_REALCREDS` 權限檢查
* `waitid(P_PIDFD, pidfd, ...)` (v5.4):以 pidfd 等待子行程,避免用裸 PID 呼叫 `waitpid` 時遇到 PID 重用問題;它仍遵守 `wait` 家族的親子關係限制,不能任意等待不相關行程
* `CLONE_PIDFD` (v5.2 隨 `clone(2)` 引入;v5.3 的 `clone3()` 亦支援):在建立子行程的同時取得 pidfd,確保 atomicity
pidfd 也可搭配 `poll()`/`epoll()` 使用:pidfd 指向的 task 終結並成為 zombie 時會回報 readable (`EPOLLIN`),被 `wait` 回收後會產生 hangup event (`EPOLLHUP`)。若使用 `PIDFD_THREAD`,特定執行緒退出並成為 zombie 時即可讀取;未使用該 flag 時,必須等 thread group 的最後一個執行緒退出。這讓事件驅動的程式 (如 process supervisor) 無需輪詢即可偵測子行程退出。systemd 自 v243 起使用 pidfd 管理服務行程。
## fork 的風險與替代方案
### fork 在多執行緒程式中的風險
若在 `fork()` 之前已透過 `pthread_create` 建立執行緒,這些執行緒不會被複製至子行程:`fork()` 僅複製呼叫者執行緒,其餘執行緒在子行程中消失。可用 `ps -Lf` 觀察親代 NLWP=3 而子行程 NLWP=1 的差異 (見實驗 13)。
子行程雖然只有一個執行緒,卻繼承完整的 address space 副本,包含其他執行緒持有的 mutex 和 condition variable 狀態。POSIX 明確規定:fork 後的子行程僅可呼叫 async-signal-safe 函式,直到執行 `execve()` 為止,否則可能因死結或不一致狀態而產生未定義行為,連 `malloc()`、`printf()`、`free()` 都不在 async-signal-safe 清單中。
`pthread_atfork` 的典型用途是在 `prepare` callback 中取得所有應用程式層級的鎖,在 `parent` 和 `child` callback 中釋放。然而實務上,函式庫很難可靠地為所有內部狀態提供 `pthread_atfork` handler。Microsoft Research 在 2019 年的論文 [A fork() in the road](https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road/) (HotOS) 中批評 `fork()` 已成為現代程式與作業系統實作的負擔,主張應把它視為歷史產物,而不是新系統的首選建立機制。
### posix_spawn:fork+exec 的替代方案
`posix_spawn` 將行程建立和映像置換包裝為單一介面,讓呼叫者不用在多執行緒環境中自行操作 fork 後、exec 前的脆弱區段:
```c
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO,
"output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
pid_t pid;
posix_spawn(&pid, "/bin/ls", &actions, NULL, argv, envp);
posix_spawn_file_actions_destroy(&actions);
waitpid(pid, &status, 0);
```
`posix_spawn` 的 file actions 機制取代 fork/exec 之間的 fd 操作,將 pre-exec 動作限制在一組明確的 file actions 和 attributes 中,避免多執行緒環境中 signal handler 或其他執行緒觀察並干擾 fork/exec 的中間狀態。若 fork/clone 步驟本身失敗,不會建立子行程;但若子行程已建立後 pre-exec 或 exec 失敗,子行程會以 exit status 127 結束。
在沒有 MMU 的嵌入式系統 (如 uClinux) 上,Linux 無法提供一般 `fork()` 所需的獨立虛擬位址空間與 CoW page-table 語意。這類系統使用 `vfork()` 語意 (親代暫停,子行程共享位址空間後立即 exec 或 `_exit()`),而 `posix_spawn` 是更安全的替代方案。
fork 在安全層面也有隱憂:fork 後若子行程不執行 exec,會保留與親代相同的 address-space layout,包含 stack、heap、mmap 區域等位置。因此 pre-fork 或 fork-only 的服務模型可能讓攻擊者跨多個子行程累積位址資訊,降低 ASLR 的防護效果。此外,fork 複製整個 fd table,包括網路 socket 和裝置 handle,若子行程忘記關閉不需要的 fd,會造成 fd 洩漏。`O_CLOEXEC` 和 `FD_CLOEXEC` 是主要的緩解手段。
### fork 後的檔案描述子共享
根據 [fork(2)](https://man7.org/linux/man-pages/man2/fork.2.html):子行程繼承親代所有開啟的檔案描述子副本,每個檔案描述子指向相同的 open file description (系統層級的開啟檔案表項目),因此親代與子代共享 file offset、file status flag 和 signal-driven I/O 屬性。
CoW 確保記憶體層面的隔離,但檔案操作不在此列。若親代與子代在 `fork()` 後各自對同一檔案進行讀寫,雙方的 file offset 會同時改變,導致資料交錯。對 socket 亦然:親代與子代共享相同的 kernel-level socket 物件 (連線狀態、send/receive buffer、socket option),同時操作可能產生 race condition。
常見做法是在 `fork()` 後,於子行程中立即關閉不需要繼承的檔案描述子。
### dup、dup2 與 close-on-exec
`dup(oldfd)` 回傳 per-process 檔案描述子表格中最小的未使用索引,指向與 `oldfd` 相同的 open file description。新舊描述子共享 file offset 和 status flag,但不共享檔案描述子 flag (即 close-on-exec flag,`FD_CLOEXEC`)。`dup()` 建立的新描述子預設不設定 `FD_CLOEXEC`。
`dup2(oldfd, newfd)` 可指定新描述子的數值。若 `newfd` 已在使用,`dup2` 會先關閉它。關鍵在於關閉 `newfd` 和指向 `oldfd` 這兩步以 atomic 方式執行。若自行模擬:
```c
close(newfd); /* 若此處被 signal handler 打斷,handler 可能佔用 newfd */
dup(oldfd); /* 此時拿到的不一定是 newfd */
```
在 signal handler 或其他執行緒同時開啟檔案的情況下,`close` + `dup` 之間的 race condition 會導致 `dup` 拿到非預期的描述子。
`dup3(oldfd, newfd, flags)` (Linux v2.6.27 引入,已納入 POSIX.1-2024) 可在建立描述子的同時設定 `O_CLOEXEC`。這在多執行緒環境中不可或缺:若以先建立描述子再用 `fcntl(F_SETFD)` 設定 `FD_CLOEXEC`,在兩步之間若其他執行緒呼叫 `fork()` + `execve()`,該描述子會在尚未設定 `FD_CLOEXEC` 的狀態下被子行程繼承,造成檔案描述子洩漏。同理,`open()` 的 `O_CLOEXEC` flag、`pipe2()` 的 `O_CLOEXEC`、`accept4()` 的 `SOCK_CLOEXEC` 皆為解決此 race condition 而設計。
## context switch 的實作
### x86 context switch 的演化
Linux 在 x86 上的 context switch 經歷幾個重要階段:
* v0.01 至 v2.0 (1991-1996 年):使用硬體 task switching,透過 `ljmp` 到 GDT 中的 TSS segment selector。CPU 自動儲存所有通用暫存器到目前的 TSS,載入新的 TSS 並恢復暫存器。每個 task 一個 TSS。速度慢,因為硬體 task switching 會重載所有 segment register 並觸發完整的保護模式檢查
* v2.2 (1999 年):放棄硬體 task switching,改為軟體 context switch。`switch_to` 巨集開始使用明確的 `pushl`/`popl` 和 `movl %esp` 進行 stack 切換。速度更快、更具跨平台移植性,且讓核心得以控制哪些狀態需要儲存/恢復。Linux 從每個 task 一個 TSS 縮減為每個 CPU 一個 TSS
* v2.6 (2003 年):分離 assembly 路徑,`__switch_to` 成為獨立的 C 函式,處理非暫存器的部分 (segment、FPU、debug register)
* v4.9 (2016 年):引入 virtually-mapped kernel stack,早期切換路徑曾以 `prepare_switch_to` 在切換前 fault in stack page;後續實作已整併,v6.18 的 `switch_to()` 不再顯式呼叫此 helper
* v4.14 (2017 年):組合語言的部分改寫為 `__switch_to_asm`,作為 `entry_64.S` 中的正式符號。考量因素包括 ORC unwinder、entry code 可維護性與後續安全緩解需求
參照 [Evolution of the x86 context switch in Linux](http://www.maizure.org/projects/evolution_x86_context_switch_linux/index.html)
### v6.18 的 switch_to 實作
在 v6.18 中,[`arch/x86/include/asm/switch_to.h`](https://github.com/torvalds/linux/blob/v6.18/arch/x86/include/asm/switch_to.h) 定義:
```c
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)
```
[`arch/x86/entry/entry_64.S`](https://github.com/torvalds/linux/blob/v6.18/arch/x86/entry/entry_64.S) 中的 `__switch_to_asm` 執行以下步驟:
1. 儲存 callee-saved 暫存器:將 `%rbp`, `%rbx`, `%r12`, `%r13`, `%r14`, `%r15` 壓入目前 (prev) 的核心 stack
2. 切換 stack 指標:將目前的 `%rsp` 儲存至 `prev->thread.sp`,再從 `next->thread.sp` 載入新的 `%rsp`。此刻 CPU 已在使用 next 的核心 stack,後續的 `pop` 都從 next 的 stack 恢復資料
3. 恢復 callee-saved 暫存器:從 next 的核心 stack 中 `pop` 出先前儲存的暫存器值
4. 切換執行流:`__switch_to_asm` 以 `jmp __switch_to` 結束,`__switch_to` 是 C 函式,負責更新 TSS、FPU、TLS 等硬體狀態。當 `__switch_to` 執行 `ret` 時,從 next 的 stack 頂端彈出返回位址,該位址是 next 上次呼叫 `__switch_to_asm` 之後的下一道指令,next 就此恢復執行
這種設計避免顯式操作 RIP 暫存器,利用 stack 切換與 `ret` 指令的語意達成執行流切換。只有 callee-saved 暫存器需要明確儲存;caller-saved 暫存器 (`%rax`、`%rcx`、`%rdx`、`%rsi`、`%rdi`、`%r8`-`%r11`) 由 C 呼叫慣例保證在函式呼叫邊界不需保留,編譯器會在呼叫點附近自行儲存到 stack。因此核心只需儲存 6 個暫存器,比硬體 task switching 盲目儲存全部 16 個通用暫存器便宜許多。
x86_64 上每個 CPU 的 TSS 主要用於三個用途:
* `sp0`:處理 privilege transition 的 stack 入口。現代 x86_64 核心在 KPTI、entry trampoline 與一般 entry path 中還會再切換到合適的 task stack,因此不宜把它簡化為「每個 task 的核心 stack 指標」
* IST entry (Interrupt Stack Table):NMI、double fault、MCE 等最多 7 組專用 stack
* I/O permission bitmap
### 第三參數 last 的設計
`__schedule()` 中的呼叫方式:
```c
switch_to(prev, next, prev);
```
三參數的設計解決一個微妙的問題。考慮 task A 切換到 task B。當 A 最終恢復執行時 (可能經過 B→C→D→...→X→A 的漫長路徑),A 的區域變數 `prev` 仍然儲存著 A 自己呼叫 `switch_to` 時的值。但 A 需要知道是誰切換回自己的 (即 X),而非自己切換到誰 (即 B)。
`__switch_to` (由 `__switch_to_asm` tail-jump 進入) 的回傳值 (放在 `%rax` 中) 是 X 視角的 prev 指標,也就是 X 本身。透過將這個值指定 `last` 參數 (在呼叫端與 `prev` 是同一個變數),恢復執行的 task A 就能對 X 的 `task_struct` 執行 `finish_task_switch(prev)` 來清理 X 的狀態 (例如,若 X 是 zombie 則釋放其 stack)。沒有這個技巧,A 會嘗試對自己或對 B 執行 `finish_task_switch`,導致 use-after-free 或邏輯錯誤。
### schedule() 與 context_switch() 的呼叫路徑
[`kernel/sched/core.c`](https://github.com/torvalds/linux/blob/v6.18/kernel/sched/core.c) 中的 `__schedule()` 是排程器的主體:
1. 停用 preemption
2. 取得目前 CPU 的 runqueue (`struct rq`)
3. `pick_next_task()`:依排程類別的 priority 順序詢問下一個 task。v6.18 的連結順序為 stop > deadline > RT > fair > sched_ext > idle;sched_ext 啟用時仍需配合其 runtime 狀態與 policy 判斷,不代表所有 fair task 都被同一方式接管
4. 若 next ≠ prev,呼叫 `context_switch()`
`context_switch()` 分為二個階段:
第一階段:切換記憶體 (`switch_mm_irqs_off()`)
將新 task 的 `pgd` 載入 CR3 (x86_64) 或 TTBR0_EL1 (Arm64),使 MMU 指向新 address space 的 page table。但這裡有一個關鍵最佳化:若 next 是核心執行緒 (`next->mm == NULL`),它沒有自己的 user address space;此時 next 沿用 prev 的 `active_mm`,通常可避免不必要的 address space 切換和 TLB flush。同理,若 prev 和 next 屬於同一行程的不同執行緒 (共享 `mm_struct`),也不需要切換 address space。
第二階段:切換暫存器 (`switch_to()`)
切換的是 callee-saved 暫存器和 stack 指標,而非所有暫存器。一個常見的誤解是 context switch 會「儲存 RIP (指令指標) 並載入新 RIP」。事實上,Linux 從不明確儲存或載入 RIP。真正的機制是:
1. 將 callee-saved 暫存器 push 到目前 task 的核心 stack
2. 將目前 RSP 儲存到 `task_struct->thread.sp`
3. 從 next 的 `task_struct->thread.sp` 載入 RSP,此刻 stack 已切換
4. 從 next 的 stack 上 pop callee-saved 暫存器
5. 以 `jmp __switch_to` 跳入 C 函式做收尾,最終 `ret` 時,stack 上的返回位址是 next 先前呼叫 `__switch_to_asm` 時的呼叫點
恢復執行不是透過載入 RIP,而是透過切換 stack 之後的 `ret` 指令:`ret` 從新 stack 的頂端取出返回位址,CPU 即從該位址繼續執行。這比硬體 task switching 精簡許多,也是 Linux 只需儲存 6 個 callee-saved 暫存器 (而非全部 16 個通用暫存器) 的原因。
### 搶佔模型
context switch 的觸發可分為二大類:user space 返回時的搶佔,以及核心內部的搶佔。
user space 搶佔:系統呼叫返回 user space 或硬體中斷處理完畢返回 user space 時,核心會檢查 `TIF_NEED_RESCHED` flag。若已設定 (例如更高 priority 的 task 被喚醒、目前 task 的 timeslice 耗盡),則在返回前呼叫 `schedule()`。這是最基本的搶佔點,所有 preemption model 都支援。
核心搶佔 (kernel preemption):即使 CPU 正在核心中執行 (例如處理系統呼叫),高 priority 的 task 仍可搶佔它。啟用 `CONFIG_PREEMPT` 時,核心在每次 `preempt_count` 降回 0 (例如釋放 spinlock 後) 且 `TIF_NEED_RESCHED` 已設定的時刻,檢查是否需要重新排程。換言之,持有 spinlock 期間 (或在其他 preempt-disable 區域) 不會被搶佔,但釋放後立即可被搶佔。這讓核心保有低延遲回應能力,同時避免 spinlock 保護的臨界區被中斷。
v6.13 之後,觸發搶佔進一步分為三類:
* 自願 (voluntary):核心程式碼代表行程呼叫 `schedule()`,例如 blocking `read()`、`mutex_lock()` contention、`nanosleep()`
* 非自願 (involuntary/forced):更高優先權的 RT 或 deadline 任務被喚醒,核心設定 `TIF_NEED_RESCHED`,在下一個 preemption point 或返回 user space 時立即重排
* 延遲 (Lazy Preemption, v6.13+):針對 fair class (EEVDF) 任務,核心僅設定 `TIF_NEED_RESCHED_LAZY`,延遲搶佔至下一個 tick 邊界或自願排程點。這減少不必要的 context switch 次數 (尤其是核心內部的搶佔),在吞吐量和延遲之間取得更好的平衡
`PREEMPT_RT` (v6.12 合併) 更進一步:將多數 `spinlock_t` 轉為以 `rt_mutex` 實作的 sleepable lock,必須維持硬體層級不可睡眠語意的路徑則改用 `raw_spinlock_t`。因此在 RT 核心中,不能再把一般 `spin_lock()` 簡化理解為「關閉搶佔並忙等到鎖釋放」;鎖的可睡眠性與搶佔行為需依 lock 類型判斷。
關於 preemption 模型的完整演進,參見〈[Linux 核心的搶佔機制](https://hackmd.io/@sysprog/linux-preempt)〉;`PREEMPT_RT` 的設計細節與合併歷程,參見〈[PREEMPT_RT](https://hackmd.io/@sysprog/preempt-rt)〉。
### CR3、PCID 與 TLB 管理
CR3 (x86_64) / TTBR0_EL1 (Arm64) 存放 top-level page table 的實體位址。寫入此暫存器等於切換 address space,但代價是 TLB flush。
傳統做法:每次寫入 CR3 會 flush 整個 TLB,代價高昂。支援 PCID (Process Context Identifier) 的 x86_64 CPU 可用 12-bit PCID 標記 TLB entry 所屬的 address space。寫入 CR3 時設定 PCID,可保留其他行程的 TLB entry,僅使用匹配目前 PCID 的 entry 進行位址翻譯。PCID 顯著降低 context switch 的 TLB 重建成本。Arm64 有類似機制:ASID (Address Space Identifier),由硬體在 TLB 查詢時自動比對;ASID 寬度依硬體能力而異,常見為 8 或 16 bit。
如前述,切換到核心執行緒時不需要寫入 CR3 / TTBR0_EL1,因為核心執行緒沒有 user address space,沿用前一個 task 的 `active_mm` (lazy TLB) 即可。同理,同一行程內的執行緒切換也不需要切換 address space。這兩種情況省去的 TLB flush 是 context switch 最有效的最佳化之一。
### context switch 的效能影響
context switch 的成本可拆解為二個部分:
* 直接成本:儲存/恢復暫存器、切換 stack、寫入 CR3/TTBR0_EL1 (若需要)、更新 per-CPU 變數。這部分在現代硬體上通常低於 1 μs
* 間接成本 (cache pollution):切換後的 task 需要將 working set 重新載入 L1/L2 cache。這個暖機代價與 working set 大小成正比,往往遠超直接成本
量測 context switch 成本的經典工具是 [lmbench](http://lmbench.sourceforge.net/) 的 `lat_ctx`,由 Larry McVoy 和 Carl Staelin 在〈[lmbench: Portable Tools for Performance Analysis](https://www.usenix.org/legacy/publications/library/proceedings/sd96/full_papers/mcvoy.pdf)〉(USENIX, 1996 年) 中提出。其方法如下:
1. 建立 N 個行程,以 UNIX pipe 串成環 (ring):行程 0 寫入 pipe → 行程 1 讀取 → 行程 1 寫入 → ... → 行程 N-1 寫入 → 行程 0 讀取
2. 一個 1 byte 的 token 在環中傳遞。每個行程呼叫 `read()` 阻塞等待 token,收到後可選擇觸碰一塊指定大小的記憶體 (模擬 cache pollution),再 `write()` 將 token 傳給下一個行程
3. `read()` 的阻塞觸發 context switch,排程器切換到下一個持有 token 的行程。一圈完成 N 次 context switch
4. 減去 pipe overhead:`lat_ctx` 先在單一行程中量測 `write()`+`read()` 的成本 (無 context switch),再從環的量測結果中扣除
公式:
$$
\text{context_switch_time} = \frac{\text{ring_pass_time}}{N} - \text{pipe_overhead_per_hop}
$$
McVoy 和 Staelin 指出 pipe overhead 占量測值的 30% 到 300%,不扣除會嚴重失真。他們也承認 overhead 是在 hot cache 下量測的,實際 context switch 情境中 cache 較冷,因此扣除後的數值略為樂觀。
`lat_ctx` 的 `-s` 參數指定每個行程在收到 token 後觸碰的記憶體大小 (KB)。觸碰的方式是以展開迴圈 (約 2700 道指令) 走訪陣列,同時汙染 data cache (與陣列大小成正比) 和 instruction cache (固定)。McVoy 和 Staelin 定義的 context switch 成本包含 cache 恢復時間:
> "The context switch times go up because a context switch is defined as the switch time plus the time it takes to restore all of the process state, including cache state."
這個定義的論點是:只有二個極小行程在 hot cache 中 ping-pong 的數字,對預測真實應用效能毫無用處。
根據此方法,context switch latency 呈現二個區間:
* 當 $N \times \text{process_size} \leq \text{cache_size}$ 時,所有行程的 working set 共存於 cache,context switch 後幾乎不產生 miss,latency 維持在基線。圖中資料點聚集在左下角
* 當 $N \times \text{process_size} > \text{cache_size}$ 時,每次 context switch 都觸發 cache miss (incoming 行程的 working set 已被 evict),latency 急劇上升
[Arm-Linux 技術報告](http://wiki.csie.ncku.edu.tw/embedded/arm-linux)在 BeagleBone Black (Arm Cortex-A8, L1 D-cache 32 KB, L2 256 KB) 上以 lmbench 驗證此行為,觀察到 L1 miss 的臨界點在 (process size) × (process count) ≈ 32 KB,L2 miss 在 ≈ 160-192 KB。
在另一台 Arm64 機器 (Neoverse 核心,L1 D-cache 64 KB, L2 512 KB) 上,以簡化的 pipe-pair 量測 (2 processes,taskset 固定在 2 個 CPU core) 重現此行為 (主要實驗主機 ThunderX2 的 per-core cache 為 L1D 32 KB、L2 256 KB,臨界點會往左移):

左圖:working set 從 0 增長到 512 KB 時,context switch latency 從 9 μs 線性攀升至 364 μs,約 40 倍差距。垂直虛線標示 L1 D-cache (64 KB) 和 L2 cache (512 KB) 的邊界。右圖:log-log 尺度下斜率約 0.68,接近 $O(n^{2/3})$,反映 cache miss penalty 與 working set 的非線性關係。
這組資料驗證 McVoy-Staelin 的關鍵洞見:context switch 的真實成本取決於行程的 working set 是否能容納在 cache 中,而非暫存器切換的直接開銷。在記憶體密集型工作負載上,一次 context switch 的代價可從基線 (≈ 10 μs) 膨脹到數百 μs。
共享 address space 的執行緒 (透過 `CLONE_VM`) 同時共享 page table,因此同一行程內執行緒切換通常不需要切換 CR3/TTBR0_EL1 或刷新 TLB;這是執行緒 context switch 常比行程 context switch 便宜的主要原因之一。此外,同一行程的執行緒有較高的 cache 重疊率,cache pollution 也較輕。
行程之間的 context switch 通常變更四項狀態:暫存器、stack 指標、page table (CR3/TTBR0_EL1)、`current`。同一行程內的執行緒切換仍會變更 `current` 和 stack,並可能切換 TLS/FPU 等架構狀態,但通常不需要切換 address space。
### 行程 context 與 interrupt context
核心在兩種 context 中執行,規則截然不同:
| | 行程 context | interrupt context |
|---|---|---|
| 觸發方式 | 系統呼叫、page fault | 硬體中斷 |
| `current` 巨集 | 有效,指向目前 task | 仍有值,但代表被中斷的 task,不代表中斷處理常式本身 |
| 可否睡眠 | 可以 | 不可以 |
| 可否存取 user space 記憶體 | 可透過 `copy_from_user()` 等受控 helper | 不應直接存取,因為不能承受 sleep 或一般 page fault 路徑 |
在 interrupt context 中呼叫可能睡眠的函式 (如 `mutex_lock()`、`kmalloc(GFP_KERNEL)`) 是嚴重的程式錯誤。可用 `in_interrupt()` 檢查目前是否處於 interrupt context。這個區分也影響同步原語的選擇:行程 context 中可使用 mutex,interrupt context 中只能使用 spinlock。
v6.12 將 `PREEMPT_RT` [合併進 mainline](https://lwn.net/Articles/991315/),把多數 `spinlock_t` 轉為以 `rt_mutex` 實作的 sleepable lock,並保留 `raw_spinlock_t` 給真正不能睡眠的低階路徑。這項歷時二十年的工程 (Thomas Gleixner 等人主導) 對同步原語的選擇有深遠影響,詳見〈[PREEMPT_RT](https://hackmd.io/@sysprog/preempt-rt)〉和〈[Linux 核心設計: 同步機制](https://hackmd.io/@sysprog/linux-sync)〉。
## 同步機制
多個執行緒共享 address space 帶來並行存取的問題,核心和 user space 各自發展出一套同步原語。本節僅摘要與行程模型直接相關的機制;完整的 Linux 核心同步原語 (spinlock、mutex、RCU、per-CPU 變數、memory barrier 等) 及其設計考量,參見〈[Linux 核心設計: 同步機制](https://hackmd.io/@sysprog/linux-sync)〉。
### Reentrancy
[reentrancy](https://en.wikipedia.org/wiki/Reentrancy_(computing)) 問題的經典案例是 [strtok](https://man7.org/linux/man-pages/man3/strtok.3.html):內部使用靜態變數儲存剖析位置,當多個執行緒同時呼叫時互相干擾。解決方案是 `strtok_r()`,以額外的 `lasts` 參數取代全域狀態。同樣的設計原則適用於所有 thread-safe 函式:避免全域/靜態可變狀態,將工作區儲存於呼叫端提供的緩衝區。
### Futex
Futex (fast userspace mutex) 是 NPTL 所有同步原語的基礎。在沒有 contention 的情況下,futex 完全在 user space 以 atomic operation 操作,無需進入核心;僅在 contention 發生時才呼叫 `sys_futex` 將執行緒放入核心的 wait queue。
Linux v5.16 引入 `futex_waitv` 系統呼叫,允許一次等待多個 futex (類似 `poll` 對檔案描述子的操作),主要為了支援 Windows 遊戲在 Wine/Proton 下使用的 `WaitForMultipleObjects` 語意。
user space 的 `pthread_mutex_timedlock` ([POSIX 規格](https://man7.org/linux/man-pages/man3/pthread_mutex_timedlock.3p.html)) 允許對 lock 設定超時,防止行程無限等待。kernel space 的 `mutex_lock()` 和 `spin_lock()` 不提供對應的 timeout API;若需要 bounded wait,通常改用 wait queue、completion、semaphore timeout 或 trylock 加上明確退避邏輯。
參照 [A futex overview and update](https://lwn.net/Articles/360699/), [Robust futexes](https://lwn.net/Articles/172149/), [Priority Inversion on Mars](http://www.slideshare.net/jserv/priority-inversion-30367388)
### workqueue
workqueue 是核心延遲執行機制。工作項目在 `kworker` 核心執行緒的行程 context 中執行,因此可以 sleep,這是與 tasklet (在 softirq context 中執行,不可 sleep) 的關鍵差異。
Linux v2.6.36 引入 [Concurrency Managed Workqueues](https://lwn.net/Articles/355700/) (cmwq, Tejun Heo),自動管理 worker 執行緒 pool 的並行程度,取代早期需要為每個子系統手動建立 workqueue 的做法。per-CPU worker 的命名格式 `kworker/CPU:ID` 反映其綁定的 CPU core 和 worker ID;unbound worker pool 則以 `kworker/u<pool>:ID` 標示,不繫結特定 CPU core。
## 行程的 address space 配置
在 x86_64 上 (4-level paging),虛擬位址空間分為 user space (低 128 TB) 和 kernel space (高 128 TB),中間的 non-canonical 區域形成位址空間的隔離。支援 5-level paging (LA57,v4.14 起) 的硬體上,user space 和 kernel space 可各自擴充至 64 PB。
user space 的典型佈局:
```
High address
┌──────────────────────┐ ← STACK_TOP_MAX
│ Stack │ grows downward ↓
│ (RLIMIT_STACK) │
├──────────────────────┤
│ Stack guard gap │ default 256 pages
├──────────────────────┤ ← mm->mmap_base
│ memory mapping │ shared libraries, mmap()
│ segment │ grows downward ↓
├──────────────────────┤
│ │
├──────────────────────┤ ← mm->brk
│ Heap │ grows upward ↑
├──────────────────────┤ ← mm->start_brk
│ BSS (zero-init) │
├──────────────────────┤ ← mm->end_data
│ Data (initialized) │
├──────────────────────┤ ← mm->start_data / end_code
│ Text (ELF binary) │ read + execute
├──────────────────────┤ ← mm->start_code
Low address
```
由低位址至高位址:
* text segment (ELF):程式的機器碼,read/execute,file-backed
* data segment:已初始化的靜態/全域變數,read/write,file-backed (private mapping)
* BSS segment:未初始化的靜態/全域變數,read/write,anonymous (填零)
* heap:由 `brk()` 擴張,向上成長。glibc `malloc()` 對大於 `MMAP_THRESHOLD` 的請求通常改用 `mmap()` 配置 anonymous mapping;此門檻初始常見值為 128 KB,並可能隨執行期間配置行為動態調整
* memory mapping segment:動態函式庫、`mmap()` 映射的檔案和 anonymous mapping,由 `mm->mmap_base` 向下成長
* stack:由 `mm->start_stack` 向下成長,預設上限 `RLIMIT_STACK` (通常 8 MB)。超過上限觸發 segmentation fault
`mm_struct` 中的 `start_code`/`end_code`、`start_data`/`end_data`、`start_brk`/`brk`、`start_stack` 等欄位記錄這些區段的邊界。可透過 `/proc/<pid>/maps` 或 `pmap <pid>` 觀察。現代 Linux 採用 ASLR (Address Space Layout Randomization) 隨機化各區段的起始位址。
每個獨立 address space 有自己的 top-level page table。切換到不同 `mm_struct` 時,核心將新的 page table 根位址載入 CR3,使切入的行程存取自己的 page set;同一行程內的執行緒則共享這些 page table。
### Exception table 與安全存取 user space 記憶體
核心必須存取可能無效的 user space 指標 (例如 `copy_from_user()`)。若核心程式碼直接對壞指標取值,通常會觸發 kernel oops,嚴重時導致 panic。Linux 的解法是 exception table:在編譯期為每條存取 user space 記憶體的指令建立 `{fault_addr, fixup_addr}` 對照表。
1. `copy_from_user()` 執行 `movq (%rsi), %rax` (從 user pointer 載入)
2. 若 user space 位址無效,CPU 產生 page fault
3. page fault handler 呼叫 `fixup_exception()`:搜尋 exception table,找到匹配的 faulting IP
4. 將執行流導向 fixup label (跳過壞的存取)
5. 低階 copy helper 回傳未複製的位元組數;上層系統呼叫路徑再轉為 `-EFAULT`
沒有 exception table,壞的 user pointer 會讓 kernel-mode page fault 走一般 oops/錯誤路徑,嚴重時 (或設定 `panic_on_oops`) 會直接 panic;有了 exception table,核心能以受控的 `-EFAULT` 回報並繼續執行。
關於 paging 機制 (multi-level page table、TLB、PCID)、VMA 管理、page fault 處理流程和 CoW 的完整細節,參見〈[Linux 核心的記憶體管理](https://hackmd.io/@sysprog/linux-memory)〉。
## 實驗:觀察行程
以下實驗一律在 Arm64/Linux 上操作,需具備 `sudo` 權限。本文多項測量取自一台 224 個 logical CPU 的 Marvell ThunderX2 雙路伺服器 (28 core × 4-way SMT × 2 socket, Ubuntu 25.04, Linux v6.14)。實驗大量運用 eBPF/bpftrace 追蹤核心行為;關於 eBPF 的原理、程式模型和應用,參見〈[Linux 核心的 eBPF](https://hackmd.io/@sysprog/linux-ebpf)〉。
### 實驗 1:觀察 PID 0/1/2 的階層關係
```shell
$ ps -eaf | head -5
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Apr14 ? 00:00:24 /sbin/init
root 2 0 0 Apr14 ? 00:00:00 [kthreadd]
root 3 2 0 Apr14 ? 00:00:00 [pool_workqueue_release]
root 4 2 0 Apr14 ? 00:00:00 [kworker/R-rcu_gp]
```
PID 1 (`init`) 和 PID 2 (`kthreadd`) 的 PPID 都是 0。方括號標示的多半是核心執行緒,其中由 `kthreadd` 建立者的 PPID 通常為 2。PID 0 (idle task/swapper) 不會出現在 `ps` 輸出中,因為它不在 task list 的一般走訪範圍。
### 實驗 2:觀察 thread group 與 TGID
```shell
$ cat /proc/self/status | grep -E '^(Pid|Tgid|Threads)'
Pid: 12345
Tgid: 12345
Threads: 1
```
對於單執行緒行程,PID 和 TGID 相同。啟動一個多執行緒程式後觀察:
```shell
$ python3 -c "import threading,time; [threading.Thread(target=lambda:time.sleep(60)).start() for _ in range(3)]; time.sleep(60)" &
$ ls /proc/$!/task/
12345 12346 12347 12348
```
`/proc/<pid>/task/` 下每個子目錄對應一個執行緒,目錄名是核心的 TID。TGID (即 user space 的 PID) 為 thread group leader 的 TID。
### 實驗 3:用 strace 觀察系統呼叫開銷
```shell
$ strace -wc echo hello 2>&1 | tail -5
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
...
100.00 0.001219 32 38 1 total
```
Arm64/Ubuntu 25.04 上最簡單的 `echo hello` 仍要走 38 次系統呼叫 (`execve`、dynamic linker 的 `mmap`/`mprotect`/`openat`、stdio 的 `write` 等);每次呼叫都涉及 user/kernel mode 切換、參數驗證與資料複製。不同架構的系統呼叫進入方式:
* Arm64:user space 以 `svc #0` 指令觸發 synchronous exception,核心從 exception vector table 的 `el0_sync` 進入處理。系統呼叫編號放在 `X8`,Linux syscall ABI 最多六個參數依序使用 `X0`-`X5`,回傳值放在 `X0`
* x86_64:user space 以 `syscall` 指令進入核心,CPU 從 `MSR_LSTAR` 載入處理函式位址 (`entry_SYSCALL_64`)。系統呼叫編號放在 `RAX`,前六個參數依序使用 `RDI`、`RSI`、`RDX`、`R10`、`R8`、`R9`
可在 Arm64 主機上驗證 vDSO 映射:
```shell
$ cat /proc/self/maps | grep -E 'vdso|vvar'
f0fa19dec000-f0fa19dee000 r--p 00000000 00:00 0 [vvar]
f0fa19dee000-f0fa19df0000 r-xp 00000000 00:00 0 [vdso]
```
為了降低頻繁的系統呼叫的開銷,Linux 提供 [vDSO](https://man7.org/linux/man-pages/man7/vdso.7.html) (Virtual Dynamic Shared Object) 機制:核心將一個小型共享函式庫映射到每個行程的 address space,位址由 ASLR 隨機化。`clock_gettime()`、`gettimeofday()` 等高頻呼叫在 vDSO 中直接讀取核心映射的資料,不需要 mode switch。vDSO 是跨架構的機制,Arm64 和 x86_64 都支援。x86_64 另有已淘汰的 [vsyscall](https://lwn.net/Articles/446528/) (固定位址 `0xffffffffff600000`,違反 ASLR,目前僅以模擬模式保留)。
關於 vDSO 的演進與實作,參見〈[Linux 核心的 vDSO](https://hackmd.io/@sysprog/linux-vdso)〉
### 實驗 4:用 /proc/\<pid\>/maps 觀察 address space
```shell
$ cat /proc/self/maps | head -8
aaaab0000000-aaaab0001000 r-xp 00000000 ... /usr/bin/cat
aaaab0010000-aaaab0011000 r--p 00000000 ... /usr/bin/cat
aaaab0011000-aaaab0012000 rw-p 00001000 ... /usr/bin/cat
aaaad0000000-aaaad0021000 rw-p 00000000 ... [heap]
ffff80000000-ffff80100000 r-xp 00000000 ... /usr/lib/aarch64-linux-gnu/libc.so.6
...
ffffbe600000-ffffbe621000 rw-p 00000000 ... [stack]
```
每一行代表一個 VMA (vm_area_struct),欄位依序為:位址範圍、權限 (r/w/x/p)、offset、device、inode、路徑。可觀察到 text (r-xp)、data (rw-p)、heap、shared library、stack 等區段。
### 實驗 5:用 ps、top 和 pidstat 觀察行程資源使用
`ps aux` 是觀察行程快照的基本工具。關鍵欄位對應前文描述的 `task_struct` 子系統:
```shell
$ ps aux | head -5
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 25680 14804 ? Ss Apr14 0:24 /sbin/init
root 2 0.0 0.0 0 0 ? S Apr14 0:00 [kthreadd]
```
| 欄位 | 來源 | 說明 |
|---|---|---|
| PID | `task_struct->tgid` | user space 的行程 ID |
| %CPU | 排程器統計 | 自上次取樣以來佔用 CPU 的百分比 |
| VSZ | `mm_struct` 的 VMA 總和 | 虛擬記憶體大小 (KB),含未映射的保留區域 |
| RSS | page table 中有映射的頁面 | 實際佔用的實體記憶體 (KB) |
| STAT | `task_struct->__state` + flags | R=TASK_RUNNING, S=INTERRUPTIBLE, D=UNINTERRUPTIBLE, T=STOPPED, Z=ZOMBIE, I=idle kernel thread |
STAT 欄的附加字元:`s` = session leader, `l` = 多執行緒 (有 `CLONE_THREAD`), `+` = foreground process group, `<` = 高 priority, `N` = 低 priority (nice > 0)。
`top` 提供即時更新的檢視,按 `1` 可展開每個 CPU core 的使用率 (對應 per-CPU runqueue 的負載),按 `H` 可切換到執行緒檢視 (顯示每個 LWP 而非 TGID)。`top` 的 `PR` 欄顯示 priority (0-39 對應 nice -20 到 +19;`rt` 表示 real-time 排程),`NI` 欄顯示 nice 值。
`pidstat -w -t` 是診斷 context switch 問題的關鍵工具,可分別顯示自願切換 (cswch/s) 和非自願切換 (nvcswch/s):
```
$ pidstat -w -u -t 1 3
...
UID TGID TID cswch/s nvcswch/s Command
0 18 - 117.10 0.00 rcu_preempt
0 - 18 117.10 0.00 |__rcu_preempt
```
二類切換的意義:
* 自願切換 (cswch) 過高:task 主動呼叫 `schedule()`,通常因為等待 I/O、mutex contention 或記憶體不足。核心執行緒 (如 `rcu_preempt`、`ksoftirqd`) 自願切換頻繁是正常行為
* 非自願切換 (nvcswch) 過高:task 的 timeslice 耗盡或被更高 priority 的 task 搶佔。大量非自願切換暗示 CPU 競爭激烈 (過多 TASK_RUNNING 的 task 爭奪有限的 CPU core)
搭配 `bpftrace` 可進一步區分切換原因:
```shell
$ sudo bpftrace -e '
tracepoint:sched:sched_switch {
if (args->prev_state == 0) {
@involuntary[args->prev_comm] = count();
} else {
@voluntary[args->prev_comm] = count();
}
}
interval:s:5 { print(@voluntary); print(@involuntary);
clear(@voluntary); clear(@involuntary); }'
```
`prev_state == 0` (TASK_RUNNING) 表示 task 在就緒狀態下被切走 (非自願);其他狀態表示 task 主動進入睡眠 (自願)。
在 Arm64 (224 個 logical CPU) 主機上 10 秒取樣的結果:

自願與非自願切換各約 50%。排程最頻繁的是 `migration` 核心執行緒 (per-CPU,負責 load balancing)、idle task (`swapper`)、容器 runtime (`containerd`) 和 workqueue worker (`kworker`)。idle 系統上 `swapper` 和 `migration` 占多數是正常行為。
### 實驗 6:用 Ftrace 觀察 context switch
```shell
$ sudo su
# cd /sys/kernel/debug/tracing
# echo 1 > events/sched/sched_switch/enable
# sleep 1
# echo 0 > events/sched/sched_switch/enable
# cat trace | head -20
```
Ftrace 的 `sched_switch` 事件記錄每次 context switch 的 prev/next task、PID、priority 和狀態。狀態碼 `R` 表示 TASK_RUNNING,`S` 表示 TASK_INTERRUPTIBLE,`D` 表示 TASK_UNINTERRUPTIBLE。
### 實驗 7:比較 fork 和 pthread_create 的成本 (含圖表輸出)
以下程式輸出每次操作的延遲 (奈秒),可直接餵入 gnuplot 產生直方圖:
```c
/* bench.c - 編譯: gcc -O2 -o bench bench.c -lpthread */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/wait.h>
#include <time.h>
#define N 2000
static long diff_ns(struct timespec *a, struct timespec *b)
{
return (b->tv_sec - a->tv_sec) * 1000000000L + b->tv_nsec - a->tv_nsec;
}
static void *nop(void *arg) { return NULL; }
int main(void)
{
struct timespec t0, t1;
long fork_ns[N], thread_ns[N];
for (int i = 0; i < N; i++) {
clock_gettime(CLOCK_MONOTONIC, &t0);
pid_t p = fork();
if (p == 0) _exit(0);
waitpid(p, NULL, 0);
clock_gettime(CLOCK_MONOTONIC, &t1);
fork_ns[i] = diff_ns(&t0, &t1);
}
for (int i = 0; i < N; i++) {
clock_gettime(CLOCK_MONOTONIC, &t0);
pthread_t t;
pthread_create(&t, NULL, nop, NULL);
pthread_join(t, NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
thread_ns[i] = diff_ns(&t0, &t1);
}
FILE *f = fopen("bench.dat", "w");
fprintf(f, "# idx fork_ns thread_ns\n");
for (int i = 0; i < N; i++)
fprintf(f, "%d %ld %ld\n", i, fork_ns[i], thread_ns[i]);
fclose(f);
printf("Data written to bench.dat\n");
}
```
產生圖表 (使用 gnuplot):
```shell
$ ./bench
$ gnuplot -persist -e "
set terminal png size 800,400;
set output 'fork_vs_thread.png';
set xlabel 'Iteration';
set ylabel 'Latency (ns)';
set title 'fork+waitpid vs pthread_create+join';
plot 'bench.dat' using 1:2 with dots title 'fork',
'bench.dat' using 1:3 with dots title 'thread'
"
```
或使用 Python matplotlib:
```python
import matplotlib.pyplot as plt
import numpy as np
data = np.loadtxt('bench.dat')
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].hist(data[:,1]/1000, bins=50, alpha=0.7, label='fork')
ax[0].hist(data[:,2]/1000, bins=50, alpha=0.7, label='thread')
ax[0].set_xlabel('Latency (μs)')
ax[0].set_ylabel('Count')
ax[0].legend()
ax[0].set_title('Latency Distribution')
ax[1].boxplot([data[:,1]/1000, data[:,2]/1000], labels=['fork','thread'])
ax[1].set_ylabel('Latency (μs)')
ax[1].set_title('Box Plot')
plt.tight_layout()
plt.savefig('fork_vs_thread.png', dpi=150)
```
在 Arm64 主機上的量測結果:

fork 的中位數延遲約 298 μs,pthread_create 約 72 μs,差距約 4 倍。此微基準量測的是建立並回收的成本 (非純 context switch 成本)。差異來自:`fork` 需要 `dup_mm()` (複製 page table,即使有 CoW),而 `pthread_create` 帶 `CLONE_VM` 共享 `mm_struct`,省去 page table 複製和後續的 TLB flush。親代的 address space 越大,`fork` 的 page table 複製成本越高。
### 實驗 8:行程狀態分佈圖
以下 shell script 每秒取樣 `/proc/*/stat` 的狀態欄位,累計 60 秒後繪圖:
```shell
#!/bin/bash
# state_sample.sh - 取樣行程狀態分佈
echo "# time R S D T Z" > states.dat
for i in $(seq 1 60); do
R=0; S=0; D=0; T=0; Z=0
for st in $(awk '{print $3}' /proc/[0-9]*/stat 2>/dev/null); do
case $st in R) ((R++));; S) ((S++));; D) ((D++));; T) ((T++));; Z) ((Z++));; esac
done
echo "$i $R $S $D $T $Z" >> states.dat
sleep 1
done
```
繪製堆疊面積圖:
```python
import matplotlib.pyplot as plt
import numpy as np
data = np.loadtxt('states.dat')
t = data[:, 0]
labels = ['R (Running)', 'S (Sleeping)', 'D (Uninterruptible)',
'T (Stopped)', 'Z (Zombie)']
colors = ['#2ecc71', '#3498db', '#e74c3c', '#f39c12', '#9b59b6']
fig, ax = plt.subplots(figsize=(10, 5))
ax.stackplot(t, data[:,1], data[:,2], data[:,3], data[:,4], data[:,5],
labels=labels, colors=colors, alpha=0.8)
ax.set_xlabel('Time (s)')
ax.set_ylabel('Number of Processes')
ax.set_title('Process State Distribution Over Time')
ax.legend(loc='upper right')
plt.tight_layout()
plt.savefig('process_states.png', dpi=150)
```
在 Arm64 主機上的量測結果:

正常系統上,絕大多數行程處於 `S` (sleeping) 狀態,大量 idle 核心執行緒處於 `I` 狀態 (224 個 logical CPU 系統上約 900 個),少量處於 `R` (running/runnable)。若 `D` 狀態行程數量異常增加,通常表示 I/O 瓶頸或核心 lock contention。
### 實驗 9:查詢核心 tracepoint 與 eBPF 插樁點
撰寫 eBPF/bpftrace 程式之前,必須先知道核心提供哪些 tracepoint。有兩種主要查詢方式:
透過 debugfs 查詢:
```
# 可追蹤的函式數量 (kprobe 可用)
$ cat /sys/kernel/debug/tracing/available_filter_functions | wc -l
# 查看特定 tracepoint 的參數格式
$ cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_execve/format
name: sys_enter_execve
ID: 718
format:
field:unsigned short common_type; offset:0; size:2; signed:0;
field:unsigned char common_flags; offset:2; size:1; signed:0;
field:unsigned char common_preempt_count; offset:3; size:1; signed:0;
field:int common_pid; offset:4; size:4; signed:1;
field:int __syscall_nr; offset:8; size:4; signed:1;
field:const char * filename; offset:16; size:8; signed:0;
field:const char *const * argv; offset:24; size:8; signed:0;
field:const char *const * envp; offset:32; size:8; signed:0;
```
透過 [bpftrace](https://github.com/bpftrace/bpftrace) 查詢 (語法類似 awk,基於 BCC 和 BPF):
```shell
# 列出所有可用的插樁點和 tracepoint
$ sudo bpftrace -l | wc -l
# 查看特定 tracepoint 的參數 (-lv 顯示參數,支援萬用字元)
$ sudo bpftrace -lv "tracepoint:syscalls:sys_enter_clone"
tracepoint:syscalls:sys_enter_clone
int __syscall_nr
unsigned long clone_flags
unsigned long newsp
int * parent_tidptr
unsigned long tls
int * child_tidptr
```
注意:aarch64 沒有獨立的 `fork` 系統呼叫入口,glibc 的 `fork()` 會走 `clone(2)` 語意;因此通常也不會看到 `sys_enter_fork` tracepoint。若需追蹤 fork 行為,應使用 `tracepoint:syscalls:sys_enter_clone` 或 `tracepoint:sched:sched_process_fork`。`clone3()` 是另一個系統呼叫;除非程式明確呼叫它,否則不應把一般 `fork()` 實驗解讀為 `clone3()`。
`format` 檔案和 `bpftrace -lv` 顯示的欄位即為 bpftrace 程式中 `args->` 可存取的成員 (如 `args->ret`、`args->filename`、`args->clone_flags`)。
### 實驗 10:用 bpftrace 追蹤行程生命週期
參照 [Measuring system-wide process execution time on Linux](https://ops.tips/notes/linux-system-wide-process-execution-time/),以 bpftrace 追蹤 `execve` 返回和 `sched_process_exit` 來量測每個行程從映像置換到終結的時間:
```shell
$ sudo bpftrace -e '
tracepoint:syscalls:sys_exit_execve /args->ret == 0/ {
@start[pid] = nsecs;
@cmd[pid] = comm;
}
tracepoint:sched:sched_process_exit /@start[pid]/ {
$ns = nsecs - @start[pid];
printf("%-6d %-20s %d ms\n", pid, @cmd[pid], $ns / 1000000);
delete(@start[pid]); delete(@cmd[pid]);
}'
```
在另一個終端機執行命令,即可觀察每個行程的存活時間。此處追蹤 `sys_exit_execve` (而非 `sys_enter_execve`) 並過濾 `ret == 0`,確保只記錄 `execve` 成功的行程,避免失敗的 `execve` 留下未清理的 `@start` 項目。搭配 cgroup 過濾可以聚焦於特定容器內的行程,減少系統雜訊。
### 實驗 11:用 bpftrace 觀察 fork 實際呼叫 clone
glibc 的 `fork()` 實際上呼叫 `clone` 系統呼叫,而非 `sys_fork`。可用以下 bpftrace 程式驗證:
```shell
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_clone {
printf("[%s PID=%d] -> clone flags=0x%lx\n", comm, pid, args->clone_flags);
}
tracepoint:syscalls:sys_exit_clone {
printf("[%s PID=%d] <- clone ret=%d\n", comm, pid, args->ret);
}'
```
`clone_flags` 的位元組合揭示建立的是執行緒還是行程:含有 `CLONE_VM | CLONE_THREAD` 的組合代表 `pthread_create`;只帶 `SIGCHLD` 和少量 TID 清理相關 flag 的組合,則對應 glibc `fork()` wrapper。
搭配一個使用 `fork()` 的測試程式:
```c
/* fork_test.c - 編譯: gcc -o fork_test fork_test.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
printf("--- Parent process (PID=%d) starts ---\n", getpid());
fflush(stdout);
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) { /* 子行程 */
printf("Child process (PID=%d, PPID=%d) is running\n",
getpid(), getppid());
fflush(stdout);
_exit(0);
}
/* 親代 */
printf("Parent process created child (PID=%d)\n", pid);
waitpid(pid, NULL, 0);
printf("--- Parent process finished ---\n");
return 0;
}
```
在一個終端機執行 bpftrace,另一個終端機執行 `./fork_test`,可觀察到:
```shell
[bash PID=5823] -> clone flags=0x1200011 # SIGCHLD | CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID
[bash PID=5823] <- clone ret=6551 # shell fork 出 fork_test
[bash PID=6551] <- clone ret=0 # fork_test 進入執行
[fork_test PID=6551] -> clone flags=0x1200011 # fork_test 呼叫 fork()
[fork_test PID=6551] <- clone ret=6552 # 親代收到子行程 PID
[fork_test PID=6552] <- clone ret=0 # 子行程收到 0,開始執行
```
觀察要點:
- 每次 `clone` 觸發兩次 `sys_exit_clone` 事件:一次在親代 (回傳子行程 PID),一次在子代 (回傳 0)
- `clone_flags` 的數值 `0x1200011` 是多個 flag 的組合,其中 `SIGCHLD` (`0x11`) 確保子行程結束時通知親代
- 在 aarch64 上,glibc 的 `fork()` 走 `clone` 路徑,反映 Linux 以同一套 task 建立機制支援行程與執行緒
可用 `strace -f ./fork_test` 交叉驗證,觀察系統呼叫序列通常為 `clone` 而非 `fork`。若程式直接呼叫 `clone3()`,或 libc、架構與 sandbox/runtime 封裝另有特殊路徑,才會看到不同結果。
### 實驗 12:觀察 sched_ext (BPF 排程器)
在支援 `CONFIG_SCHED_CLASS_EXT` 的 v6.18 核心上,可透過載入 BPF 程式來實作自訂排程策略。例如載入 `scx_simple`:
```shell
# 需先安裝 sched_ext 使用者層級工具
$ sudo scx_simple
```
載入後,核心會輸出類似 `sched_ext: BPF scheduler "simple" attached` 的訊息。若核心啟用 `CONFIG_SCHED_DEBUG`,可觀察 `/proc/sched_debug` 中的 `scx` 統計資訊;也可使用 `bpftrace` 追蹤 sched_ext 相關 tracepoint。sched_ext 可全域接管 `SCHED_NORMAL`、`SCHED_BATCH`、`SCHED_IDLE` 和 `SCHED_EXT` 任務,也可透過 `SCX_OPS_SWITCH_PARTIAL` 只接管明確設為 `SCHED_EXT` 的任務。若 BPF 排程器結束、觸發 SysRq-S、發生內部錯誤,或 runnable task stall,核心會中止該 BPF scheduler,並將任務退回 fair class;普通 fair task 之後仍走 EEVDF 路徑。
### 實驗 13:fork 在多執行緒程式中的行為
以下程式建立二個執行緒後呼叫 `fork()`,觀察子行程的執行緒數量:
```c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
void *worker(void *arg)
{
(void) arg;
sleep(60);
return NULL;
}
int main(void)
{
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pid_t pid = fork();
if (pid == 0) {
sleep(60);
_exit(0);
}
waitpid(pid, NULL, 0);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
```
```
$ gcc -O2 -o threadtest threadtest.c -lpthread && ./threadtest &
$ ps -Lf -p $(pgrep -o threadtest) # 親代: NLWP=3
$ ps -Lf -p $(pgrep -n threadtest) # 子行程: NLWP=1
```
親代有 3 個 LWP (main + 2 執行緒),子行程僅 1 個。`fork()` 只複製呼叫者執行緒。
### 實驗 14:從核心資料結構觀察行程的隔離屬性
前面的實驗透過 `ps`、`strace`、bpftrace 觀察行程的外在行為;這個實驗轉向內部,檢視 `task_struct` 中決定行程看見什麼、能做什麼的欄位。同一套機制從最基本的一般行程到容器都適用,差異僅在於各欄位指向的實體是否共享。
```c
struct task_struct {
...
struct nsproxy *nsproxy; /* namespace:行程看見哪個世界 */
struct fs_struct *fs; /* 根目錄與工作目錄 */
struct css_set __rcu *cgroups; /* cgroup:資源配額與計量 */
const struct cred *cred; /* 身份、capability */
...
};
```
第一步:觀察一般行程的 namespace 與 credential
所有行程都有 namespace 和 credential,不限於容器。先從自己的 shell 開始:
```shell
# 查看目前 shell 的 namespace inode
$ ls -la /proc/$$/ns/
lrwxrwxrwx 1 user user 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 user user 0 ... mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 user user 0 ... pid -> 'pid:[4026531836]'
...
# 查看自己的 capability
$ grep Cap /proc/$$/status
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
```
非特權行程的 `CapPrm`/`CapEff` 為 0 (無特權),但 `CapBnd` (bounding set) 通常仍保留一組可取得 capability 的上限。實際能否取得 capability 還取決於 setuid、file capability、user namespace、`no_new_privs` 和 LSM 等條件。
第二步:用 `unshare` 建立新 namespace,觀察 `nsproxy` 的變化
不需要 Docker,[`unshare`](https://man7.org/linux/man-pages/man1/unshare.1.html) 就能建立隔離環境:
```shell
# 建立新的 UTS 和 PID namespace (需要 root)
$ sudo unshare --uts --pid --fork --mount-proc bash
# 在新 namespace 中,hostname 可以獨立修改
$ hostname isolated-host && hostname
isolated-host
# PID namespace 獨立:bash 在新 namespace 中是 PID 1
$ echo $$
1
```
從 host 端比較 namespace inode:
```shell
# host init 的 PID namespace
$ sudo ls -la /proc/1/ns/pid
lrwxrwxrwx ... pid -> 'pid:[4026531836]'
# unshare 行程的 PID namespace (用 host 端看到的 PID)
$ sudo ls -la /proc/<unshare-pid>/ns/pid
lrwxrwxrwx ... pid -> 'pid:[4026532xxx]'
```
inode 不同,代表二者處於不同的 PID namespace。在核心中,這對應到 `task_struct->nsproxy->pid_ns_for_children` 指向不同的 `struct pid_namespace` 實體。
第三步:組合多個 namespace + capability 削減,模擬容器式隔離
容器 (Docker、Podman 等) 本質上就是將 `unshare` 的各項 namespace 組合使用,搭配 cgroup 限制資源和 capability 削減。不需要安裝容器執行環境,用 `unshare` 即可模擬容器式隔離的一部分:
```shell
# 建立隔離環境的一部分 (UTS + PID + mount + IPC namespace)
$ sudo unshare --uts --pid --mount --ipc --fork --mount-proc bash
# 驗證隔離效果
$ hostname container-sim && hostname # UTS 獨立
container-sim
$ echo $$ # PID namespace 獨立
1
$ mount | wc -l # mount namespace 獨立
```
從 host 端 (另一個終端機) 觀察差異:
```shell
# 比較 namespace inode,多個 namespace 都不同
$ sudo ls -la /proc/1/ns/ | awk '{print $NF}'
$ sudo ls -la /proc/<unshare-pid>/ns/ | awk '{print $NF}'
# 比較 capability (unshare 以 root 執行,所以 cap 仍是全滿)
$ grep Cap /proc/<unshare-pid>/status
# 用 capsh 同時削減 capability,模擬容器行為
$ sudo capsh --drop=cap_sys_admin,cap_net_admin,cap_sys_ptrace \
--uid=0 -- -c 'grep Cap /proc/self/status'
```
若系統上有容器執行環境,也可直接觀察:
```shell
# 若系統已安裝 systemd-nspawn,可用它啟動輕量容器
$ sudo systemd-nspawn -D /path/to/rootfs --as-pid2 bash
# 或若有 Docker/Podman:
$ docker run --rm -d --name test alpine sleep 3600
$ CPID=$(docker inspect --format '{{.State.Pid}}' test)
$ grep Cap /proc/$CPID/status # capability 被削減
$ sudo ls -la /proc/$CPID/ns/ # 比較容器與 host 的 namespace
$ docker rm -f test
```
觀察要點:
* `nsproxy`:隔離行程的 `uts_ns`、`ipc_ns`、`mnt_ns`、`pid_ns_for_children`、`net_ns` 都指向獨立的 namespace 實例,與 host 的 `init_uts_ns`、`init_pid_ns` 等全域實例不同。`pid_ns_for_children` 較為特殊:它記錄的是子行程將使用的 PID namespace,必須在 `fork()` 後才會生效,新 namespace 中 fork 出的第一個子行程成為該 namespace 的 PID 1
* `fs`:隔離行程的 `fs->root` 指向不同的根檔案系統 (如 overlay mount 或 `--mount-proc` 建立的 mount),與 host 的 root dentry 不同。`chroot`/`pivot_root` 的本質就是改變 `task_struct->fs->root` 的指向
* `cred`:從一般行程 (CapEff=0)、root 行程 (CapEff=全滿)、到 capability 受限的行程 (CapEff=子集),capability bitmask 逐層變化,反映從無特權→完全特權→受限特權的漸進設計
* `cgroups`:透過 `/proc/<pid>/cgroup` 可觀察行程歸屬的 cgroup 階層;容器執行環境通常將行程放在獨立的 cgroup 子樹下,限制 CPU、記憶體、I/O 等資源
### 實驗 15:用 bpftrace 量測 fork/exec 延遲分佈
以 bpftrace 量測 `fork` (clone) 和 `execve` 的延遲直方圖,無需編譯 C 程式:
```
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_clone {
@fork_start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_clone /@fork_start[tid]/ {
@fork_us = hist((nsecs - @fork_start[tid]) / 1000);
delete(@fork_start[tid]);
}
tracepoint:syscalls:sys_enter_execve {
@exec_start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_execve /@exec_start[tid]/ {
@exec_us = hist((nsecs - @exec_start[tid]) / 1000);
delete(@exec_start[tid]);
}'
```
在另一個終端機反覆執行 `for i in $(seq 100); do /bin/true; done`,按 Ctrl-C 結束 bpftrace 後會印出延遲直方圖。典型 Arm64 系統上,clone 延遲集中在 50-200 μs,execve 集中在 200-800 μs (取決於 ELF 大小和動態連結複雜度)。
### 實驗 16:用 bpftrace 量測 runqueue 延遲
runqueue 延遲是 task 從 wakeup (進入 runqueue) 到實際獲得 CPU 的時間差,反映排程器的回應速度:
```shell
$ sudo bpftrace -e '
tracepoint:sched:sched_wakeup {
@qtime[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$ns = @qtime[args->next_pid];
if ($ns) {
@runqlat_us = hist((nsecs - $ns) / 1000);
delete(@qtime[args->next_pid]);
}
}'
```
在 Arm64 主機上的量測結果 (左圖為 runqueue latency,右圖為 wakeup-to-on-CPU latency,見實驗 22):

runqueue latency 集中在 4-8 μs (中位數約 6 μs),wakeup latency 集中在 8-32 μs (涵蓋 enqueue 成本)。在 idle 系統上,大多數 task 的 runqueue 延遲低於 10 μs;在負載較高的系統上,延遲會拉長到 ms 等級。搭配 `stress-ng --cpu $(nproc)` 施加 CPU 壓力,可觀察直方圖的尾端延遲顯著增加。
此實驗等同於 BCC 工具集的 [`runqlat`](https://github.com/iovisor/bcc/blob/master/tools/runqlat.py)。更多 BCC/bpftrace 排程分析工具參見 Brendan Gregg 的 [BPF Performance Tools](https://www.brendangregg.com/bpf-performance-tools-book.html)。
### 實驗 17:用 bpftrace 觀察 context switch 與 CPU 遷移
追蹤 task 在不同 CPU core 之間的遷移,以及 context switch 頻率:
```shell
$ sudo bpftrace -e '
tracepoint:sched:sched_switch {
@csw[args->next_comm] = count();
}
tracepoint:sched:sched_migrate_task {
printf("migrate: %s PID=%d CPU %d -> %d\n",
args->comm, args->pid, args->orig_cpu, args->dest_cpu);
@migrations = count();
}
interval:s:5 { print(@csw); print(@migrations); clear(@csw); clear(@migrations); }'
```
搭配 `taskset -c 0 stress-ng --cpu 1` 將 workload 固定在 CPU 0,可觀察到固定後遷移事件消失。核心的 load balancer (`migration` 核心執行緒) 會週期性檢查各 runqueue 的負載,將 task 從過載的 CPU 搬移到閒置的 CPU。在 NUMA 系統上,跨 NUMA node 的遷移成本更高 (cache 冷啟動),排程器會偏好 node-local 搬移。
### 實驗 18:用 bpftrace 追蹤 OOM killer
OOM (Out of Memory) killer 在記憶體耗盡時選擇犧牲行程,可用 bpftrace 即時觀察:
```shell
$ sudo bpftrace -e '
kprobe:oom_kill_process {
printf("OOM kill triggered at %s\n", strftime("%H:%M:%S", nsecs));
}
tracepoint:oom:mark_victim {
printf("OOM victim: PID=%d comm=%s\n", args->pid, str(args->comm));
}'
```
在另一終端機觸發 OOM (需小心操作,僅在測試環境執行):
```shell
$ stress-ng --vm 1 --vm-bytes $(awk '/MemAvailable/{print $2}' /proc/meminfo)K --timeout 30s
```
核心在 OOM 路徑中以 `oom_badness()` 計算每個行程的分數 (主要依據 RSS 和 `oom_score_adj`),分數最高者成為犧牲者。`/proc/<pid>/oom_score_adj` 可微調個別行程的 OOM 優先順序 (-1000 到 1000,-1000 表示永不被 OOM kill)。
### 實驗 19:用 bpftrace 追蹤 signal 傳遞
觀察 signal 在行程之間的傳遞,是理解行程生命週期的重要面向:
```shell
$ sudo bpftrace -e '
tracepoint:signal:signal_generate {
printf("signal %d -> PID=%d (%s) from PID=%d result=%d\n",
args->sig, args->pid, args->comm, pid, args->errno);
}
tracepoint:signal:signal_deliver {
printf("deliver signal %d to %s (PID=%d)\n", args->sig, comm, pid);
}'
```
在另一終端機執行 `sleep 60 & kill -TERM $!`,可觀察 `SIGTERM` (signal 15) 的產生和傳遞。搭配 `kill -STOP` 和 `kill -CONT` 可觀察 job control signal 的行為,驗證前述狀態轉換圖中 `TASK_RUNNING` ↔ `__TASK_STOPPED` 的轉換。
### 實驗 20:用 bpftrace 追蹤排程器 wakeup 呼叫堆疊
前文描述的狀態轉換和搶佔模型,最終在核心中收斂到 `try_to_wake_up()` 這條路徑。追蹤它的呼叫堆疊,可直接觀察誰喚醒誰、為什麼喚醒:
```shell
$ sudo bpftrace -e '
kprobe:try_to_wake_up {
printf("=== %s (PID=%d) waking up a task ===\n", comm, pid);
printf("%s\n", kstack);
}'
```
在另一終端機執行任意命令 (如 `ls`),可觀察到多條不同的 wakeup 路徑。典型的堆疊來源包括:
* timer interrupt → softirq → `rcu_core` → `swake_up_one` → `try_to_wake_up`:RCU 回報靜止狀態後喚醒 RCU grace period kthread
* I/O completion → `__wake_up` → `try_to_wake_up`:磁碟或網路 I/O 完成,喚醒等待的 task
* `epoll` → `ep_poll_callback` → `__wake_up_common` → `try_to_wake_up`:epoll 事件就緒,喚醒阻塞在 `epoll_wait` 的 task
* `mutex_unlock` → `__mutex_unlock_slowpath` → `try_to_wake_up`:mutex 釋放,喚醒等待者
* `do_idle` → timer interrupt → scheduler tick → `try_to_wake_up`:idle CPU 收到 timer tick,喚醒就緒的 task
bpftrace 輸出的堆疊是從低位址 (被探測的函式) 到高位址 (呼叫源頭),由上往下閱讀。BCC 工具 (如 `offcputime`、`wakeuptime`) 的 Python 腳本會將堆疊反轉,以符合火焰圖 (flame graph) 根在底部的慣例。
若要聚焦於特定行程的 wakeup 來源,可加上 PID 過濾:
```shell
$ TARGET_PID=12345 # 替換為目標行程的 PID
$ sudo bpftrace -e "
kprobe:try_to_wake_up /arg0 != 0/ {
\$p = (struct task_struct *)arg0;
if (\$p->tgid == $TARGET_PID) {
printf(\"waking %s (PID=%d) from %s (PID=%d)\n\",
\$p->comm, \$p->tgid, comm, pid);
printf(\"%s\n\", kstack);
}
}"
```
`try_to_wake_up` 的第一個參數是被喚醒 task 的 `task_struct` 指標,`comm` 和 `pid` 則是呼叫者 (喚醒者)。使用雙引號讓 shell 展開 `$TARGET_PID`,bpftrace 變數則以 `\$` 跳脫。
注意:`ttwu_do_wakeup` 在較舊的核心 (v6.3 之前) 是獨立函式,可直接以 `kprobe:ttwu_do_wakeup` 追蹤。v6.3 之後此函式被內嵌至 `ttwu_do_activate`,應改用 `kprobe:ttwu_do_activate` 或直接追蹤 `try_to_wake_up`。
### 實驗 21:用 Ftrace function_graph 追蹤 wakeup 內部呼叫
bpftrace 的 `kstack` 顯示的是進入函式之前的呼叫鏈 (誰呼叫了它)。若要觀察函式內部的子呼叫 (它呼叫了什麼),需要使用 Ftrace 的 function_graph tracer:
```shell
$ sudo bash -c '
cd /sys/kernel/debug/tracing
echo nop > current_tracer
echo 0 > tracing_on
echo function_graph > current_tracer
echo ttwu_do_activate > set_graph_function
echo 1 > tracing_on
sleep 2
echo 0 > tracing_on
head -60 trace
echo nop > current_tracer
echo > set_graph_function
'
```
參考輸出 (Arm64):
```
# CPU DURATION FUNCTION CALLS
# | | | | | | |
9) | ttwu_do_activate() {
9) | enqueue_task() {
9) | enqueue_task_fair() {
9) | enqueue_entity() {
9) 0.950 us | update_curr();
9) 0.600 us | __update_load_avg_se();
9) 0.535 us | __update_load_avg_cfs_rq();
9) | place_entity() {
9) 0.465 us | avg_vruntime();
9) 1.480 us | }
9) 0.555 us | __enqueue_entity();
9) 9.675 us | }
9) | dl_server_start() {
9) | enqueue_dl_entity() {
9) | start_dl_timer() {
...
9) | resched_curr() {
9) 0.415 us | __resched_curr.constprop.0();
9) 1.335 us | }
```
從這個輸出可以讀出 wakeup 的內部流程:
1. `ttwu_do_activate()` 呼叫 `enqueue_task()`,將 task 放回 runqueue
2. `enqueue_task_fair()` → `enqueue_entity()`:EEVDF fair class 的入隊邏輯,計算 `vruntime`、更新 load average、呼叫 `place_entity()` 決定新實體的虛擬時間
3. `__enqueue_entity()`:將 scheduling entity 插入 runqueue 的資料結構
4. `dl_server_start()`:啟動 fair server 的 deadline entity (v6.6+ 的 DL server 機制)
5. `resched_curr()`:若被喚醒的 task priority 高於目前 CPU 上的 task,設定 `TIF_NEED_RESCHED`,觸發搶佔
若 `resched_curr()` 判定需要搶佔另一個 CPU 上的 task,會呼叫 IPI (Inter-Processor Interrupt) 通知該 CPU 重新排程。在 x86 上是 `native_smp_send_reschedule()` → APIC IPI;在 Arm64 上是 `smp_cross_call()` → GIC SGI。
bpftrace (`kstack`) 和 Ftrace (`function_graph`) 是互補的工具:前者回答這個函式從哪裡被呼叫 (向下追溯呼叫鏈),後者回答這個函式內部做什麼 (向上展開子呼叫)。二者結合,可完整還原任何核心路徑的行為。
### 實驗 22:用 bpftrace 量化 wakeup 到實際執行的延遲
結合 `try_to_wake_up` 和 `sched_switch`,量化從決定喚醒到實際獲得 CPU 的端到端延遲:
```shell
$ sudo bpftrace -e '
kprobe:try_to_wake_up {
$p = (struct task_struct *)arg0;
@wakeup_ts[$p->pid] = nsecs;
}
tracepoint:sched:sched_switch {
$ns = @wakeup_ts[args->next_pid];
if ($ns) {
@wakeup_lat_us = hist((nsecs - $ns) / 1000);
delete(@wakeup_ts[args->next_pid]);
}
}'
```
這個直方圖量測的是「從 `try_to_wake_up` 被呼叫到 task 實際上 CPU」的完整延遲,涵蓋 enqueue、IPI 傳遞、排程決策和 context switch 的全部成本。搭配 `stress-ng --cpu $(nproc)` 施加壓力,可觀察 wakeup latency 從 idle 系統的個位數微秒膨脹到數百微秒甚至毫秒等級。
此實驗是前述實驗 16 (runqueue latency) 的更精確版本:實驗 16 從 `sched_wakeup` tracepoint 開始計時 (在 `try_to_wake_up` 內部、enqueue 之後才觸發),而本實驗從 `try_to_wake_up` 入口開始,涵蓋 enqueue 本身的成本。
### 實驗 23:觀察核心執行緒與一般行程的差異
前文〈核心執行緒概覽〉指出,核心執行緒與一般行程在排程層面無異,差異在 `mm` 指標和執行模式。本實驗從五個面向驗證。
第一步:用 `ps` 列出核心執行緒,觀察方括號標示和 VSZ/RSS 為 0
```shell
$ ps aux | awk 'NR==1 || $6==0' | head -10
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 2 0.0 0.0 0 0 ? S Apr14 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S Apr14 0:00 [pool_workqueue_release]
root 4 0.0 0.0 0 0 ? I< Apr14 0:00 [kworker/R-rcu_gp]
...
```
VSZ 和 RSS 皆為 0,因為核心執行緒的 `mm` 為 `NULL`,沒有 user address space。一般行程即使靜止,VSZ 也不為 0 (至少包含 text segment 和 stack mapping)。
第二步:透過 `/proc/<pid>/status` 確認 `VmSize` 欄位的差異
```
# 核心執行緒:無 VmSize 欄位
$ grep Vm /proc/2/status
(無輸出)
# 一般行程:有完整的 Vm 統計
$ grep Vm /proc/1/status
VmPeak: 25716 kB
VmSize: 25680 kB
VmRSS: 14804 kB
...
```
核心在 `proc_pid_status()` 中以 `task->mm != NULL` 作為輸出 `Vm*` 欄位的前提。這直接對應前文表格中 `task_struct->mm` 一列的差異。
第三步:用 bpftrace 比較核心執行緒與 user 行程的排程行為
直接讀取 `task_struct->mm` 是區分核心執行緒最可靠的方式。Arm64 上 `__switch_to` 被標記為 `notrace`,無法以 kprobe 追蹤;可改探測 `finish_task_switch`。實際符號名稱會受編譯器與最佳化影響,可能是 `finish_task_switch.isra.0` 或其他同源變體,可先用 `sudo bpftrace -l 'kprobe:finish_task_switch*'` 查詢:
```shell
$ sudo bpftrace -e '
kprobe:finish_task_switch.isra.0 {
$prev = (struct task_struct *)arg0;
if ($prev->mm == 0) {
@kthread = count();
} else {
@user = count();
}
}
interval:s:5 { exit(); }'
```
參考輸出 (224 個 logical CPU 的 idle 系統):
```
@user: 252
@kthread: 975
```
在 idle 系統上,核心執行緒的 context switch 次數往往占多數 (idle task `swapper`、`ksoftirqd`、`rcu_preempt`、`kworker` 頻繁切換)。施加 CPU 壓力 (`stress-ng --cpu $(nproc)`) 後,user 行程的比例上升,核心執行緒的占比相對下降。
第四步:用 bpftrace 觀察核心執行緒的 `active_mm` 沿用
前文提到核心執行緒切入時沿用前一個 task 已持有的 `active_mm` (lazy TLB)。`finish_task_switch` 的 `arg0` 是剛被切出的 `prev`,而 `curtask` (bpftrace 內建變數) 指向目前正在執行的 task (即切入後的 `next`)。以下程式利用這二者,觀察核心執行緒的 `active_mm` 來源:
```shell
$ sudo bpftrace -e '
kprobe:finish_task_switch.isra.0 {
$prev = (struct task_struct *)arg0;
$next = (struct task_struct *)curtask;
if ($next->mm == 0) {
printf("kthread [%s] uses active_mm (prev was [%s])\n",
$next->comm, $prev->comm);
}
}' | head -10
```
參考輸出:
```
kthread [swapper/98] uses active_mm (prev was [migration/98])
kthread [swapper/42] uses active_mm (prev was [migration/42])
kthread [swapper/70] uses active_mm (prev was [migration/70])
...
```
在 idle 系統上,多數切換發生在核心執行緒之間 (`swapper` ↔ `migration`),此時 `active_mm` 沿用的是前一個 task 已持有的 `active_mm`,未必直接來自某個 user 行程。當有 user 行程活躍時,會觀察到核心執行緒從 user 行程繼承 `active_mm`。這讓核心執行緒能存取 kernel space 的映射 (所有行程共享 kernel page table 的上半部),而不需要為核心執行緒維護獨立的 page table。
第五步:統計各核心執行緒的 context switch 次數
沿用 `finish_task_switch.isra.0`,以 `mm == 0` 篩選核心執行緒,統計每個核心執行緒被切出的次數:
```shell
$ sudo bpftrace -e '
kprobe:finish_task_switch.isra.0 {
$prev = (struct task_struct *)arg0;
if ($prev->mm == 0 && $prev->pid > 0) { /* pid > 0: 排除 idle task */
@kthread_csw[$prev->comm] = count();
}
}
interval:s:5 { exit(); }'
```
參考輸出 (224 個 logical CPU 的 idle 系統,5 秒取樣,摘錄尾端):
```
@kthread_csw[kcompactd0]: 6
@kthread_csw[kcompactd1]: 6
@kthread_csw[kworker/11:1]: 8
@kthread_csw[rcu_preempt]: 13
@kthread_csw[migration/98]: 1
@kthread_csw[migration/42]: 1
...
```
觀察要點:idle 系統上 `migration` (每個 CPU core 一個實例) 各僅 1 次 context switch (load balancing timer 週期性觸發);`rcu_preempt` 和 `kcompactd` 較為活躍;`kworker` 的次數隨 I/O 和 deferred work 波動。這些行為驗證核心執行緒確實被排程器視為一般的 schedulable entity。
## 參考資料
* [Elements of a process](https://www.bottomupcs.com/elements_of_a_process.xhtml) (Ian Wienand)
* [Linux Insides](https://0xax.gitbooks.io/linux-insides/) (0xAX):[Kernel initialization](https://0xax.gitbooks.io/linux-insides/Initialization/)、[System calls](https://0xax.gitbooks.io/linux-insides/SysCall/)、[Program startup process in userspace](https://0xax.gitbooks.io/linux-insides/Misc/linux-misc-4.html)
* [Evolution of the x86 context switch in Linux](http://www.maizure.org/projects/evolution_x86_context_switch_linux/index.html) (MaiZure)
* [NPTL design whitepaper](https://www.akkadia.org/drepper/nptl-design.pdf) (Drepper & Molnar)
* [The Native POSIX Thread Library](https://lwn.net/Articles/10710/)
* [An EEVDF CPU scheduler for Linux](https://lwn.net/Articles/925371/) (2023 年)
* [Proxy Execution](https://lwn.net/Articles/934049/) (2023 年)
* [Introducing the Maple Tree](https://lwn.net/Articles/866573/) (2021 年)
* [The many faces of "wait"](https://lwn.net/Articles/627057/) (2015 年),wait queue 與行程等待機制
* [Rethinking the futex API](https://lwn.net/Articles/823513/) (2020 年)
* [Light-weight processes discussion archive](https://yarchive.net/comp/linux/light_weight_processes.html) (Linus Torvalds 等)
* [pthreads(7) man page](https://man7.org/linux/man-pages/man7/pthreads.7.html),LinuxThreads 與 NPTL 的差異
* Brian Long, "Kylix Tutorial Part 9" (Linux Format, 2002/5),Kylix 的執行緒與 signal 模型,以及 `LD_ASSUME_KERNEL=2.4.1` workaround 的背景
* [How programs get run: ELF binaries](https://lwn.net/Articles/631631/) (2015 年)
* [Increasing the kernel PID limit](https://lwn.net/Articles/701389/) (2016 年)
* [A fork() in the road](https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road/) (2019 年),fork 在現代系統中的問題
* [lmbench: Portable Tools for Performance Analysis](https://www.usenix.org/legacy/publications/library/proceedings/sd96/full_papers/mcvoy.pdf) (McVoy & Staelin, USENIX 1996 年),context switch latency 量測方法論
* [BPF Performance Tools](https://www.brendangregg.com/bpf-performance-tools-book.html) (Brendan Gregg, 2019 年),eBPF/bpftrace 效能分析工具
* [Lazy preemption](https://lwn.net/Articles/994828/) (2024 年),v6.13 引入的延遲搶佔機制
* [sched_ext: Pluggable CPU scheduler](https://lwn.net/Articles/974387/) (2024 年),BPF 可程式化排程器