【Linux】一文吃透进程概念:从 PCB、fork 到虚拟地址空间

前言

在学习 Linux 系统编程时,很多同学第一次遇到“进程”这个概念,往往会把它简单理解成“正在运行的程序”。这个说法没错,但只说对了一半。

如果站在操作系统内核的视角来看,进程并不是一句“程序跑起来了”就能解释清楚的东西。进程本质上是操作系统进行资源分配、调度和隔离的基本实体。换句话说,进程是操作系统管理 CPU、内存、文件、I/O 等资源时抽象出来的核心对象。

本文我们从操作系统的管理思想出发,依次聊清楚:

  • 操作系统为什么要有进程
  • PCB 和 Linux 中的 task_struct
  • fork() 创建进程的本质
  • Linux 进程状态、僵尸进程和孤儿进程
  • 优先级、上下文切换与 O(1) 调度
  • 环境变量为什么能被子进程继承
  • 程序地址空间与虚拟地址空间的底层逻辑

如果你能把这些知识串起来,进程这块就不再是零散概念,而是一套完整的 OS 管理模型。

一、操作系统的本质:先描述,再组织

学习进程之前,必须先理解操作系统到底在干什么。

从整体上看,操作系统就是一款“搞管理”的软件:

  • 对下:管理硬件资源,比如 CPU、内存、磁盘、网卡、显示器等
  • 对上:给用户程序提供稳定、统一、易用的运行环境

那问题来了,所谓“管理”到底是什么意思?

答案非常朴素:先描述,再组织。

这六个字看似简单,却是操作系统设计的核心方法论。我们可以从三个层面来理解:

1. 描述:建立对象的数字化档案

“描述”的本质是将现实世界中的实体抽象为计算机可以理解和处理的数据结构。就像学校管理学生需要建立学生档案一样,操作系统管理任何资源都需要先创建对应的“档案”:

  • 进程task_struct(PCB)
  • 文件inodedentryfile 结构体
  • 内存区域vm_area_struct
  • 设备device 结构体
  • 网络连接socket 结构体

每个结构体都包含了管理该对象所需的所有属性信息。例如,task_struct 包含了进程 ID、状态、优先级、内存指针、打开文件列表等上百个字段。这种“描述”让操作系统能够以统一的方式看待和管理各种资源。

2. 组织:建立对象之间的关系网络

仅有描述还不够,操作系统需要高效地管理和查找这些对象。这就需要“组织”——用合适的数据结构将它们连接起来:

  • 线性组织:数组、链表用于顺序访问
  • 层次组织:树结构用于文件系统目录
  • 哈希组织:哈希表用于快速查找
  • 优先级组织:堆、优先队列用于调度
  • 空间组织:页表用于内存映射

以进程管理为例,Linux 内核使用:

  • 运行队列(runqueue)组织可运行进程
  • 等待队列(wait_queue)组织等待特定事件的进程
  • PID 哈希表快速根据 PID 查找进程
  • 进程树维护父子关系

3. 管理:基于描述和组织的操作

有了描述和组织,真正的“管理”操作就变得简单而高效:

  • 创建:分配结构体并初始化属性
  • 查找:通过数据结构快速定位
  • 调度:从运行队列中选择合适进程
  • 同步:通过等待队列管理进程等待
  • 销毁:释放结构体并从数据结构中移除

这种“先描述,再组织”的模式贯穿操作系统各个子系统:

管理对象 描述结构 组织方式
进程 task_struct 运行队列、等待队列、PID 哈希表
文件 inode, file 目录树、打开文件表
内存 vm_area_struct 红黑树、链表
设备 device 设备树、总线列表

现实世界的类比

为了更好地理解,我们可以看几个现实世界的例子:

  1. 图书馆管理图书

    • 描述:每本书有 ISBN、书名、作者、分类号、馆藏位置等信息
    • 组织:按分类号排架,建立索引卡片或数据库
    • 管理:借阅、归还、查询、上架都基于这个体系
  2. 医院管理病人

    • 描述:病历记录病人基本信息、病史、诊断、治疗方案
    • 组织:按科室、病区、病情严重程度分类
    • 管理:安排检查、用药、手术、出院都依据病历和组织结构
  3. 电商平台管理商品

    • 描述:商品有 ID、名称、价格、库存、分类、商家等信息
    • 组织:按分类、销量、价格、商家等多维度索引
    • 管理:搜索、推荐、下单、库存扣减都依赖这些数据

操作系统的具体体现

在 Linux 内核中,这种思想无处不在:

// 描述:定义进程控制块
struct task_struct {
    pid_t pid;                    // 进程ID
    long state;                   // 进程状态
    struct mm_struct *mm;         // 内存描述符
    struct files_struct *files;   // 打开文件表
    // ... 上百个字段
};

// 组织:将进程加入运行队列
void activate_task(struct rq *rq, struct task_struct *p, int flags)
{
    enqueue_task(rq, p, flags);  // 入队操作
    // ...
}

// 管理:调度器选择进程
static struct task_struct *pick_next_task(struct rq *rq)
{
    // 从运行队列中选择下一个要运行的进程
    return rq->curr;
}

为什么这种设计有效?

  1. 解耦:描述关注“是什么”,组织关注“怎么找”,管理关注“怎么做”,三者分离使得系统更清晰
  2. 可扩展:新增资源类型只需定义新的描述结构,不影响现有组织和管理逻辑
  3. 高效:合适的数据结构(如哈希表、红黑树)让查找、插入、删除操作高效
  4. 统一:不同资源采用相同模式管理,降低系统复杂度

从描述到组织的转换过程

当操作系统启动或创建新对象时,遵循这样的流程:

创建对象 → 分配描述结构体 → 初始化属性 → 插入到合适的数据结构 → 开始管理

例如创建进程:

  1. 分配 task_struct 内存
  2. 初始化 PID、状态、优先级等字段
  3. 设置父进程指针,建立进程树关系
  4. 根据优先级插入到对应运行队列
  5. 调度器开始考虑调度该进程

总结“先描述,再组织”的核心价值

这种设计模式让操作系统能够:

  • 以数据为中心,而不是以代码为中心
  • 通过数据结构的变化反映系统状态的变化
  • 用统一的框架管理多样化的资源
  • 方便监控、调试和优化(因为一切都有明确的数据表示)

理解了“先描述,再组织”,我们就能明白为什么操作系统内核中有那么多结构体定义,为什么要有各种链表、树、队列。这不是为了增加复杂度,而是为了降低复杂度——通过良好的抽象和数据结构设计,让混乱的现实世界变得有序、可管理。

比如学校要管理学生,不能只靠老师脑子记,必须先给每个学生建立档案。档案中包含姓名、学号、班级、成绩、状态等信息。然后再把这些档案按班级、年级、学院等方式组织起来。

操作系统管理硬件、文件、进程也是类似思路:

  • 描述对象:用结构体记录对象属性
  • 组织对象:用链表、数组、树、队列等数据结构管理对象

所以进程管理也一样。操作系统想管理进程,必须先把进程描述起来,再把进程组织起来。

这就引出了 PCB。

二、进程是什么:不只是“运行起来的程序”

教科书上常见定义是:

进程是程序的一次执行实例。

这个定义适合入门,但如果从内核角度来看,进程更准确的说法是:

进程是操作系统分配系统资源的基本实体。

程序是静态的,通常是磁盘上的一个可执行文件;进程是动态的,是程序被加载到内存并参与 CPU 调度之后形成的运行实体。

举个例子:

./test
./test
./test

同一个可执行程序可以运行多次,对应多个不同的进程。它们使用的是同一份程序代码,但拥有各自的进程属性、资源信息和运行状态。

所以一句话总结:

程序是文件,进程是资源分配和调度实体。

三、PCB:操作系统眼中的进程档案

操作系统不会凭空“认识”一个进程,它认识的是描述进程的数据结构,这个结构就叫 PCB。

PCB 全称是 Process Control Block,即进程控制块。它可以理解为进程的属性集合。

在 Linux 内核中,PCB 的具体实现就是 task_struct

task_struct 中保存了大量和进程相关的信息,例如:

  • 标识符:比如 PID,用来唯一标识一个进程
  • 状态:运行、睡眠、停止、僵尸等状态
  • 优先级:决定调度时谁更容易获得 CPU
  • 程序计数器:记录下一条即将执行的指令
  • 内存指针:指向代码段、数据段、堆、栈等内存区域
  • 上下文数据:进程切换时需要保存的寄存器信息
  • I/O 信息:打开的文件、使用的设备、I/O 请求等
  • 记账信息:CPU 使用时间、运行时长等

有了 task_struct,操作系统就能做到“描述进程”。接下来,内核还需要把大量进程组织起来。

Linux 中所有正在运行或等待运行的进程,本质上都会以某种内核数据结构组织起来,比如链表、运行队列、红黑树等。

这也是 OS 管理思想的落地:

描述进程靠 task_struct,组织进程靠内核数据结构。

四、如何查看进程:/proc、ps 与 top

Linux 给用户提供了很多观察进程的方式。

最底层、最直观的方式是 /proc 文件系统。比如:

ls /proc/1

这里的 1 是 PID,/proc/1 中保存了 1 号进程的相关信息。

平时更常用的是:

ps aux
ps axj
top

其中:

  • ps aux:查看系统中大量进程的基本信息
  • ps axj:查看进程组、会话、父进程等信息
  • top:动态观察进程状态、CPU 和内存占用

在 C 语言中,也可以通过系统调用获取当前进程和父进程 ID:

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

int main()
{
    printf("pid: %d\n", getpid());
    printf("ppid: %d\n", getppid());
    return 0;
}

getpid() 获取当前进程 ID,getppid() 获取父进程 ID。

五、fork:一个函数,两个返回值

Linux 中创建进程最经典的方式是 fork()

先看代码:

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

int main()
{
    int ret = fork();

    if (ret < 0) {
        perror("fork");
        return 1;
    } else if (ret == 0) {
        printf("I am child, pid: %d, ret: %d\n", getpid(), ret);
    } else {
        printf("I am parent, pid: %d, ret: %d\n", getpid(), ret);
    }

    return 0;
}

fork() 最让初学者疑惑的地方是:它为什么会有两个返回值?

原因很简单:fork() 调用成功后,执行流变成了两个。

  • 父进程中,fork() 返回子进程 PID
  • 子进程中,fork() 返回 0
  • 如果创建失败,返回负数

注意,这不是一个函数“真的返回了两次”,而是父子进程都从 fork() 返回后的代码位置继续执行。

更进一步,父子进程具有如下特点:

  • 代码共享
  • 数据各自私有
  • 刚创建时内容看似一样
  • 后续修改互不影响

这里背后有一个非常重要的机制:写时拷贝。

fork() 后,父子进程并不会立刻把所有数据复制一份,否则成本太高。操作系统会让它们先共享同一份物理内存,并把相关页标记为只读。当某一方尝试写入时,再触发缺页异常,由 OS 复制出新的物理页。

这就是为什么 fork() 看起来复制了进程,但性能并没有想象中那么夸张。

六、Linux 进程状态:R 不一定正在运行

Linux 中进程有多种状态,常见的包括:

  • R:Running,运行状态
  • S:Sleeping,可中断睡眠
  • D:Disk sleep,不可中断睡眠,通常等待 I/O
  • T:Stopped,停止状态
  • Z:Zombie,僵尸状态
  • X:Dead,死亡状态,通常不会在任务列表中看到

这里有一个细节非常关键:

R 状态并不代表进程一定正在 CPU 上运行,它也可能在运行队列中等待调度。

CPU 个数是有限的,而系统进程数量通常远多于 CPU 数量。一个进程只要处于可运行状态,就可能显示为 R,但它未必正在占用 CPU。

S 状态也很常见,比如进程在等待键盘输入、网络数据、定时器等事件时,就可能进入睡眠状态。

D 状态一般和 I/O 等不可中断等待相关,如果系统中大量进程长期处于 D 状态,往往要重点排查磁盘、文件系统或驱动层面的问题。

七、僵尸进程:进程死了,但 PCB 还在

僵尸进程是 Linux 进程学习中的高频考点。

当子进程退出后,它的代码和数据资源大多已经释放,但退出状态仍需要保留,因为父进程可能要通过 wait()waitpid() 获取子进程的退出结果。

如果父进程一直不回收子进程,那么子进程就会进入 Z 状态,也就是僵尸状态。

伪代码如下:

pid_t id = fork();

if (id == 0) {
    exit(0);
} else {
    sleep(30);
}

子进程先退出,父进程还活着但没有调用 wait(),此时子进程的 task_struct 不能被完全释放,于是形成僵尸进程。

僵尸进程的危害在于:

  • 它已经不运行,但仍占用 PCB 等内核资源
  • 如果大量产生,会造成资源泄漏
  • 严重时可能影响系统创建新进程

解决方式也很明确:

父进程必须回收子进程。

常用方式是调用 wait()waitpid()

八、孤儿进程:父进程提前退出怎么办

如果父进程先退出,而子进程还在运行,那么这个子进程就变成孤儿进程。

孤儿进程并不可怕,因为它会被 1 号进程领养。在传统 Linux 系统中,1 号进程通常是 init;在现代发行版中,通常是 systemd

被领养后,孤儿进程退出时会由 1 号进程负责回收。

所以要区分:

  • 僵尸进程:子进程退出了,父进程没回收
  • 孤儿进程:父进程退出了,子进程还在运行

僵尸进程是需要重点处理的问题;孤儿进程本身是系统正常机制的一部分。

九、优先级、nice 值与进程调度

CPU 是稀缺资源,进程之间天然具有竞争性。操作系统要决定谁先运行、谁后运行,这就需要优先级。

在 Linux 中,常见字段包括:

  • PRI:进程优先级
  • NI:nice 值,用来修正优先级

需要注意:

nice 值不是优先级本身,而是影响优先级的修正值。

nice 值范围通常是 -20 ~ 19

  • nice 值越小,进程越“不客气”,优先级更高
  • nice 值越大,进程越“谦让”,优先级更低

可以使用如下命令调整:

nice -n 10 ./a.out
renice -n 5 -p 进程PID

也可以在 top 中按 r,输入 PID 和 nice 值进行调整。

这部分要结合几个概念一起理解:

  • 竞争性:进程争夺 CPU 等资源
  • 独立性:每个进程拥有独立资源,互不干扰
  • 并行:多个 CPU 上多个进程真正同时运行
  • 并发:单 CPU 上通过快速切换让多个进程都向前推进

并发不是同一时刻真正一起运行,而是时间片切换造成的宏观同时。

十、上下文切换:CPU 保存现场再恢复现场

进程切换的核心是 CPU 上下文切换。

当操作系统决定暂停当前进程、运行另一个进程时,需要保存当前进程的运行现场,包括:

  • 程序计数器
  • 通用寄存器
  • 栈指针
  • 状态寄存器
  • 其他体系结构相关信息

这些信息会保存到当前进程的上下文中。等下次该进程重新被调度时,再把这些寄存器内容恢复回来,进程就能从上次暂停的位置继续执行。

所以进程切换并不是简单地“换个程序跑”,它本质上是:

保存当前 CPU 上下文,恢复下一个进程的 CPU 上下文。

这也是为什么上下文切换不是免费的。频繁切换会带来额外开销,包括寄存器保存恢复、缓存失效、TLB 影响等。

十一、Linux 2.6 的 O(1) 调度思想

Linux 2.6 曾使用 O(1) 调度器。所谓 O(1),指的是选择下一个可运行进程的时间复杂度不随进程数量增长。

它的核心结构可以概括为:

  • 每个 CPU 有自己的运行队列 runqueue
  • 普通优先级范围是 100 ~ 139
  • 实时优先级范围是 0 ~ 99
  • 每个优先级对应一个队列
  • 使用 bitmap 快速定位非空队列
  • 使用 active / expired 两组队列管理时间片

活动队列 active 保存时间片尚未耗尽的进程;过期队列 expired 保存时间片已经耗尽、等待重新分配时间片的进程。

调度时,内核会从 bitmap 中快速找到最高优先级的非空队列,然后取出队首进程运行。

当 active 队列耗尽后,只需要交换 active 和 expired 指针,新的调度周期就开始了。

这个设计的精髓在于:

用优先级数组和 bitmap,把“查找最合适进程”的成本压到常数级。

虽然现代 Linux 已经使用 CFS 等更复杂的调度器,但 O(1) 调度器对于理解调度设计思想仍然非常有价值。

十二、环境变量:进程运行环境的一部分

环境变量是操作系统提供给进程的一组运行参数。

常见环境变量包括:

  • PATH:命令搜索路径
  • HOME:用户家目录
  • SHELL:当前 shell

比如执行命令时,为什么 ls 可以直接运行,而自己编译出来的 hello 通常要写成 ./hello

原因就在于 PATH

Shell 执行命令时,会到 PATH 指定的目录中查找可执行程序。如果当前目录不在 PATH 里,就必须显式写路径。

可以这样查看:

echo $PATH
echo $HOME
env

也可以这样设置:

export MYENV="hello linux"
unset MYENV

在 C 程序中,环境变量可以通过第三个参数获取:

#include <stdio.h>

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

也可以通过 getenv() 获取:

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

int main()
{
    printf("%s\n", getenv("PATH"));
    return 0;
}

环境变量还有一个非常重要的特性:

环境变量可以被子进程继承。

这是因为子进程是以父进程为模板创建出来的,环境表自然也会被带过去。

但注意,Shell 中普通变量和环境变量不是一回事:

MYENV="hello"

这只是 Shell 本地变量,子进程不一定能看到。

只有导出后:

export MYENV="hello"

它才会进入子进程环境表。

十三、程序地址空间:C 语言看到的地址是真的吗?

在 C/C++ 中,我们经常打印变量地址:

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

int g_val = 0;

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

    if (id == 0) {
        g_val = 100;
        printf("child: %d, addr: %p\n", g_val, &g_val);
    } else {
        sleep(3);
        printf("parent: %d, addr: %p\n", g_val, &g_val);
    }

    return 0;
}

你可能会观察到一个非常有意思的现象:

child: 100, addr: 0x80497e8
parent: 0, addr: 0x80497e8

父子进程打印出的地址一样,但变量值不一样。

这说明什么?

如果这个地址是物理地址,那么同一个地址上的内容不应该一边是 100,一边是 0。

所以结论非常关键:

C/C++ 中看到的地址不是物理地址,而是虚拟地址。

每个进程都有自己独立的虚拟地址空间。父子进程可以拥有相同的虚拟地址,但这些虚拟地址最终可以映射到不同的物理内存。

操作系统负责通过页表完成虚拟地址到物理地址的转换。

十四、mm_struct 与 vm_area_struct:进程地址空间的内核描述

既然每个进程都有独立地址空间,那么 Linux 内核也必须描述和管理它。

在内核中,进程地址空间主要由 mm_struct 描述。普通用户进程的 task_struct 中会有指针指向自己的 mm_struct

可以简单理解为:

task_struct -> mm_struct -> vm_area_struct

其中:

  • task_struct 描述进程
  • mm_struct 描述整个进程地址空间
  • vm_area_struct 描述某一段连续虚拟内存区域

例如代码段、数据段、堆、栈、共享库映射区、环境变量区域等,都可以通过不同的 VMA 来描述。

当 VMA 数量较少时,可以用链表组织;当 VMA 数量较多时,可以用红黑树提高查找效率。

这再次体现了操作系统的管理套路:

用结构体描述对象,用数据结构组织对象。

进程如此,地址空间也是如此。

十五、为什么必须有虚拟地址空间

如果程序直接访问物理内存,会出现很多问题。

第一,安全性无法保证。

如果所有进程都能随便读写物理内存,那么一个异常程序就可能修改其他进程甚至内核的数据,系统稳定性和安全性都无从谈起。

第二,地址不稳定。

程序每次加载到物理内存的位置可能都不同。如果程序直接使用物理地址,编译、链接、加载都会变得极其复杂。

第三,内存管理效率低。

如果进程必须整体装入连续物理内存,那么内存碎片和交换成本都会很高。虚拟内存和分页机制可以让程序的虚拟空间连续,而物理内存可以离散分配。

第四,可以实现延迟分配。

当我们调用 malloc()new 时,很多情况下只是先在虚拟地址空间中申请一段区域,物理内存未必立刻分配。只有真正访问时,操作系统才可能通过缺页异常分配物理页并建立页表映射。

所以虚拟地址空间的价值在于:

  • 保护进程间的数据隔离
  • 让每个进程看到统一、有序的地址布局
  • 解耦进程管理和物理内存管理
  • 支持分页、换页、共享库、写时拷贝等高级机制

这也是现代操作系统内存管理的核心基础。

总结

进程不是孤立概念,而是操作系统管理思想的一次完整落地。

从宏观上看,操作系统通过“描述 + 组织”的方式管理一切资源。进程通过 task_struct 描述,通过运行队列等结构组织;地址空间通过 mm_struct 描述,通过 VMA 链表或红黑树组织;调度器通过优先级队列和 bitmap 提高选择效率。

从运行机制上看,fork() 创建子进程,父子进程代码共享、数据私有;进程状态反映其调度和等待情况;僵尸进程说明父进程回收机制的重要性;环境变量体现了父子进程之间运行环境的继承关系;虚拟地址空间则保证了进程隔离、安全和内存管理效率。

最后用一句话收尾:

进程是程序运行的表象,PCB 是操作系统管理进程的抓手,虚拟地址空间是进程独立性的根基,调度器则决定了进程如何在 CPU 上真正跑起来。

理解到这一层,再回头看 Linux 进程,就不再只是会敲 pstop,而是真的能从内核管理的角度看懂它为什么这样设计。

Logo

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

更多推荐