名詞釐清#
| 名詞 | 含義 |
|---|---|
| Process(行程) | 有獨立位址空間的執行單元 |
| Thread(執行緒) | 共享位址空間的執行單元 |
| Task | 核心對「可被排程的東西」的統稱(thread 與 process 一視同仁) |
| LWP(lightweight process) | Linux 對 thread 的舊稱 |
Linux 沒有 process 與 thread 的區別,只有 task。每個 thread 都是一個獨立的 task,只是它們透過 clone() 時共享了 mm、files、signal handlers 等。POSIX thread 概念是 userspace(NPTL)建構在這之上。
task_struct ── 一切的起點#
/include/linux/sched.h 定義的 struct task_struct 是核心追蹤每個任務的資料結構,欄位上千個。重要欄位摘要:
struct task_struct {
/* 狀態 */
unsigned int __state; // TASK_RUNNING, TASK_INTERRUPTIBLE, ...
int exit_state;
int exit_code;
/* 識別 */
pid_t pid; // 全域唯一 task ID
pid_t tgid; // thread group leader 的 pid(相當於 POSIX getpid)
char comm[16]; // command name
/* 親屬關係 */
struct task_struct __rcu *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
/* 排程 */
int prio, static_prio, normal_prio;
unsigned int policy; // SCHED_NORMAL, SCHED_FIFO, ...
struct sched_entity se; // CFS / EEVDF 實體
cpumask_t cpus_mask; // CPU affinity
/* 記憶體 */
struct mm_struct *mm; // 行程位址空間(kernel thread 為 NULL)
struct mm_struct *active_mm;
/* 檔案、命名空間、cgroup */
struct files_struct *files;
struct fs_struct *fs;
struct nsproxy *nsproxy;
struct css_set __rcu *cgroups;
/* 訊號 */
struct signal_struct *signal;
struct sighand_struct __rcu *sighand;
sigset_t blocked, real_blocked;
struct sigpending pending;
/* 計時 */
u64 utime, stime; // 使用者時間、核心時間
u64 start_time;
// ... 上千個欄位
};幾個關鍵概念:
- pid vs tgid:對 multi-thread 行程,所有 thread 的
tgid相同(即 thread group leader 的 pid),而每個 thread 有自己的pid。getpid()回的是tgid,gettid()回的是pid。 - kernel thread(kthread) 沒有 user mm,
mm == NULL,active_mm借用某個 user 行程的或 init_mm。 - task 在哪? 全域
init_task是 PID 0(idle thread)的 task_struct。所有 task 透過tasks雙向鏈結串列串起來。
task 狀態#
+------------------+
| TASK_RUNNING | ← 在 runqueue 上,可能正在跑或等 CPU
+--------+---------+
|
(sleep) | (wakeup)
v
+-------------+-------------+
| TASK_INTERRUPTIBLE | ← 阻塞,可被訊號喚醒(S 狀態)
| TASK_UNINTERRUPTIBLE | ← 阻塞,不可被訊號喚醒(D 狀態,常見於 disk I/O)
| TASK_KILLABLE | ← 介於兩者:不可被普通訊號中斷,但 SIGKILL 可
+---------------------------+
+------------------+
| TASK_STOPPED | ← SIGSTOP 後(T)
+------------------+
+------------------+
| __TASK_TRACED | ← 被 ptrace 中
+------------------+
+------------------+
| EXIT_ZOMBIE | ← 已 exit,等 parent wait()(Z)
+------------------+
| EXIT_DEAD | ← 完全結束,正在被釋放
+------------------+ps 看到的單字母對應這些狀態。D 狀態(Uninterruptible)是「無法 kill -9」的元兇 ── 通常表示在等真實的 I/O,核心無法安全中斷。
fork、vfork、clone#
UNIX 經典是 fork() + exec():
pid_t pid = fork();
if (pid == 0) {
// 子行程:exec 換上新程式
execve("/bin/ls", argv, envp);
perror("execve"); _exit(1);
} else if (pid > 0) {
// 父行程
waitpid(pid, &status, 0);
} else {
perror("fork");
}核心實作上三個 syscall 都呼叫同一個 kernel_clone(struct kernel_clone_args *):
| syscall | 行為 |
|---|---|
fork() | 完全複製:新 mm(COW)、新 fd table 副本、新訊號狀態 |
vfork() | 共享 mm,父行程暫停直到子 exec 或 exit(古老) |
clone() | 細粒度:透過 flags 控制要共享/複製哪些資源 |
clone3() | 新版,用結構體傳參數,更可擴展 |
POSIX thread 用 clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...) 實作 ── 共享一切,只新建一個排程實體。
容器(unshare)用 clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUSER | ...) 建立新的 namespace。
Copy-On-Write#
fork 不會真的複製 4GB 記憶體 ── 新行程的頁表指向舊頁、所有頁標記為 read-only。任一方寫入時觸發 page fault,核心才複製那一頁。
fork() 之後:
parent_pgd ──► page A (RO)
page B (RO)
page C (RO)
child_pgd ──► page A (RO) ← 共享同一個物理頁
page B (RO)
page C (RO)
子行程寫 page B 時:
page fault → 核心複製 B → B'
parent_pgd ──► page A (RO)
page B (RO) ← parent 仍指原頁
page C (RO)
child_pgd ──► page A (RO)
page B' (RW) ← child 改指新頁
page C (RO)代價:第一次寫的延遲、page fault 開銷。對「立刻 exec」的場景幾乎零成本。對「fork 後大量寫」的場景(例如 Redis 的 BGSAVE)寫放大會是問題。
exec:換上新程式#
execve() 不建新行程,它在當前 task 上「重新載入」:
- 載入 ELF/script header
- 把舊的 mm 整個丟掉,建立新的 vma 圖
- 把可執行檔的 segment 映射進來(mmap)
- 設定堆疊、把 argv/envp 推上去
- 把 PC 設到 ELF entry point
返回後,從 userspace 看就像「從新 binary 的 _start 開始執行」。
排程:CFS、SCHED_FIFO、SCHED_DEADLINE#
Linux 把 task 分幾個排程類別:
| Class | policy | 用途 |
|---|---|---|
| Stop / Idle | 系統內部 | CPU hotplug、idle task |
| Deadline | SCHED_DEADLINE | 即時硬性截止 |
| Real-time | SCHED_FIFO、SCHED_RR | 即時軟性 |
| Fair | SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE | 一般任務(CFS / EEVDF) |
Class 之間是嚴格優先:只要有 Deadline 任務 ready,CFS 任務一定不會跑。實務上 99% 的 task 走 Fair class。
CFS(Completely Fair Scheduler)#
核心想法:每個 task 應該得到「公平的 CPU 比例」。實作靠vruntime:
vruntime = wall-clock running time × NICE_0_LOAD / weight- 每個 task 跑時
vruntime累加(依優先權加權) - runqueue 是個紅黑樹,key 是 vruntime
- 排程選最左邊的節點(最小 vruntime)
效果:優先權高(nice 小)的 task 跑得快、累加慢,會更常被選;優先權低的累加快,較少被選。
// 概念碼,大幅簡化
struct cfs_rq {
struct rb_root_cached tasks_timeline;
u64 min_vruntime;
// ...
};
struct task_struct *pick_next(struct cfs_rq *rq) {
return rb_entry(rb_first_cached(&rq->tasks_timeline),
struct sched_entity, run_node);
}CFS 沒有「時間片」這個欄位,是動態計算的:當前 task 的「應得時間」依當前 runqueue 上其他 task 的權重而定。
EEVDF(6.6+)#
CFS 在 2007 加入主線,跑了 16 年。EEVDF(Earliest Eligible Virtual Deadline First) 在 6.6 開始接手成為預設 fair scheduler。它解決 CFS 的兩個痛點:
- 延遲敏感的小工作容易被長跑工作搶光時間
- nice 值的語意之前不夠精確(nice -5 對應多少倍 CPU 沒明確定義)
EEVDF 引入兩個概念:
- eligible time:「這個 task 應該得到的最小服務時間」
- virtual deadline:「應在何時前完成這次配額」
排程選 eligible 的、virtual deadline 最早的 task。這比 CFS 純靠 vruntime 排序提供更可預測的延遲。
對應用層幾乎完全相容,不需要改程式。
SCHED_NORMAL仍是SCHED_NORMAL,nice仍是nice。差異主要是延遲的尾分布變好。
Real-time 排程#
SCHED_FIFO:純優先權,高優先 task 不主動讓出就會跑到完。SCHED_RR:同優先權的 round-robin。
struct sched_param p = { .sched_priority = 50 };
sched_setscheduler(0, SCHED_FIFO, &p);優先權範圍 1~99(越大越高)。RT task 可以餓死所有 fair task ── 寫死迴圈的 RT task 會把整顆 CPU 鎖死。所以核心預設限制 RT task 不能用滿 100% CPU(/proc/sys/kernel/sched_rt_runtime_us),給其他人留 5% 餘地。
SCHED_DEADLINE(CBS / GEDF)給每個 task 三個參數 (runtime, deadline, period):「每 period 給我至少 runtime 量的 CPU,且必須在 deadline 前用完」。如果超過就被 throttle。適合即時控制系統。
SMP 排程:runqueue per CPU#
每個 CPU 有自己的 runqueue(struct rq):
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);優點:
- 每 CPU 各自選 next,不需要全域鎖
- 當前 task 大概率在自己 cache 裡熱
缺點:
- 不平衡 ── 一個 CPU 過載而其他閒置
於是核心定期執行 load balancing:把 task 從忙的 runqueue 搬到閒的。觸發點:
- tick 中斷檢查
- task 喚醒時的「wake balance」
- CPU 進入 idle 時主動拉 task
平衡並非無代價:搬遷 task 會丟掉 cache(cold cache penalty)。所以核心會考慮 NUMA、cache 拓樸、SMT:相鄰 CPU 之間搬比較便宜,跨 socket 搬要謹慎。
CPU affinity 與 isolation#
taskset -c 0,1 ./myproc # 把 myproc 限制在 CPU 0,1
taskset -p 0xff $(pidof firefox) # 設定遮罩
# 核心開機參數:
# isolcpus=2,3 把 CPU 2,3 從一般排程拿掉,只給明確 affinity 的 task
# nohz_full=2,3 進一步去掉 timer tick,最大化 user code 不被打擾isolcpus + nohz_full 是 HFT、5G base station 等對抖動敏感的應用會做的事。
訊號處理#
訊號是「給 task 的事件通知」。每個 task 有:
- pending mask:等待被處理的訊號
- blocked mask:暫時遮蔽的訊號
- handler 表:每個訊號的處理函式(SIG_DFL / SIG_IGN / 自訂)
訊號的傳遞時機:不是非同步打斷!訊號真正執行 handler 是在 task 即將返回 user space 時(從 syscall 返回、或從中斷返回 user)。所以一個跑死迴圈的 user task 收 SIGTERM 還是會觸發;但一個卡在 D 狀態的 task 連 SIGKILL 都殺不掉。
幾個重要規則:
SIGKILL、SIGSTOP不能被擋、不能被自訂處理- handler 內能呼叫的 libc 是「async-signal-safe」白名單,
printf不在裡面 - 訊號傳遞順序不保證,重複訊號可能被合併
context switch 成本#
切換 task 涉及:
- 儲存當前 task 的 CPU 狀態(GPR、FPU、segment register)
- 切換頁表(CR3 / TTBR0)── 觸發 TLB flush(除非 PCID/ASID)
- 載入新 task 的 CPU 狀態
- 可能觸發 cache miss(新 task 的工作集不在 L1/L2)
直接成本:x86 約 1~2 μs。間接成本(cache miss)可能是直接成本的數倍到數十倍。所以高吞吐伺服器寧可少 task、多用事件迴圈(epoll、io_uring)。
觀測工具#
ps -eLo pid,tid,state,comm # 所有 thread
top -H # 顯示 thread
pidstat 1 # 每秒 task CPU 使用
schedtool -p 1 -P SCHED_FIFO -e ./prog # 設定排程策略
chrt -f 50 ./prog # 同上
perf sched record/report # 排程追蹤
cat /proc/<pid>/status # 詳細 task 狀態
cat /proc/<pid>/sched # CFS 內部統計
cat /sys/kernel/debug/sched/debug # 系統級排程診斷小結#
- Linux 沒有 process/thread 之分,只有 task。
cloneflags 決定共享什麼 task_struct是核心追蹤一切的根- CFS / EEVDF 是 Fair class,多數 task 走這裡
- RT class(
SCHED_FIFO/RR/DEADLINE)優先於 fair,但會餓死別人 - SMP 用 per-CPU runqueue + load balancing,平衡與 cache locality 的取捨
- context switch 不便宜,間接成本(cache pollution)常被低估