linux系统复习:进程优先级与O(1)调度

一、为什么需要优先级

CPU 资源分配的先后顺序,就是进程的优先权(priority)。

优先权高的进程有优先执行权。配置进程优先级对多任务环境的 Linux 很有用,可以改善系统性能;还可以把进程绑到指定 CPU 上,把不重要的进程安排到某个 CPU,进一步改善整体性能。

📌 现实里的例子:ssh 会话要秒响应(优先级高),批量压缩文件可以慢慢跑(优先级低)。两者抢 CPU 时,你会希望交互优先。


二、怎么查看优先级:ps -l

复制代码
ps -l

你会看到类似这样的输出(示意):

复制代码
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000  12345  1230  0  80   0 -  4281 wait   pts/0    00:00:00 bash
0 R  1000  12346 12345  0  80   0 -  1120 -      pts/0    00:00:00 ps

重点字段:

字段 含义
UID 执行者身份
PID 进程代号
PPID 父进程代号
PRI 进程可被执行的优先级,值越小越早被执行
NI 进程的 nice 值(优先级的修正数值)

三、PRI 和 NI 到底是什么关系

3.1 PRI 是"最终优先级"

PRI 就是进程的优先级,通俗说就是程序被 CPU 执行的先后顺序,此值越小,进程优先级越高。

3.2 NI 是"修正值"

nice 值表示进程可被执行的优先级的修正数值。

关系式(课件原句):

复制代码
PRI(new) = PRI(old) + nice

推导一下:

  • nice 是负值 → PRI(new) 变小 → 优先级变高 → 更快被 CPU 执行;
  • nice 是正值 → PRI(new) 变大 → 优先级变低 → 更慢被 CPU 执行。

所以:

调整进程优先级,在 Linux 下就是调整 nice 值。

3.3 nice 的范围

复制代码
nice ∈ [-20, 19],一共 40 个级别
  • -20:最"不善良"------抢 CPU 最凶(最高优先级);
  • 19:最"善良"------最愿意让出 CPU(最低优先级)。

📌 想想为什么叫 nice:「你越 nice(谦让),越不容易抢到 CPU」,命名很形象。

3.4 为什么单独需要一个 NI,不直接把 PRI 给你改?

进程的 nice 值不是进程的优先级,它们不是一个概念 ,但 nice 值会影响优先级变化。 可以把 nice 值理解为优先级的修正数据。

好处有三:

  1. 好算:PRI 是"算出来的最终结果",nice 只是"我额外让/抢了多少",语义清晰;
  2. 范围可控:nice 只有 40 档,用户随便调也不会把系统调崩(否则设成一个极端值就能饿死所有进程);
  3. 好判断"谁在捣乱" :ps 里看到谁 NI 是负数,就知道是谁在抢资源。

四、怎么改优先级

4.1 用 top 改(已运行进程)

复制代码
top
# 进入 top 后按 r → 输入进程 PID → 输入 nice 值

4.2 用命令改

复制代码
nice -n -5 ./a.out      # 以 nice=-5 启动(普通用户通常不允许负值)
renice -n 10 -p 12345   # 把 PID 12345 的 nice 改成 10

📌 普通用户只能 把自己的进程 nice 值调大(降低优先级);调小(提高优先级,负值)需要 root 权限。这是保护机制,否则谁都能抢占 CPU。

4.3 用系统调用改

复制代码
#include <sys/time.h>
#include <sys/resource.h>

int getpriority(int which, int who);
int setpriority(int which, int who, int prio);

which 常见取值 PRIO_PROCESS,who 是 pid(0 表示自己)。


五、四个必须分清的概念:竞争、独立、并行、并发

这一组是面试高频,定义要背准。

5.1 竞争性

系统进程数目众多,而 CPU 资源只有少量,甚至只有 1 个,所以进程之间是具有竞争属性 的。 为了高效完成任务、更合理地竞争相关资源,便有了优先级。

竞争 → 需要优先级 → 需要调度。这是因果链。

5.2 独立性

多进程运行,需要独享各种资源,多进程运行期间互不干扰。

这个"独立",技术上靠虚拟地址空间 + 写时拷贝保证(第 6 篇会讲透)。

5.3 并行(parallel)

多个进程在多个 CPU 下分别、同时运行,称之为并行。

关键词:多个 CPU + 同时。3 个进程在 3 个核心上真的同时在算,这就是并行。

5.4 并发(concurrent)

多个进程在一个 CPU 下采用进程切换的方式,在一段时间之内让多个进程都得以推进,称之为并发。

关键词:一个 CPU + 轮流。看起来"同时"在跑,其实是切换得快。

对照记:

CPU 数量 机制 本质
并行 多核 真的同时执行 物理同时
并发 单核(或多核也好使) 进程切换(时间片) 逻辑同时

📌 并行是硬件能力 ,并发是调度技巧。你现在打开的任务管理器里几十个进程都在"动",其实是并发。


六、进程切换:上下文切换

6.1 什么是上下文切换

CPU 上下文切换,实际含义是任务切换 ,或者 CPU 寄存器切换。

当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是 CPU 寄存器中的全部内容 。这些内容被保存在任务自己的堆栈 中。入栈完成后,就把下一个将要运行的任务的当前状况从它的栈中重新装入 CPU 寄存器,并开始下一个任务的运行。这一过程就是 context switch。

画成图:

复制代码
进程 A 正在跑              内核决定切换            进程 B 开始跑
──────────────           ─────────────          ──────────────
CPU 寄存器 = A 的值   →   1. 把寄存器全部压入 A 的栈
                          2. 从 B 的栈里弹出 B 的寄存器值
                          3. 装入 CPU 寄存器        →   CPU 寄存器 = B 的值

所以所谓"进程被切走了",本质就是:它的寄存器值被存进了它的栈,CPU 转去装别人的值。

6.2 时间片

当代计算机都是分时操作系统,每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从 CPU 中剥离下来。

时间片(time slice)就是"这次让你跑多久"。跑完:

  • 如果还没结束 → 状态从 R 变成"就绪",重新排队;
  • 如果结束了 → 继续等下一轮。

📌 时间片太大 → 交互卡顿;太小 → 切换开销占比太高。这是一门平衡的艺术,调度器就是干这个的。


七、Linux 2.6 内核的 O(1) 调度队列

这一节是课件里最"硬"的部分,我们按"为什么要 → 怎么设计 → 为什么是 O(1)"的顺序拆。

7.1 为什么要专门设计一个调度器

老调度器有个致命问题:每次挑进程都要遍历一遍所有进程。进程一多,挑一次就要 O(n),系统越忙越慢。

目标很明确:不管有多少进程,挑出下一个该跑的进程,代价都是常数。这就是 O(1) 的由来。

7.2 一个 CPU 一个 runqueue

复制代码
    CPU0 → runqueue0
    CPU1 → runqueue1
    CPU2 → runqueue2

如果有多个 CPU,还要考虑进程个数的负载均衡------不能让一个核忙死、另一个核闲着。

7.3 优先级范围:0~139

类别 范围 说明
实时优先级 0 ~ 99 我们不关心(实时进程)
普通优先级 100 ~ 139 我们写的程序都在这里

📌 呼应一下:普通优先级 100~139 正好 40 档 ,与 nice 的 -20~19 一一对应。也就是说 nice = -20 对应优先级 100(最高),nice = 19 对应 139(最低)。

7.4 活动队列(active):queue140

复制代码
struct prio_array {
    unsigned int nr_active;                        // 有几个运行态进程
    DECLARE_BITMAP(bitmap, MAX_PRIO + 1);          // 位图,标记哪些队列非空
    struct list_head queue[MAX_PRIO];              // 140 个队列
};

关键点(课件原话):

  • 时间片还没有结束的所有进程,都按照优先级放在活动队列里;
  • nr_active:总共有多少个运行状态的进程;
  • queue[140]:一个元素就是一个进程队列,相同优先级的进程按照 FIFO 规则排队 ,数组下标就是优先级;
  • 从该结构中挑选最合适进程的流程:
    1. 从下标 0 开始遍历 queue[140];
    2. 找到第一个非空队列,该队列必定是优先级最高的;
    3. 拿到选中队列的第一个进程,开始运行,调度完成;
    4. 遍历 queue[140] 时间复杂度是常数......但还是太低效了。

为什么"常数"还嫌慢?因为 140 次循环也要时间,尤其是高频调度时。于是有了位图。

7.5 位图 bitmap5:把 140 次变成几个字

bitmap[5]:一共 140 个优先级、140 个进程队列,为了提高查找非空队列的效率,可以用 5 × 32 个比特位表示队列是否为空,这样便可以大大提高查找效率。

复制代码
bitmap(5 个 32 位整数 = 160 位,够用 140 位)
bit[0]  bit[1]  ...  bit[139]
  1       0            1        ← 1 表示该优先级队列非空

查找流程变成:

  1. 先找第一个不为 0 的 32 位整数(最多看 5 个整数);
  2. 再用 ffs(找最低位 1)定位到具体是哪一位;
  3. O(1) 完成,最多 5 次判断。

📌 这就是"用位运算换时间"的经典案例,也是所有高性能调度器的必备技巧。

7.6 过期队列(expired)

过期队列和活动队列结构一模一样。

区别只有一条:

  • 过期队列上放的是时间片耗尽的进程;
  • 当活动队列上的进程都被处理完毕后,对过期队列的进程重新计算时间片。

为什么要两个队列?因为如果只有一个队列,时间片耗尽的进程重新插回队尾,会导致"刚跑完的又立刻跑到前面",饿死新进程。分成两队就干净了:跑完的进 expired,等 active 空了再整体换过来。

7.7 active 指针和 expired 指针:一次 swap 全场复活

复制代码
struct rq {
    ...
    struct prio_array *active, *expired, arrays[2];
    ...
};
  • active 指针永远指向活动队列;
  • expired 指针永远指向过期队列。

问题来了:active 上的进程会越来越少,expired 会越来越多(因为时间片到期是常态)。

解法极其优雅:

在合适的时候,只要交换 active 指针和 expired 指针的内容,就相当于拥有了一批新的活动进程!

复制代码
交换前:
   active  → [ 剩下的 3 个 ]
   expired → [ 攒下的 100 个 ]

一次 swap(只是交换两个指针,O(1)):
   active  → [ 攒下的 100 个 ]   ← 它们的时间片已被重新计算
   expired → [ 剩下的 3 个 ]

📌 一次指针交换 = 整个队列"复活"。这是内核设计里最漂亮的 trick 之一。

7.8 总结:为什么叫 O(1)

在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加 ,我们称之为进程调度 O(1) 算法。

结构全景图:

复制代码
                  struct rq(每个 CPU 一个)
                  ┌──────────────────────────────┐
                  │  active   ──┐                │
                  │             ├→ arrays[0] / [1]│
                  │  expired  ──┘                │
                  └──────────────────────────────┘
                                │
              prio_array(140 个队列 + bitmap)
        ┌───────────────────────────────────────────┐
        │ queue[0]   queue[1]   ...   queue[139]     │
        │   ↓          ↓                  ↓          │
        │ [进程链表] [进程链表]         [进程链表]      │
        │                                            │
        │ bitmap: 0 1 0 0 ... 1   ← 1 表示该队列非空   │
        └───────────────────────────────────────────┘

📌 补充一句现实:现代 Linux 早已换成 CFS(完全公平调度) 和更高版本的其他调度器,2.6 的 O(1) 已经不是主力。但它是理解调度思想的最佳样本:分优先级队列 + 位图加速 + 双队列轮换,这套思路至今到处都在用。


八、易错点

误解 正确理解
nice 值就是优先级 nice 是修正值 ,PRI(new) = PRI(old) + nice
nice 越大越优先 越小越优先(-20 最高,19 最低)
普通用户能随便调优先 只能把 nice 调大(降优先级),调小需要 root
并行 = 并发 并行要多核 ,并发是单核切换出来的假象
进程切换很便宜 要保存/恢复全部寄存器、刷 TLB,是很贵的

本篇小结

  • 优先级决定谁先上 CPU;PRI 是最终值,NI 是修正值,PRI(new) = PRI(old) + nice,nice ∈ -20, 19。
  • 竞争性(因为抢 CPU)→ 产生优先级;独立性(靠地址空间);并行(多核同时);并发(单核切换)。
  • 上下文切换 = 保存当前寄存器到自己的栈 + 装入别人的寄存器;时间片到了就被剥下来。
  • Linux 2.6 O(1) 调度器 = 每 CPU 一个 runqueue + 140 个优先级队列 + bitmap 位图加速 + active/expired 双队列 + 指针交换。
  • 挑下一个进程的代价是常数,与进程数量无关------这就是"O(1)"。
相关推荐
古法老登瞎折腾1 小时前
大数据智能运维实战(六):任务一直在等——排查调度队列与执行资源
java·大数据·运维·spring ai·mcp
开开心心就好1 小时前
安卓PDF处理工具,免费无广告零弹窗
android·服务器·开发语言·算法·pdf·ocr
Pniubi1 小时前
HashMap扩容全流程
java·开发语言·数据结构
syagain_zsx3 小时前
spdlog实战:日志分级、滚动与性能调优
linux·c++·日志库·二次开发日志库
考虑考虑9 小时前
JDK26中的Map.ofLazy
java·后端·java ee
鶴哥只手遮天13 小时前
Spring AI 2.0.1 深度解析-第 14 章 MCP 服务端(上):注解式实现与工具来源
java·人工智能·开源
鶴哥只手遮天13 小时前
Spring AI 2.0.1 深度解析-第 15 章 MCP 服务端(下):三种协议形态、Streamable 传输与状态管理
java·人工智能·开源
鶴哥只手遮天13 小时前
Spring AI 2.0.1 深度解析-第 16 章 向量存储(上):`VectorStore` 接口体系、`BatchingStrategy`
java·人工智能·开源
鶴哥只手遮天13 小时前
Spring AI 2.0.1 深度解析-第 12 章 工具循环的流式形态、Usage 累加与扩展体系
java·人工智能·开源