审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
内核里没有"进程"和"线程"两种东西——只有 task_struct,以及 clone() 的 flags 旋钮。fork 快不是因为复制得快,而是因为几乎不复制。
2026-09-22 · Processes and Threads, task_struct and Process Creation

第 1 关 · 进程与线程:task_struct 与进程创建

定位

第一关回答内核里最基本的问题:"进程"和"线程"到底是什么。答案比直觉更激进:在 Linux 内核里,它们是同一种东西——都是 task_struct。用户态看到的"进程 vs 线程"之别,只是 clone() 时传了不同的 flags 组合。"线程"的全部定义,就是 CLONE_THREAD 让新 task 的 tgid 继承组长。

这一关的三个机制支点:copy_process 的 flag 旋钮(创建)、COW(fork 为什么快)、execve 的替换语义(地址空间的整体换血)。M02 的 init/kthreadd、M03 的 slab 分配 task_struct、M04 的 COW 全链条,都是从这里长出去的。

核心机制深讲

1.1 task_struct:统一表示,flags 定身份

内核的调度实体只有一种:struct task_struct(include/linux/sched.h)。每个 task 有两个号:pid(自身)和 tgid(线程组组长)。用户态的 getpid() 返回 tgid,gettid() 返回 pid——单线程进程里两者相等,多线程进程里组内每个线程 pid 不同、tgid 相同。

创建新 task 的唯一入口是 clone() 系统调用(fork()/vfork()/pthread_create() 都是它的包装)。copy_process() 里按 flags 决定"复制还是共享":

fork() ≈ clone(SIGCHLD)(几乎全复制),pthread_create() ≈ clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|...)(几乎全共享)。没有第三种生物。

1.2 fork:快是因为几乎不复制

fork() 的返回值语义是经典 trick:父进程拿到子进程的 pid,子进程拿到 0——一次调用、两个返回。机制在 copy_thread():它把子进程内核栈上保存的 rax 置 0,子进程从 ret_from_fork 返回用户态时,rax 就是系统调用的返回值。

真正的创建逻辑在 copy_process():dup_task_struct(复制 task_struct 本体)→ copy_files/copy_fs/copy_sighand(按 flags 复制或共享)→ copy_mm(地址空间)→ copy_thread(寄存器现场)。

copy_mm 是 fork 代价的核心。COW(写时复制)下它只做两件事:copy_page_range 逐项复制页表、把父子双方的 PTE 都标成只读(清 RW,靠 vma 语义记为 COW)。物理页一页都不碰——fork 的代价从 O(地址空间) 降到了 O(页表大小)。这也是 vfork() 被边缘化的原因:vfork 当年是为了"fork 太贵"发明的拐杖,COW 让 fork 本身够便宜了——vfork 系统调用至今还在(kernel/fork.c 仍有 SYSCALL_DEFINE0(vfork)),但直接用它的人已经很少,拐杖名存实亡。

注意这个代价模型的另一面:如果 fork 之后立刻 execve(shell 起进程的经典路径),旧地址空间直接被丢弃——连页表复制的那部分工作都白做了。这是 posix_spawn 存在的理由(M01 Q6 的优化项之一)。

1.3 execve:替换,不是新建

execve() 不创建新进程。它做的是:de_thread() 先终止同组其他线程(只剩调用线程存活),然后 begin_new_exec() 扔掉旧的 mm_struct、建新的、载入新程序。铁证是 tgid 不变:exec 前后 getpid() 返回同一个值——如果这是个"新进程",pid 应该变。此前的 pthread_t 全部失效,因为线程组已经被清空了。

"替换而非新建"的设计理由:shell 的工作模式就是 fork+exec 循环——fork 负责"复制执行上下文",exec 负责"换掉地址空间里的内容"。两个系统调用各做一半,组合起来才是"启动一个新程序"。

1.4 内核线程:借来的 mm 与 lazy TLB

内核线程(kthreadd 的子孙们)没有用户地址空间:mm == NULL。调度到它时,内核让它借用上一个被调度进程的 mm(active_mm)。这不是临时方案,是刻意设计:切换到内核线程时如果连页表一起换,x86 就要重载 CR3 + 刷新 TLB——而内核线程根本不用用户地址空间,这次切换纯属浪费。借 prev 的 mm 就是"别切换"的 lazy TLB。

但借来的 mm 有一条硬边界:拿它翻译用户指针是 bug。那个 mm 属于一个不相关的用户进程,用它访问用户内存地址,翻译出来的是别人的东西。真要访问用户内存,走 use_mm() / kthread_use_mm() 显式绑定目标进程的 mm_struct——"借来跑内核代码"和"绑定目标地址空间"是两回事。

1.5 死亡的两种形态:D 状态与僵尸

ps 里两种"不动"的状态,完全是两码事:

父先死则是另一种结局:子进程变成孤儿,被 init(PID 1)收养——收养的是"还在跑的孤儿",和等收尸的僵尸不是一回事。

为什么这样设计

为什么进程和线程共用一个 task_struct,而不是两套结构?

因为调度器只认 task。统一表示让线程天生可以分布到多核上并行跑,schedule() 不需要知道"这是进程还是线程"。代价是每个线程都要背 KB 级的 task_struct + 16KB 内核栈(x86-64 默认 THREAD_SIZE_ORDER=2)——十万线程光内核栈就约 1.6GB。所以十万空闲连接的场景不用 1:1 线程模型,上 epoll/协程:统一 task_struct 是"正确且贵"的选择,贵到一定规模就必须换模型。

为什么 fork 用 COW,而不是创建时直接复制地址空间?

因为"创建时复制"是 O(地址空间),而 fork+exec 路径下复制的东西下一秒就被扔掉。COW 把代价拆成两部分:fork 当时只付 O(页表大小) 的页表复制,真正的页面复制按需发生(第一次写时)。"当时不做、按需再做"的懒惰,是整个 fork 代价模型的精髓——M04 会把这条链走完(PTE 只读 → #PF → do_wp_page)。

为什么僵尸状态不直接释放 task_struct?

因为退出值是父子进程间的同步契约。wait() 既是"收尸"也是"取退出码"——父进程靠它知道子进程怎么死的。没有僵尸这个中间态,父进程就永远拿不到这个信息。代价是父进程不 wait() 就会堆积僵尸——这是 API 设计,不是内核 bug。

与已学模块的关联

本关是地基,后续关卡都在它上面展开:

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. fork() 一次调用、两个返回:父进程和子进程各拿到什么返回值?子进程返回 0 的机制是什么?

父进程拿到子进程的 pid,子进程拿到 0。机制在 copy_thread():它把子进程内核栈上保存的 rax 置 0——子进程从 ret_from_fork 返回用户态时,rax 正是系统调用的返回值。"两个返回"不是两次执行 fork,而是一次创建、两个执行流各自返回。

Q2. 为什么 fork() 很快?vfork() 为什么被边缘化了?

因为 COW 下 fork 几乎不复制:copy_mm → copy_page_range 只复制页表、标只读+COW,物理页一页不碰。代价从 O(地址空间) 降到 O(页表大小)。vfork() 当年是为"fork 太贵"发明的拐杖(子进程借用父进程地址空间、父进程挂起等 exec/_exit);COW 让 fork 本身够便宜了,拐杖失去存在理由——但注意它并未被删除,SYSCALL_DEFINE0(vfork) 至今还在 kernel/fork.c 里,只是直接用它的人已经很少。

Q3. 内核线程的 mm 是 NULL,调度到它时内核怎么做?为什么这是刻意设计而不是临时方案?借来的 mm 能拿来访问用户内存吗?

内核让它借用上一个被调度进程的 mm(active_mm)。刻意设计(lazy TLB):内核线程不用用户地址空间,如果还切换页表,x86 就要白白重载 CR3 + 刷新 TLB——"别切换"省的就是这次浪费。借来的 mm 不能拿来翻译用户指针——那是某个不相关用户进程的地址空间,用它访问用户内存是 bug。真要访问用户内存,走 use_mm() / kthread_use_mm() 显式绑定目标进程的 mm_struct。

Q4. pthread_create() 和 fork() 在内核里是两种创建路径吗?CLONE_THREAD 的作用是什么?

不是两种路径——底层都是 clone() 系统调用、都走 copy_process(),区别只是 flags 组合。fork() ≈ clone(SIGCHLD)(几乎全复制);pthread_create() ≈ clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|…)(几乎全共享)。CLONE_THREAD 让新 task 的 tgid 继承组长——"线程"在内核里的全部定义就是这一行。

Q5. D 状态和僵尸状态有什么区别?SIGKILL 能杀死 D 状态的进程吗?

完全是两码事。D 状态(TASK_UNINTERRUPTIBLE)是活着的进程在内核临界区等事件(如磁盘 I/O);僵尸(EXIT_ZOMBIE)是已死的进程,task_struct 留着等父进程 wait() 收尸。SIGKILL 不能立即杀死 D 状态进程——信号只能挂起,因为打断临界区可能破坏内核数据结构,要等它自己从内核出来。僵尸已经死了,不存在"杀"的问题,只存在"收"的问题。

Q6. 一个 50GB 地址空间的进程 fork,主要代价在哪里?给出数量级,并列出至少两种优化思路。

代价在 copy_mm 逐项复制页表项——50GB / 4KB ≈ 1300 万个 PTE,每个都要复制并清 RW 标 COW。注意物理页根本没复制(COW),瓶颈是页表项的处理量。优化思路:(1) posix_spawn()——vfork 语义,跳过页表复制(fork+exec 路径);(2) huge page(2MB 页让 PTE 数量降两个数量级);(3) 预 fork worker 池 / 线程池——架构上避开"大地址空间 + 频繁 fork"的组合;(4) 把大 mmap 和频繁 fork 拆到不同进程。

Q7. execve() 会创建新进程吗?de_thread() 做了什么?"tgid 不变"说明了什么?

不会。execve() 是替换:de_thread() 先终止同组其他线程(只剩调用线程存活),然后扔掉旧 mm_struct、建新的、载入新程序。tgid 不变是"替换而非新建"的铁证——如果这是个新进程,getpid() 应该返回新值。此前的 pthread_t 全部失效,因为线程组已经被清空了。

Q8. 同一进程的两个线程,task_struct 里哪三处是共享的?getpid() 和 gettid() 各返回哪个号?

共享的三处:tgid(线程组 ID)、mm(地址空间 mm_struct)、sighand(信号处理表)——对应 CLONE_THREAD / CLONE_VM / CLONE_SIGHAND。getpid() 返回 tgid,gettid() 返回 pid。单线程进程两者相等;多线程进程组内每个线程 pid 不同、tgid 相同。

Q9. 僵尸进程存在的理由是什么?子先死和父先死,分别产生什么状态、由谁处理?

僵尸存在的理由:让父进程有机会通过 wait() 获取子进程的退出值——task_struct 暂存退出信息,wait() 既是收尸也是取退出码。子先死 → 子变僵尸,等父 wait();父先死 → 子变孤儿,被 init(PID 1)收养。注意别搞反:"被 init 收养"的是还活着的孤儿,不是等收尸的僵尸。

Q10. 设计题:十万个 mostly-idle 的长连接,方案 (a) 每个连接一个线程(1:1),方案 (b) epoll + 少量线程。从统一 task_struct 的 trade-off 出发论证选型。

选 (b)。统一 task_struct 的好处:schedule() 只认 task,线程天生可分布多核并行——这是"正确"的部分。代价:每线程 KB 级 task_struct + 8KB 内核栈,十万线程光内核栈就约 800MB,还没算调度器扫描 runqueue 的开销和上下文切换。大多数连接 idle 时,这些线程除了占内存什么都不干——为"偶尔有事做"付出常驻 800MB,是亏的。(b) 用少量线程 + epoll 管理十万连接,把"等待"从"线程阻塞"变成"事件就绪",内存和调度代价都降一到两个数量级。统一 task_struct 是正确且贵的选择,贵到一定规模就必须换模型——这正是 C10K 问题的内核视角。

延伸阅读

clone(flags)
  └─ copy_process():唯一的创建入口
       ├─ fork()           ≈ clone(SIGCHLD)
       │    └─ 几乎全复制:不共享 mm / files / fs / sighand
       └─ pthread_create() ≈ clone(CLONE_VM|CLONE_FS|CLONE_FILES
       │                           |CLONE_SIGHAND|CLONE_THREAD|…)
            └─ 几乎全共享
            └─ CLONE_THREAD → 新 task 的 tgid 继承组长
               = "线程"在内核里的全部定义

看懂这张图就抓住了第一关的一半:fork 和 pthread_create 走的是同一条
copy_process 路径,区别只在 flags。"线程"的全部定义就是 CLONE_THREAD
让 tgid 继承组长 —— 内核里没有第二种生物。

fork() → copy_mm → copy_page_range(只动页表,不动物理页)

父进程页表                子进程页表
PTE: RW ──复制──▶ PTE: RO    PTE: RW ──复制──▶ PTE: RO
  │                               │
  └────── 指向同一物理页(共享) ──┘

代价:O(页表大小),物理页零复制
第一次写 → #PF → do_wp_page 分配+复制 → 恢复 RW(M04 展开)

fork 快的全部秘密:copy_page_range 逐项复制 PTE 并标只读,
物理页一页不碰。代价从 O(地址空间) 降到 O(页表大小) ——
Q6 的 1300 万 PTE 就是这么来的。真正的页面复制推迟到第一次写(M04)。

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。