【进程管理】Linux下的进程管理

前言

在面试中,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_structfiles_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_structfiles 指针指向父进程的 files_structparent 指针也指向父进程自己。

2.2 第二步:洗白"绝不能共享"的字段

逐字节复制之后,内核会立刻把子进程的以下字段重置或重新分配

字段 处理方式
pid / tgid 分配新的唯一 ID
parent 指向当前父进程(current
state 强制设为 TASK_RUNNING
pending 信号队列 清空,不继承父进程的未决信号
调度相关链表节点 重新初始化,从父进程的调度队列中摘除
时间统计字段 归零(start_timeutimestime 等)

关键点来了 :在刚复制完的那一刻,子进程的 mmfiles 还指向父进程的同一个对象。但紧接着,fork() 内部会根据传参标志决定怎么处理这两个指针:

  • 对于 fork()(不带任何共享标志):内核会调用 copy_mm() 创建新的 mm_struct 并复制页表(写时拷贝),调用 copy_files()不复制 files_struct,而是让父子共享同一个文件表(仅引用计数 f_count +1)。
  • 对于 clone() 创建线程 :内核直接共享 父进程的 mmfiles,不做任何复制,只把引用计数 +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 的三步

  1. 复制页表,不复制物理页fork() 时,内核会为子进程创建全新的顶级页表(PGD 页),并把父进程页表中的所有表项(PTE) 拷贝一份。此时父子进程的虚拟地址映射到同一批物理内存页
  2. 清空写权限 :内核把父子进程页表中所有可写的页表项临时改为只读(Read-Only) ,并在物理页框的描述符中把引用计数从 1 增加到 2。
  3. 写时复制 :当父子任一方向某块内存发起写入时,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() 创建子进程时:

  1. 内核为子进程分配一个全新的物理页作为其 PGD 页。
  2. 把父进程 PGD 页中的全部表项拷贝到子进程的新 PGD 页中。
  3. 子进程的 mm_struct->pgd 指向这个新物理页(地址与父进程不同)。
  4. 所有二级页表(PUD/PMD/PTE)暂不复制,父子共享------通过 COW 机制按需复制。

这正好回应了"复制了整个页表"这个说法------准确地说是复制了顶级页表的表项数组,而整棵页表树的深层节点是在发生写操作时才逐步复制。


五、文件描述符:真正共享的是什么?

很多开发者知道"fork 后父子进程共享文件描述符",但能说清楚"共享的到底是什么"的人并不多。

5.1 三层结构

文件描述符在内核中涉及三个层次:

层次 内核结构 存储位置 说明
用户态看到的整数 int fd 用户程序的栈或局部变量 只是个"门牌号"
进程级描述符表 files_struct->fdtable 内核空间(随 task_struct 指针数组,下标就是 fd
内核级文件对象 struct file 内核 Slab 缓存 包含 f_pos(偏移量)、f_modef_flagsf_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() 时,内核做了这样几件事:

  1. 释放所有资源 :物理内存、文件对象(struct file 引用计数减一)、信号队列等全部释放。
  2. 保留 task_struct:内核把退出码(exit_code)写入这个残存的 task_struct,并把进程状态标记为 EXIT_ZOMBIE
  3. 通知父进程 :通过 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 filefork 共享同一个
僵尸进程 已退出但父进程未 waittask_struct 残留 无法用 kill 杀掉,只能等父进程 wait 或杀死父进程
孤儿进程 父先退出,子过继给 init init 自动回收,不会变僵尸
ptrace task_struct 加标记,wait 捕获信号 strace 慢,因为每次 syscall 都上下文切换
进程隔离 mm_struct + files_struct + cred + nsproxy 四层隔离:内存、资源、权限、视图

还没写完,写累了,,,,

相关推荐
charlie1145141913 天前
Cinux · 第一次跳进 Ring 3:用户态与特权隔离
开发语言·c++·操作系统·开源项目
程序猿乐锅3 天前
【操作系统 | 第一章】从资源管理到虚拟机
操作系统
CoovallyAIHub5 天前
一个集装箱从船到火车的两个小时,AI 搭档 Coco在中间做了什么
操作系统·agent·资讯
驱动探索者5 天前
RISC-V 指令集深度解析:从 ISA 到 Zephyr
网络·计算机·操作系统·os
码农小韩6 天前
Linux操作系统(六)——软件包管理
linux·操作系统·linux驱动·嵌入式软件开发·linux应用
纵有疾風起6 天前
I/O 软件层次结构:从用户态到硬件中断的完整旅程
操作系统·408·中断·lut·驱动程序·设备独立性
TechEdu2026066 天前
[操作系统]操作系统文件系统与输入输出:架构、行为与调优
操作系统·计算机科学·os
charlie1145141916 天前
Cinux是如何管理进程的 —— 上下文与调度
开发语言·c++·操作系统·开源项目
The Chosen One9857 天前
【操作系统】操作系统引导、虚拟机、进程通信、信号
linux·运维·服务器·操作系统·虚拟机·信号