本页目录

task_struct 与进程生命周期

覆盖: task_struct 核心字段与演进 → 进程状态机 → fork/exec/exit 完整路径 → 内核线程 内核版本: 2.6 ~ 6.x,重点标注 5.x+ 变更

概述

Linux 中一切皆进程。内核线程是进程,用户线程也是进程——区别仅在于 clone() 时共享了什么。这套统一模型之所以能工作,全靠 task_struct 这个巨型结构体把调度、内存、文件、信号等子系统串联在一起。

本文从 task_struct 的核心字段出发,追踪一个进程从 fork() 诞生到 exit() 消亡的完整生命周期,解释每一步内核做了什么、数据结构如何变化。


task_struct:进程描述符

定义在 include/linux/sched.h,是内核中最复杂的结构体之一(当前主线 ~700 行)。初次阅读不需要逐字段记忆,理解分层归属即可:

flowchart LR
    subgraph TS["task_struct (进程描述符)"]
        ID["🆔 身份标识<br/>pid, tgid<br/>comm (进程名)"]
        SCHED["⏱️ 状态与调度<br/>__state, prio<br/>se (CFS) / rt / dl"]
        MM["🧠 内存管理<br/>mm / active_mm<br/>vmacache"]
        FS["📂 文件与 IO<br/>files (fd table)<br/>fs (root/pwd)"]
        REL["👥 亲属关系<br/>parent / children<br/>sibling"]
        SIG["📡 信号<br/>signal (共享描述)<br/>sighand (处理表)"]
        STACK["📚 内核栈<br/>stack → thread_info"]
        STAT["📊 统计与审计<br/>start_time, utime/stime<br/>sum_exec_runtime"]
    end

    classDef ts fill:#e3f2fd,stroke:#1565c0
    classDef cat fill:#fff3e0,stroke:#ef6c00
    class ID,SCHED,MM,FS,REL,SIG,STACK,STAT cat

为什么线程也是进程?

Linux 不区分"进程"和"线程"——两者都是 task_struct。区别仅在于 clone() 的 flags:

// fork(): 全新进程
clone(SIGCHLD, NULL);

// pthread_create(): 共享地址空间的"线程"
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
      CLONE_THREAD | CLONE_SYSVSEM, ...);

// vfork(): 阻塞父进程直到子进程 exec/exit (已基本被 fork+COW 取代)
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, NULL);

关键 flags:

Flag效果
CLONE_VM共享 mm_struct(不复制地址空间)
CLONE_FS共享 fs_struct(root/pwd 一致)
CLONE_FILES共享 files_struct(fd 表共享)
CLONE_SIGHAND共享 sighand_struct(信号处理函数共享)
CLONE_THREAD同一线程组(tgid 与父进程相同)
CLONE_VFORK父进程阻塞直到子进程 exec/exit

getpid() 返回的是 tgid(线程组 ID),不是 pid。同一个进程的所有线程有相同 tgid,但各自有独立的 pid。这就是为什么 ps -eLf 能看到同一个 PID 下列出多个 LWP。

task_struct 如何被找到?

// 当前进程的 task_struct (x86_64 上最快)
#define current get_current()

// 实现: 从内核栈指针的低位掩码得到 thread_info,再取 task
static __always_inline struct task_struct *get_current(void) {
    return this_cpu_read_stable(pcpu_hot.current_task);
}

5.x+ 已把 task_struct * 直接放进 per-CPU 变量 pcpu_hot.current_task,不再需要从内核栈指针间接计算。


进程状态机

stateDiagram-v2
    [*] --> TASK_RUNNING : fork()
    TASK_RUNNING --> TASK_INTERRUPTIBLE : wait_event_*()
    TASK_RUNNING --> TASK_UNINTERRUPTIBLE : io_schedule()
    TASK_RUNNING --> TASK_STOPPED : SIGSTOP / ptrace
    TASK_RUNNING --> EXIT_ZOMBIE : do_exit()
    
    TASK_INTERRUPTIBLE --> TASK_RUNNING : 信号 / wake_up()
    TASK_UNINTERRUPTIBLE --> TASK_RUNNING : IO 完成 / wake_up()
    TASK_STOPPED --> TASK_RUNNING : SIGCONT
    EXIT_ZOMBIE --> EXIT_DEAD : 父进程 wait()
    EXIT_DEAD --> [*] : RCU grace period

各状态详解

TASK_RUNNING (R 状态) — 正在 CPU 上执行,或在 runqueue 中等待调度。ps/top 看到的所有 R 状态进程都可能正在等 CPU,不一定是当前在跑的那个。

cat /proc/<pid>/status | grep State  # 也可看 /proc/<pid>/stat 第3字段

TASK_INTERRUPTIBLE (S 状态) — 可中断睡眠。绝大多数等待都是这种:等 pipe 数据、等 socket、等信号量。可以被信号唤醒(返回 -EINTR)。

TASK_UNINTERRUPTIBLE (D 状态) — 不可中断睡眠,典型场景是等磁盘 IO 完成。这个状态之所以存在,是因为某些 IO 路径不能被信号打断(比如写回页缓存时的 lock_page())。D 状态过长 → top/负载中显示为 D state hang。

5.x+ 引入 TASK_KILLABLE:一种新的睡眠模式,挂载在 NFS 等慢 IO 时可用。和 UNINTERRUPTIBLE 的区别是——可以被 SIGKILL 杀死。这解决了经典的"NFS 挂了进程 D 状态永远杀不掉"问题。实现上是 wait_event_killable() 宏,内部检查 fatal_signal_pending()

TASK_STOPPED (T 状态)SIGSTOP/SIGTRAP/ptrace 暂停。进程被暂停后仍在进程表中,可以通过 SIGCONT 恢复。

TASK_TRACED (t 状态) — 被 ptrace 调试中。与 STOPPED 的区别在于:信号传递和 ptrace 事件处理方式不同。

EXIT_ZOMBIE (Z 状态) — 进程已死亡但 task_struct 还在,等待父进程 wait() 读取退出码。僵尸不占内存(mm 已释放),只占一个 PID 和一个 task_struct。

EXIT_DEAD — 父进程 wait() 之后,task_struct 等待 RCU grace period 后真正回收。这是为了防止其他 CPU 还持有 rcu_dereference 引用。

状态存储演进

// 旧 (2.6 ~ 5.12): long state
task->state = TASK_UNINTERRUPTIBLE;

// 新 (5.14+): 通过 WRITE_ONCE/READ_ONCE 访问
WRITE_ONCE(task->__state, TASK_UNINTERRUPTIBLE);

改为 __state 的目的是强制使用 WRITE_ONCE/READ_ONCE 宏,避免编译器撕裂写(tearing)或乱序导致的状态不一致。


fork:进程诞生

入口与路径

// glibc: fork() → clone(SIGCHLD, ...)
// kernel (5.x 统一入口):
SYSCALL_DEFINE0(fork)kernel_clone(SIGCHLD, ...)
SYSCALL_DEFINE2(clone3, ...)kernel_clone(args->flags, ...)

kernel_clone() (kernel/fork.c) 是统一的 clone 入口(5.x 重构,之前叫 _do_fork())。传入的 flags 决定了共享哪些资源。

copy_process — 核心(~1500行)

copy_process():fork 内部的复制流水线(kernel/fork.c) 大致按此顺序执行,部分步骤依 clone flags 可跳过 ① dup_task_struct() · 复制 task_struct+内核栈(vmap栈) ② sched_fork() · 初始化调度实体 se/rt/dl ③ copy_semundo() · SysV sem undo ④ copy_files() · 复制 fd table ⑤ copy_fs() · 复制 fs_struct ⑥ copy_sighand() · 复制信号处理表 ⑦ copy_signal() · 复制 signal_struct ⑧ copy_mm() · COW 映射,只复制页表不复制物理页 ⑨ copy_namespaces() · 复制 namespace ⑩ copy_io() · 复制 IO context ⑪ copy_thread() · 设置子进程返回地址 ⑫ sched_post_fork() · 调度后处理 ⑬ 写 pid / tgid 关键在 ⑧ copy_mm():除非 CLONE_VM,dup_mmap() 只复制页表并标记只读(COW), 真正的数据复制推迟到写入触发的 page fault——这是 fork() 能做到 <1ms 的原因。

Copy-on-Write 是 fork 快的秘密

// kernel/fork.c: copy_page_range() → copy_p4d_range() → ... → copy_pte_range()
static inline int copy_present_pte(struct vm_area_struct *dst_vma, ...) {
    // 1. 两个进程的 PTE 指向同一个物理页
    // 2. 两个 PTE 都标记为只读 (即使原来是可写的)
    // 3. 物理页的 refcount++
    
    if (is_cow_mapping(vma->vm_flags)) {
        ptep_set_wrprotect(src_mm, addr, src_pte);  // 父进程 PTE → 只读
        set_pte_at(dst_mm, addr, dst_pte, entry);   // 子进程 PTE → 只读
        atomic_inc(&page->_refcount);                 // 物理页 +1 引用
    }
}

后继 page fault 时才真正复制:

父子任一进程写入 → page fault → do_wp_page()
  → wp_page_copy() → alloc_page() → copy_user_highpage() → 各自可写

这就是为什么 fork() 可以 <1ms,而实际内存开销只发生在写入时。

vfork 的微妙之处

vfork() 曾经是为了避免 fork 复制页表的开销(在 COW 出现之前)。现在已基本被 COW 淘汰,但在 posix_spawn() 路径中仍然使用——父进程阻塞在 CLONE_VFORK 上直到子进程 exec,避免无意义的 COW fault。内核通过 completion 机制实现父进程的阻塞等待。


exec:脱胎换骨

// fs/exec.c
SYSCALL_DEFINE3(execve, ...)do_execve()do_execveat_common()exec_binprm()

exec 不创建新进程,而是把当前进程的地址空间整个换掉。PID 不变,但运行的代码、数据、栈全部换新。

执行流程

exec_binprm():exec 换血的六个阶段(fs/exec.c) 顺序:先读左列 ①→③,再读右列 ④→⑥ ① prepare_binprm() 读 ELF 头(前 256B)→ 识别格式:ELF / #! / a.out / misc ② 搜索 binary handler search_binary_handler() 遍历 formats 链表 load_elf_binary / load_script(#!) / load_misc_binary ③ flush_old_exec() de_thread() 脱线程组 → flush_old_files() 关 O_CLOEXEC fd → exec_mmap() 释放旧 mm、创建新 mm ④ setup_new_exec() __set_task_comm() 设新进程名(截断 15 字符) setup_arg_pages() 布置栈:argv + envp + auxv ⑤ load_elf_binary() elf_map()→vm_mmap() 映射 LOAD 段;set_brk() 设堆起始 load_elf_interp() 加载 ld.so;start_thread() 设 IP→entry ⑥ 返回用户态 从 entry point(或 ld.so)开始执行 exec 不创建新进程,而是把当前进程的地址空间整个换掉——PID 不变,但代码、数据、栈全部换新。

setuid/setgid 与 credential

如果 ELF 文件设置了 suid bit,exec 还会替换 credential:

// fs/exec.c
if (bprm->cred->uid != bprm->file->f_owner.uid) {
    // setuid binary: 替换 euid/egid 等
    bprm->cred->euid = bprm->file->f_owner.uid;
}

这是内核中权限模型的核心——prepare_binprm() 中完成。


exit:进程消亡

do_exit 路径

// kernel/exit.c
void __noreturn do_exit(long code) {
    // 1. 设标志
    tsk->flags |= PF_EXITING;
    
    // 2. 释放资源
    exit_mm(tsk);        // 释放 mm_struct (如果没有其他线程共享)
    exit_sem(tsk);       // SysV sem undo
    exit_shm(tsk);       // SysV shm
    exit_files(tsk);     // 关闭文件 (如果没有共享)
    exit_fs(tsk);        // 释放 fs_struct
    exit_task_namespaces(tsk);
    exit_rcu(tsk);
    
    // 3. 通知
    exit_notify(tsk, group_dead);
    //    ├─ 给子进程找新父进程 (reparent)
    //    └─ 给父进程发 SIGCHLD
    
    // 4. 最后一次调度 — 永不再返回
    do_task_dead();
}

僵尸进程详解

僵尸进程回收链:do_exit() 到真正释放的三个阶段 EXIT_ZOMBIE (do_exit() 后) · task_struct 还在 · mm/files 可能已释放 (无其他线程共享时) 父进程 wait() 父进程 wait() → release_task() · __exit_signal() 清理信号 · __unhash_process() 移出 PID hash grace period RCU grace period 后 真正回收 · put_task_struct() 延迟释放 · delayed_put_task_struct() · free_task() → kmem_cache_free() 僵尸不占内存(mm 已释放),只占一个 PID 和 task_struct;若父进程不 wait(), 只能 kill 父进程,由 init(PID 1)收养后自动回收——这是 PID 1 循环 wait() 的原因。

孤儿进程收养⁠:如果父进程先于子进程退出,exit_notify() 会调用 find_new_reaper() 把子进程过继给:

  • 同一线程组的其他线程
  • 同一 PID namespace 的 init (PID 1)

这就是为什么 PID 1 不会让僵尸积累——init 进程主动循环 wait()

僵尸进程调试

# 查找僵尸进程
ps aux | awk '$8 == "Z" { print $2, $11 }'

# 僵尸占用的资源
cat /proc/<zombie_pid>/status  # 能看到 State: Z,但大多数字段是 0

# 僵尸的父进程
cat /proc/<zombie_pid>/stat | awk '{print "PPID:", $4}'

# 如果父进程不 wait(),只能 kill 父进程
# 然后 init 收养僵尸 → 自动回收

内核线程

内核线程是没有用户空间的特殊进程(mm == NULL)。它们运行在内核态,创建和管理不同于用户进程。

// include/linux/kthread.h
struct task_struct *kthread_create(int (*threadfn)(void *data), ...);
void kthread_bind(struct task_struct *k, unsigned int cpu);
int kthread_stop(struct task_struct *k);
bool kthread_should_stop(void);

// 快捷版: 创建 + 唤醒一步到位
#define kthread_run(threadfn, data, namefmt, ...)

kthreadd (PID 2)

所有内核线程的父进程。启动路径:

start_kernel() → rest_init() → kernel_thread(kernel_init, ...)
                                    └─ kernel_thread(kthreadd, ...)

kthreadd 循环处理 kthread_create_list:取出请求 → 调用 create_kthread()kernel_thread() 创建新线程。

内存与调度特点

// 内核线程没有自己的 mm,借用上一个进程的 active_mm
task->mm = NULL;
task->active_mm = &init_mm;  // 全局内核地址空间引用

// 上下文切换时:
// if (prev->mm == NULL)  // 前一个进程是内核线程
//     enter_lazy_tlb(prev->active_mm, next);
//     不切换页表,因为内核地址空间是共享的

这解释了为什么内核线程上下文切换极快——不需要刷 TLB(x86: 不写 CR3)。

常见内核线程

线程作用
ksoftirqd/<N>处理 softirq(网络收包、timer 等)
kworker/<N>:<name>workqueue worker
migration/<N>调度器负载均衡
rcu_gp / rcu_par_gpRCU grace period 管理
jbd2/<dev>-<N>ext4 journal 提交
kcompactd<N>内存碎片整理
# 查看所有内核线程
ps -eo pid,comm,state | grep -E '\[.*\]' | head -20

# kthreadd 的子进程树
pstree -p 2

关键 /proc 接口

# /proc/<pid>/stat — 进程状态(纯数字,快速解析)
cat /proc/1/stat
# 字段: pid comm state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime ...

# /proc/<pid>/status — 人类可读版本
cat /proc/self/status
# 含: Name, State, Tgid, Pid, PPid, Uid, VmSize, VmRSS, Threads, voluntary_ctxt_switches, nonvoluntary_ctxt_switches

# /proc/<pid>/task/<tid>/ — 线程目录
ls /proc/self/task/  # 每个线程一个目录

# /proc/sys/kernel/pid_max — PID 上限 (默认 32768,可调到 4194304)
cat /proc/sys/kernel/pid_max

参考与延伸

  • 内核文档⁠: Documentation/filesystems/proc.rst, Documentation/scheduler/sched-design-CFS.rst
  • LWN 深度文章⁠:
    • "The task_struct and process creation" 系列
    • "clone(), fork(), and vfork()" (lwn.net/Articles/176929/)
    • "TASK_KILLABLE" (lwn.net/Articles/288056/)
  • 源码文件⁠:
    • include/linux/sched.h — task_struct 定义
    • kernel/fork.c — copy_process(), kernel_clone()
    • fs/exec.c — do_execve(), exec_binprm(), load_elf_binary()
    • kernel/exit.c — do_exit(), release_task()
    • kernel/kthread.c — kthreadd 管理

关键词: task_struct, fork, COW, exec, zombie, kernel_clone, TASK_KILLABLE, 内核线程, kthreadd