进程的概念与状态转换
进程(Process)是正在运行的程序实例——程序是硬盘上的"菜谱",进程是锅里正在炒的"菜"
程序和进程有什么区别?
你手机桌面上有一个”微信”图标——那是程序(Program),是存放在硬盘上的一堆字节。当你点开它,微信开始运行——那是进程(Process)。
📖 程序:静态的,存在磁盘上的一串指令 进程:动态的,是程序的一次执行过程,有生命周期
类比:菜谱 vs 炒菜
程序 = 菜谱 进程 = 炒菜
───────────────── ─────────────────
• 静态的文字和图片 • 动态的烹饪过程
• 存在书架上(磁盘) • 在锅里进行(内存)
• 不会变 • 状态不断变化
• 可以复印多份 • 可以同时炒多份
给不同的人看 用不同的锅
💡 一个程序可以启动多个进程——比如你开三个 Chrome 窗口,就是三个 Chrome 进程。
进程的构成
一个进程在内存中包含以下部分:
高地址
┌──────────────────────┐
│ 栈(Stack) │ ← 局部变量、函数调用、返回地址
├──────────────────────┤
│ ↓ │
│ 闲置区域 │
│ ↑ │
├──────────────────────┤
│ 堆(Heap) │ ← 动态分配的内存(malloc)
├──────────────────────┤
│ 数据段(Data) │ ← 全局变量、静态变量
├──────────────────────┤
│ 代码段(Text) │ ← 程序的指令(只读)
└──────────────────────┘
低地址
| 组成部分 | 存放内容 | 大小变化 |
|---|---|---|
| 代码段 | CPU 执行的指令 | 固定(只读) |
| 数据段 | 全局/静态变量 | 固定 |
| 堆 | malloc 分配的内存 | 动态增长/缩小 |
| 栈 | 局部变量、函数调用 | 动态增长/缩小 |
进程控制块(PCB)
操作系统为每个进程维护一个进程控制块(Process Control Block, PCB)——它是进程的”身份证”:
进程的 PCB 包含:
┌──────────────────────────┐
│ PID(进程 ID) │ ← 唯一标识
│ 状态(就绪/运行/阻塞) │
│ 程序计数器(PC) │ ← 下一条指令地址
│ CPU 寄存器 │ ← 进程被切换时保存
│ 内存地址空间 │ ← 页表基址
│ 打开的文件列表 │
│ I/O 状态 │
│ 调度信息(优先级等) │
└──────────────────────────┘
🏫 类比:学生档案 每个学生入学就建一个档案(PCB),记录学号(PID)、当前状态(在读/休学/毕业)、成绩(寄存器值)、选课列表(打开的文件)等。学生毕业了档案也不会立即销毁——留着做统计。
进程的状态
一个进程在其生命周期中会经历不同的状态:
┌──────────────┐
│ 创建 │
│ New │
└──────┬───────┘
│ admitted(入队)
▼
┌──────────────┐
┌─── │ 就绪 │ ←─────────────┐
│ │ Ready │ │
│ └──────┬───────┘ │
│ │ dispatch(调度选中) │
│ ▼ │
│ ┌──────────────┐ │
│ │ 运行 │ │
│ │ Running │ │
│ └──────┬───────┘ │
│ │ │
│ timeout │ I/O 或事件等待 │
│(时间片到)│ │
│ ▼ │
│ ┌──────────────┐ │
└────│ 阻塞 │───────────────┘
│ Waiting │ I/O 完成或事件到达
└──────────────┘
| 状态 | 含义 | 例子 |
|---|---|---|
| 新建(New) | 进程正在被创建 | 你双击了微信图标 |
| 就绪(Ready) | 进程已就绪,等待 CPU | 排队等打饭 |
| 运行(Running) | CPU 正在执行该进程 | 正在打饭 |
| 阻塞(Waiting) | 进程在等某事件(I/O) | 等米饭蒸熟 |
| 终止(Terminated) | 进程执行完毕 | 吃完饭走了 |
💡 三状态模型(就绪/运行/阻塞)是所有调度算法的基础。你会在进程调度算法中看到操作系统如何在这三个状态之间做决策。
进程如何被创建?
fork()——Unix/Linux 的进程创建方式
#include <stdio.h>
#include <unistd.h>
int main() {
printf("父进程 PID: %d\n", getpid());
pid_t pid = fork(); // ← 关键系统调用
if (pid == 0) {
// 子进程
printf("我是子进程!PID: %d\n", getpid());
} else if (pid > 0) {
// 父进程
printf("我是父进程,刚生了个子进程 PID: %d\n", pid);
} else {
// fork 失败
printf("fork 失败了!\n");
}
return 0;
}
运行结果:
父进程 PID: 1234
我是父进程,刚生了个子进程 PID: 1235
我是子进程!PID: 1235
💡
fork()一次调用,两次返回——子进程从 fork 返回 0,父进程返回子进程的 PID。父进程和子进程的执行顺序是不确定的(取决于调度器)。
fork 的”写时复制”优化
传统 fork 会把父进程的整个内存空间复制给子进程——很慢。现代 OS 使用写时复制(Copy-on-Write, CoW):
fork() 刚发生时:
父进程 ──→ 内存页(标记为共享,只读)
子进程 ──→ 同一份内存页(只读)
│
子进程要写入时:
▼
父进程 ──→ 原来的内存页
子进程 ──→ 新复制的内存页(可以写了)
💡 大多数 fork 之后紧接着就是
execve()(加载新程序)——如果之前真的复制了所有内存,那就白复制了。写时复制只在必要时才复制,大大加快了 fork 速度。
exec()——替换进程内容
fork() 创建了子进程,但子进程和父进程执行同样的代码。要执行不同的程序,需要 exec():
pid_t pid = fork();
if (pid == 0) {
// 子进程中执行"ls"程序
execlp("ls", "ls", "-l", NULL);
// 如果 exec 成功,下面的代码不会执行
printf("这行不会被打印\n");
}
💡 在 Unix/Linux 中,“创建新进程”的典型流程是 fork + exec:先 fork 复制自身,再 exec 加载新程序。Windows 用
CreateProcess一步完成。
进程的终止
进程终止的原因:
- 正常退出:main 函数 return,或调用
exit() - 错误退出:参数错误、文件不存在等
- 致命错误:段错误(Segmentation Fault)、除零等
- 被杀死:被其他进程 kill
#include <stdlib.h>
#include <signal.h>
exit(0); // 正常退出
exit(1); // 错误退出
abort(); // 异常终止
kill(pid, SIGKILL); // 杀死进程(从外部)
小结
| 概念 | 要点 |
|---|---|
| 进程 vs 程序 | 程序是静态的指令集合,进程是动态的执行实例 |
| 进程的组成 | 代码段 + 数据段 + 堆 + 栈 + PCB |
| 进程三状态 | 就绪→运行→阻塞 |
| fork() | Unix/Linux 创建进程的方式,写时复制优化 |
| exec() | 替换进程执行的程序 |
| PCB | 进程的”身份证”,记录所有信息 |
为什么先学这个? 进程是操作系统管理 CPU 的基本单位——理解了进程是什么,才能理解 CPU 如何在进程间切换(上下文切换)和如何调度(进程调度算法)。