【Linux】 进程(7)深度剖析 进程内核O(1)调度队列 + 饥饿问题


Linux2.6 O(1)调度队列深度剖析|进程切换核心原理+面试题

一、背景:为什么需要O(1)调度器

**在Linux2.4及更早内核,调度器是O(n)算法:**每次调度需要遍历全部就绪进程,进程数量越多,调度耗时越长,多核场景下锁竞争严重,服务器压力大时调度延迟飙升。

**Linux2.6的O(1)调度器核心目标:**无论系统存在多少就绪进程,选择下一个运行进程的时间为常数时间O(1),大幅提升大并发、多核机器调度性能。


二、核心数据结构 runqueue


2.1 一个CPU拥有一个runqueue

每个CPU核心,独立维护一个本地runqueue运行队列。

  • 本CPU上所有处于就绪(TASK_RUNNING)状态的进程,全部挂入本CPU的runqueue;
  • 设计目的:多核环境下,尽量减少跨CPU全局锁竞争,每个CPU只操作自己本地队列,提升并发性能;
  • 进程可以通过负载均衡,被迁移到其他CPU的runqueue。

runqueue内部维护两组完全一样的优先级数组prio_array

  1. active:活动队列,存放时间片还没有耗尽的就绪进程
  2. **expired:**过期队列 ,存放时间片耗尽、已经重新算好时间片的就绪进程
cpp 复制代码
struct runqueue {
    spinlock_t lock;                //自旋锁,保护整个runqueue,访问队列必须拿锁,SMP防并发修改
    unsigned long nr_running;       //本CPU就绪状态(可运行)进程总数量
#ifdef CONFIG_SMP
    unsigned long cpu_load;         //CPU负载因子,采样runqueue进程数量,用于SMP多CPU负载均衡计算
#endif
    unsigned long nr_switches;      //该CPU上累计发生的进程上下文切换总次数
    unsigned long nr_uninterruptible;//处于TASK_UNINTERRUPTIBLE不可中断睡眠进程数目(IO阻塞,计入负载统计)
    unsigned long expired_timestamp;//记录上一次active/expired指针交换的时间戳
    unsigned long timestamp_last_tick;//上一次时钟tick调度事件时间,负载均衡时间判断
    struct task_struct *curr;       //*curr:指向当前CPU正在运行的进程task_struct
    struct task_struct *idle;       //*idle:本CPU空闲进程(idle进程,没有就绪进程时运行)
    struct mm_struct *prev_mm;      //保存上一个进程的虚拟内存地址空间,加速上下文切换
    struct prio_array_t *active;    //【活跃队列指针】指向array[0]:时间片还没用完的就绪进程
    struct prio_array_t *expired;   //【过期队列指针】指向array[1]:时间片耗尽的就绪进程
    struct prio_array_t arrays[2];  //实际存储两个prio_array_t数组 arrays[0]=active,arrays[1]=expired
    int best_expired_prio;          //expired队列里最高优先级,负载均衡辅助参数
    atomic_t nr_iowait;             //等待IO休眠进程数量
    struct sched_domain *sd;        //调度域,SMP负载均衡域结构
    int active_balance;             //标记是否需要主动做CPU负载迁移
    struct task_struct *migration_thread;//迁移线程,专门负责跨CPU迁移进程
    struct list_head migration_queue; //待迁移进程请求链表
};

2.2 进程优先级

cpp 复制代码
struct prio_array_t {
    unsigned int nr_active;         //本数组内就绪进程总个数
    unsigned long bitmap[5];        //优先级位图,5个unsigned long,5*32bit=160bit,覆盖全部140个优先级
    struct list_head queue[140];    //140条双向链表,下标就是进程优先级0~139
};

O(1)调度器一共维护 140个优先级 0~139

优先级区间 进程类型 说明
0 ~ 99 实时进程 SCHED_FIFO、SCHED_RR,优先级高于普通进程;0 优先级最高
100 ~139 普通分时进程 对应用户 nice 值 -20 ~ +19,nice=-20 → prio=100;nice=+19 → prio=139
2.21 bitmap 5 位图:O (1) 查找最高优先级进程(调度器加速核心)
  • 如果 queue k 链表不为空(存在就绪进程),就把 bitmap 对应第 k 位设置为 1;链表为空对应 bit 置 0。
  • 调度选进程时,调用内核函数**find_first_bit()****,硬件指令快速扫描 bitmap,直接找到值为 1 的最低 bit 下标,就是当前最高就绪优先级。**

不管队列有几千进程,找最高优先级进程时间固定,真正实现 O (1)。

5 个 unsigned long:5×32bit=160bit,大于 140,足够容纳全部优先级位标记。

2.22 nr_active

记录当前 prio_array 里面一共有多少就绪进程。

当runqueue‑>active‑>nr_active == 0,代表 active 队列全部进程时间片耗尽,触发 active 与 expired 指针交换。


2.3 active 活动队列

active指针指向活动prio_array数组:

  • 存放时间片尚未消耗完毕的就绪进程;
  • CPU调度永远优先从active队列挑选进程运行;
  • 调度逻辑:查bitmap找到最高优先级位,取出该链表头部进程投入CPU运行;
  • 进程运行消耗时间片;当时间片耗尽,该进程会被移出active队列。

2.4 expired 过期队列

expired指针指向过期prio_array数组:

  • 存放时间片已经耗尽的普通进程;
  • 当普通进程时间片用完,调度器会重新计算该进程的新时间片,根据新的优先级,插入expired队列对应优先级链表;
  • 实时进程时间片耗尽不会进入expired队列;实时进程会重新放回active队列。

expired队列上的进程,此时已经拥有全新时间片,只是暂时得不到CPU。


2.5 active指针与expired指针交换(O(1)调度最巧妙设计)

当active队列内部所有进程全部耗尽时间片,active彻底为空,不需要把expired队列所有进程拷贝迁移到active。

只需要交换runqueue内部active、expired两个指针的值,就完成一轮调度轮回。

伪代码示意:

复制代码
if(rq->active->nr_active == 0){
    // 仅仅交换指针,零拷贝,O(1)完成队列切换
    swap(rq->active, rq->expired);
}

交换之后:

  1. 原来的expired → 变成新的active,一批拥有全新时间片的进程开始被调度执行;
  2. 原来空的active → 变成新的expired,等待本轮时间片耗尽的进程填入。

关键点:没有拷贝任何进程链表,仅仅交换两个指针变量,这是O(1)名称的重要来源。


三、完整调度流程梳理

场景 1:时钟 tick 中断(时间片递减)

  1. 当前进程运行,每次时钟中断,减少进程剩余时间片;
  2. 如果时间片没有耗尽:继续运行该进程;
  3. 如果时间片耗尽:
    • 将该进程从 active 队列摘除;
    • 重新计算交互式进程的动态优先级;
    • 插入到expired优先级数组对应 queue 链表;
    • 更新 bitmap 位图;
    • 触发调度,需要选下一个进程运行。

场景 2:active 队列全部耗尽(active‑>nr_active ==0

✅ 指针交换(O (1) 操作,无进程拷贝)

复制代码
//伪代码,仅仅交换指针
struct prio_array_t *temp = rq->active;
rq->active = rq->expired;
rq->expired = temp;
  1. 原来 expired 队列(装满时间片耗尽进程)直接变成新 active 队列,所有进程获得全新一轮时间片;
  2. 原来空掉的 active 队列变成新 expired 队列,等待本轮时间片用完的进程;
  3. 设置 expired_timestamp 标记纪元切换时间。

重点:不需要移动 task_struct 链表节点,仅仅修改指针变量,所以常数时间。


场景 3:挑选下一个要运行进程

  1. runqueue‑>active‑>bitmap位图调用 find_first_bit,找到最低置 1bit,即最高就绪优先级;
  2. active‑>queue[该下标]链表第一个进程;
  3. 将该进程设置为 runqueue‑>curr,执行进程上下文切换。

场景 4:新进程创建

根据进程静态优先级,挂入 runqueue 的 active 对应 queue 链表,更新 bitmap、nr_active 计数。


场景 5:SMP 负载均衡

每个 runqueue 有 cpu_load 负载因子;调度域 sd;migration_thread 迁移线程。

当发现 CPU 之间负载差距大,把进程从繁忙 CPU runqueue 迁移到空闲 CPU runqueue。


四、O(1)调度器优缺点总结

**✅**优点

  1. 进程选择时间复杂度O(1),不受进程数量影响;
  2. per‑CPU runqueue,多核锁开销小,扩展性强;
  3. bitmap位图快速定位最高优先级就绪任务;
  4. 指针交换代替批量进程迁移,开销极低;
  5. 实时进程具备高优先级保障。

**❌**致命缺陷(最终被CFS替换的根源)

  1. 普通进程公平性差,容易饥饿;交互式进程启发式识别逻辑复杂,bug多;
  2. 时间片是固定静态划分,多进程并发时调度体验不佳;
  3. nice调整对CPU时间分配的比例控制不够平滑。

五、高频内核面试题

面试题1:Linux2.6 O(1)调度器,O(1)指什么?

答:选择下一个待运行进程的时间复杂度是常数O(1),与系统就绪进程总数无关。依靠优先级位图快速定位最高优先级进程;active/expired指针交换,不需要搬运进程节点。


面试题2:runqueue为什么每个CPU一个,而不是全局一个runqueue?

答:全局runqueue多核访问时需要大锁,多核会频繁锁竞争,性能差。每个CPU独立runqueue,CPU优先操作自己本地队列,减少锁冲突;跨CPU调度依靠负载均衡迁移进程。


面试题3:active与expired队列什么时候交换?交换做了什么操作?

答:当active队列所有进程时间片耗尽,active队列没有就绪进程的时候触发交换。仅仅交换runqueue中active和expired两个指针,不会拷贝、移动任何进程链表节点,开销极低。


面试题4:进程时间片用完,一定会进入expired队列吗?

答:不会。普通进程时间片耗尽,重新计算时间片后放入expired;实时进程时间片耗尽,放回active队列,不会进入expired。


面试题5:bitmap位图作用是什么?

答:1bit对应1条优先级链表;bit=1代表该优先级链表存在就绪进程;利用CPU指令快速找到第一个置1的bit,直接拿到最高优先级就绪队列,避免遍历全部140条链表,保证查找O(1)。


面试题6:O(1)调度器有什么缺点,为什么被CFS替代?

答:普通进程公平性不足,低优先级进程饥饿;交互式进程启发算法复杂容易出错;时间片静态分配,多任务场景时间分配不够平滑,因此2.6.23后被CFS完全公平调度器取代。


面试题7:nice值和O(1)调度器内部优先级怎么对应?

答:nice范围-20,19,映射内部优先级100~139;nice越小,内部优先级数字越小,优先级越高。


六、延伸思考

CFS调度器不再使用active/expired双队列+位图,改用红黑树维护就绪进程,根据虚拟运行时间调度,追求公平分配CPU时间,不再区分静态时间片。但O(1)调度器的per‑CPU runqueue设计思想,在CFS中依然继承保留。


O (1) 调度器:饥饿问题 + nice 值完整解析

结合上面runqueueprio_array_tO (1) 调度模型来讲。

一、nice 值的意义

1. nice 存在的意义

  1. 普通用户可以调整进程权重,不需要 root(只能调大 nice,也就是降低自己进程优先级) 普通用户只能把 nice 往大改,让自己程序优先级变低;只有 root 可以设置‑20 最高 nice。
  2. 区分任务权重 后台批量任务(编译、数据处理)设置高 nice,少抢占 CPU;前台交互程序用默认 nice (0,static_prio=120) 优先响应。
  3. 作为动态优先级计算的基准 O (1) 调度器不会直接使用 static_prio,会根据进程是 IO 交互型还是 CPU 消耗型,在static_prio基础上计算prio动态优先级:
  • IO 密集(经常睡眠,交互程序:编辑器、shell):提升动态优先级,给更多 CPU;
  • CPU 密集(一直跑,计算程序):降低动态优先级。

⚠️****注意:nice只作用普通分时进程;实时进程 SCHED_FIFO/SCHED_RR 不受 nice 控制,使用 0‑99 实时优先级。

2. nice 的局限

nice 只是权重偏移,O (1) 调度下不是比例分配 CPU,是时间片分配:

nice 越小,分配得到的时间片长度越长;nice 越大,分得时间片越短。

nice=-20:时间片最长;nice=+19:时间片最短。


二、O (1) 调度器中的饥饿问题

什么是饥饿

饥饿 (starvation):低优先级进程永远得不到 CPU 时间,一直无法运行。

1. O (1) 调度为什么会产生饥饿?

回顾 O (1) 调度逻辑:调度器每次永远选active 队列里最高优先级的进程运行。

场景:系统一直存在大量高优先级就绪进程。

  1. 高优先级进程时间片用完 → 丢进expired队列;
  2. 但是只要active队列还有高优先级进程,就永远优先跑高优先级;
  3. 低优先级进程即使在 active 队列,只要持续有高优先级就绪任务,低优先级得不到调度;
  4. 等到 active 队列耗尽,才交换 active/expired 指针,原先 expired 里面所有进程(包括低优先级)全部拿到新时间片。

关键点:只要 active 队列不为空,就不会去看 expired 队列。


两种饥饿情况
① 普通分时进程饥饿

系统持续不断有高 nice(小数值,比如 nice=-5)CPU 密集进程。 只要 active 队列始终有高优先级任务,低优先级进程得不到运行机会。 虽然时间片耗尽会扔到 expired,但只要 active 不空,expired 队列里的进程不会被调度。

O (1) 做了补救:交互进程奖励机制,IO 密集低优先级进程会动态提升优先级缓解饥饿,但不能彻底根除。

② 实时进程饥饿(SCHED_FIFO)

实时进程优先级 0‑99,优先级高于所有普通进程。 如果一个高优先级 SCHED_FIFO 实时进程一直就绪不阻塞,同 CPU 上所有更低优先级实时进程、全部普通进程直接饥饿,完全抢不到 CPU。

SCHED_FIFO 没有时间片概念:只要不主动 sleep / 退出,就一直占 CPU。


2.O (1) 调度器缓解饥饿的机制(不能完全消除)

  1. **动态优先级(交互进程优待)**IO 密集进程经常睡眠,内核识别为交互任务,动态抬高它的有效优先级。即使它 nice 很高,也能获得更高调度优先级,防止交互程序饿死。 CPU 密集任务会被惩罚,动态降低优先级。
  2. **纪元切换(active/expired 交换)**当 active 队列nr_active ==0,强制交换 active、expired 指针。所有 expired 队列里无论优先级高低,全部进入 active,获得一轮运行机会。

但是!如果active 永远不为空(源源不断高优先级进程),纪元永远不会触发交换,低优先级进程依旧饥饿。👉这就是 O (1) 调度器最大痛点。

举例:源源不断创建高优先级进程,active 队列一直有任务,active‑>nr_active永远不等于 0,expired 队列里面低优先级进程会长期得不到调度。

3. CFS 如何解决饥饿(对比理解)

后来 CFS 调度器抛弃 active/expired 双队列 + bitmap 方案,采用完全公平,按权重比例分配 CPU 时间。 不再有 "优先选最高优先级" 逻辑,维护虚拟运行时间vruntime,永远挑 vruntime 最小进程运行。

  • 低优先级进程哪怕权重小,随着时间累积vruntime,终究会被调度;从机制上杜绝饥饿问题。
  • nice 依旧保留,nice 决定权重,决定分得 CPU 时间占比。

三. 新进程的加入情况

内核真实行为(Linux2.6 O (1) 原版)

fork()创建子进程:

  1. sched_fork():给子进程分配完整全新时间片 timeslice;
  2. wake_up_new_task():把新进程设置为就绪态,enqueue_task 加入 runqueue‑>active 队列对应优先级链表;
  3. 更新 active 的 bitmap、nr_runningnr_active计数。

✅逻辑:新进程时间片是满的,属于 "时间片未消耗",理应进active,参与本轮调度,可以抢占当前 CPU 进程。

expired队列语义:时间片已经用光的就绪进程。只有进程运行后时钟 tick 把时间片减到 0,才会被迁移到 expired。

场景 加入哪个队列 原因
fork 新创建进程 active 分配完整初始时间片,时间片未消耗
睡眠唤醒 (wakeup),进程还有剩余时间片 active 时间片还没用完
睡眠唤醒 (wakeup),时间片已经耗尽 expired 时间片已经用光
进程运行,tick 时钟时间片耗尽 expired 时间片耗尽,本轮不能继续跑

四、面试简答整理

nice

  1. nice 范围‑20 ~ +19,仅针对普通分时进程;nice 越小优先级越高。
  2. 用户用来调整进程 CPU 权重,nice 大代表进程 "友好",主动少占用 CPU。
  3. 普通用户只能增大 nice 降低优先级;root 可设置全部范围。
  4. nice 是静态优先级基准,O (1) 基于它计算动态优先级,决定进程时间片长短。

饥饿

  1. 饥饿:低优先级进程长期无法获得 CPU。
  2. O (1) 调度根源:调度总是优先选择 active 队列中最高优先级进程;只要 active 队列持续存在高优先级就绪任务,expired 队列不会被切换,低优先级进程得不到运行。
  3. O (1) 缓解手段:交互进程动态提优先级;active 空时交换 active/expired 指针。
  4. 缺陷:如果 active 永远不为空,纪元切换永远不发生,饥饿依旧会发生。
  5. SCHED_FIFO 实时进程饥饿更严重:高优先级 FIFO 不退出,低优先级完全无法运行。

新进程

  1. 错误:新进程加入 expired 队列,防止饥饿。
  2. ✅正确:新进程加入 active 队列,因为拥有完整初始时间片;expired 存放时间片耗尽进程;源源不断创建高优先级新进程会造成低优先级进程饥饿。

相关推荐
疯狂打码的少年31 分钟前
【数据库技术】关系完整性约束(实体/参照/用户定义)
运维·服务器·数据库·笔记
小谈不敲代码39 分钟前
【12-kubenetes的持久化存储】
运维·kubernetes
梦想的旅途21 小时前
企业微信API如何实现自动化办公
运维·自动化·企业微信
刚入门的大一新生1 小时前
Linux-命令行参数和环境变量
linux·运维·服务器
FreeTinker1 小时前
Java文件服务器的技术选型与实现路径:从嵌入式工具到企业级系统
java·服务器·开发语言
我星期八休息2 小时前
Linux I/O多路转接—epoll
java·linux·运维·服务器·开发语言·jvm·算法
大模型码小白2 小时前
AI 提示词专栏:使用系统指令(System Prompt)实现全局约束
java·大数据·运维·开发语言·人工智能·python·prompt
土星云SaturnCloud2 小时前
超轻量 OCR 实战:PP-OCR 边缘部署实践
服务器·人工智能·ai·ocr·边缘计算
AI备忘录2 小时前
(十六)GRE/IPSec 隧道配置命令五厂商对照:华为 华三 锐捷 迈普 思科
运维·服务器·网络·网络协议·网络安全·华为