跳到主要内容
预计阅读 42 分钟

02|进程管理

如果操作系统是一家公司,进程就是公司里的一个个项目。如何立项、如何推进、如何调度资源,决定了整个公司的运转效率。本章将带你深入理解进程管理的方方面面。

📋 开篇自测:你已经知道多少?

  1. 程序和进程有什么区别?它们之间是什么关系?
  2. 你能画出进程状态转换图吗?一个进程从创建到终止,会经历哪些状态?
  3. 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)
          +--------+--------+--------+

规则:

  1. 新进程进入最高优先级队列
  2. 在当前队列用完时间片但未完成,降到下一级
  3. 如果主动放弃 CPU(等待 I/O),留在当前级别
  4. 高优先级队列有进程时,低优先级队列不执行
  5. 定期将所有进程提升到最高优先级(防饥饿)

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 实现公平调度          |
|                                                   |
+---------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 画出进程的五状态转换图,标注每条转换边的触发条件。
  2. Linux 中 task_struct 包含哪些关键信息?至少列举 5 类。
  3. fork() 调用一次返回两次是什么意思?父子进程分别得到什么返回值?
  4. 什么是写时复制(COW)?它解决了 fork() 的什么问题?
  5. Linux 的 CFS/EEVDF 调度器的核心数据结构是什么?它如何保证调度的公平性?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90