Skip to content

第8章:进程上下文——谁在运行你的代码


plaintext
┌─────────────────────────────────────────────────────────────┐
│  元数据卡                                                    │
│  ├─ 主题:进程上下文                                          │
│  ├─ 难度:★★★★☆                                              │
│  ├─ 前置要求:第7章(异常与系统调用)                            │
│  ├─ 核心概念:进程生命周期、PCB、fork/exec/wait、COW、信号     │
│  └─ 预计阅读:40 min                                           │
└─────────────────────────────────────────────────────────────┘

你在哪

"穿过内核态的边界后,你发现 CPU 不是只运行你的程序。它同时运行着几十个进程,像时间魔术师一样在它们之间切来切去。你站在地心的调度中心,看着进程们在 CPU 上轮流登场。"

你写了一个简单的程序:

c
#include <stdio.h>

int main() {
    printf("Hello, world!\n");
    return 0;
}

你双击它,就"运行"了。但你有没有想过——

"运行"到底是什么意思?

进程和程序是一回事吗?两个终端运行同一个可执行文件是同一个进程吗?打开 Chrome,打开了十几个"窗口",这些是一个进程还是几十个?为什么程序退出后PID还不消失——僵尸进程到底是什么?

这一章,我们来拆解进程这个现代操作系统最核心的抽象。


你的任务

本章分层

  • 必读:程序 vs 进程的根本区别;进程的完整生命周期;PCB(进程控制块)的概念
  • 选读fork() 的写时复制(COW)原理;exec() 族函数的作用
  • 深水区:僵尸进程与孤儿进程的成因及处理;clone() 系统调用 flags 本章不会要求你掌握
  • clone() 的所有 flags 细节
  • cgroup 和 namespace 的完整配置
  • Linux 调度器(CFS)的具体实现

学完本章后,你应该能:

  1. 画出进程的完整生命周期(创建 → 就绪 → 运行 → 阻塞 → 终止)
  2. 说明 fork() 的返回值为什么能区分父子进程
  3. 解释写时复制(COW)为什么能高效创建子进程
  4. 解释僵尸进程和孤儿进程的成因和解决方案
  5. 说明 exec() 族函数做了什么——不创建新进程但替换了进程上下文
  6. 追踪环境变量的传递路径

遭遇战——程序 vs 进程

先搞清楚最基础的概念:

程序(Program)                进程(Process)
─────────────                  ──────────────
静态文件(磁盘上的二进制)        动态实体(内存中的执行环境)
不消耗CPU                       消耗CPU + 内存
一个程序可以 → N个进程           一个进程只能对应 ← 一个程序
就像菜谱                        就像一次烹饪操作

你打开两个终端,都运行 ./hello

bash
# 终端1
$ ./hello &
[1] 12345

# 终端2
$ ./hello &
[2] 12346

两个进程,同一个程序。 PID 不同,地址空间不同,PC 寄存器不同,栈不同。它们不知道彼此的存在。这就是进程作为"假 CPU"的魔力——每个进程都以为自己拥有了整个 CPU

PCB——进程的身份证

操作系统用一个叫 进程控制块(Process Control Block, PCB) 的结构记录进程的一切。

在 Linux 中,PCB 就是 task_struct——一个超过 200 个字段的结构体,定义在 include/linux/sched.h

c
// 简化版本——真实 task_struct 约 1000+ 行
struct task_struct {
    /* 进程标识 */
    pid_t                 pid;              // 进程 ID
    pid_t                 tgid;             // 线程组 ID

    /* 状态 */
    volatile long         state;            // TASK_RUNNING / TASK_INTERRUPTIBLE 等

    /* 调度信息 */
    int                   prio;
    unsigned int          time_slice;

    /* 内存管理 */
    struct mm_struct      *mm;              // 用户地址空间
    struct mm_struct      *active_mm;       // 内核态使用的 mm

    /* 文件系统 */
    struct fs_struct      *fs;
    struct files_struct   *files;           // 打开的文件描述符表

    /* 信号 */
    struct signal_struct  *signal;

    /* 父子关系 */
    struct task_struct    *parent;
    struct list_head      children;
    struct list_head      sibling;

    /* 等等超过 200 个字段... */
};

💡 洞见: "进程"这个抽象的本质就是 PCB + 地址空间。没有 PCB,你的代码只是一堆躺磁盘上的字节。有了 PCB,操作系统才能追踪"谁在运行、谁在等什么、谁的资源是什么"。


你的任务(续)——进程状态机

进程在生命周期中会经历几种状态:

         ┌─────────────────────────────┐
         │        创建 (Created)        │
         │    fork() 返回的瞬间         │
         └─────────────┬───────────────┘
                       │ 就绪队列入队

         ┌─────────────────────────────┐
    ┌───│    就绪 (Ready)               │
    │   │    等待 CPU 调度              │
    │   └─────────────┬───────────────┘
    │                 │ 调度器选中
    │                 ▼
    │   ┌─────────────────────────────┐
    │   │    运行 (Running)            │ ◄── 这是你代码在 CPU
    │   │    正在执行指令              │     上真正执行的唯一状态
    │   └──────┬──────────────┬───────┘
    │          │              │
    │    时间片用完      等待I/O/事件
    │          │              │
    │          ▼              ▼
    │   ┌──────────┐   ┌──────────────┐
    └───│ Return   │   │  阻塞 (Blocked) │
        │          │   │ 等磁盘/等网络    │
        └──────────┘   └──────────────┘
                              │ I/O完成

                       ┌─────────────────────────────┐
                       │    终止 (Terminated)          │
                       │    僵尸状态(等父进程收尸)    │
                       └─────────────────────────────┘

关键点:一次切换就是一个"上下文切换"——保存当前进程的寄存器、PC、栈指针、页表,加载下一个进程的。这就是 perf 里看到的 context-switches


获得技能——fork() 的魅力

💡 操作系统视角:进程是现代操作系统最核心的抽象。以下用 Unix/Linux 的 fork/exec/wait 模型来说明——这是理解进程最直观的路径。如果你熟悉 Windows 的 CreateProcess 或 Java 的 ProcessBuilder,它们在概念上是等价的,只是 API 不同。

fork:一次调用,两次返回

c
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        /* 子进程 */
        printf("[子] 我的 PID = %d,父 PID = %d\n",
               getpid(), getppid());
    } else if (pid > 0) {
        /* 父进程 */
        printf("[父] 子进程 PID = %d,我的 PID = %d\n",
               pid, getpid());
    } else {
        perror("fork 失败");
    }

    return 0;
}

编译运行:

[父] 子进程 PID = 28374,我的 PID = 28373
[子] 我的 PID = 28374,父 PID = 28373

注意 fork() 只调用了一次,却返回了两次。这是怎么做到的?

fork 的内部机制

  1. 父进程调用 fork() ——进入内核
  2. 内核执行 copy_process()
    • 分配新的 PID 和 PCB(task_struct
    • 复制父进程的地址空间——但这里用了一个极其精妙的优化(见下文)
    • 复制打开的文件描述符、信号处理器等
    • 初始化子进程的内核栈(包含父进程返回地址的副本)
  3. 在子进程的内核栈上,把返回值 (rax) 设为 0
  4. 在父进程的内核栈上,把返回值设为子进程的 PID
  5. 两个进程都返回到用户态

所以实际上,子进程和父进程回到的是同一个 printf 行——但子进程的 rax 里是 0,父进程的 rax 里是 PID。

写时复制(Copy-on-Write, COW)

传统做法是 fork() 直接复制整个地址空间。但大部分 fork() 后紧接着 exec()——旧地址空间全部作废。如果每次都完整复制,简直是浪费。

Linux 的做法:

  1. fork() 时,父进程的页表被复制给子进程,但所有页都标记为只读
  2. 父子进程共享相同的物理页
  3. 其中一方试图写入时,触发缺页故障
  4. 内核分配新物理页,复制内容,更新页表
  5. 然后重试写指令
c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *shared = malloc(sizeof(int));
    *shared = 42;

    pid_t pid = fork();

    if (pid == 0) {
        /* 子进程——此时 shared 指向同一物理页(只读) */
        printf("[子] 修改前: %d\n", *shared);
        *shared = 100;  /* 💥 触发 COW!分配新页 */
        printf("[子] 修改后: %d (子进程地址: %p)\n",
               *shared, (void*)shared);
    } else {
        /* 父进程——shared 仍然指向原物理页(仍只读) */
        wait(NULL);
        printf("[父] 子修改后我看: %d (父进程地址: %p)\n",
               *shared, (void*)shared);
    }

    free(shared);
    return 0;
}

典型输出:

[子] 修改前: 42
[子] 修改后: 100 (子进程地址: 0x5555555592a0)
[父] 子修改后我看: 42 (父进程地址: 0x5555555592a0)

看!地址相同(虚拟地址),值不同(物理地址不同)。这就是 COW 的魔法——同一份虚拟地址映射到了不同的物理页。

💡 洞见: COW 的核心哲学是"拖延"——尽一切可能不复制,直到不得不写。这是操作系统设计中反复出现的模式:缺页时再加载、写时再复制、懒绑定。


常见陷阱——exec、wait、僵尸

💡 Unix 模型说明fork + exec 是 Unix 经典范式。Windows 用 CreateProcess 一步创建新进程;Java 用 ProcessBuilder.start() 创建操作系统进程(底层依赖平台 API,Windows 上就是 CreateProcess)。

exec()——不创建新进程,但替换一切

fork() 复制了一个几乎一样的进程。exec() 做的事情正好相反:它不创建新进程,但把当前进程的整个用户地址空间扔掉,加载一个新的程序进去。

c
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        /* 子进程 — 变成 "ls" */
        execlp("ls", "ls", "-l", NULL);

        /* 只有 execlp 失败才会走到这里 */
        perror("exec 失败");
        return 1;
    }

    /* 父进程等子进程结束 */
    wait(NULL);
    printf("子进程已结束\n");
    return 0;
}

运行:

$ ./a.out
total 32
-rwxr-xr-x 1 user user 16712 Jun 20 14:32 a.out
子进程已结束

关键理解

  • fork() 后子进程的 PID 不变
  • execlp() 把子进程的地址空间、栈、堆全部清空,加载 ls 的代码和数据
  • PID 不变,PCB 仍然活着,因为打开的文件描述符、信号处理设置等可以有选择地继承
c
// exec 后 PID 保持不变
printf("PID 没变: %d\n", getpid());  // 跟 fork 之前一样

僵尸进程——活着但死去的进程

一个进程终止后,内核不会立刻完全清理它的 PCB。因为父进程可能需要知道它的退出状态。

c
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("[子] 我马上退出\n");
        return 42;  /* 子进程退出码 */
    }

    /* 父进程——不调用 wait,直接去睡觉 */
    printf("[父] 子 PID = %d,我睡 30 秒不收尸...\n", pid);
    sleep(30);

    /* 现在收尸 */
    int status;
    wait(&status);
    printf("[父] 子进程退出码: %d\n", WEXITSTATUS(status));

    return 0;
}

在另一个终端上运行 ps

bash
# 在父进程 sleep 的 30 秒内
$ ps aux | grep a.out
steven   28373  0.0  0.0   4340   720 pts/0    S+   14:35   0:00 ./a.out
steven   28374  0.0  0.0      0     0 pts/0    Z+   14:35   0:00 [a.out] <defunct>

看到 Z+<defunct> 了吗?这就是僵尸进程

bash
# 看 status 文件
$ cat /proc/28374/status
Name:   a.out
State:  Z (zombie)
Pid:    28374
...

僵尸的本质:进程已终止(释放了所有内存、关闭了所有文件描述符),但 PCB 还留在内核里,等父进程来 wait() 读走退出状态。

孤儿进程——父进程先死了

c
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        /* 子进程 */
        printf("[子] 父 PID = %d\n", getppid());
        sleep(5);
        printf("[子] 5秒后父 PID = %d\n", getppid());
        /* 如果父进程已经在 5 秒内退出了... */
    } else {
        /* 父进程立即退出 */
        printf("[父] 我先走了\n");
        return 0;
    }

    return 0;
}

运行:

[父] 我先走了
[子] 父 PID = 28373
[子] 5秒后父 PID = 1

子进程的父 PID 变成了 1——它被 init 进程(systemd) 收养了。这就是孤儿进程的处理方式。

💡 实战规则:

  • 创建子进程后,父进程必须 wait() / waitpid(),否则子进程变成僵尸
  • 如果父进程不关心退出状态,可以注册 SIGCHLD 处理函数
  • 善用 signal(SIGCHLD, SIG_IGN) —— Linux 会自动清理僵尸
  • 或者更推荐的方式:sigaction + SA_NOCLDWAIT

通关挑战——完整的多进程模式

组合 fork + exec + wait,写一个微型 shell:

c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>

#define MAX_CMD 256

int main() {
    char cmd[MAX_CMD];

    while (1) {
        printf("$ ");
        fflush(stdout);

        if (!fgets(cmd, MAX_CMD, stdin))
            break;

        /* 去掉换行符 */
        cmd[strcspn(cmd, "\n")] = '\0';

        if (strcmp(cmd, "exit") == 0)
            break;

        pid_t pid = fork();

        if (pid == 0) {
            /* 子进程:执行命令 */
            execlp("/bin/sh", "sh", "-c", cmd, NULL);
            perror("exec 失败");
            exit(1);
        } else if (pid > 0) {
            /* 父进程:等待子进程 */
            int status;
            wait(&status);
            printf("退出码: %d\n", WEXITSTATUS(status));
        } else {
            perror("fork 失败");
        }
    }

    return 0;
}

运行测试:

$ echo "Hello from a subprocess"
Hello from a subprocess
退出码: 0
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
退出码: 2
$ exit

这就是 Shell 最原始的原理。你每天打的每一条命令,都经历了一次 fork + exec + wait 的完整旅程。


验收标准

读完本章后,请确认你能回答:

  1. 程序和进程的核心区别是什么?
  2. fork() 为什么能返回两次?子进程的返回路径是什么?
  3. 写时复制是在复制什么?什么时候才真的复制?
  4. 僵尸进程和孤儿进程分别是怎么产生的?怎么避免僵尸?
  5. exec() 不创建新进程到底是什么意思?创建了还是没创建?

常见卡点

卡点真相
"子进程从 main 开头执行"子进程从 fork() 返回处继续,和父进程的 PC 位置相同
"fork 复制了一切"真实实现是 COW,虚拟地址空间共享物理页
"exec 创建了新进程"exec 只是替换内容,PID、文件描述符等会继承
"僵尸进程很可怕"只是一个很小的 PCB 残留,但大量僵尸会导致 PID 耗尽
"我可以不用管子进程"不管 = 僵尸。注册 SIG_IGN 或 wait

现在不需要理解

  • Linux 调度器(CFS)的具体实现 —— 第 10 章调度会深入
  • cgroup 和 namespace 细节 —— 容器技术专题
  • clone() 系统调用的所有 flags —— 线程实现时再讲
  • procfs 的完整文件系统结构 —— 需要时查手册

旅人笔记

你知道"进程"这个词让我困惑了很久吗?程序嘛,双点一下就能跑。但"进程"好像一个看不见的东西,教科书说是"运行中的程序实例"——但这到底是个什么玩意儿?

直到我开始学操作系统,才真正看到它的面目:进程就是操作系统把 CPU 和内存包装成的一个"盒子"。每个进程一个盒子,盒子里有自己的地址空间、自己的寄存器、自己的栈、自己的文件。盒子之间互不相见。

fork() 是复制这个盒子——但不是真的全复制,而是先共享物理内存,谁写谁复制(COW)。

exec() 是把盒子里的东西全倒掉,装进新的程序。

wait() 是父进程等着收子进程的盒子,读完退出状态后,内核才把 PCB 彻底销毁。

这就像一家餐厅接单——后厨(内核)收到一个新订单(fork),把菜单复制一份贴墙上(PCB),然后厨师开始照着新菜单做菜(exec)。做好了出餐(exit),服务员把订单勾掉(wait),残页扔掉(释放PCB)。

当你明白每一行 printf、每一次回车命令背后都跑过这么一圈——你就会对这个"莫名其妙就能跑"的世界,多一分敬意。


→ 下一站预告

第9章:线程与同步——分身与协作

进程切换太慢了——每次都要保存和加载整个地址空间。更轻量的选择出现了:线程。同一进程内的多个线程共享内存,协作完成任务。但共享带来了新的问题——锁、竞争、同步——下一章进入并发的世界。

Built with VitePress | Software Systems Atlas