Linux系统篇(十二)——进程(二):深入剖析 Linux 进程:状态变迁、优先级及调度切换逻辑
深入 Linux 进程内核:六种进程状态与变迁、僵尸孤儿进程、优先级机制,以及 O(1) 调度器的位图与队列原理
项目背景
在 Linux 系统中,进程就像一个个独立的"工作单元",系统能否高效运转,取决于对进程的管理能力。进程此刻在做什么?能不能使用 CPU?为什么有的进程 kill -9 都杀不死?为什么系统会出现"僵尸"?
这些问题都指向三大核心主题:进程状态、进程优先级与调度切换。本文深入 Linux 内核视角,讲解六种进程状态的变迁、僵尸/孤儿进程的来龙去脉、优先级的作用边界,以及 O(1) 调度器如何用位图与队列实现高效调度。
技术方案
1. 进程状态
进程状态是操作系统对进程所处运行阶段的标识,描述进程此刻在做什么、能否使用 CPU、是否在等待资源。内核会根据进程行为与资源情况自动切换状态,调度器也依据状态决定是否给进程分配 CPU。
Linux 内核中状态的定义如下:
#define TASK_RUNNING 0 // R 运行/就绪态
#define TASK_INTERRUPTIBLE 1 // S 可中断睡眠
#define TASK_UNINTERRUPTIBLE 2 // D 不可中断睡眠
#define __TASK_STOPPED 4 // T 进程被暂停
#define __TASK_TRACED 8 // T 被调试器跟踪
/* 位于 tsk->exit_state */
#define EXIT_ZOMBIE 16 // Z 僵尸进程
#define EXIT_DEAD 32 // 进程彻底消亡
用 ps -al 即可查看进程的当前状态。
运行状态 R
这是进程唯一能在 CPU 上执行的状态:
- 单核 CPU:同一时刻只能有 1 个进程真正在 CPU 上执行,但就绪队列里可以有很多 R 状态进程排队等待;
- 多核/多 CPU:每个核心可同时运行 1 个进程,其余 R 状态进程在各队列排队。
#include <stdio.h>
#include <unistd.h>
int main()
{
int ret = fork();
if (ret == 0)
{
// 子进程:每秒打印一次,主要处于睡眠
while (1)
{
printf("我是一个子进程:%d\n", getpid());
sleep(1);
}
}
else if (ret > 0)
{
// 父进程:空转死循环,持续占用 CPU,处于 R 状态
while (1) {}
}
return 0;
}
2. 睡眠(阻塞)状态
睡眠状态指进程主动暂停执行、放弃 CPU,等待某个事件发生才能被唤醒。分为两种:
可中断睡眠 S: 进程处于等待状态,可被信号唤醒,也可被等待的事件唤醒,不占用 CPU,属于正常阻塞。scanf、sleep、read、write 这类"等事件"的系统调用默认都会让进程进入 S 状态,可用 kill -9 杀掉。
不可中断睡眠 D: 进程处于深度等待状态,忽略所有信号,只能被等待的硬件/IO 事件唤醒,用于保证原子操作。典型场景是等待磁盘读写:写磁盘属于硬件操作,内核必须保证这次 I/O 原子性完成,不能中途被打断,因此进程进入 D 状态——此时连 kill -9 都杀不死。
#include <stdio.h>
int main()
{
printf("我的进程:%d\n", getpid());
while (1)
{
int x;
scanf("%d", &x); // 等待输入期间进程处于 S 状态
printf("ok\n");
}
}
3. 暂停状态 T
进程被外部信号强制挂起,完全停止执行、不占用 CPU、保留现场,直到收到恢复信号才继续运行。例如调试器打断点跟踪(__TASK_TRACED)就属于这一大类。
4. 僵尸进程与孤儿进程
僵尸进程:
- 定义:子进程正常退出,父进程存活但未调用系统函数回收子进程退出状态,子进程残留的 PCB 即为僵尸进程;
- 运行机制:进程退出后,代码段、栈、堆等资源立即释放,但 PCB 会短暂保留,用于向父进程传递退出码;若父进程一直不回收,PCB 会长期驻留进程表,造成内存泄漏;
- 进程状态:Z(Zombie),不占用 CPU、内存,仅占用进程表项;
- 危害:单个僵尸无影响,但大量堆积会占满进程表,导致系统无法创建新进程。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return 1;
}
else if (pid == 0)
{
// 子进程:运行 2 秒后直接退出,成为僵尸
printf("子进程 PID: %d 即将退出\n", getpid());
sleep(2);
exit(0);
}
else
{
// 父进程:持续运行,不回收子进程
while (1)
{
printf("父进程 PID: %d 运行中\n", getpid());
sleep(2);
}
}
return 0;
}
孤儿进程:
- 定义:父进程先终止退出,子进程仍在运行,该子进程称为孤儿进程;
- 运行机制:Linux 中所有孤儿进程会被 PID=1 的 init/systemd 进程自动收养,由 init 充当新父进程并负责后续资源回收;
- 进程状态:正常运行态(R/S),与普通进程无区别;
- 危害:无,系统自动管理,不占用额外资源。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork");
return 1;
}
else if (pid == 0)
{
// 子进程:持续运行,父进程退出后被 init 收养
while (1)
{
printf("子进程 PID: %d, 父进程 PID: %d\n", getpid(), getppid());
sleep(2);
}
}
else
{
// 父进程:休眠 1 秒后退出
sleep(1);
printf("父进程即将退出\n");
exit(0);
}
return 0;
}
孤儿进程 vs 僵尸进程对比:
| 对比项 | 孤儿进程 | 僵尸进程 |
|---|---|---|
| 形成原因 | 父进程先退出,子进程存活 | 子进程先退出,父进程存活且不回收 |
| 进程状态 | R / S | Z |
| 管理方 | 被 PID=1 进程收养 | 父进程持有 PCB,无人主动回收 |
| 资源占用 | 正常占用系统资源 | 仅占用进程表项,无 CPU/内存占用 |
| 危害性 | 无害 | 大量堆积会导致无法创建新进程 |
系统架构
进程优先级
进程优先级是操作系统决定哪个进程优先获得 CPU 时间片的数值/权重:数值越小 → 优先级越高 → 越先被执行。
优先级调整: 在 top 界面按 r(renice),输入要修改的进程标识符与修改值即可调整。
优先级为什么有极限:
- 防止无限抢占:若普通进程优先级可无限调高,会挤掉内核与关键系统进程的执行时间,导致系统卡顿甚至崩溃;
- 保障调度公平:若没有下限,低优先级进程可能永远得不到 CPU,被"饿死";
- 内核模型固定区间:普通进程 nice 值固定为 -20
19,实时进程调度优先级固定为 199,这是调度算法的固有约束。
最终权限值: 最终权限值 = 进程优先级(默认 80)+ nice 值。例如:80 + 19 = 99,80 + (-20) = 60。
进程切换
为什么需要切换? 即使某个进程死循环,其他进程依然能执行——现代操作系统实现抢占式多任务,CPU 不会被单个进程独占。在时间片轮转调度(RR)中,每个就绪进程分到固定长度的时间片(如 10ms~100ms),时间片耗尽即切换。进程切换解决两个核心问题:并发执行(多进程看起来同时在运行)与资源隔离(每个进程都感觉独占 CPU)。
上下文是什么? 进程切换本质是保存与恢复进程上下文——即进程的"运行快照":
- 通用寄存器(eax、ebx、ecx、edx 等):存储运算数据;
- 程序计数器(PC/EIP):记录下一条指令地址;
- 栈指针(ESP/EBP):记录当前栈位置;
- 标志寄存器(eflags):记录运算结果状态(进位、为零等)。
切换完整流程(进程 A 让出 CPU 给进程 B):
- 保存现场:把进程 A 所有寄存器值保存到它的
struct task_struct(PCB)中; - 恢复现场:从进程 B 的
task_struct中把之前保存的寄存器值恢复到 CPU; - 继续执行:CPU 从进程 B 的 EIP 指向的指令继续执行。
一句话总结:"把进程 A 的状态打包存起来,再把进程 B 的状态加载进 CPU"。
进程调度
进程切换是"换人上 CPU"的动作,调度器则是决定"下一个该谁上"的决策者。目标是:公平性(每个进程都能分到 CPU)、效率(CPU 尽量不空闲)、低延迟(交互式进程响应快)。
调度队列与优先级: 用户态看到的 nice 值(-20~19)会映射成内核动态优先级。调度器维护多个队列:
- 活动队列(active):存放还没耗尽时间片的进程;
- 过期队列(expired):存放已耗尽时间片、等待下一轮调度的进程。
调度器优先从高优先级队列挑选进程运行,活动队列调度完毕的进程链入过期队列,两者此消彼长;活动队列为空时与过期队列交换,继续下一轮调度。
O(1) 调度算法
早期 Linux 把进程优先级分为 0~139 共 140 个等级,核心数据结构如下:
nr_active: 记录当前队列中就绪进程总数,调度器据此做负载统计与调度决策(判断队列是否为空、是否需要负载均衡),本质是一个计数器。
bitmap[5](位图数组): 5 × 32 = 160 位,足以覆盖 140 个优先级,是典型的"用空间换时间"设计。每一位代表一个优先级:为 1 表示该优先级队列有就绪进程,为 0 表示队列为空。调度器直接找 bitmap 中第一个为 1 的位,即可 O(1) 定位最高优先级。
queue[140](队列数组): 数组下标 0~139 对应优先级(数值越小优先级越高),每个 queue[i] 是存放优先级为 i 的就绪进程链表。调度器根据 bitmap 找到最高优先级 k 后,直接取 queue[k] 的第一个进程运行即可。
Linux 2.6 内核中 O(1) 调度器的核心数据结构:
struct rq {
spinlock_t lock;
unsigned long nr_running; // 运行队列中进程数
unsigned long raw_weighted_load;
unsigned long long nr_switches; // 切换次数统计
unsigned long nr_uninterruptible; // 不可中断睡眠进程数
unsigned long expired_timestamp;
struct task_struct* curr, * idle; // 当前进程与空闲进程
struct mm_struct* prev_mm;
struct prio_array* active, * expired, arrays[2]; // 活动/过期队列
int best_expired_prio;
atomic_t nr_iowait;
};
struct prio_array {
unsigned int nr_active; // 就绪进程计数
DECLARE_BITMAP(bitmap, MAX_PRIO + 1); // 优先级位图
struct list_head queue[MAX_PRIO]; // 按优先级分组的队列数组
};
实施过程
完整的进程观察与实验流程可以按以下步骤操作:
- 观察状态:编译运行"父子进程"示例,用
ps -al查看父子进程分别处于 R、S 还是 Z 状态; - 制造僵尸:运行"子进程退出、父进程不回收"的示例,用
ps -al观察 Z 状态进程; - 制造孤儿:运行"父进程先退出"的示例,观察子进程的 PPID 变为 1(被 init/systemd 收养);
- 调整优先级:
top中按r调整进程 nice 值,观察调度行为变化; - 理解调度:结合 O(1) 调度器的位图与队列结构,理解高优先级进程为何能更快获得 CPU。
应用价值
- 排障利器:理解 R/S/D/T/Z 状态后,看到
top里的 D 状态进程就不会盲目kill -9,看到大量 Z 进程能立刻定位到"父进程未回收"的代码问题; - 系统调优基础:理解 nice 值与优先级边界,才能安全合理地调整进程优先级,避免影响系统稳定性;
- 性能优化认知:上下文切换成本、调度队列设计,是评估高并发服务性能时不可忽略的底层因素;
- 内核进阶的敲门砖:O(1) 调度器的位图与队列思想,也是理解 CFS 等现代调度器的重要前置知识。
SEO关键词
Linux, 进程状态, 僵尸进程, 孤儿进程, 进程优先级, 进程调度, 上下文切换, O(1)调度器
