02|进程管理
如果操作系统是一家公司,进程就是公司里的一个个项目。如何立项、如何推进、如何调度资源,决定了整个公司的运转效率。本章将带你深入理解进程管理的方方面面。
📋 开篇自测:你已经知道多少?
- 程序和进程有什么区别?它们之间是什么关系?
- 你能画出进程状态转换图吗?一个进程从创建到终止,会经历哪些状态?
- Linux 中 fork() 和 exec() 分别做了什么?为什么要把”创建进程”拆成两步?
一、从程序到进程:静态与动态的分界线
1.1 程序 vs 进程
打个比方:菜谱是程序,做菜的过程是进程。
菜谱(程序)写在纸上不会变,它是一段静态的指令集合,安安静静地躺在硬盘里。而当你打开灶台开始按菜谱做菜时,“做菜”这个动态过程就是进程——它有开始、有进行、有结束,它占用着灶台(CPU)、锅碗瓢盆(内存)、食材(数据)。
更精确地说:
| 维度 | 程序 (Program) | 进程 (Process) |
|---|---|---|
| 本质 | 磁盘上的可执行文件 | 正在运行的程序实例 |
| 状态 | 静态 | 动态 |
| 存储位置 | 硬盘 | 内存 |
| 生命周期 | 持久存在 | 有创建和终止 |
| 关系 | 一个程序 | 可对应多个进程 |
一个 Chrome 浏览器程序(/usr/bin/google-chrome)可以同时产生几十个进程——每个标签页就是一个独立进程。
1.2 进程的内存布局
当一个程序被加载到内存中成为进程时,操作系统会为它划分出这样一片内存空间:
高地址
+---------------------------+ 0xFFFFFFFF (32位系统)
| |
| 内核空间 | (用户进程不可直接访问)
| |
+---------------------------+ 0xC0000000
| 栈 (Stack) | 局部变量、函数调用链
| | | 从高地址向低地址增长
| v |
| |
| 空闲区域 |
| |
| ^ |
| | |
| 堆 (Heap) | malloc/new 动态分配
+---------------------------+
| BSS 段 | 未初始化的全局/静态变量
+---------------------------+
| Data 段 | 已初始化的全局/静态变量
+---------------------------+
| Text 段 | 程序代码(只读)
+---------------------------+ 0x08048000
低地址
每个进程都有这样一份独立的内存空间——进程 A 无法直接访问进程 B 的内存。这就是进程隔离,操作系统通过虚拟内存机制来保证这一点(第 5 章详细讲解)。
二、进程控制块(PCB):进程的”身份证”
2.1 什么是 PCB
操作系统需要管理成百上千个进程,它如何记住每个进程的状态呢?靠的就是进程控制块(Process Control Block, PCB)。
PCB 是内核中一个数据结构,Linux 中对应的是 task_struct,它是 Linux 内核中最庞大的数据结构之一,包含了描述一个进程所需的全部信息:
+------------------------------------------+
| task_struct (PCB) |
+------------------------------------------+
| 进程标识信息 |
| - pid: 进程ID |
| - tgid: 线程组ID |
| - ppid: 父进程ID |
| - uid/gid: 用户/组ID |
+------------------------------------------+
| 进程状态信息 |
| - state: 运行/就绪/阻塞/僵尸/停止 |
| - exit_code: 退出码 |
| - flags: 进程标志位 |
+------------------------------------------+
| CPU 上下文信息 |
| - 寄存器值 (通用寄存器、PC、SP等) |
| - 浮点寄存器状态 |
+------------------------------------------+
| 调度信息 |
| - priority: 优先级 |
| - policy: 调度策略 |
| - cpu_allowed: 允许运行的CPU核 |
+------------------------------------------+
| 内存管理信息 |
| - mm_struct: 虚拟地址空间描述 |
| - 页表指针 |
+------------------------------------------+
| 文件系统信息 |
| - files_struct: 打开的文件描述符表 |
| - fs_struct: 文件系统相关信息 |
+------------------------------------------+
| 信号处理信息 |
| - 待处理信号、信号掩码 |
| - 信号处理函数指针 |
+------------------------------------------+
| 其他 |
| - 计时信息、资源限制、命名空间等 |
+------------------------------------------+
2.2 进程 ID 与进程树
每个进程都有一个唯一的进程 ID(PID),从 1 开始编号。在 Linux 中,PID 为 1 的进程是 init(或现代系统中的 systemd),它是所有进程的祖先。
# 查看进程树
pstree -p
systemd(1)─┬─sshd(1234)───sshd(5678)───bash(5680)───vim(5700)
├─nginx(2000)─┬─nginx(2001)
| └─nginx(2002)
├─mysqld(3000)
└─cron(4000)
所有进程构成一棵树形结构——每个进程都有父进程(除了 PID=1),可以有零个或多个子进程。这种关系通过 PCB 中的 parent 指针和 children 链表维护。
三、进程状态转换:生命周期的五个阶段
3.1 五状态模型
一个进程在其生命周期中,会在以下状态之间转换:
创建(fork)
|
v
+---------------+
| 新建态 |
| (Created) |
+-------+-------+
|
| 内核初始化完成
v
+---------------+ 时间片用完
| 就绪态 | <--------------+
| (Ready) | |
+-------+-------+ |
| |
| 被调度器选中 |
v |
+---------------+ |
I/O完成 | 运行态 | ------->--------+
+-------> | (Running) | 被抢占
| +-------+-------+
| | \
| 等待I/O | \ 调用exit()
| 等待信号 | \
| v v
| +---------------+ +---------------+
| | 阻塞态 | | 终止态 |
+--------- | (Blocked) | | (Terminated) |
+---------------+ +---------------+
新建态(Created):进程刚被创建,内核正在为它分配资源、初始化 PCB。
就绪态(Ready):进程已准备好运行,只等待 CPU 的分配。就绪队列中可能有很多进程在排队。
运行态(Running):进程正在 CPU 上执行指令。在单核 CPU 上,同一时刻只有一个进程处于运行态。
阻塞态(Blocked/Waiting):进程因为等待某个事件(I/O 完成、获取锁、接收信号等)而暂时无法继续执行。即使给它 CPU 也没用。
终止态(Terminated):进程执行完毕或被强制终止,等待父进程回收资源。
3.2 Linux 中的进程状态
Linux 内核中的进程状态比经典五状态模型更细致:
TASK_RUNNING (R) 运行或就绪 (Linux 不区分这两种)
TASK_INTERRUPTIBLE (S) 可中断睡眠 (等待事件,可被信号唤醒)
TASK_UNINTERRUPTIBLE(D) 不可中断睡眠 (等待I/O,不响应信号)
TASK_STOPPED (T) 停止 (收到 SIGSTOP 信号)
TASK_TRACED (t) 被调试器跟踪
EXIT_ZOMBIE (Z) 僵尸进程 (已终止但父进程未回收)
EXIT_DEAD (X) 彻底死亡
# 查看进程状态
ps aux
# 输出示例:
# USER PID %CPU %MEM STAT COMMAND
# root 1 0.0 0.1 Ss /sbin/init
# www 2001 0.5 1.2 S nginx: worker
# root 3000 2.3 5.6 Sl /usr/sbin/mysqld
# user 5700 0.0 0.3 T vim myfile.txt
# user 6000 0.0 0.0 Z [myapp] <defunct>
注意 STAT 列:S 表示睡眠、R 表示运行、T 表示停止、Z 表示僵尸。后面的小写字母是修饰符,比如 s 表示会话领导者,l 表示多线程,+ 表示前台进程组。
3.3 僵尸进程与孤儿进程
僵尸进程(Zombie):子进程已经终止了,但父进程还没有调用 wait()/waitpid() 来读取它的退出状态。此时子进程的 PCB 还占用着内核中的一个条目(虽然已不占用内存和 CPU),状态显示为 Z。
孤儿进程(Orphan):父进程先于子进程终止了。此时子进程会被 PID=1 的 init/systemd “收养”,由 init 负责回收它的资源。
正常流程:
父进程 fork() --> 子进程运行 --> 子进程 exit()
| |
+--- wait() 回收子进程 <--------+
僵尸进程:
父进程 fork() --> 子进程运行 --> 子进程 exit()
| |
| (忘记调用 wait!) v
| 僵尸状态 (Z)
| PCB 残留在内核
孤儿进程:
父进程 fork() --> 子进程运行中...
|
父进程 exit()
|
init/systemd 接管 --> 后续负责 wait() 回收子进程
大量僵尸进程会耗尽系统的 PID 资源(Linux 内核默认最大 PID 为 32768,现代发行版中 systemd 会在 64 位系统上将其调整为 4194304),导致无法创建新进程。
🤔 想一想 如果一个父进程创建了 1000 个子进程但从不调用 wait(),系统会怎样?如何排查和解决这个问题?
四、进程的创建:fork() 与 exec()
4.1 fork():复制自己
在 Linux 中,创建新进程的方式是调用 fork() 系统调用。fork() 的行为非常独特——它会创建一个当前进程的几乎完全相同的副本:
#include <stdio.h>
#include <unistd.h>
int main() {
printf("I am the original process, PID = %d\n", getpid());
pid_t pid = fork();
if (pid < 0) {
// fork 失败
perror("fork failed");
} else if (pid == 0) {
// 子进程:fork() 返回 0
printf("I am the child, PID = %d, parent PID = %d\n",
getpid(), getppid());
} else {
// 父进程:fork() 返回子进程的 PID
printf("I am the parent, PID = %d, child PID = %d\n",
getpid(), pid);
}
return 0;
}
执行结果:
I am the original process, PID = 1000
I am the parent, PID = 1000, child PID = 1001
I am the child, PID = 1001, parent PID = 1000
fork() 的精妙之处在于:调用一次,返回两次。父进程得到子进程的 PID,子进程得到 0。程序员通过判断返回值来区分当前代码运行在父进程还是子进程中。
4.2 写时复制(Copy-on-Write)
fork() 理论上要复制父进程的整个地址空间,如果父进程占用了 1GB 内存,难道每次 fork() 都要复制 1GB 吗?那也太浪费了。
Linux 使用写时复制(Copy-on-Write, COW)来优化:fork() 时并不真正复制物理内存,而是让父子进程共享同一份物理页面,并将这些页面标记为只读。只有当某一方尝试写入某个页面时,内核才会真正复制这一页。
fork() 之后:
父进程虚拟地址空间 物理内存 子进程虚拟地址空间
+--------+ +--------+
| 代码段 |----+ +----------+ +------| 代码段 |
+--------+ +----->| 共享页面A |<----+ +--------+
| 数据段 |----+ +----------+ +------| 数据段 |
+--------+ +----->| 共享页面B |<----+ +--------+
| 堆 |----+ +----------+ +------| 堆 |
+--------+ +----->| 共享页面C |<----+ +--------+
+----------+
(全部标记为只读)
子进程写入数据段时:
父进程虚拟地址空间 物理内存 子进程虚拟地址空间
+--------+ +--------+
| 代码段 |--------> +----------+ <---------| 代码段 |
+--------+ | 共享页面A | +--------+
| 数据段 |--------> +----------+ | 数据段 |---+
+--------+ | 共享页面B | +--------+ |
| 堆 |--------> +----------+ <---------| 堆 | |
+--------+ | 共享页面C | +--------+ |
+----------+ |
+----------+ |
| 复制页面B' | <--------+
+----------+
(只复制被修改的那一页)
这种设计极大地减少了 fork() 的开销——如果子进程紧接着调用 exec() 去加载另一个程序,那之前的页面根本就不需要复制。
4.3 exec():脱胎换骨
fork() 创建的子进程和父进程执行的是相同的代码。如果我们想让子进程运行一个完全不同的程序,就需要 exec() 系列函数。
exec() 会用一个新程序的代码和数据替换当前进程的地址空间,但进程 ID 不变:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:用 ls 程序替换自己
execlp("ls", "ls", "-l", "/tmp", NULL);
// 如果 exec 成功,下面这行永远不会执行
perror("exec failed");
} else {
// 父进程等待子进程结束
wait(NULL);
printf("Child finished.\n");
}
return 0;
}
fork() + exec() 过程:
父进程 子进程
+----------+ fork() +----------+
| 程序A代码 | ------------> | 程序A代码 | (和父进程相同)
| 程序A数据 | | 程序A数据 |
+----------+ +----------+
|
| exec("ls")
v
+----------+
| ls的代码 | (完全替换!)
| ls的数据 |
+----------+
(PID 不变)
4.4 为什么 fork + exec 要分两步?
这是 Unix 设计哲学的经典体现。如果把”创建进程”和”加载新程序”合并成一个操作,你就失去了在两步之间做事情的机会。
在 fork() 之后、exec() 之前,子进程可以:
- 重定向标准输入/输出(实现 Shell 中的
>和<) - 关闭不需要的文件描述符
- 修改环境变量
- 设置信号处理方式
- 改变工作目录
Shell 实现管道 ls | grep txt 正是靠这个机制:
Shell 进程
|
|--- fork() --> 子进程1
| |
| |-- 创建管道
| |-- 重定向 stdout 到管道写端
| |-- exec("ls")
|
|--- fork() --> 子进程2
|
|-- 重定向 stdin 到管道读端
|-- exec("grep", "txt")
🤔 想一想 Windows 的 CreateProcess() 是一步完成”创建进程+加载程序”的。和 Unix 的 fork+exec 相比,各有什么优劣?
五、进程调度:谁来使用 CPU
5.1 为什么需要调度
一台普通的 Linux 服务器上可能同时运行着几百个进程,但 CPU 核心数通常只有个位数到几十个。如何决定在每个时刻哪个进程获得 CPU,这就是进程调度要解决的问题。
调度的核心矛盾是:
- CPU 密集型进程:希望获得尽可能长的连续 CPU 时间
- I/O 密集型进程:希望在 I/O 完成后能尽快得到响应
- 交互式进程:希望响应延迟尽可能低
- 公平性:不能让某些进程永远得不到 CPU(饥饿问题)
5.2 经典调度算法
先来先服务(FCFS, First Come First Served)
最简单的策略——谁先到就绪队列谁先执行,执行完或阻塞后才切换。
就绪队列: P1(24ms) P2(3ms) P3(3ms)
执行时间线:
|---- P1 (24ms) ----|-- P2 (3ms) --|-- P3 (3ms) --|
平均等待时间 = (0 + 24 + 27) / 3 = 17ms
问题:如果一个长任务排在最前面,后面的短任务全部被阻塞(车队效应/Convoy Effect)。
最短作业优先(SJF, Shortest Job First)
优先执行预计运行时间最短的进程。理论上平均等待时间最优。
就绪队列: P1(24ms) P2(3ms) P3(3ms)
排序后: P2(3ms) P3(3ms) P1(24ms)
执行时间线:
|-- P2 (3ms) --|-- P3 (3ms) --|---- P1 (24ms) ----|
平均等待时间 = (0 + 3 + 6) / 3 = 3ms
问题:实际中很难准确预测进程的运行时间,而且长任务可能永远得不到执行(饥饿)。
时间片轮转(RR, Round Robin)
每个进程被分配一个固定的时间片(比如 10ms),时间片用完就切换到下一个进程,被中断的进程排到队尾。
就绪队列: P1(24ms) P2(3ms) P3(3ms)
时间片: 4ms
执行时间线:
|P1|P2|P3|P1|P1|P1|P1|P1|
4 3 3 4 4 4 4 4
P2 和 P3 很快完成,P1 分多个时间片执行完毕
时间片的选择是关键:太大则退化为 FCFS,太小则上下文切换频繁,浪费 CPU。
多级反馈队列(MLFQ, Multi-Level Feedback Queue)
这是现实中最常用的调度策略之一,也是很多操作系统(包括早期 Linux)的基础:
高优先级 +--------+
Queue 0: | P_new | 时间片 = 8ms
+--------+
|
| 用完时间片,降级
v
中优先级 +--------+--------+
Queue 1: | P_cpu1 | P_cpu2 | 时间片 = 16ms
+--------+--------+
|
| 用完时间片,降级
v
低优先级 +--------+--------+--------+
Queue 2: | P_long | P_bg | P_batch| 时间片 = 32ms (FCFS)
+--------+--------+--------+
规则:
- 新进程进入最高优先级队列
- 在当前队列用完时间片但未完成,降到下一级
- 如果主动放弃 CPU(等待 I/O),留在当前级别
- 高优先级队列有进程时,低优先级队列不执行
- 定期将所有进程提升到最高优先级(防饥饿)
5.3 Linux 的调度器:从 CFS 到 EEVDF
Linux 2.6.23(2007年)引入了完全公平调度器(CFS, Completely Fair Scheduler),它的核心思想非常优雅——不追求”绝对公平”,而是追求”虚拟运行时间的公平”。
CFS 为每个进程维护一个 vruntime(虚拟运行时间)。vruntime 增长最慢的进程,就是最”饥饿”的进程,应该优先得到 CPU。CFS 用红黑树来组织所有就绪进程,树中最左边的节点就是 vruntime 最小的进程。
CFS/EEVDF 红黑树
(P3: vruntime=50)
/ \
(P1: vruntime=30) (P5: vruntime=70)
/ \ / \
(P0: vruntime=20) (P2:40) (P4:60) (P6:80)
最左节点 = P0 (vruntime=20) --> 下一个获得CPU的进程
优先级高的进程,vruntime 增长得慢(相当于”时间过得慢”),所以它们更频繁地成为最左节点,获得更多的 CPU 时间。
自 Linux 6.6(2023年10月) 起,CFS 被 EEVDF(Earliest Eligible Virtual Deadline First) 调度器替换。EEVDF 同样基于 vruntime 和红黑树,但引入了”虚拟截止时间(virtual deadline)“的概念——每个进程不仅有 vruntime,还有一个根据请求时间片计算出的截止时间。调度器优先选择截止时间最早且”有资格运行”的进程,从而在保持公平性的同时显著改善了延迟敏感型任务的响应速度。对于大多数用户来说,EEVDF 的行为与 CFS 类似,但在交互延迟和公平性方面表现更好。
六、进程调度的实践观察
6.1 查看进程优先级
# 查看进程的优先级(NI 列为 nice 值,PRI 列为实际优先级)
ps -eo pid,ni,pri,comm | head -20
# nice 值范围:-20 (最高优先级) 到 19 (最低优先级)
# 默认 nice 值为 0
# 启动一个低优先级的进程
nice -n 10 ./my_program
# 修改已运行进程的优先级
renice -n 5 -p 1234
6.2 实时进程
Linux 还支持实时调度策略,用于对时间要求极其严格的场景(如工业控制、音频处理):
- SCHED_FIFO:实时先来先服务,一旦获得 CPU 就一直运行到主动让出
- SCHED_RR:实时轮转,有时间片限制
- SCHED_NORMAL:普通进程使用 CFS/EEVDF
- SCHED_BATCH:批处理进程
- SCHED_IDLE:最低优先级,只在系统空闲时运行
# 查看进程的调度策略
chrt -p 1234
# 以实时优先级运行程序
sudo chrt -f 50 ./realtime_program
实时进程的优先级始终高于普通进程。如果一个 SCHED_FIFO 的进程陷入死循环,它会独占 CPU,导致其他普通进程无法执行。这就是为什么设置实时优先级需要 root 权限。
七、进程间的关系
7.1 进程组和会话
Linux 中的进程不是孤立的个体,它们通过进程组和会话组织起来:
登录会话 (Session)
SID = 1000
|
+-- 前台进程组 (PGID = 2000)
| |
| +-- bash (PID=2000, 组长)
| +-- vim (PID=2001)
|
+-- 后台进程组 (PGID = 3000)
| |
| +-- make (PID=3000, 组长)
| +-- gcc (PID=3001)
| +-- gcc (PID=3002)
|
+-- 后台进程组 (PGID = 4000)
|
+-- top (PID=4000, 组长)
进程组:一组相关进程的集合。Shell 中的一条管道命令 cat file | grep pattern | sort 中的三个进程属于同一个进程组。
会话(Session):一个登录会话包含一个或多个进程组。当你通过 SSH 登录服务器时,就建立了一个会话。
当你在终端按下 Ctrl+C,信号会发送给前台进程组的所有进程。
7.2 守护进程(Daemon)
守护进程是一种在后台持续运行的特殊进程。Web 服务器(nginx)、数据库(mysql)、SSH 服务(sshd)都是守护进程。
守护进程的特点:
- 脱离终端(不受终端关闭影响)
- 以 root 或特定用户身份运行
- 通常以 “d” 结尾命名(httpd、sshd、mysqld)
- 记录日志到文件而非终端
# 查看系统中的守护进程
systemctl list-units --type=service --state=running
# 通过 systemd 管理守护进程
systemctl start nginx # 启动
systemctl stop nginx # 停止
systemctl restart nginx # 重启
systemctl status nginx # 查看状态
八、本章总结
+---------------------------------------------------+
| 进程管理核心知识 |
+---------------------------------------------------+
| |
| 程序(静态) --加载--> 进程(动态) |
| |
| PCB (task_struct): 进程的全部元数据 |
| |
| 五状态: 新建 -> 就绪 <-> 运行 -> 终止 |
| ^ | |
| +- 阻塞 -+ |
| |
| 创建: fork() 复制 + exec() 替换 |
| 优化: 写时复制(COW)减少 fork 开销 |
| |
| 调度: FCFS < SJF < RR < MLFQ < CFS/EEVDF |
| CFS/EEVDF: 红黑树 + vruntime 实现公平调度 |
| |
+---------------------------------------------------+
📝 结尾自测:检验你的收获
- 画出进程的五状态转换图,标注每条转换边的触发条件。
- Linux 中 task_struct 包含哪些关键信息?至少列举 5 类。
- fork() 调用一次返回两次是什么意思?父子进程分别得到什么返回值?
- 什么是写时复制(COW)?它解决了 fork() 的什么问题?
- Linux 的 CFS/EEVDF 调度器的核心数据结构是什么?它如何保证调度的公平性?
购买课程解锁全部内容
系统底层入门:10 章掌握操作系统核心
¥29.90