前言
在面试中,fork() 的原理、写时拷贝(COW)、僵尸进程、ptrace 调试、进程隔离等话题几乎必考。
本文从内核源码视角出发,把进程的创建、内存管理、文件系统、回收机制这几个模块串成一条线。读完你会彻底理解:进程到底是一个怎样的内核对象,线程和进程在内核眼里差在哪,以及 Docker 容器为什么比虚拟机轻量这么多。
一、进程的内核骨架:task_struct
在 Linux 内核中,每一个进程都由一个结构体struct task_struct来描述,它包含了进程的全部上下文信息。
可以把 struct task_struct看成一个PCB。
arduino
struct task_struct {
pid_t pid; // 进程ID
pid_t tgid; // 线程组ID(主线程PID)
struct mm_struct *mm; // 内存描述符(页表、代码段/数据段/堆栈边界)
struct files_struct *files; // 打开的文件描述符表
struct task_struct *parent; // 父进程指针
struct list_head children; // 子进程链表
int state; // 进程状态(运行/睡眠/僵尸/追踪等)
// ... 还有调度相关、时间统计、等等近百个字段
};
这里有个关键点:线程在内核里也是 task_struct,只不过它与同一进程内的其他线程共享 mm_struct、files_struct 等资源。换句话说,线程就是"轻量级的进程"------这个"轻量"主要体现在不复制页表 和不复制文件描述符表上,我们后文会详细展开。
其中比较重要的是
arduino
struct mm_struct {
// ===== 1. 页表根基 =====
pgd_t *pgd; // 指向顶级页表(PGD),CPU通过它做地址转换
// ===== 2. 内存区域(VMA)管理 =====
struct vm_area_struct *mmap; // VMA 链表头
struct rb_root mm_rb; // VMA 红黑树
struct vm_area_struct *mmap_cache; // 最近使用的VMA(加速局部性访问)
// ===== 3. 地址空间边界(划定各区域) =====
unsigned long task_size; // 用户态地址空间最大尺寸
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆
unsigned long start_stack; // 栈
...
};
二、fork() 的核心机制:先复制,后改造
当进程调用 fork() 时,它的流程可以分为两大步。
2.1 第一步:逐字节复制 task_struct
内核调用 dup_task_struct(),它的核心就是一行:
ini
*dst = *src; // 逐字节拷贝整个 task_struct
此时,子进程的 task_struct 是父进程的完全镜像 ------它的 mm 指针指向父进程的 mm_struct,files 指针指向父进程的 files_struct,parent 指针也指向父进程自己。
2.2 第二步:洗白"绝不能共享"的字段
逐字节复制之后,内核会立刻把子进程的以下字段重置或重新分配:
| 字段 | 处理方式 |
|---|---|
pid / tgid |
分配新的唯一 ID |
parent |
指向当前父进程(current) |
state |
强制设为 TASK_RUNNING |
pending 信号队列 |
清空,不继承父进程的未决信号 |
| 调度相关链表节点 | 重新初始化,从父进程的调度队列中摘除 |
| 时间统计字段 | 归零(start_time、utime、stime 等) |
关键点来了 :在刚复制完的那一刻,子进程的 mm 和 files 还指向父进程的同一个对象。但紧接着,fork() 内部会根据传参标志决定怎么处理这两个指针:
- 对于
fork()(不带任何共享标志):内核会调用copy_mm()创建新的mm_struct并复制页表(写时拷贝),调用copy_files()时不复制files_struct,而是让父子共享同一个文件表(仅引用计数f_count+1)。 - 对于
clone()创建线程 :内核直接共享 父进程的mm和files,不做任何复制,只把引用计数 +1。
创建进程和创建线程,底层都调用了同一个系统调用------
clone(),区别在于传入的标志位(flags) 不同:
| 创建方式 | 底层调用 | 关键标志 | 资源关系 | |||||
|---|---|---|---|---|---|---|---|---|
fork() |
clone(SIGCHLD, 0) |
不共享任何资源 | 父子进程资源独立(写时复制) | |||||
vfork() |
`clone(CLONE_VFORK | CLONE_VM | SIGCHLD, ...)` | 共享内存,子进程先运行 | 主要用于exec()前的临时进程 |
|||
pthread_create() |
`clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | ...)` | 共享一切 | 线程间共享地址空间、文件表、信号处理器 |
这解释了为什么线程创建比进程创建快得多------线程不需要复制页表(即不复制 mm_struct),也不需要复制文件描述符表。
三、写时拷贝(COW):fork 高效的核心秘密
如果 fork() 每次都要把父进程的全部物理内存复制一份,那么创建进程的代价将极其高昂。实际上,Linux 采用了写时拷贝(Copy-on-Write, COW) 技术,让 fork() 几乎瞬间返回。
3.1 COW 的三步
- 复制页表,不复制物理页 :
fork()时,内核会为子进程创建全新的顶级页表(PGD 页),并把父进程页表中的所有表项(PTE) 拷贝一份。此时父子进程的虚拟地址映射到同一批物理内存页。 - 清空写权限 :内核把父子进程页表中所有可写的页表项临时改为只读(Read-Only) ,并在物理页框的描述符中把引用计数从 1 增加到 2。
- 写时复制 :当父子任一方向某块内存发起写入时,CPU 的 MMU 会因为页表只读(写保护错误)而触发缺页中断(Page Fault) 。中断处理程序检测到该页被标记为 COW 后,会真正分配一个新的物理页,复制原页内容,然后重新建立可写的页表映射。

3.2 面试高频追问:fork + exec 组合的效率
面试官常问: "既然 fork 复制了页表,如果 fork 后立即 exec 加载新程序,之前的复制工作岂不是白费了?"
答:exec() 会丢弃整个旧的 mm_struct,重新建立新的地址空间。由于 COW 的存在,fork() 时只复制了页表项(PTE),并没有复制任何物理内存页 。而 exec() 发生时,子进程还没来得及写入任何内存,所以完全没有触发物理页的复制 ------之前复制的那些页表项,连同整个旧 mm_struct,直接被释放了。
四、页表的核心地位:从虚拟地址到物理内存
你已经知道,每个进程的 mm_struct 里存着顶级页表(PGD)的指针。但为了彻底理解进程的内存隔离,我们需要明确页表本身存储在哪里。
4.1 页表的物理位置
mm_struct中存储的是指针 :字段pgd_t *pgd存放的是 PGD 页的内核虚拟地址。- 真正的页表存储在物理内存中:PGD、PUD、PMD、PTE 这些表项全部存放在物理内存页里,由内核的 Buddy 系统分配。
- CPU 如何找到它? 进程切换时,内核会把
mm_struct->pgd对应的物理地址 加载到 CPU 的CR3寄存器中。此后,CPU 的 MMU 就从 CR3 指向的物理地址开始,逐级遍历页表。
4.2 页表在 fork 时的行为
当父进程调用 fork() 创建子进程时:
- 内核为子进程分配一个全新的物理页作为其 PGD 页。
- 把父进程 PGD 页中的全部表项拷贝到子进程的新 PGD 页中。
- 子进程的
mm_struct->pgd指向这个新物理页(地址与父进程不同)。 - 所有二级页表(PUD/PMD/PTE)暂不复制,父子共享------通过 COW 机制按需复制。
这正好回应了"复制了整个页表"这个说法------准确地说是复制了顶级页表的表项数组,而整棵页表树的深层节点是在发生写操作时才逐步复制。
五、文件描述符:真正共享的是什么?
很多开发者知道"fork 后父子进程共享文件描述符",但能说清楚"共享的到底是什么"的人并不多。
5.1 三层结构
文件描述符在内核中涉及三个层次:
| 层次 | 内核结构 | 存储位置 | 说明 |
|---|---|---|---|
| 用户态看到的整数 | int fd |
用户程序的栈或局部变量 | 只是个"门牌号" |
| 进程级描述符表 | files_struct->fdtable |
内核空间(随 task_struct) |
指针数组,下标就是 fd |
| 内核级文件对象 | struct file |
内核 Slab 缓存 | 包含 f_pos(偏移量)、f_mode、f_flags、f_inode 等 |
结构如图:

5.2 fork 时到底复制了什么?
fork() 调用 copy_files() 时:
- 复制了
fdtable(指针数组) :子进程获得一张独立的指针数组,数组大小和父进程一样。 - 共享的是
struct file对象 :子进程数组里填的地址值和父进程一模一样,都指向内核中同一个struct file实体。内核只把这个实体的引用计数f_count+1。 - 不复制
struct file本身 :父子进程操作同一个struct file,因此共用里面的f_pos(文件偏移量)。
重要结论 :f_pos 不属于磁盘上的文件(inode),而属于"打开文件实例"(struct file)。同一个文件被打开两次(两次 open 调用),会得到两个独立的 struct file 和两个独立的 f_pos。而 fork() 复制的 fd,指向的是同一个 struct file,所以共用偏移量。
也就是:

可见,父子进程调用write系统调用时,操作的是同一个f_pos。
5.3 并发写入为什么可能覆盖?
父子进程同时用继承来的 fd 写同一个文件,数据可能发生覆盖:
write() 系统调用内部包含"读取 f_pos → 写入数据 → 更新 f_pos"三个子步骤,它们不是原子操作。
写入数据量较小(小于 4KB)时:操作系统会将该过程加锁。没有问题。
写入数据量较大时:父进程读到 f_pos=0 后、尚未更新 f_pos 之前被调度切换,子进程也读到 f_pos=0,两个进程就会从同一个位置写入,后写的覆盖先写的。这是典型的"丢失更新"问题。
如何解决: O_APPEND 标志的作用,就是把"读取末尾位置"和"写入数据"绑成一个不可分割的原子操作,从根本上避免覆盖。
六、进程生命周期:僵尸与孤儿
子进程退出后,父进程必须调用 wait() 来回收它的残留状态,否则子进程会变成僵尸。理解这个机制需要深入内核的回收逻辑。
6.1 僵尸进程(Zombie)的本质
子进程调用 exit() 时,内核做了这样几件事:
- 释放所有资源 :物理内存、文件对象(
struct file引用计数减一)、信号队列等全部释放。 - 保留
task_struct:内核把退出码(exit_code)写入这个残存的task_struct,并把进程状态标记为EXIT_ZOMBIE。 - 通知父进程 :通过
wake_up_parent()唤醒父进程(如果父进程正阻塞在wait()上),并发送SIGCHLD信号。
为什么必须保留 task_struct ? 因为父进程将来可能调用 wait() 或 waitpid() 来读取子进程的退出码。读完退出码之后,内核才会把那个残留的 task_struct 彻底删除。
如果父进程一直不调用 wait(),这个 task_struct 就会永远驻留在内核内存中。单个僵尸只占 1~2KB,但若长期运行的服务频繁 fork 却从不回收,僵尸堆积最终将耗尽内核内存,导致 fork() 返回 EAGAIN。
6.2 孤儿进程(Orphan)的处理
如果父进程先于 子进程退出,子进程就成了孤儿。内核的处理机制非常简单:在父进程退出时,内核会遍历它的所有子进程,把这些子进程的 parent 指针重新指向 PID=1 的 init 进程 (或当前命名空间的 subreaper)。init 进程会循环调用 wait(),自动替所有孤儿"收尸",因此孤儿不会变成僵尸。
6.3 工程级最佳实践
在工业级网络服务(如 Nginx、Redis)中,标准的 SIGCHLD 处理方法是:
arduino
void sigchld_handler(int signo) {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 处理退出码 status
if (WIFEXITED(status)) {
// 正常退出,可以记录日志
}
}
}
为什么必须用 while 循环? 因为 Linux 信号不支持排队。如果多个子进程同时退出,内核可能只发送一个 SIGCHLD 信号。如果处理函数里只调用一次 wait(),剩余子进程就会变成僵尸。用 while + WNOHANG 可以一次性收割所有已退出的子进程。
如果父进程完全不关心子进程退出码,还有更高效的方式:在 fork() 之前调用 signal(SIGCHLD, SIG_IGN),内核会检测到这一行为,子进程退出时直接释放 task_struct,根本不给父进程留"收尸"的机会。
七、总结:一张图串起全局
| 概念 | 内核实现 | 关键点 |
|---|---|---|
| 进程 | task_struct + 独立 mm_struct |
每个进程有独立的页表和内存空间 |
| 线程 | task_struct + 共享 mm_struct |
线程切换不切换页表,TLB 不失效 |
| fork | 复制 task_struct,共享 struct file,COW 复制页表 |
不复制物理内存 |
| exec | 丢弃旧 mm_struct,加载新程序 |
配合 COW 不浪费物理页复制 |
| 文件偏移量 f_pos | 存储在 struct file 中 |
一个 open 一个 struct file,fork 共享同一个 |
| 僵尸进程 | 已退出但父进程未 wait,task_struct 残留 |
无法用 kill 杀掉,只能等父进程 wait 或杀死父进程 |
| 孤儿进程 | 父先退出,子过继给 init |
init 自动回收,不会变僵尸 |
| ptrace | task_struct 加标记,wait 捕获信号 |
strace 慢,因为每次 syscall 都上下文切换 |
| 进程隔离 | mm_struct + files_struct + cred + nsproxy |
四层隔离:内存、资源、权限、视图 |
还没写完,写累了,,,,