【Linux】进程概念全攻略:从 PCB、进程状态到调度与虚拟内存,一篇打通底层逻辑

🔥 本文定位:面向已经掌握 Linux 基础命令与开发工具、准备学习系统编程的同学,从硬件与操作系统视角重新认识进程。
💡 学习目标:不仅知道 PID 和 fork(),还要理解 PCB、状态转换、上下文切换、环境继承、写时拷贝与虚拟地址空间之间的联系。

在这里插入图片描述


文章目录


前言

刚接触进程时,很多人会形成一个过于简单的认识:

进程 = 正在运行的程序

这句话没有错,却不足以解释下面这些现象:

  • 一个程序为什么可以同时启动多次,并拥有不同 PID?
  • fork() 明明只调用一次,为什么父子进程会得到不同返回值?
  • 进程睡眠时不占用 CPU,内核为什么还能找到并唤醒它?
  • 子进程已经退出,为什么 ps 中还会出现 Z
  • 父子进程打印出相同的变量地址,为什么变量值却能不同?
  • 单核 CPU 同一时刻只能执行一个任务,多个进程为何看起来同时运行?

要回答这些问题,必须建立一条完整主线:

硬件负责执行指令
        ↓
操作系统描述并组织进程
        ↓
调度器决定谁获得 CPU
        ↓
进程状态记录当前能否运行
        ↓
虚拟内存提供独立的地址视图

本文示例建议在普通用户的独立目录中执行:

mkdir -p ~/linux-process-lab
cd ~/linux-process-lab
pwd

⚠️ 实验提醒:文中的僵尸进程示例只让状态短暂存在,并在父进程结束前完成回收。不要在生产服务器上批量创建进程、持续制造僵尸或随意调整关键服务的优先级。


一、理解进程之前:先看硬件和操作系统

1.1 冯·诺依曼体系的关键不是“五大部件”

通用计算机通常可以抽象为:

  • 输入设备;
  • 输出设备;
  • 存储器;
  • 运算器;
  • 控制器。

运算器和控制器共同构成 CPU。学习进程时,更重要的不是背诵名称,而是理解数据流:

磁盘上的程序 ──加载──> 内存 ──取指/读写──> CPU
键盘、网卡等输入 ─────> 内存 ────────────> CPU
CPU 处理结果 ─────────> 内存 ────────────> 输出设备

忽略缓存与 DMA 等细节时,可以先建立一个简化模型:CPU 主要从内存取得指令与数据,程序必须先被加载到内存,才能真正进入执行阶段。

1.2 程序为什么不能随意操作硬件?

如果每个应用都可以直接修改物理内存、控制磁盘和配置网卡,那么一个程序的错误就可能破坏整个系统。操作系统位于应用与硬件之间,负责:

  • 管理 CPU、内存、文件和设备;
  • 隔离不同程序;
  • 检查访问权限;
  • 提供稳定的编程接口;
  • 在多个任务之间分配资源。

1.3 系统调用和库函数不是一回事

应用程序通常不会直接编写进入内核的底层指令,而是使用库函数:

应用程序
    ↓
C 标准库 / 其他用户态库
    ↓
系统调用接口
    ↓
操作系统内核
    ↓
硬件

例如 printf() 是 C 标准库函数。它负责格式化输出和维护用户态缓冲区;真正把字节写向终端或文件,最终仍需要内核提供的写入能力。

因此:

库函数:更友好、更可移植的用户态接口
系统调用:进入内核、请求系统服务的受控边界

一个库函数可能使用系统调用,也可能完全在用户态完成;一个系统调用也可能被多个不同库函数封装。


二、操作系统怎样管理进程

2.1 管理的通用方法:先描述,再组织,最后决策

操作系统面对的不是一个进程,而是大量进程、文件、内存页和设备。高效管理通常遵循三步:

  1. 先描述:用数据结构记录对象属性;
  2. 再组织:用链表、树、哈希表或队列管理对象;
  3. 做决策:按照策略完成调度、分配、保护和回收。

在这里插入图片描述

以进程为例:

task_struct 描述一个进程
        ↓
内核数据结构组织大量进程
        ↓
调度器从可运行任务中选择下一个进程

2.2 “描述起来”为什么这么重要?

程序正在 CPU 上执行时,寄存器中保存了它的运行现场;程序等待磁盘时,内核需要知道它在等待什么;程序退出后,父进程还需要读取它的退出状态。

这些信息不能只存在于“正在运行”这个动作中,而要被持久地记录在内核数据结构里。否则进程一旦离开 CPU,系统就失去了恢复它的依据。

核心设计解读

操作系统管理的对象最终都会落到数据结构上。所谓“创建进程”,不仅是把代码加载到内存,还包括创建内核对象、建立资源关系并把任务加入适当的组织结构;所谓“删除进程”,也不仅是代码不再执行,还包括回收这些管理信息。


三、什么是进程:程序运行起来还不够

3.1 程序与进程的区别

对比项 程序 进程
存在形式 磁盘上的文件 内核管理的动态执行实体
是否执行 静态,不占用 CPU 可能运行、等待、停止或退出
标识方式 文件路径、文件内容 PID、状态、资源关系等
数量关系 一个程序文件 可以对应多个进程实例
生命周期 文件存在即可长期保留 从创建到退出、回收

同一个可执行文件可以启动多次:

./app &
./app &
./app &
ps -o pid,ppid,state,cmd -C app

三个进程执行相同代码,但它们拥有不同 PID、调度状态和地址空间视图。

3.2 从内核视角定义进程

可以先建立一个概念模型:

进程 = 被加载的程序代码和数据
     + 内核维护的 PCB
     + 资源与地址空间关系

PCB 是 Process Control Block,即进程控制块。Linux 使用 task_struct 描述任务。

在这里插入图片描述

task_struct 中典型信息可以分为:

类别 典型内容 解决的问题
身份与关系 PID、线程组、父子关系、凭据 它是谁、属于谁
状态与信号 运行状态、退出信息、信号处理 它现在处于什么阶段
CPU 上下文 寄存器、程序计数器等现场 离开 CPU 后怎样恢复
地址空间 代码、堆、栈和映射区域 它可以访问哪些虚拟地址
文件与 I/O 打开文件、工作目录等 它正在使用哪些文件资源
调度与统计 策略、优先级、CPU 时间 何时获得处理器

💡 task_struct 非常庞大,而且字段会随内核版本演进。学习时先掌握信息类别和管理目的,不要把某个版本的完整结构体当成固定 API 背诵。

3.3 PID、PPID 与进程关系

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    printf("pid=%ld, ppid=%ld\n",
           (long)getpid(),
           (long)getppid());
    return 0;
}

编译运行:

gcc -Wall -Wextra pid_demo.c -o pid_demo
./pid_demo
  • PID:当前进程标识符;
  • PPID:创建或当前托管该进程的父进程标识符。

3.4 通过 ps/proc 观察进程

ps -ef
ps aux
ps -o pid,ppid,state,pri,ni,cmd
top

Linux 还通过 /proc 虚拟文件系统暴露进程信息。假设目标 PID 为 1234

cat /proc/1234/status
tr '\0' ' ' < /proc/1234/cmdline
readlink /proc/1234/exe
readlink /proc/1234/cwd
ls -l /proc/1234/fd
cat /proc/1234/maps

/proc/1234 不是磁盘上为每个进程永久保存的普通目录,而是内核状态的接口视图。进程退出后,对应 PID 目录也会消失。


四、fork 初识:一次调用为什么返回两次

4.1 fork() 创建新的执行流

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    pid_t id = fork();

    if (id < 0) {
        perror("fork");
        return 1;
    }

    printf("pid=%ld, ppid=%ld, fork_ret=%ld\n",
           (long)getpid(),
           (long)getppid(),
           (long)id);
    return 0;
}

fork() 成功后,父子进程都会从调用点之后继续执行,因此后面的 printf() 会执行两次。

4.2 三种返回情况

在这里插入图片描述

返回位置 fork() 返回值 含义
父进程 大于 0 新创建子进程的 PID
子进程 等于 0 当前执行流是子进程
原进程 小于 0 创建失败,没有产生子进程

典型分流写法:

pid_t id = fork();

if (id < 0) {
    perror("fork");
    return 1;
} else if (id == 0) {
    /* child branch */
} else {
    /* parent branch, id is child PID */
}

为什么父进程需要得到子进程 PID?因为父进程后续需要等待、管理或向特定子进程发送信号。

4.3 为什么一个变量能同时满足不同分支?

id 并不是一个被父子进程同时修改的共享普通变量。fork() 返回后,父子进程各自拥有独立的虚拟地址空间视图;它们在各自执行流中接收到不同返回值。

这也解释了下面的现象:

int g_value = 10;

pid_t id = fork();
if (id == 0) {
    g_value = 100;
    printf("child: value=%d, addr=%p\n", g_value, (void *)&g_value);
} else if (id > 0) {
    printf("parent: value=%d, addr=%p\n", g_value, (void *)&g_value);
}

父子进程可能打印出相同的地址数字,却看到不同值。这个地址是虚拟地址,不是可以跨进程直接比较的物理位置。

4.4 写时拷贝让 fork() 不必立即复制全部内存

如果 fork() 一开始就复制父进程全部物理内存,创建成本会很高。实际系统通常使用 Copy-on-Write(COW):

fork 刚完成
    ↓
父子进程暂时共享只读物理页
    ↓
任意一方尝试写入
    ↓
触发保护异常,由内核复制物理页
    ↓
更新写入方页表,父子从此映射不同物理页

写时拷贝并不表示父子可以随意共享普通变量,而是对“创建后的物理复制时机”进行优化。

核心设计解读

fork() 的三个关键点必须同时理解:

  1. 父子拥有两个独立执行流;
  2. 返回值为父子提供分流依据;
  3. 虚拟内存和写时拷贝在保证隔离的同时降低创建成本。

只记住“调用一次返回两次”,就很难解释变量地址、数据独立和后续进程回收。


五、Linux 进程状态:R、S、D、T、Z

5.1 状态是在回答“当前能不能运行”

进程不可能始终占用 CPU。它可能等待输入、等待磁盘、被调试器暂停,或者已经退出但尚未回收。内核用状态记录当前阶段,并把进程放入相应组织结构。

在这里插入图片描述

5.2 常见状态字符

状态 含义 常见场景
R 正在运行或处于可运行队列 正在占用 CPU,或等待被调度
S 可中断睡眠 等待定时器、管道、终端输入等事件
D 不可中断睡眠 等待某些关键内核或 I/O 操作完成
T 被作业控制信号停止 收到 SIGSTOP
t 被调试器跟踪停止 断点或跟踪事件
Z 僵尸 已退出,等待父进程读取退出状态
X 死亡状态 过渡状态,通常难以在用户态观察

⚠️ R 不等于“此刻一定正在 CPU 上执行”。运行队列中已经具备运行条件、正在等待 CPU 的任务,也可能显示为 R

5.3 SD 有什么区别?

S 是可中断睡眠,进程等待的事件尚未发生,但某些信号可以唤醒或打断等待。大多数正常等待都处于这一类。

D 是不可中断睡眠,进程位于内核中的某段关键等待路径。它通常不能按普通信号语义立即返回用户态处理信号。短暂 D 并不一定异常;如果大量进程长期处于 D,则应排查存储、网络文件系统、设备或内核路径。

5.4 怎样观察状态?

ps -o pid,ppid,state,wchan:24,cmd -p 1234
cat /proc/1234/status
top

psSTAT 列可能出现 S+Ssl 等组合。第一个大写字符通常是主状态,后面的符号是会话、前台进程组、多线程等附加标记。

5.5 停止与继续

在测试目录运行一个进程:

sleep 300 &
pid=$!
ps -o pid,ppid,state,cmd -p "$pid"

暂停它:

kill -STOP "$pid"
ps -o pid,ppid,state,cmd -p "$pid"

继续执行:

kill -CONT "$pid"

结束实验:

kill "$pid"
wait "$pid" 2>/dev/null

六、僵尸进程与孤儿进程

6.1 僵尸进程为什么存在?

子进程退出时,大部分资源会被释放,但下面的信息必须暂时保留:

  • 正常退出码;
  • 是否被信号终止;
  • 资源使用统计;
  • 供父进程确认子任务结果的状态。

父进程通过 wait()waitpid() 读取这些信息。父进程尚未读取时,子进程处于 Z 状态。

子进程退出
    ↓
释放地址空间、打开文件等大部分资源
    ↓
保留 PID、退出状态和少量统计信息
    ↓
父进程 wait / waitpid
    ↓
剩余内核记录被回收

6.2 僵尸进程的危害应该怎样准确理解?

僵尸进程已经不再执行,也不再保留完整用户态地址空间。它主要占用 PID、进程表项和少量内核信息。

单个短暂僵尸通常不是系统故障,但如果父进程持续创建子进程且从不回收,就可能耗尽可用 PID 或进程表资源,最终导致新进程创建失败。

6.3 为什么 kill -9 对僵尸没有意义?

僵尸已经完成退出,没有可继续终止的执行流。即使向它发送强制终止信号,也不能替代父进程读取退出状态。

正确处理方式是:

  1. 修复父进程的 wait() / waitpid() 逻辑;
  2. 检查父进程是否卡死或忽略了 SIGCHLD
  3. 在无法修复的情况下,按业务规范重启或终止有缺陷的父进程,使子进程由系统重新托管并回收。

6.4 孤儿进程不是僵尸进程

父进程先退出、子进程仍在运行时,子进程成为孤儿:

孤儿进程:还可能正常运行,只是原父进程提前退出
僵尸进程:已经退出,只等待退出状态被读取

系统会把孤儿重新托管给具有收养职责的进程,例如 PID 1 或配置为 subreaper 的进程。之后由新的父进程负责回收其退出状态。

💡 现代服务管理和容器环境中,收养者不一定简单等同于“传统 init”。判断实际关系时应观察 PPID,而不是死记某一个进程名。


七、优先级、调度与上下文切换

7.1 并发、并行、竞争与独立

概念 含义
并发 多个任务在一段时间内交替推进
并行 多个任务在多个处理器上同一时刻执行
竞争 多个可运行任务争夺有限 CPU 或其他资源
独立 进程拥有相对独立的地址空间和资源视图

单核 CPU 也能并发,因为调度器在多个进程之间快速切换;并行则要求同时存在多个执行单元。

7.2 调度器解决什么问题?

当可运行进程数量大于 CPU 数量时,调度器必须决定:

  • 接下来运行谁;
  • 允许运行多久;
  • 某个进程阻塞或唤醒后放到哪里;
  • 怎样兼顾吞吐、交互延迟和公平性;
  • 多核环境中怎样分配和迁移任务。

7.3 上下文切换为什么不可避免?

在这里插入图片描述

进程 P1 离开 CPU 前,内核需要保存它的程序计数器、栈指针和通用寄存器等现场;进程 P2 被选中后,再恢复 P2 上次保存的现场。

P1 运行
  ↓ 时间片耗尽、主动阻塞或被抢占
保存 P1 上下文
  ↓
调度器选择 P2
  ↓
恢复 P2 上下文
  ↓
P2 从此前位置继续运行

上下文切换会消耗 CPU 时间,还可能影响缓存和地址转换缓存。因此“创建更多进程”不一定提升性能,关键要看任务是 CPU 密集、I/O 密集,还是受其他资源限制。

7.4 PRI 和 NI 应该怎样理解?

ps -o pid,ppid,pri,ni,state,cmd
nice -n 10 ./app
renice 5 -p 1234
  • PRI:工具显示的调度优先级信息,具体语义与调度策略有关;
  • NI:传统 nice 值,用于影响普通任务的调度竞争倾向;
  • nice 值常见范围是 -2019
  • nice 越大,通常越“谦让”;降低 nice 值、提高竞争优先级通常需要更高权限。

不要把下面的写法当成所有现代内核上的严格公式:

PRI(new) = PRI(old) + nice

它可以帮助入门理解“nice 会影响优先级”,但实际调度行为还取决于调度类别、内核实现、系统负载和 CPU 拓扑。

7.5 Linux 2.6 O(1) 调度器:适合学思想,不代表当前全貌

早期 Linux 2.6 使用过 O(1) 调度器。其经典模型包括:

每个 CPU 一条运行队列
        ↓
active:时间片尚未耗尽的任务
expired:时间片耗尽、等待重新分配的任务
        ↓
优先级数组 + 位图快速找到非空队列
        ↓
active 耗尽后交换 active / expired 指针

它说明了两个重要思想:

  1. 进程必须被组织进可高效查找的数据结构;
  2. 调度策略与查找机制可以分开设计。

但 O(1) 调度器是历史实现。现代通用 Linux 的调度器已经多次演进,内部结构和策略不能再用这张历史模型完整描述。学习内核源码时一定要先确认目标版本。


八、命令行参数与环境变量

8.1 argcargv

#include <stdio.h>

int main(int argc, char *argv[])
{
    for (int i = 0; i < argc; ++i) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

运行:

./args_demo hello Linux 42
  • argc:参数个数;
  • argv:字符串指针数组;
  • argv[0]:通常是启动程序时使用的名称或路径;
  • argv[argc]:空指针,用于标记数组结束。

命令行选项本质上也是字符串。-l--help 之所以具有特殊含义,是因为程序自己解析并定义了它们。

8.2 常见环境变量

变量 作用
PATH Shell 查找可执行命令的目录列表
HOME 当前用户家目录
SHELL 登录 Shell 路径
PWD 当前工作目录
LANG 语言、字符编码与区域设置
echo "$PATH"
printenv HOME
env

8.3 Shell 变量与环境变量

MY_ENV="hello"

这会创建当前 Shell 变量,但普通子进程通常还看不到它:

export MY_ENV

或者一步完成:

export MY_ENV="hello"

删除变量:

unset MY_ENV

8.4 环境变量怎样传给子进程?

在这里插入图片描述

创建子进程时,子进程获得创建时环境的副本。它可以修改自己的环境,但这种普通继承是单向的:

父进程环境 ──创建子进程──> 子进程环境副本
子进程修改自己的环境 ──X──> 不会自动反向修改父进程

这就是为什么脚本中执行 cd 或修改变量,通常不会改变调用它的父 Shell;而使用 source script.sh 时,命令是在当前 Shell 环境中执行,效果不同。

8.5 在 C 程序中读取环境变量

读取特定变量:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *value = getenv("MY_ENV");
    printf("MY_ENV=%s\n", value ? value : "(unset)");
    return 0;
}

遍历环境表:

#include <stdio.h>

extern char **environ;

int main(void)
{
    for (char **item = environ; *item != NULL; ++item) {
        puts(*item);
    }
    return 0;
}

环境表可以概念化为:

environ
  ├──> "PATH=/usr/local/bin:/usr/bin:/bin\0"
  ├──> "HOME=/home/alice\0"
  ├──> "LANG=zh_CN.UTF-8\0"
  └──> NULL

8.6 为什么自己的程序要写 ./app

Shell 收到不含斜杠的命令名时,会按照 PATH 中的目录顺序搜索:

printf '%s\n' "$PATH" | tr ':' '\n'

当前目录通常不在 PATH 中,所以:

app       # 在 PATH 中找名为 app 的程序
./app     # 明确执行当前目录下的 app

不建议把 . 放到 PATH 前部。否则当前目录中的同名恶意程序可能抢在系统命令之前执行。


九、进程地址空间与虚拟内存

9.1 “程序地址空间”为什么不够准确?

磁盘上的程序文件本身没有正在扩展的堆、活动调用栈和运行时映射。真正拥有这些内容的是运行中的进程,因此更准确的说法是“进程虚拟地址空间”。

一个典型用户态地址空间可以概念化为:

高地址
+-------------------------+
| 命令行参数、环境变量、栈 |
| mmap 区域、共享库        |
|            空闲区域      |
| 堆                       |
| BSS:未初始化全局数据    |
| Data:已初始化全局数据   |
| Text:代码与只读数据     |
+-------------------------+
低地址

具体布局受体系结构、链接方式、共享库和 ASLR 等影响,不能把示意图中的顺序和比例当成固定物理地址。

9.2 C 对象通常位于哪里?

#include <stdio.h>
#include <stdlib.h>

int g_uninitialized;
int g_initialized = 10;

int main(int argc, char *argv[])
{
    static int static_value = 20;
    int local_value = 30;
    int *heap_value = malloc(sizeof(*heap_value));

    printf("code          : %p\n", (void *)main);
    printf("initialized   : %p\n", (void *)&g_initialized);
    printf("uninitialized : %p\n", (void *)&g_uninitialized);
    printf("static        : %p\n", (void *)&static_value);
    printf("heap          : %p\n", (void *)heap_value);
    printf("stack         : %p\n", (void *)&local_value);
    printf("argv[0]       : %p\n", (void *)argv[0]);

    free(heap_value);
    return argc == 0;
}

这段程序用于观察区域相对关系,不要依赖某次输出的具体地址。启用 ASLR 后,不同运行之间的地址很可能变化。

9.3 相同虚拟地址为什么能保存不同数据?

在这里插入图片描述

父子进程都可能把 g_value 显示为虚拟地址 0x4000,但各自页表可以建立不同映射:

父进程:VA 0x4000 → 物理页 A → g_value = 10
子进程:VA 0x4000 → 物理页 B → g_value = 100

因此“地址数字相同”只表示它们位于各自虚拟地址空间中的相同位置,不表示最终访问同一物理字节。

9.4 页表和 MMU 做什么?

CPU 生成虚拟地址后,硬件内存管理单元(MMU)依据页表完成地址转换和权限检查:

虚拟页号 + 页内偏移
        ↓
查询页表 / 地址转换缓存
        ↓
物理页号 + 同一页内偏移
        ↓
访问物理内存

如果映射不存在或访问权限不满足,CPU 会触发异常,由内核判断:

  • 是否可以按需分配物理页;
  • 是否需要从文件或交换空间加载数据;
  • 是否属于写时拷贝;
  • 是否是非法访问,应向进程发送信号。

9.5 mm_struct 与 VMA

Linux 使用内存描述结构记录进程地址空间,VMA(Virtual Memory Area)描述具有相同属性的一段虚拟地址区间,例如:

  • 代码与只读数据;
  • 可写数据;
  • 堆和栈;
  • 共享库;
  • 文件映射;
  • 匿名映射。
task_struct
    ↓ mm
mm_struct:描述整个用户态地址空间
    ↓
多个 VMA:分别描述一段连续虚拟地址区间及其权限、映射来源

教材中常用链表、红黑树解释 VMA 的组织。不同内核版本的数据结构会演进,但核心职责不变:快速回答某个虚拟地址是否合法、具有什么权限、对应何种映射。

可以直接观察当前 Shell 的映射:

cat /proc/$$/maps
pmap $$

9.6 为什么一定要有虚拟内存?

如果程序直接操作物理地址,会面临:

  1. 一个进程可能随意破坏其他进程或内核;
  2. 程序每次装载位置不确定,链接和运行困难;
  3. 程序必须关心物理内存碎片和真实分布;
  4. 难以按需分配和延迟加载;
  5. 难以高效实现共享库、文件映射和写时拷贝。

虚拟内存带来的主要价值:

  • 隔离与保护:进程不能仅凭普通用户态指针访问其他进程物理内存;
  • 统一视图:每个进程看到近似连续、规则的虚拟地址布局;
  • 管理解耦:进程管理不必依赖物理页的实际位置;
  • 按需分配:申请虚拟空间不代表立即获得全部物理页;
  • 高效共享:多个进程可以把不同虚拟地址映射到同一物理页;
  • 写时拷贝:父子先共享,真正写入时再复制。

核心设计解读

虚拟地址空间不是“凭空制造更多内存”,而是一层受内核和硬件共同维护的地址抽象。它把进程使用内存的方式与物理页实际位置解耦,同时提供权限边界。交换空间和文件映射可以扩展后备存储能力,但不能让系统无成本地突破物理资源限制。


十、综合实战:观察父子进程、环境继承与回收

下面用一个程序同时验证:

  • PID 与 PPID;
  • 命令行参数;
  • 环境变量继承;
  • fork() 的返回值;
  • 父子变量的虚拟地址和值;
  • waitpid() 回收和退出状态。

10.1 编写实验程序

process_lab.c

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static int g_value = 10;

int main(int argc, char *argv[])
{
    setvbuf(stdout, NULL, _IONBF, 0);

    const char *lab_env = getenv("PROCESS_LAB_ENV");

    printf("before fork: pid=%ld, ppid=%ld\n",
           (long)getpid(),
           (long)getppid());
    printf("argc=%d, argv[0]=%s, env=%s\n",
           argc,
           argv[0],
           lab_env ? lab_env : "(unset)");

    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        g_value = 100;
        printf("child : pid=%ld, ppid=%ld, fork_ret=0, "
               "value=%d, addr=%p\n",
               (long)getpid(),
               (long)getppid(),
               g_value,
               (void *)&g_value);
        _exit(7);
    }

    printf("parent: pid=%ld, child=%ld, fork_ret=%ld, "
           "value=%d, addr=%p\n",
           (long)getpid(),
           (long)child,
           (long)child,
           g_value,
           (void *)&g_value);

    int status = 0;
    if (waitpid(child, &status, 0) == -1) {
        perror("waitpid");
        return 1;
    }

    if (WIFEXITED(status)) {
        printf("reaped child=%ld, exit_code=%d\n",
               (long)child,
               WEXITSTATUS(status));
    } else if (WIFSIGNALED(status)) {
        printf("reaped child=%ld, signal=%d\n",
               (long)child,
               WTERMSIG(status));
    }

    return 0;
}

10.2 编译运行

gcc -Wall -Wextra -g -O0 process_lab.c -o process_lab
export PROCESS_LAB_ENV="inherited-from-shell"
./process_lab alpha beta

可能看到:

before fork: pid=4200, ppid=1800
argc=3, argv[0]=./process_lab, env=inherited-from-shell
parent: pid=4200, child=4201, fork_ret=4201, value=10, addr=0x...
child : pid=4201, ppid=4200, fork_ret=0, value=100, addr=0x...
reaped child=4201, exit_code=7

父子输出顺序可能不同,这是正常调度结果。真正稳定的结论是:

  • 父进程获得子进程 PID,子进程获得 0
  • 子进程继承创建时的环境变量;
  • 父子看到的 &g_value 数字可能相同;
  • 子进程修改 g_value 后,父进程仍保持原值;
  • 父进程通过 waitpid() 读取到退出码 7

10.3 为什么使用 _exit()

fork() 会复制用户态缓冲区状态。如果父子进程都调用 exit(),某些在 fork() 前尚未刷新的缓冲内容可能被重复刷新。

示例先用 setvbuf() 关闭 stdout 缓冲,再让子进程调用 _exit(),目的是让“进程创建和回收”成为观察重点。

10.4 一个可控的僵尸状态实验

zombie_demo.c

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

int main(void)
{
    pid_t child = fork();
    if (child < 0) {
        perror("fork");
        return 1;
    }

    if (child == 0) {
        _exit(42);
    }

    printf("parent=%ld, child=%ld\n",
           (long)getpid(),
           (long)child);
    printf("child remains zombie for about 10 seconds\n");
    sleep(10);

    int status = 0;
    if (waitpid(child, &status, 0) == -1) {
        perror("waitpid");
        return 1;
    }

    printf("child reaped, exit_code=%d\n",
           WIFEXITED(status) ? WEXITSTATUS(status) : -1);
    return 0;
}

终端一:

gcc -Wall -Wextra zombie_demo.c -o zombie_demo
./zombie_demo

终端二,在 10 秒内使用程序输出的 PID:

ps -o pid,ppid,state,cmd -p <parent-pid>,<child-pid>

回收前子进程显示 Z;父进程执行 waitpid() 后,对应 PID 记录消失。这个实验会自动完成回收,不会留下长期僵尸。

实战设计解读

综合实验把多个抽象概念映射到可观测证据:

概念 可观测证据
进程身份 getpid()getppid()
父子分流 fork() 返回值不同
环境继承 子进程可读取 PROCESS_LAB_ENV
地址独立 地址数字可能相同,但变量值互不影响
退出状态 _exit(7)WEXITSTATUS(status)
僵尸回收 waitpid() 前为 Z,回收后 PID 消失

学习操作系统最有效的方式之一,就是把“结构和机制”转换成可重复观察的现象,而不是只记结论。


十一、核心知识点与避坑指南

11.1 进程等于可执行文件吗?

不等于。可执行文件是进程的代码与初始数据来源之一;进程还包含 PCB、地址空间、打开文件、调度状态和其他运行时资源。

11.2 R 状态是不是一定在 CPU 上运行?

不是。它也可能已经位于可运行队列中,具备执行条件但正在等待 CPU。

11.3 为什么 fork() 后不能依赖输出顺序?

父子进程都是独立可运行任务,谁先获得 CPU 由调度器决定。即使某次实验总是父进程先打印,也不能把它当成程序保证。

11.4 fork() 后父子是不是共享所有变量?

普通用户态变量通常位于各自虚拟地址空间中。开始时底层物理页可能因 COW 暂时共享,但一方写入后会获得独立物理页。需要真正共享数据,应使用管道、共享内存、套接字等进程间通信机制。

11.5 僵尸进程还能被 kill -9 杀掉吗?

不能用“再杀一次”解决。它已经退出,剩余的是等待父进程读取的内核记录。应让父进程执行 wait() / waitpid()

11.6 孤儿进程一定由名为 init 的进程收养吗?

不一定。在不同服务、容器和 subreaper 配置下,实际收养者可能不同。可靠方法是查看进程 PPID 和运行环境。

11.7 nice 值越小,程序就一定先完成吗?

不一定。nice 影响普通任务竞争 CPU 的倾向,但完成时间还受 I/O、锁竞争、CPU 亲和性、调度类别和系统负载影响。

11.8 为什么调度器切换进程前要保存寄存器?

寄存器包含程序计数器、栈指针和中间计算结果。若不保存,进程再次运行时就不知道应该从哪条指令、哪个调用栈继续。

11.9 为什么 MY_ENV=value 后 C 程序读不到?

它可能只是当前 Shell 变量。需要导出:

export MY_ENV=value

然后再启动子进程。

11.10 为什么当前目录默认不放进 PATH?

主要是降低命令劫持风险。显式使用 ./app 能清楚表明“我要执行当前目录中的这个文件”。

11.11 malloc() 成功是否表示物理内存已经全部分配?

不一定。它首先获得的是进程虚拟地址空间中的可用区域;物理页可能在真正访问时按需建立。具体行为受分配器、内核策略、内存承诺和资源限制影响。

11.12 虚拟地址空间是不是让内存无限大?

不是。虚拟地址提供统一视图和映射能力,不能消除物理内存、交换空间、磁盘和系统策略的资源上限。资源不足时仍可能出现严重换页、分配失败或进程被终止。

11.13 教材中的内核结构为什么和当前源码不同?

内核持续演进。task_struct、调度队列和 VMA 组织方式都可能变化。学习时应分清:

  • 稳定概念:描述、组织、调度、映射、保护;
  • 版本实现:具体字段、数据结构和算法。

查源码前先确认内核版本,避免把 Linux 2.6 的历史实现当成现代内核细节。


总结

理解 Linux 进程,可以抓住四条主线:

  1. 进程是什么:程序代码和数据、PCB、资源关系共同组成动态执行实体;
  2. 进程能否运行:状态与等待队列记录它是可运行、睡眠、停止还是等待回收;
  3. 谁获得 CPU:调度器依据策略选择任务,上下文切换负责保存与恢复现场;
  4. 进程怎样使用内存:虚拟地址空间、页表和 VMA 提供隔离、映射与按需分配。

fork()、环境变量、僵尸进程和写时拷贝看似是不同主题,其实都建立在这套模型上:内核先描述并组织进程,再管理它的执行状态、资源关系和地址映射。


写在最后

建议你亲手完成文中的两个实验,并在运行期间同时观察:

ps -o pid,ppid,state,pri,ni,cmd
cat /proc/<pid>/status
cat /proc/<pid>/maps

当 PID、状态、退出码和地址映射能够与代码行为一一对应时,“进程”就不再是抽象定义,而会变成一套可观察、可验证的系统模型。

如果本文对你有帮助,欢迎点赞、收藏,也欢迎在评论区分享你在学习 fork() 或虚拟内存时遇到的疑问。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐