名詞釐清#

名詞含義
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 有自己的 pidgetpid() 回的是 tgidgettid() 回的是 pid
  • kernel thread(kthread) 沒有 user mm,mm == NULLactive_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 上「重新載入」:

  1. 載入 ELF/script header
  2. 把舊的 mm 整個丟掉,建立新的 vma 圖
  3. 把可執行檔的 segment 映射進來(mmap)
  4. 設定堆疊、把 argv/envp 推上去
  5. 把 PC 設到 ELF entry point

返回後,從 userspace 看就像「從新 binary 的 _start 開始執行」。

排程:CFS、SCHED_FIFO、SCHED_DEADLINE#

Linux 把 task 分幾個排程類別:

Classpolicy用途
Stop / Idle系統內部CPU hotplug、idle task
DeadlineSCHED_DEADLINE即時硬性截止
Real-timeSCHED_FIFOSCHED_RR即時軟性
FairSCHED_NORMALSCHED_BATCHSCHED_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 的兩個痛點:

  1. 延遲敏感的小工作容易被長跑工作搶光時間
  2. nice 值的語意之前不夠精確(nice -5 對應多少倍 CPU 沒明確定義)

EEVDF 引入兩個概念:

  • eligible time:「這個 task 應該得到的最小服務時間」
  • virtual deadline:「應在何時前完成這次配額」

排程選 eligible 的、virtual deadline 最早的 task。這比 CFS 純靠 vruntime 排序提供更可預測的延遲。

對應用層幾乎完全相容,不需要改程式。SCHED_NORMAL 仍是 SCHED_NORMALnice 仍是 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 都殺不掉。

幾個重要規則:

  • SIGKILLSIGSTOP 不能被擋、不能被自訂處理
  • handler 內能呼叫的 libc 是「async-signal-safe」白名單,printf 在裡面
  • 訊號傳遞順序不保證,重複訊號可能被合併

context switch 成本#

切換 task 涉及:

  1. 儲存當前 task 的 CPU 狀態(GPR、FPU、segment register)
  2. 切換頁表(CR3 / TTBR0)── 觸發 TLB flush(除非 PCID/ASID)
  3. 載入新 task 的 CPU 狀態
  4. 可能觸發 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。clone flags 決定共享什麼
  • 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)常被低估