【操作系统】进程相关概念
进程不是简单的“运行中的程序”。站在操作系统的角度看,进程是内核进行资源分配与调度的基本实体。本文从计算机体系结构出发,依次介绍操作系统如何管理进程、PCB、
fork()、进程状态、调度、环境变量和虚拟地址空间。
一、理解进程前:冯诺依曼体系结构
现代计算机大多遵循冯诺依曼体系结构,主要由以下部分组成:
- 输入设备:键盘、鼠标、网卡等;
- 存储器:这里主要指内存;
- CPU:包括运算器、控制器和寄存器;
- 输出设备:显示器、打印机等。
一个关键结论是:CPU 主要与内存交换数据,外设的数据也要先进入内存,才能被 CPU 处理。
例如发送一条聊天消息时,数据大致经历以下过程:
键盘输入
↓
数据进入内存
↓
CPU 执行聊天软件代码,对消息进行处理
↓
操作系统通过网络驱动控制网卡发送数据
↓
对方网卡接收数据,并写入对方计算机内存
↓
对方 CPU 处理消息,最终显示在屏幕上
由此也能看出,操作系统位于应用程序与硬件之间,是二者沟通的桥梁。
二、操作系统如何进行“管理”
操作系统的主要目的有两个:
- 向下管理计算机的软硬件资源;
- 向上为应用程序提供稳定、安全、易用的运行环境。
操作系统管理对象时通常采用“先描述,再组织”的方法:
硬件或软件对象 → 用结构体描述属性 → 用链表、树等数据结构组织
例如,内核要管理大量进程,就先为每个进程建立描述信息,再把这些信息组织起来,以便查找、调度和回收。
操作系统还会通过系统调用向用户程序提供服务。系统调用比较底层,C 标准库等用户层库会在其上进行封装,让开发者更方便地使用操作系统能力。
三、什么是进程
从用户视角看,进程是程序的一次执行实例;从内核视角看,进程是分配 CPU 时间、内存等系统资源的实体。
可以将进程概括为:
进程 = 程序代码和数据 + 内核维护的进程控制信息
程序文件静静地存放在磁盘中,是一个静态概念;进程则具有状态、优先级、地址空间和执行上下文,是一个动态概念。同一个程序可以被启动多次,从而产生多个彼此独立的进程。
1. PCB 与 task_struct
描述进程的结构称为 PCB(Process Control Block,进程控制块)。Linux 内核使用 task_struct 描述进程,它包含的主要信息包括:
- 标识符:PID、PPID 等;
- 进程状态:运行、睡眠、停止、僵尸等;
- 优先级与调度信息;
- 程序计数器;
- CPU 寄存器上下文;
- 内存和地址空间信息;
- 已打开文件与 I/O 信息;
- CPU 使用时间等记账信息。
因此,创建进程不仅意味着把代码加载到内存,也意味着内核要创建和维护对应的 PCB。
2. 查看进程
Linux 使用 /proc 虚拟文件系统暴露进程信息。例如:
# 查看 PID 为 1 的进程信息
ls /proc/1
# 查看当前进程
ps aux
# 动态观察系统进程
top
也可以通过系统调用获得当前进程和父进程的编号:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
printf("pid = %d\n", getpid());
printf("ppid = %d\n", getppid());
return 0;
}
四、使用 fork 创建子进程
Linux 可以使用 fork() 创建子进程:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) {
printf("child: pid=%d, ppid=%d\n", getpid(), getppid());
} else {
printf("parent: pid=%d, child_pid=%d\n", getpid(), id);
}
return 0;
}
fork() 调用一次,却会在父、子两个进程中分别返回:
- 父进程得到子进程的 PID;
- 子进程得到
0; - 创建失败时返回
-1。
父子进程创建后共享同一份代码,但拥有相互独立的数据空间。Linux 通常采用写时拷贝(Copy-On-Write):刚创建时尽可能共享物理页面,某一方尝试修改数据时,再复制对应页面。这能减少不必要的内存复制。
五、Linux 的进程状态
常见进程状态如下:
| 状态 | 含义 |
|---|---|
R |
正在 CPU 上运行,或位于运行队列中等待调度 |
S |
可中断睡眠,正在等待某个事件 |
D |
不可中断睡眠,通常正在等待 I/O |
T |
被暂停或停止 |
Z |
僵尸状态,进程已退出但退出信息尚未被回收 |
X |
死亡状态,通常难以在进程列表中观察到 |
可通过以下命令查看状态:
ps aux
ps axj
ps -l
需要注意,R 不等于“此刻一定占用 CPU”,它也可能表示进程已经准备好,只是在运行队列中等待。
1. 僵尸进程
子进程退出后,内核会暂时保留其退出码等信息,等待父进程调用 wait() 或 waitpid() 读取。如果父进程一直不回收,子进程就会成为僵尸进程。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
if (id == 0) {
printf("child exits: %d\n", getpid());
exit(0);
}
waitpid(id, NULL, 0); // 回收子进程
printf("child has been reaped\n");
return 0;
}
僵尸进程已经不再执行代码,但它的 PCB 等退出信息仍占用内核资源。若大量产生且长期不回收,可能耗尽可用的进程表项。
2. 孤儿进程
如果父进程先退出,而子进程仍在运行,该子进程就成为孤儿进程。系统会将其交由特定的系统进程接管,并在其退出后负责回收。
二者不要混淆:
- 僵尸进程:子进程已经退出,父进程尚未回收;
- 孤儿进程:父进程先退出,子进程仍在运行。
六、进程优先级、并发与并行
系统中的进程数量往往多于 CPU 核心数量,所以进程会竞争 CPU。调度器根据调度策略、优先级和时间片等因素选择下一个运行的进程。
使用 ps -l 时,经常会看到:
PID:进程编号;PPID:父进程编号;PRI:调度优先级相关信息;NI:nice 值。
nice 值通常位于 -20 到 19 之间。它是影响调度优先级的参数,而不是优先级本身。普通用户降低 nice 值通常会受到权限限制。
# 启动程序时指定 nice 值
nice -n 10 ./a.out
# 修改已有进程的 nice 值
renice 5 -p 进程PID
几个容易混淆的概念:
- 竞争性:多个进程会竞争有限的 CPU 和其他系统资源;
- 独立性:不同进程拥有独立的地址空间,正常情况下互不干扰;
- 并发:多个任务在一段时间内交替推进;
- 并行:多个任务在多个 CPU 核心上同时执行。
进程切换
当 CPU 从进程 A 切换到进程 B 时,内核需要保存 A 的寄存器、程序计数器等上下文,再恢复 B 先前保存的上下文:
进程 A 运行
↓ 保存 A 的 CPU 上下文
调度器选择进程 B
↓ 恢复 B 的 CPU 上下文
进程 B 继续运行
上下文切换保证了多个进程可以交替执行,但切换本身也存在时间成本,因此并非越频繁越好。
关于 Linux 2.6 的 O(1) 调度器
课件中的活动队列、过期队列、优先级数组和位图,是 Linux 2.6 早期 O(1) 调度器的典型设计:
- 时间片未耗尽的进程位于活动队列;
- 时间片耗尽的进程转移到过期队列;
- 位图帮助内核快速找到最高优先级的非空队列;
- 活动队列为空时,交换活动与过期队列的指针。
其选取任务的时间复杂度不随进程数量线性增长,因此称为 O(1) 调度器。这部分适合用来理解调度思想,但属于特定历史版本的实现,不应直接等同于所有现代 Linux 内核的调度机制。
七、命令行参数与环境变量
环境变量是操作系统和程序运行环境中的一组键值信息。常见变量包括:
PATH:命令搜索路径;HOME:用户主目录;SHELL:当前 Shell;PWD:当前工作目录。
常用命令如下:
echo "$PATH" # 查看变量
env # 查看已导出的环境变量
export MYENV=hello # 导出环境变量
unset MYENV # 删除变量
Shell 执行 hello 时会到 PATH 所列目录中搜索可执行文件;执行 ./hello 则明确指定当前目录下的文件。
程序可通过 getenv() 获取环境变量:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *path = getenv("PATH");
if (path != NULL) {
printf("PATH=%s\n", path);
}
return 0;
}
环境变量会随进程环境传给新创建或新执行的程序。普通 Shell 变量若没有 export,通常不会进入子进程的环境表。
八、进程地址空间与虚拟地址
一个典型进程的用户地址空间可以抽象为:
用户在 C/C++ 程序中看到的地址通常是虚拟地址,不是可直接观察到的物理内存地址。操作系统与硬件的内存管理单元会依据页表,把虚拟地址转换为物理地址。
fork() 后,父子进程中同一变量可能打印出相同的虚拟地址,却保存不同的值:这是因为两个进程拥有各自独立的地址空间,相同的虚拟地址可以映射到不同的物理页面。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int g_value = 0;
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) {
g_value = 100;
printf("child: value=%d, address=%p\n", g_value, (void *)&g_value);
exit(0);
} else {
waitpid(id, NULL, 0);
printf("parent: value=%d, address=%p\n", g_value, (void *)&g_value);
}
return 0;
}
为什么需要虚拟地址空间
虚拟地址空间主要带来以下好处:
- 安全隔离:一个普通进程不能随意读写其他进程或内核的物理内存;
- 地址稳定:程序不必关心每次运行时被装入物理内存的具体位置;
- 按需分配:申请虚拟空间不一定立即得到全部物理页面,实际访问时再按需建立映射;
- 提高利用率:内存可以分页管理,不必把进程视为一个连续的巨大物理内存块;
- 模块解耦:进程管理看到的是有序的虚拟空间,内存管理负责实际物理页面的分配与映射。
在 Linux 内核中,进程的内存空间由相关内存描述结构管理,并进一步使用虚拟内存区域(VMA)描述代码段、数据段、堆、栈和内存映射区等不同区域。
九、总结
本文的核心结论可以归纳为:
- 操作系统通过“先描述、再组织”的方式管理软硬件资源;
- 进程是程序的一次执行实例,也是内核分配资源和进行调度的实体;
- PCB 保存进程状态、上下文、内存、文件和调度等关键信息;
fork()创建子进程,父子进程代码共享、数据独立,并常借助写时拷贝提高效率;- 僵尸进程需要父进程回收,孤儿进程则会被系统接管;
- 并发依赖调度和上下文切换,并行则依赖多个 CPU 核心;
- 环境变量描述程序运行环境,并可传递给子进程;
- 虚拟地址空间提供安全隔离、按需分配和统一的地址视图。
理解这些概念后,再学习进程控制、进程间通信、信号、线程和内存管理,就会顺畅许多。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)