深入理解Linux内核进程调度:从基础概念到O(1)调度算法

🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 | 《小游戏与项目》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。
📝 前言
在多任务操作系统中,CPU就像一个超级繁忙的交通枢纽,而进程调度就是决定谁先过马路、谁后过的交通警察。你有没有想过:为什么你的电脑可以同时运行几十个程序,而不会出现一个程序霸占CPU导致其他程序完全卡死的情况?
这就涉及到进程优先级、上下文切换以及调度算法这些核心概念了。
| 核心概念 | 作用 | 重要性 |
|---|---|---|
| 进程优先级 | 决定进程获得CPU时间的先后顺序 | ⭐⭐⭐ |
| 上下文切换 | 保存/恢复进程执行状态 | ⭐⭐⭐⭐ |
| O(1)调度算法 | 高效选择下一个执行的进程 | ⭐⭐⭐⭐⭐ |
通过本文,你将掌握:
| 技能 | 应用场景 |
|---|---|
| 理解进程优先级的组成(PRI和NI) | 优化进程执行顺序 |
| 理解进程竞争性与独立性 | 理解操作系统设计哲学 |
| 理解并行与并发 | 区分多核与单核执行模式 |
| 理解进程上下文切换原理 | 理解"切换"到底在做什么 |
| 理解O(1)调度算法架构 | 理解Linux调度器的设计精髓 |
🔍 核心问题: 操作系统是如何在数百个进程中高效选择一个来执行,并且保证调度的**时间复杂度始终为O(1)(常数时间)?
文章目录
- 深入理解Linux内核进程调度:从基础概念到O(1)调度算法
-
- [📝 前言](#📝 前言)
- [一、🌲 进程调度基础概念](#一、🌲 进程调度基础概念)
-
- [1.1 什么是进程调度](#1.1 什么是进程调度)
- [1.2 竞争性与独立性](#1.2 竞争性与独立性)
- [1.3 并行与并发](#1.3 并行与并发)
- [二、🔍 进程优先级详解](#二、🔍 进程优先级详解)
-
- [2.1 什么是进程优先级](#2.1 什么是进程优先级)
- [2.2 PRI与NI的关系](#2.2 PRI与NI的关系)
- [2.3 深入理解PRI和NI](#2.3 深入理解PRI和NI)
- [2.4 如何调整进程优先级](#2.4 如何调整进程优先级)
- [三、🔄 进程上下文切换](#三、🔄 进程上下文切换)
-
- [3.1 什么是上下文切换](#3.1 什么是上下文切换)
- [3.2 上下文切换的过程](#3.2 上下文切换的过程)
- [3.3 时间片概念](#3.3 时间片概念)
- [3.4 进程切换的时机](#3.4 进程切换的时机)
- [四、🛠️ Linux2.6内核O(1)调度算法](#四、🛠️ Linux2.6内核O(1)调度算法)
-
- [4.1 为什么需要O(1)调度算法](#4.1 为什么需要O(1)调度算法)
- [4.2 O(1)调度器的核心数据结构](#4.2 O(1)调度器的核心数据结构)
- [4.3 优先级范围](#4.3 优先级范围)
- [4.4 优先级数组结构](#4.4 优先级数组结构)
- [4.5 位图算法:O(1)的关键](#4.5 位图算法:O(1)的关键)
- [4.6 活动队列与过期队列](#4.6 活动队列与过期队列)
- [4.7 active与expired指针交换](#4.7 active与expired指针交换)
- [4.8 O(1)调度算法总结](#4.8 O(1)调度算法总结)
- [五、📊 调度相关的核心概念](#五、📊 调度相关的核心概念)
-
- [5.1 进程竞争性带来的优先级](#5.1 进程竞争性带来的优先级)
- [5.2 进程饥饿问题](#5.2 进程饥饿问题)
- [5.3 分时操作系统 vs 实时操作系统](#5.3 分时操作系统 vs 实时操作系统)
- [六、💻 实践:查看和调整进程优先级](#六、💻 实践:查看和调整进程优先级)
-
- [6.1 查看进程优先级](#6.1 查看进程优先级)
- [6.2 调整进程优先级](#6.2 调整进程优先级)
- [6.3 验证优先级效果](#6.3 验证优先级效果)
- [七、🤔 几个思考题](#七、🤔 几个思考题)
-
- [1️⃣ 进程的nice值从20调整为-10,优先级如何变化?](#1️⃣ 进程的nice值从20调整为-10,优先级如何变化?)
- [2️⃣ 为什么O(1)调度算法使用位图而不是直接遍历140个队列?](#2️⃣ 为什么O(1)调度算法使用位图而不是直接遍历140个队列?)
- [3️⃣ 活动队列和过期队列交换时,为什么不把过期队列清空再放回活动队列?](#3️⃣ 活动队列和过期队列交换时,为什么不把过期队列清空再放回活动队列?)
- [4️⃣ 什么是进程饥饿?如何避免?](#4️⃣ 什么是进程饥饿?如何避免?)
- [5️⃣ 并行和并发的本质区别是什么?](#5️⃣ 并行和并发的本质区别是什么?)
- 本节完
一、🌲 进程调度基础概念
1.1 什么是进程调度
**进程调度(Process Scheduling)**是操作系统内核的核心功能之一,它决定了在任意时刻,哪个进程将获得CPU的使用权。
💡 为什么需要进程调度?
- CPU核心数量远少于进程数量
- 需要合理分配有限的CPU资源
- 保证系统响应性和公平性
1.2 竞争性与独立性
竞争性体现在:系统中进程数目众多,而CPU资源只有少量(甚至只有1个),所以进程之间具有天然的竞争属性。为了高效竞争资源,便产生了优先级的概念。
独立性体现在:多进程运行需要独享各种资源,多进程运行期间互不干扰,一个进程的崩溃不会影响其他进程。
| 特性 | 说明 |
|---|---|
| 竞争性 | 多进程争夺有限CPU资源 → 产生优先级 |
| 独立性 | 进程间资源互不干扰 → 进程隔离保护 |
1.3 并行与并发
| 概念 | 定义 | 条件 |
|---|---|---|
| 并行(Parallelism) | 多个进程在多个CPU下分别同时运行 | 多核CPU |
| 并发(Concurrency) | 多个进程在一个CPU下采用切换方式,一段时间内让多个进程都得以推进 | 单核或多核 |
💡 生活类比:
- 并行:餐厅有多个厨师,每个厨师同时做自己的菜
- 并发:只有一个厨师,但快速切换做炒饭、做汤、做凉菜,一段时间内三道菜都完成了
二、🔍 进程优先级详解
2.1 什么是进程优先级
**进程优先级(Priority)**是指CPU资源分配的先后顺序。优先权高的进程有优先执行的权利。
配置进程优先权对多任务环境的Linux系统很有用,可以改善系统性能。还可以把进程运行到指定的CPU上,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
2.2 PRI与NI的关系
在Linux系统中,用ps -l命令会看到以下几个重要字段:
bash
$ ps -l
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 0 1234 1222 0 80 0 - 1234 - pts/0 00:00:00 bash
| 字段 | 含义 | 说明 |
|---|---|---|
| UID | User ID | 执行者的身份 |
| PID | Process ID | 进程的代号 |
| PPID | Parent PID | 父进程的代号 |
| PRI | Priority | 进程可被执行的优先级,值越小越早执行 |
| NI | Nice值 | 进程优先级的修正数值 |
2.3 深入理解PRI和NI
PRI(Priority) 代表进程可被执行的优先级,其值越小越早被执行。
NI(Nice) 是进程的nice值,表示进程可被执行的优先级的修正数值。
它们的关系如下:
PRI(new) = PRI(old) + NI
| NI值范围 | 对PRI的影响 | 实际效果 |
|---|---|---|
| 负值(-20 ~ -1) | PRI减小 | 优先级提高,更早执行 |
| 零值(0) | 不变 | 保持默认优先级 |
| 正值(1 ~ 19) | PRI增大 | 优先级降低,更晚执行 |
⚠️ 重要提醒:
- nice值的取值范围是 -20 至 19,一共40个级别
- nice值不是进程的优先级,而是影响优先级的修正数据
- 调整进程优先级,在Linux下就是调整进程的nice值
2.4 如何调整进程优先级
使用top命令:
bash
# 进入top后按"r" → 输入进程PID → 输入nice值
top
# 按 r
# 输入PID: 1234
# 输入nice值: 10
使用nice/renice命令:
bash
# 启动时指定nice值
nice -n 10 ./myprogram
# 调整已运行进程的nice值
renice 10 -p 1234
使用系统函数:
c
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who); // 获取优先级
int setpriority(int which, int who, int prio); // 设置优先级
三、🔄 进程上下文切换
3.1 什么是上下文切换
上下文切换(Context Switch),也称为CPU上下文切换或任务切换,是指CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。
这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行。
3.2 上下文切换的过程

┌─────────────────────────────────────────────────────────────┐
│ CPU 上下文切换过程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 进程A正在运行 │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 1. 保存进程A的上下文 │ │
│ │ - 保存通用寄存器值 │ │
│ │ - 保存程序计数器(PC) │ │
│ │ - 保存堆栈指针(SP) │ │
│ │ - 保存状态寄存器等 │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 2. 更新进程A的task_struct │ │
│ │ - 保存进程状态 │ │
│ │ - 更新调度信息 │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ 3. 从进程B的task_struct恢复上下文 │ │
│ │ - 恢复通用寄存器值 │ │
│ │ - 恢复程序计数器(PC) │ │
│ │ - 恢复堆栈指针(SP) │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 进程B开始运行 │
│ │
└─────────────────────────────────────────────────────────────┘
3.3 时间片概念
**时间片(Time Slice)**是当代分时操作系统的核心概念。每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU中剥离下来。
💡 为什么需要时间片?
- 保证所有进程都有机会获得CPU时间
- 实现多任务"同时"运行的假象
- 避免某个进程长时间霸占CPU
3.4 进程切换的时机
| 触发条件 | 说明 |
|---|---|
| 时间片耗尽 | 进程A的时间片用完,触发调度 |
| 进程阻塞 | 进程A等待I/O,主动让出CPU |
| 中断发生 | 硬件中断打断当前进程 |
| 高优先级进程就绪 | 更重要的进程需要运行 |
四、🛠️ Linux2.6内核O(1)调度算法
4.1 为什么需要O(1)调度算法
在早期的Linux版本中,调度算法的时间复杂度是O(n),意味着选择下一个要运行的进程所需的时间与系统中进程的数量成正比。当进程数量增加时,调度开销也会线性增长。
O(1)调度算法的核心思想是:无论系统中有多少个进程,选择下一个执行进程的时间都是常数时间,不随进程数量增加而增加。
💡 设计目标:
- 查找最合适调度的进程时间复杂度为 O(1)
- 不随进程增多而导致时间成本增加
4.2 O(1)调度器的核心数据结构

┌────────────────────────────────────────────────────────────────────┐
│ runqueue (运行队列) 结构 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ struct rq { │
│ spinlock_t lock; // 自旋锁,保证并发安全 │
│ unsigned long nr_running; // 运行队列中的进程数 │
│ struct task_struct *curr; // 当前运行的进程 │
│ struct task_struct *idle; // idle进程 │
│ struct prio_array *active; // 指向活动队列的指针 │
│ struct prio_array *expired; // 指向过期队列的指针 │
│ struct prio_array arrays[2]; // 两个优先级数组(活动+过期) │
│ }; │
│ │
└────────────────────────────────────────────────────────────────────┘
4.3 优先级范围
| 优先级类型 | 范围 | 说明 |
|---|---|---|
| 实时优先级 | 0 ~ 99 | 最高优先级,实时任务使用 |
| 普通优先级 | 100 ~ 139 | 普通进程使用,对应nice值 -20 ~ 19 |
┌────────────────────────────────────────────────────────────────┐
│ 优先级范围 0 ~ 139 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 0 99 100 139 │
│ │─────────│─────────│───────────────────────────────│ │
│ ↑ ↑ ↑ ↑ │
│ 实时 实时 普通 普通 │
│ 最高 最低 nice=-20 nice=19 │
│ │
└────────────────────────────────────────────────────────────────┘
4.4 优先级数组结构

┌────────────────────────────────────────────────────────────────────┐
│ struct prio_array (优先级数组) │
├────────────────────────────────────────────────────────────────────┤
│ │
│ struct prio_array { │
│ unsigned int nr_active; // 活跃进程数量 │
│ DECLARE_BITMAP(bitmap, MAX_PRIO+1); // 位图 [140位] │
│ struct list_head queue[MAX_PRIO]; // 140个优先级队列 │
│ }; │
│ │
│ struct list_head queue[140]; // queue[0]是优先级0的进程队列 │
│ // queue[99]是优先级99的进程队列 │
│ // queue[100]~[139]是普通优先级 │
│ │
└────────────────────────────────────────────────────────────────────┘
关键理解:
| 字段 | 说明 | 作用 |
|---|---|---|
| nr_active | 活动进程总数 | 快速判断是否有可运行进程 |
| bitmap | 140个比特位 | 标记哪些优先级队列非空 |
| queue140 | 140个双向链表 | 同优先级的进程FIFO排队 |
4.5 位图算法:O(1)的关键
传统方式:遍历140个队列找第一个非空队列,最坏需要检查140次。
位图算法:用bitmap标记哪些优先级有进程,只需5个unsigned int(5×32=160位)即可覆盖140个优先级。
┌────────────────────────────────────────────────────────────────────┐
│ bitmap 位图查找算法 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 假设优先级120的队列有进程: │
│ │
│ bitmap[0]: 00000000 00000000 00000000 00000000 [优先级 0-31] │
│ bitmap[1]: 00000000 00000000 00000000 00000000 [优先级 32-63] │
│ bitmap[2]: 00000000 00000000 00000000 00000000 [优先级 64-95] │
│ bitmap[3]: 00000000 00000000 00000000 10000000 [优先级 96-127] │
│ ↑ 第24位=1,表示优先级120的队列非空 │
│ bitmap[4]: 00000000 00000000 00000000 00000000 [优先级 128-139] │
│ │
│ 查找过程: │
│ 1. 找到第一个非空的unsigned int → bitmap[3] │
│ 2. 找到第一个置位的bit → bit 24 │
│ 3. 优先级 = 3×32 + 24 = 120 │
│ │
└────────────────────────────────────────────────────────────────────┘
查找算法时间复杂度分析:
| 步骤 | 操作 | 复杂度 |
|---|---|---|
| 查找第一个非空unsigned int | 最多5次比较 | O(1) |
| 查找第一个置位bit | 硬件指令(ffs) | O(1) |
| 从队列取出第一个进程 | 链表操作 | O(1) |
| 总计 | O(1) |
4.6 活动队列与过期队列
| 队列类型 | 存放内容 | 处理时机 |
|---|---|---|
| active | 时间片未耗尽的进程 | 实时调度选择 |
| expired | 时间片已耗尽的进程 | 等待active清空后交换 |
工作流程:
┌─────────────────────────────────────────────────────────────────────┐
│ O(1)调度流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ① active队列中选择优先级最高的进程执行 │
│ │ │
│ ▼ │
│ ② 进程时间片耗尽 → 移入expired队列 │
│ │ │
│ ▼ │
│ ③ active队列空? │
│ │ │
│ ├─ 否 → 继续选择下一个进程 │
│ │ │
│ └─ 是 → 交换active和expired指针 │
│ (swap(active, expired)) │
│ └── 过期队列成为新的活动队列 │
│ │
└─────────────────────────────────────────────────────────────────────┘
4.7 active与expired指针交换
这是O(1)调度器的精妙设计:
c
// 交换指针 - 极快的操作!
active = expired;
expired = arrays + (active == arrays); // 交换到另一个数组
💡 为什么这样设计?
- 如果把耗尽的进程重新放回原队列,需要遍历找到合适位置,O(n)
- 交换指针后,过期队列"瞬间"变成新的活动队列,O(1)
- 过期队列的进程时间片在下次成为活动队列时重新计算
4.8 O(1)调度算法总结
| 特性 | 说明 |
|---|---|
| 时间复杂度O(1) | 选择进程的时间与进程数量无关 |
| 位图优化 | 5个unsigned int覆盖140个优先级 |
| 双队列设计 | 活动队列与过期队列分离 |
| 指针交换 | O(1)时间完成队列角色切换 |
五、📊 调度相关的核心概念
5.1 进程竞争性带来的优先级
因为系统进程数目众多,而CPU资源只有少量,进程之间具有竞争属性。为了高效完成任务,更合理竞争相关资源,便有了优先级机制。
| 优先级场景 | 说明 |
|---|---|
| 高优先级进程 | 实时任务、交互任务 |
| 普通优先级 | 大多数后台任务 |
| 低优先级 | 批处理任务、后台服务 |
5.2 进程饥饿问题
**进程饥饿(Starvation)**是指某些低优先级进程长时间得不到CPU时间的情况。
⚠️ 如何避免饥饿?
- Linux调度器保证每个进程最终都会获得CPU时间
- 过期队列机制确保时间片耗尽的进程最终会被调度
- 长时间等待的进程会获得优先级提升
5.3 分时操作系统 vs 实时操作系统
| 特性 | 分时操作系统 | 实时操作系统 |
|---|---|---|
| 目标 | 公平分配时间 | 按时完成截止时间 |
| 响应时间 | 相对较慢 | 必须确定 |
| 优先级 | 动态调整 | 静态配置 |
| 典型系统 | Linux, Windows | VxWorks, RTLinux |
| 应用场景 | 通用计算 | 工业控制、航空 |
六、💻 实践:查看和调整进程优先级
6.1 查看进程优先级
bash
# 查看所有进程的优先级
ps -el
# 查看特定进程的优先级
ps -eo pid,ni,pri,cmd | grep myprocess
# 使用top命令实时查看
top
# 按 Shift+f 可以选择显示列,按 n 显示NI列
6.2 调整进程优先级
bash
# 启动时设置nice值
nice -n -5 ./high_priority_program
nice -n 10 ./low_priority_program
# 调整已运行的进程
renice -5 -p 1234
renice 10 -p 5678
# 查看调整结果
ps -eo pid,ni,pri,cmd | head -20
6.3 验证优先级效果
c
#include <stdio.h>
#include <unistd.h>
int main() {
printf("进程ID: %d\n", getpid());
printf("父进程ID: %d\n", getppid());
// 模拟CPU密集型任务
for (int i = 0; i < 100000000; i++) {
// 空循环消耗CPU时间
}
return 0;
}
七、🤔 几个思考题
学完本文,来试试回答这些问题:
1️⃣ 进程的nice值从20调整为-10,优先级如何变化?
答:
nice值从20调整为-10,减少了30个单位。
PRI(new) = PRI(old) + NI
PRI(old) = 100 + 20 = 120 (普通优先级默认80 + nice值20)
PRI(new) = 100 + (-10) = 90
所以PRI从120降低到90,优先级提高了30个级别,进程会更早被调度执行。
💡 nice值为负数时,进程优先级提高;为正数时,优先级降低。
2️⃣ 为什么O(1)调度算法使用位图而不是直接遍历140个队列?
答:
直接遍历最坏情况需要检查140个队列,时间复杂度O(n)。
使用位图后:
- CPU有专门的指令(如x86的
bsf)可以在一指令内找到第一个置位的位置 - 5个unsigned int = 160位,只需要最多5次比较就能找到非空优先级
- 再用硬件指令找到该unsigned int中第一个置位的bit
- 总时间复杂度O(1),且实际执行极快
💡 这是一种典型的空间换时间的算法优化思想。
3️⃣ 活动队列和过期队列交换时,为什么不把过期队列清空再放回活动队列?
答:
如果不交换,而是把过期队列的进程逐个移回活动队列并重新计算时间片:
- 需要遍历所有过期进程:O(n)
- 需要为每个进程重新计算时间片
- 总时间复杂度变为O(n)
而交换指针:
- 只需要交换两个指针:O(1)
- 过期队列的进程在下次成为活动队列时统一重新计算时间片
- 大大提高了调度效率
💡 这是O(1)调度算法的精髓:用指针交换代替大量数据移动。
4️⃣ 什么是进程饥饿?如何避免?
答:
**进程饥饿(Starvation)**是指高优先级进程一直占用CPU,导致低优先级进程长时间得不到执行机会。
Linux的解决方案:
- 时间片机制:每个进程时间片耗尽后必须让出CPU
- 过期队列:耗尽时间片的进程进入过期队列,保证所有进程最终都会被调度
- 优先级衰减:长时间未执行的进程优先级会动态提升
💡 设计良好的调度算法需要同时满足响应性 和公平性。
5️⃣ 并行和并发的本质区别是什么?
答:
| 概念 | 并行(Parallel) | 并发(Concurrent) |
|---|---|---|
| 硬件要求 | 多核CPU或多处理器 | 单核或多核均可 |
| 执行方式 | 真正同时执行 | 同一时刻只有一个执行 |
| 关键特征 | 物理上的同时 | 逻辑上的同时 |
| 时间感知 | 真正的并行 | 感觉像是同时 |
简单比喻:
- 并行:两个人同时用两把铲子挖坑
- 并发:一个人快速切换用铲子挖、用铁锹挖,看起来两边都在推进
💡 现代计算机往往是多核+多任务,同时具备并行和并发的特性。
本节完
✅ 本节完...
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!