操作系统 进程生命周期 与资源模型

从内核视角看进程从创建到销毁的完整旅程,以及它所占用的一切系统资源

🤔 什么是进程?

进程是操作系统进行资源分配和调度的基本单位。它是程序的一次执行实例,拥有独立的地址空间、系统资源和执行上下文。

类比:程序就像一份菜谱(静态的),进程就像正在炒菜的过程(动态的,需要锅、灶台、食材等资源)。

🗺️ 全局生命周期概览

🆕 创建
fork() / clone()
⏳ 就绪
等待调度
▶️ 运行
获得 CPU
💤 阻塞
等待 I/O
⏳ 就绪
I/O 完成
▶️ 运行
正常退出
💀 僵尸
wait() 未回收
🗑️ 终止
资源回收

📖 关键概念速览

PCB(进程控制块)

内核为每个进程维护的数据结构,记录 PID、状态、寄存器、内存信息、打开文件、信号等一切上下文。

地址空间

每个进程拥有独立的虚拟地址空间(4GB on 32-bit),通过页表映射到物理内存,进程间互不可见。

上下文切换

CPU 保存当前进程寄存器状态到 PCB,加载下一个进程的寄存器状态。开销约数微秒到数十微秒。

内核栈

每个进程在内核态有独立的栈(通常 8KB),用于系统调用和内核函数执行。

进程创建的完整过程

从用户态发起 fork() 到内核完成进程创建的每一个步骤

⏱️ 创建时间线

Step 1 — 用户程序调用 fork()
用户态程序发起 pid = fork() 系统调用,触发 int 0x80syscall 指令,CPU 从用户态切换到内核态。
Step 2 — 内核入口与参数校验
内核进入 sys_fork() 入口,检查资源限制(RLIMIT_NPROC:最大进程数),检查 PID 分配。
Step 3 — 分配 PID 和 PCB
从 PID 位图中分配一个唯一的进程标识符(PID),然后通过 struct task_struct *p = alloc_task_struct() 分配 PCB(Linux 中即 task_struct),从 kmem_cache 的 slab 分配器获取。
Step 4 — 复制 PCB(大部分字段)
将父进程的 task_struct 复制到子进程,关键修改:
• PID 设为新值
• PPID 设为父进程 PID
• 状态设为 TASK_UNINTERRUPTIBLE(创建中)
• 返回值:父进程得到子进程 PID,子进程得到 0
Step 5 — 创建内核栈
为子进程分配独立的内核栈(thread_info + 8KB stack),栈顶设置为 ret_from_fork 的返回地址,确保子进程首次被调度时从正确的位置开始执行。
Step 6 — 复制 / 共享文件相关资源
共享 文件描述符表(引用计数 +1)
共享 文件系统信息(cwd, root)
继承 信号处理函数表
继承 环境变量、命令行参数
Step 7 — 创建进程地址空间
fork() 的核心开销在这里:
① 复制父进程的 mm_struct(内存描述符)
② 复制页目录(PGD)
不复制物理页! 所有页表项设为只读,父子进程共享同一份物理内存(Copy-on-Write)
④ 任何一方写入时触发缺页异常,内核才分配新物理页并复制内容
# fork 后的内存状态(COW) 父子进程页表 → [同一物理页,标记为只读] 父进程 write() → 触发 page fault → 内核分配新物理页,复制原内容 → 更新父进程页表指向新页 → 重设页面为可写 → 继续执行
Step 8 — 设置子进程执行状态
子进程状态改为 TASK_RUNNING(就绪),加入调度器的运行队列。内核将子进程的 CPU 寄存器快照设置为"从 fork 返回"的伪状态。
Step 9 — 唤醒子进程 & 返回用户态
调用 wake_up_new_task() 将子进程放入可运行队列。fork() 系统调用返回:
• 父进程返回子进程的 PID(> 0)
• 子进程返回 0
两者继续执行 fork() 之后的代码
⚡ COW 是 fork 高效的关键:典型的 fork+exec 模式下(如 shell 启动命令),fork 后几乎立即 exec,COW 使得无需复制大部分物理页,fork 本身只需微秒级。

fork() 详解

深入理解 fork 的工作原理、资源继承规则和常见模式

🔀 fork 执行流程图

👨 父进程 (PID: 1000)
执行中,调用 pid = fork()
内核创建子进程
👨 父进程 (PID: 1000)
fork() 返回 1001
pid > 0,走父进程分支
返回值 = 子进程 PID
👶 子进程 (PID: 1001)
fork() 返回 0
pid == 0,走子进程分支
返回值 = 0

📄 fork 代码模式

// 最经典的 fork 用法
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();   // 一分为二!

    if (pid < 0) {
        // fork 失败(资源不足、进程数达上限)
        perror("fork failed");
    } else if (pid == 0) {
        // 👶 子进程 —— pid == 0
        printf("我是子进程, PID=%d, PPID=%d\n", getpid(), getppid());
    } else {
        // 👨 父进程 —— pid > 0
        printf("我是父进程, PID=%d, 子进程PID=%d\n", getpid(), pid);
        wait(NULL);  // 等待子进程结束
    }
    return 0;
}

📊 fork 资源继承规则

资源类型继承方式说明
代码段(Text) 共享 只读,父子进程指向同一物理页
数据段(Data + BSS) COW fork 时共享,写入时才复制(写时复制)
堆(Heap) COW malloc 的内存,COW 机制,写入时复制
栈(Stack) COW 每个进程会获得独立的栈空间视图
文件描述符表 共享 表项复制,但指向同一个打开文件表项(偏移量共享!)
文件描述符的 close-on-exec 继承 标志位被复制
当前工作目录 继承 子进程继承父进程的 cwd
信号处理方式 继承 子进程继承父进程的信号 disposition
环境变量 继承 完整的 environ 数组被复制
真实/有效 UID/GID 继承 用户身份和权限信息
资源限制(RLIMIT) 继承 文件大小限制、内存限制等
进程间锁(flock) 继承 文件锁也由子进程继承
pending 信号 不继承 fork 时 pending 信号集被清空
定时器(alarm/setitimer) 不继承 子进程不会继承父进程的定时器
内存锁(mlock) 不继承 mlock 锁定的页面不继承

⚡ fork vs vfork vs clone

fork()

地址空间
完整复制(COW)
执行顺序
不确定(父子调度顺序随机)
性能
较重(需复制页表)
用途
通用场景,shell 启命令

vfork()

地址空间
完全共享(无 COW)
执行顺序
子进程先运行,父进程阻塞
性能
极轻(几乎零开销)
用途
fork+exec 优化
⚠️ vfork 中子进程不能修改变量、不能 return,只能调用 exec 或 _exit

clone() — Linux 底层接口

fork 和 vfork 在 Linux 中都是基于 clone() 实现的库函数。clone() 通过 flags 参数精确控制共享哪些资源:

// clone 的关键 flags
CLONE_VM        // 共享地址空间(线程)
CLONE_FS        // 共享文件系统信息
CLONE_FILES     // 共享文件描述符表
CLONE_SIGHAND   // 共享信号处理
CLONE_THREAD    // 放入同一个线程组

// fork  = clone(SIGCHLD, 0)      — 全部独立
// vfork = clone(CLONE_VM|CLONE_VFORK|SIGCHLD, 0)
// 线程  = clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD, ...)

进程的 五种状态

进程在运行过程中,会在不同状态之间切换

🆕 新建
NEW / 创建中
↓ fork() 完成
⏳ 就绪
READY / 可运行
↑ I/O 完成 / 信号到达
▶️ 运行
RUNNING
↑ 唤醒
时间片用完
/ 被抢占
⏳ 就绪
请求 I/O
/ sleep / wait
💤 阻塞
进程结束
💀 僵尸
ZOMBIE
🗑️ 终止
父进程 wait()

📝 各状态详解

🆕 新建 (NEW)

进程正在被创建:内核已分配 PCB,正在复制地址空间、设置资源。此时进程还不能被调度运行。

⏳ 就绪 (READY)

进程已准备就绪,等待 CPU 时间片。被放在调度器的运行队列(runqueue)中。可能因时间片用完从运行态回到此状态。

▶️ 运行 (RUNNING)

进程正在 CPU 上执行。单核上同一时刻只有一个 RUNNING 进程;多核上可以同时有多个。时间片耗尽或高优先级进程抢占时退出。

💤 阻塞 (BLOCKED/WAITING)

进程等待某事件完成(I/O 操作、锁获取、信号等),被移出运行队列。阻塞原因消除后被内核唤醒回到就绪态。

💀 僵尸 (ZOMBIE)

进程已终止,但其 PCB 仍保留在内核中(存储退出码、资源使用统计等信息),等待父进程调用 wait() 回收。如果父进程不回收,就变成僵尸进程泄漏。

⚠️ 僵尸进程泄漏:如果父进程一直不 wait(),僵尸进程的 PCB 永远不会被释放。大量僵尸进程会耗尽 PID 空间。解决方法:父进程注册 SIGCHLD 信号处理器,或在代码中正确调用 wait/waitpid。

🔍 Linux 内核实际状态(更多细分)

在 Linux 内核源码中(include/linux/sched.h),进程状态比教科书模型更细致:

/* 进程状态定义 */
#define TASK_RUNNING        0   // 正在运行 或 在运行队列中等待
#define TASK_INTERRUPTIBLE  1   // 可中断睡眠(可被信号唤醒,如 wait、select)
#define TASK_UNINTERRUPTIBLE 2 // 不可中断睡眠(不被信号唤醒,如磁盘 I/O)
#define TASK_STOPPED        4   // 暂停(收到 SIGSTOP / 被 debugger 挂起)
#define TASK_TRACED         8   // 被跟踪(ptrace 调试中)

// 退出状态(在 exit_notify 中设置)
#define EXIT_ZOMBIE         16  // 僵尸进程
#define EXIT_DEAD           32  // 终止(等待父进程回收后最终状态)

进程控制块 (PCB / task_struct)

内核为每个进程维护的核心数据结构,约 400+ 个字段

📋 struct task_struct 每个进程一个实例 · slab 分配器管理
🔗 进程标识
pid_t pid进程号
pid_t tgid线程组 ID
pid_t ppid父进程号
uid_t uid真实用户 ID
gid_t gid真实组 ID
char comm[16]进程名
📊 进程状态
volatile long state当前状态
int exit_state退出状态
int exit_code退出码
unsigned int flags进程标志位
int prio动态优先级
int static_prio静态优先级
int policy调度策略
cpumask_t cpus允许的 CPU
💾 内存管理
struct mm_struct *mm用户空间内存
struct mm_struct *active_mm内核线程借用
unsigned long start_stack栈起始地址
unsigned long start_code代码段起始
unsigned long arg_start参数区起始
unsigned long env_start环境变量起始
📁 文件系统
struct files_struct *files打开的文件表
struct fs_struct *fs文件系统信息
struct namespace *nsproxy命名空间
📡 上下文与寄存器
struct thread_struct threadCPU 寄存器
unsigned long sp栈指针
unsigned long ip指令指针
void *stack内核栈指针
📡 信号处理
struct signal_struct *signal共享信号
struct sigpending pending待处理信号
sigset_t blocked信号屏蔽字
struct sighand_struct *sighand信号处理函数
🕐 时间与统计
u64 utime用户态时间
u64 stime内核态时间
unsigned long nvcsw主动切换次数
unsigned long nivcsw被动切换次数
struct timespec start_time创建时间
cputime_t realtime真实运行时间
🔗 家族关系
struct task_struct *parent父进程指针
struct list_head children子进程链表
struct list_head sibling兄弟进程链表
struct task_struct *real_parent真实父进程
🔍 如何查看?使用 cat /proc/[pid]/status 可以看到 PCB 中的大部分关键字段。/proc/[pid]/stat 提供更详细的统计信息。

进程的 虚拟内存布局

每个进程看到的独立地址空间(以 x86-64 Linux 为例)

⬆ 高地址 (0xFFFFFFFFFFFFF...)
📐 内核空间 所有进程共享同一份内核空间的映射
📡 栈 (Stack) 向下增长 · 局部变量、函数调用帧
↕️ 空闲区域 栈和堆之间的空间,可随需要扩展
📦 堆 (Heap) 向上增长 · malloc/new 动态分配
💾 BSS 段 未初始化的全局变量(默认 0)
📊 数据段 (Data) 已初始化的全局/静态变量
📖 代码段 (Text) 程序的机器指令 · 只读 + 可执行
⬇ 低地址 (0x0000000000400000)

🔍 fork 后的内存状态

👨 父进程虚拟地址空间

→ 物理页 A (COW)
→ 物理页 B (COW)
Data + BSS→ 物理页 C (COW)
Text (代码)→ 物理页 D (共享只读)

👶 子进程虚拟地址空间

→ 物理页 A (COW)
→ 物理页 B (COW)
Data + BSS→ 物理页 C (COW)
Text (代码)→ 物理页 D (共享只读)
fork 后,父子进程的虚拟地址空间看起来完全一样,页表也几乎一样,但所有用户空间页面都被标记为只读。代码段本来就是只读的所以永远共享;数据段、堆、栈在写入时才触发 COW 复制。

⚙️ 关键系统调用

brk() / sbrk()

调整堆顶指针(program break),是 malloc 底层的实现方式之一。现代 glibc 更多使用 mmap。

mmap() / munmap()

将文件或匿名内存映射到虚拟地址空间。大型内存分配(> 128KB)使用 mmap 而非 brk,便于归还。

mprotect()

修改内存页的保护属性(读/写/执行),COW 机制就是将页设为只读实现的。

进程占用的 全部系统资源

一个进程运行时究竟消耗了操作系统中的哪些资源?

资源类别具体内容查看方式
💾 CPU 时间 用户态时间 (utime)、内核态时间 (stime)、实际墙钟时间 /proc/pid/stat
time 命令
🧠 虚拟内存 代码段、数据段、堆、栈、内存映射区(mmap)、共享库映射 /proc/pid/maps
pmap 命令
📄 物理内存 (RSS) 实际占用的物理页数量,包括共享库(按比例分摊 PSS) /proc/pid/statm
smem 工具
📂 打开的文件 每个 fd 对应一个 file 结构体(含引用计数、文件偏移量、访问模式) /proc/pid/fd
lsof -p pid
📁 文件系统上下文 当前工作目录 (cwd)、根目录 (root)、umask /proc/pid/cwd
/proc/pid/root
📡 信号 信号处理函数表、信号屏蔽字、待处理信号队列 /proc/pid/status
🔓 文件锁 使用 fcntl/flock 加的文件锁 /proc/locks
🔗 网络资源 打开的 socket(TCP/UDP/Unix)、监听端口、连接状态 /proc/pid/net/tcp
netstat/ss
🧵 线程 属于同一进程的所有线程(共享地址空间的轻量级进程) /proc/pid/task
ps -Lp pid
📦 IPC 资源 System V 信号量、消息队列、共享内存段 ipcs 命令
🏷️ 内核资源 PCB (task_struct)、内核栈 (8KB)、页表、slab 缓存 /proc/pid/smaps_rollup
🔑 凭证 真实 UID/GID、有效 UID/GID、保存的 UID/GID、capabilities /proc/pid/status
🖼️ 命名空间 Mount、UTS、IPC、PID、Network、User 命名空间 ls /proc/pid/ns
📝 cgroup 进程归属的控制组(CPU、内存、IO 限制) /proc/pid/cgroup

🔎 实战:查看一个进程的全部资源

# 假设进程 PID 为 1234

# 1. 基本信息
cat /proc/1234/status

# 2. 内存映射
cat /proc/1234/maps
pmap -x 1234

# 3. 打开的文件
ls -la /proc/1234/fd
lsof -p 1234

# 4. 网络连接
ss -tnp | grep 1234

# 5. 线程
ls /proc/1234/task

# 6. 文件锁
cat /proc/locks | grep 1234

# 7. 环境变量和命令行
cat /proc/1234/environ | tr '\0' '\n'
cat /proc/1234/cmdline | tr '\0' ' '

# 8. 资源限制
cat /proc/1234/limits

进程树与进程关系

所有进程构成树状结构,init/systemd 是一切的祖先

🌳 典型 Linux 进程树

🏗️ systemd
PID: 1
🖥️ Display Manager
PID: 500
🌐 sshd
PID: 800
📂 systemd-journald
PID: 200
🖥️ Desktop Env
PID: 501
🖥️ sshd session
PID: 801
🖥️ Terminal
PID: 600
🖥️ bash
PID: 802
⚙️ ./myapp
PID: 700
⚙️ vim
PID: 900
⚙️ worker thread
PID: 701
⚙️ worker thread
PID: 702

🔗 PCB 中的进程关系指针

struct task_struct {
    // 父子关系
    struct task_struct *parent;          // 指向父进程
    struct list_head    children;        // 所有子进程的链表
    struct list_head    sibling;         // 兄弟进程链表
    struct task_struct *real_parent;     // 创建自己的进程
    struct task_struct *group_leader;   // 线程组组长
};

// 遍历所有子进程的宏
list_for_each_entry(child, &current->children, sibling) {
    printk("child pid: %d\n", child->pid);
}

🔍 进程关系特殊情况

🏚️ 孤儿进程

父进程先于子进程退出时,子进程成为孤儿。内核会将其托付给 init/systemd (PID 1)作为新的父进程,由 init 负责回收。

💀 僵尸进程

子进程退出但父进程未调用 wait() 回收,子进程的 PCB 不被释放。PCB 中保留了退出状态和资源使用统计。

危害:僵尸进程不占 CPU 和内存,但占用 PID 和 PCB 条目。大量僵尸会导致 PID 耗尽。

🍼 守护进程化

通过 fork() → 父进程退出 → 子进程被 init 收养 的方式,让进程脱离终端控制,在后台独立运行。步骤:
① fork + 父退出
② setsid() 创建新会话
③ 再次 fork 防止获取终端
④ chdir("/") 释放挂载点
⑤ 关闭继承的 fd

exec()wait()

fork 之后最常配合使用的两个系统调用

🔄 fork + exec — 进程启动的标准模式

👨 Shell (bash)
读取用户命令 "ls -la"
1️⃣ fork()
👨 Shell (父)
继续等待用户输入
waitpid(pid)
👶 子进程
fork() 返回 0
2️⃣ exec("ls")
⚙️ ls 进程
地址空间被替换
PID 不变!
3️⃣ exit(0)
💀 僵尸
等待父进程回收
4️⃣ Shell 收到 SIGCHLD,wait() 回收僵尸

📝 exec 做了什么?

exec 系列调用(execve 是真正的系统调用)用一个新程序替换当前进程的地址空间,但保持 PID 不变

① 读取可执行文件头
检查 ELF 格式,解析 program header,确定需要加载哪些 segment
② 释放旧地址空间
unmap 原来的代码段、数据段、堆等(但保留打开的文件描述符!除非设了 FD_CLOEXEC)
③ 映射新程序
将新程序的代码段、数据段映射到虚拟地址空间,设置新的栈、新的堆
④ 设置入口点
将指令指针 (RIP) 设为新程序的入口点(_start),设置栈为 argc/argv/envp
⑤ 开始执行
从新程序的入口开始执行。PID、PPID、打开的文件、当前目录、信号处理等全部保留。
🔑 exec 后什么不变?PID、PPID、打开的文件描述符(除非 close-on-exec)、当前工作目录、进程优先级、控制终端、文件锁、资源限制。
什么变了?地址空间(代码/数据/堆/栈全部替换)、信号处理函数(恢复默认)、寄存器、指令指针。

📝 wait 做了什么?

wait() / waitpid() / wait3() / wait4() — 父进程等待子进程终止,并回收子进程资源:

// wait 的三种用途

pid_t waitpid(pid_t pid, int *status, int options);

// 参数说明:
// pid > 0    等待指定 PID 的子进程
// pid == -1  等待任意子进程(同 wait())
// pid == 0   等待同进程组的任意子进程
// status     输出:子进程退出状态(可用 WIFEXITED 等宏检查)
// options    WNOHANG(非阻塞)/ WUNTRACED(也等被暂停的子进程)

// 使用示例
int status;
waitpid(child_pid, &status, 0);  // 阻塞等待

if (WIFEXITED(status)) {         // 正常退出
    printf("exit code: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {   // 被信号杀死
    printf("killed by signal: %d\n", WTERMSIG(status));
}

wait() 在内核中执行的关键操作:
① 如果子进程还在运行,父进程进入阻塞(TASK_INTERRUPTIBLE)
② 子进程退出时触发 do_exit()exit_notify()
exit_notify() 向父进程发送 SIGCHLD 信号
④ 父进程被唤醒,waitpid 返回子进程 PID
⑤ 内核释放子进程的 PCB (task_struct)和剩余资源
⑥ 子进程从僵尸态变为完全终止

🔐 僵尸进程的预防

方法 1:显式 wait

// 在知道子进程的地方调用
waitpid(pid, &status, 0);

方法 2:SIGCHLD 处理

signal(SIGCHLD, SIG_IGN);
// 告知内核:不关心子进程状态
// 子进程退出后自动回收,不产生僵尸

方法 3:异步回收

// 注册 SIGCHLD handler
struct sigaction sa;
sa.sa_handler = handler;
sa.sa_flags |= SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);

void handler(int sig) {
  while (waitpid(-1, NULL, WNOHANG) > 0);
}