LINUX KERNEL · 入门图解
从 fork 到 copy_process,拆解一个新任务的诞生过程
在终端中启动一个程序,看起来只是敲下一条命令。到了内核里,却需要准备任务对象、地址空间、文件描述符和执行现场,最后让它参与 CPU 调度。
上一张图回答了"进程里有什么"。一图读懂 Linux 进程
这一次,我们沿着 fork() 的创建路径,看看这些资源是怎样组织起来的。
本文阅读路线
创建全景 → 统一入口 → copy_process → 资源复制与共享 → 写时复制 → 调度与返回 → 线程对照 → 动手验证
本文根据项目《linux内核知识学习.md》中"进程是如何创建的?"整理,以 Linux 6.1 为源码参照,主要讨论有 MMU 的系统。函数调用与步骤按讲解需要简化,具体入口会随架构、内核和 libc 版本变化。
01 · 一张图看懂进程创建

图:上半部对比进程与线程的创建路径,下半部展示普通 fork 后的父子资源关系。
读图时先从上往下看:进程与线程通过不同的创建参数,进入相同的内核任务创建机制。再看下半部:普通 fork 后,父子各有任务对象和资源视图,部分底层对象仍然共享。
先记住主线: 进入内核 → 准备新任务 → 建立资源和身份 → 加入运行队列 → 父子分别继续执行。
02 · fork、clone:走向同一个创建入口
fork() 的语义是创建一个子进程,让它继承调用者的大量运行状态。clone() 和 clone3() 则提供更细的资源共享控制。
在 Linux 6.1 中,这些内核入口汇入 kernel_clone(),再由 copy_process() 完成新任务的准备。较早版本常见 do_fork() 或 _do_fork() 等名字,阅读源码时要先确认版本。
/* 概念调用链,省略架构相关入口与参数 */
fork / clone / clone3 系统调用
→ kernel_clone()
→ copy_process()
→ wake_up_new_task()
用户程序调用的 fork() 通常是 libc 提供的封装。glibc 可以使用具有 fork 语义的 clone 系统调用,因此抓取系统调用时,看到 clone 并不意味着程序创建的是线程。
源码参照:Linux 6.1 · kernel/fork.c;接口与 libc 差异见 fork(2)。
03 · copy_process:准备一个能运行的新任务
Linux 用 task_struct 描述任务。创建子进程,首先要有新的任务对象,但复制一个结构体只是起点:里面许多指针和状态,还需要根据创建方式重新处理。
| 准备内容 |
关键函数或对象 |
作用 |
| 任务骨架 |
dup_task_struct() |
准备新的任务对象和内核栈 |
| 调度属性 |
sched_fork() |
初始化新任务的调度状态 |
| 资源关系 |
copy_mm()、copy_files() 等 |
按标志复制或引用已有资源 |
| 执行现场 |
copy_thread() |
准备架构相关的寄存器与启动上下文 |
| 身份与关系 |
PID、TGID、父任务、线程组 |
让新任务有身份,并接入任务关系 |
current 指向当前执行的任务,也是创建时继承状态的重要来源。普通 fork 创建的新任务成为自己的线程组组长,内核中的 pid 与 tgid 相等。
创建过程中还会检查参数、权限和资源限制。某一步失败,内核会释放已经准备的资源,向调用者返回错误;图里展示的是成功路径。
04 · copy 的关键:复制,还是共享?
copy_* 这个名字容易让人以为所有资源都会完整复制。实际行为由 clone 标志决定:有些资源创建新对象,有些资源增加引用后直接共享。
| 资源 |
普通 fork |
相关共享标志 |
地址空间 mm_struct |
新地址空间;私有可写页采用 COW |
CLONE_VM |
目录上下文 fs_struct |
复制根目录、工作目录等上下文 |
CLONE_FS |
文件描述符表 files_struct |
新 fd 表,表项引用原打开文件对象 |
CLONE_FILES |
| 信号处理动作表 |
复制处理动作 |
CLONE_SIGHAND |
| 命名空间 |
默认继承现有命名空间 |
CLONE_NEW* 请求新建对应命名空间 |
fd 表独立,不代表文件对象独立。 父子进程对应的 fd 通常指向同一个打开文件描述,因此共享文件偏移量和文件状态标志。子进程关闭自己的 fd,不会直接关闭父进程 fd 表中的对应项。
创建进程也不等于创建容器。 普通 fork 不会自动建立新的 PID、网络或挂载命名空间;隔离需要额外机制和创建参数。
标志语义见 clone(2)。
05 · 内存复制:先独立视图,再按需分离
普通 fork 没有设置 CLONE_VM。在 copy_mm() 中,它会走向 dup_mm(),准备新的 mm_struct 和相应的内存映射、页表结构。
/* copy_mm 的逻辑示意,不是完整源码 */
if (flags & CLONE_VM)
引用并共享 current->mm;
else
通过 dup_mm() 准备新的地址空间;
这不意味着立即把所有物理内存复制一遍。对于常见的私有可写映射,父子可以暂时指向同一物理页,相关页表项受到写保护。
当其中一方尝试写入时,会触发写保护缺页异常。内核在需要时分配新页、复制内容,并更新写入方的映射。这就是写时复制(Copy-on-Write,COW)。
fork 后,尚未写入:
父地址空间 ──→ 物理页 A ←── 子地址空间
子进程写入,需要分离时:
父地址空间 ──→ 物理页 A
子地址空间 ──→ 物理页 B(复制后修改)
独立地址空间与暂时共享物理页,可以同时成立。 对普通私有内存,子进程后续修改变量不会改变父进程中的变量。
这里讨论私有映射。MAP_SHARED 等共享映射仍遵循共享语义;COW 也不意味着 fork 没有任务对象、页表和映射管理的开销。
06 · wake_up_new_task:准备好,才能参与调度
copy_process() 返回时,新任务已经准备好,但尚需进入可调度的队列。kernel_clone() 随后调用 wake_up_new_task(),将它变为可运行任务,并加入选定 CPU 的运行队列。
可运行,不等于已经占用 CPU。 只有被调度器选中,新任务才真正获得执行机会。父进程和子进程先运行谁没有固定保证,不能用"通常打印顺序"替代同步。
首次调度时,子任务沿着架构相关的返回路径回到用户空间。普通 fork 的子进程从 fork 返回处继续执行,并不会重新从 main() 开始。
07 · 一次调用,为什么会有两个返回值?
fork 成功后,多了一个可以独立执行的任务。内核为它准备执行上下文时,安排了不同的返回结果;父子随后执行同一份程序里的分支代码。
| 执行者 |
fork 返回值 |
常见用途 |
| 父进程 |
子进程的 PID,大于 0 |
记录子任务、等待退出 |
| 子进程 |
0 |
执行子任务逻辑或加载新程序 |
| 创建失败时的调用者 |
-1,并设置 errno |
处理资源不足或数量限制等错误 |
若要让子进程执行另一个程序,常见方式是随后调用 execve():它替换当前进程的程序映像,成功后不会返回原来的调用位置。普通单线程子进程的 PID 保持不变。
创建与加载分两步理解: fork 创建执行实例;execve 为已有进程换上新的程序映像。
加载行为见 execve(2)。Shell 的实际启动方式可能使用其他接口,本文采用 fork + exec 的典型模型。
08 · 进程与线程:内核里都是 task_struct
从应用程序的视角看,进程提供运行程序的资源环境,线程是其中的一条执行路径。一个进程可以只有一个线程,也可以有多个线程。
线程在内核中也是一个 task_struct。 每个线程都有自己的任务对象,包括通常所说的"主线程"。Linux 将这些执行实体统一抽象为任务(task),调度时分别考虑各个可运行任务。
因此,一个有三个线程的普通进程,对应三个 task_struct,而不是一个任务对象里面装着三条执行路径。这三个任务通过线程组关系组织在一起,并共享地址空间等资源。
同样是任务,区别在哪里?
| 对比项 |
普通 fork 创建的父子进程 |
同一进程中的 POSIX 线程 |
| 内核表示 |
各有独立的 task_struct |
每个线程也各有独立的 task_struct |
| 地址空间 |
不同的 mm_struct;私有可写页可暂时通过 COW 共享 |
共享同一个 mm_struct,能访问相同的全局变量、堆和映射 |
| 任务身份 |
属于不同线程组;普通单线程父子进程各自 pid = tgid |
各有不同的内核任务 ID(TID),共享同一 TGID |
| 线程组组长 |
普通 fork 子任务是新线程组的组长 |
group_leader 指向同一个线程组组长 |
| 文件与目录上下文 |
fd 表、目录上下文独立;继承的 fd 仍可共享打开文件对象 |
通常共享 fd 表及工作目录等上下文 |
| 执行现场与栈 |
各有寄存器上下文和内核栈;用户栈位于各自地址空间 |
各有寄存器上下文和内核栈;用户栈区域各自使用,但位于共享地址空间 |
| 修改普通变量 |
修改私有内存中的变量通常不影响另一方 |
对共享变量的修改可被其他线程观察,并发访问需要适当同步 |
这里对比普通 fork 与 glibc NPTL 的 POSIX 线程。clone 可以组合不同共享标志,资源共享方式不能仅凭"进程"或"线程"的名称判断。
PID、TID、TGID 怎么对应?
/* 同一个进程的三个线程,ID 为示意值 */
主线程:task_struct.pid = 1000,tgid = 1000
线程 A:task_struct.pid = 1001,tgid = 1000
线程 B:task_struct.pid = 1002,tgid = 1000
各线程 getpid() → 1000(用户态进程 ID)
各线程 gettid() → 自己的任务 ID
内核 task_struct.pid 标识具体任务;用户态通常说的进程 PID 则对应线程组 ID。理解这个区别,就不会把"每个线程都有自己的任务 ID"误认为"每个线程都是一个独立的用户态进程"。示意值按同一 PID 命名空间理解。
pthread_create 如何建立这些共享关系?
pthread_create() 是线程库接口。glibc 负责准备用户栈、线程本地存储等,再通过 clone 系列系统调用创建内核任务;具体使用 clone 还是 clone3 取决于实现和运行环境。
/* glibc NPTL 常见标志组合的讲解示意 */
CLONE_VM | CLONE_FS | CLONE_FILES |
CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM |
CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID
CLONE_VM 等标志让新任务共享资源;CLONE_THREAD 让它加入调用者的线程组。因此,同一进程中的线程有各自的内核任务 ID,通常共享 TGID,用户态 getpid() 返回共同的进程 ID。
线程有各自的执行上下文、内核栈,并使用各自的用户栈区域。用户栈仍位于共享地址空间中,不是对其他线程不可见的独立内存。
信号处理动作与信号掩码要分开: CLONE_SIGHAND 共享信号处理动作表;阻塞信号掩码仍是每个线程独立维护的状态。
标志组合参照 glibc 2.36 · pthread_create.c;线程共享与独立属性见 pthreads(7),任务 ID 见 gettid(2)。
09 · 动手验证:子进程改变量,父进程会变吗?
把下面这个单线程示例保存为 fork_demo.c。子进程修改局部变量,父进程用 waitpid() 等待它结束,再打印自己的变量。
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int value = 10;
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
value = 20;
printf("child: value=%d, pid=%ld, ppid=%ld\n",
value, (long)getpid(), (long)getppid());
fflush(stdout);
_exit(0);
}
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
return 1;
}
printf("parent: value=%d, child_pid=%ld\n",
value, (long)pid);
return 0;
}
gcc -Wall -Wextra -std=c11 fork_demo.c -o fork_demo
./fork_demo
输出类似下面这样,PID 以实际运行结果为准:
child: value=20, pid=1235, ppid=1234
parent: value=10, child_pid=1235
两份变量一开始都是 10,子进程改成 20 后,父进程仍然读到 10。这验证了私有内存的独立修改语义;它不能单独证明物理页何时发生复制。示例中的打印顺序由等待操作保证。
多线程程序调用 fork 后,子进程只保留调用 fork 的那一个线程;在 exec 之前,可安全调用的函数受到异步信号安全规则限制。上面的示例限定为单线程程序。
10 · 创建过程观察速查表
| 想观察什么 |
命令 |
| 进程创建与退出系统调用 |
strace -f -e trace=process ./fork_demo |
| PID、父 PID 与状态 |
ps -eo pid,ppid,stat,comm |
| 进程树 |
pstree -p |
| 线程身份 |
ps -eLo pid,tid,comm |
| 地址空间与 fd |
cat /proc/PID/maps、ls -l /proc/PID/fd |
strace 和 pstree 需已安装。/proc 命令中的 PID 要替换为仍在运行且有权限观察的进程;这个示例退出很快。strace 能观察系统调用,不能直接展示 copy_process 等内核函数。
写在最后
沿着这条创建链路看,进程的诞生可以归纳为三件事:准备任务、组织资源、参与调度。
kernel_clone 组织创建;copy_process 准备任务及资源;wake_up_new_task 让任务进入运行队列。普通 fork 建立独立地址空间,并对私有可写页采用写时复制;线程同样各有一个 task_struct,通过共享标志复用资源,再加入同一个线程组。
再读内核里的 copy_*,可以带着一个问题往下看:这里创建了新对象,还是增加了对原对象的引用?理解这个选择,就能把进程、线程和资源继承串起来。
LINUX KERNEL · 从一张图开始理解内核
源码参照 Linux 6.1 / glibc 2.36
图与调用链为讲解示意,实际以目标版本源码为准。