Linux 任务调度探秘(上):调度框架与优先级仲裁

在Linux中,task_struct 用于描述一个任务(进程或线程)的完整状态,而 rq(就绪队列)则是调度器用来管理“可运行任务”的核心数据结构。两者紧密配合:task_struct 描述“谁”,rq 决定“何时运行谁”。既然 rq 决定了哪个任务运行,那么它从哪里获取候选任务?当多个任务就绪时,谁先谁后又是如何确定的呢?

本系列文章将围绕这两个核心问题展开探讨。磨刀不误砍柴工,我们先了解一下就绪队列。

什么是就绪队列?

在kernel\sched\core.c中,rq 被定义为Per-CPU变量。

DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

这意味着,在多核 CPU 系统中,会为每个 CPU 分配一个独立的就绪队列(rq)实例。从而实现调度本地化:避免多核竞争,支持无锁或低开销调度,显著提升并发性能与可扩展性。

可以用NanCode的dt命令查看rq结构,结果如下:

其中的关键字段说明如下:
dt lk!rq
//调度核心状态
+0x018 nr_running       : Uint4B     // 可运行任务总数
+0x070 nr_switches      : Uint8B     // 发生的上下文切换总次数
+0xb00 nr_uninterruptible : Uint4B   // 处于 uninterruptible 睡眠状态的任务数

//当前运行任务与特殊任务
+0xb08 curr             : Ptr64 task_struct   // 当前正在该 CPU 上执行的任务
+0xb10 idle             : Ptr64 task_struct   // 该 CPU 对应的 idle 任务(swapper/X)
+0xb18 stop             : Ptr64 task_struct   // stop 任务(用于 CPU 紧急操作,如热插拔)

//调度时钟
+0xb38 clock            : Uint8B        // 本 CPU 的调度时钟(纳秒)
+0xb40 clock_task       : Uint8B        // 仅在非 idle 任务运行时推进的时钟

//调度子队列
+0x200 cfs              : cfs_rq   //CFS(完全公平调度器)队列,管理普通进程/线程
+0x3c0 rt               : rt_rq    //实时任务队列
+0xa88 dl               : dl_rq    //截止时间调度队列

//一些链表结构
+0xae8 leaf_cfs_rq_list : list_head     // 叶子 CFS 运行队列列表
+0xaf8 tmp_alone_branch : Ptr64 list_head // 临时孤立分支(用于负载均衡)
+0xbf8 cfs_tasks        : list_head     // 当前 CPU 上所有可运行的 CFS 任务链表

其中curr这个指针指向了在当前cpu上真正运行着的task_struct,调度的时候会改变这个指针的指向,指向谁呢?下面探讨第一个关键问题:调度器从哪里获取候选任务?

候选任务从哪来?

要弄清这个问题,不妨先看一下调度器的核心函数__schedule(),下面是这个函数的简化版代码:

static void __sched notrace __schedule(unsigned int sched_mode)
{
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    unsigned long prev_state;
    struct rq_flags rf;
    struct rq *rq;
    int cpu;

    cpu = smp_processor_id();
    rq = cpu_rq(cpu);       //获取当前 CPU 的 rq
    prev = rq->curr;        // 当前正在运行的任务

    /* 关键步骤 1:选择下一个要运行的任务 */
    next = pick_next_task(rq, prev, &rf);

   /* 关键步骤 2:如果需要切换 */
    if (likely(prev != next)) {
        rq->nr_switches++;
        switch_count = &prev->nivcsw;   // 非自愿上下文切换计数

        /* 执行架构相关的上下文切换 */
        context_switch(rq, prev, next, &rf);
    } else {
        rq->clock_update_flags &= ~(RQCF_ACT_SKIP | RQCF_REQ_SKIP);
        rq_unlock_irq(rq, &rf);
    }

简单解释一下这个过程:先把当前任务用prev存起来,然后调用pick_next_task()来选出下一个任务,最后在context_switch()执行上下文切换的时候,会修改curr,指向选出的下一个任务。

pick_next_task()里能会通过各个子队列来获取task_struct。

各个子队列中并不直接存放 task_struct,而是通过调度实体与 task_struct 建立关联。调度实体是与具体调度策略绑定的数据结构,不同调度策略可能在数据结构设计、排序依据以及关键调度参数等方面存在差异,自然各个调度队列会有独特的调度实体。

我用一张图来表示它们的关系:


  • • cfs_rq (调度实体结构:struct sched_entity)

cfs_rq ←→ struct rb_node (in sched_entity) ←→ struct sched_entity ←[内嵌有]— task_struct
  • • rt_rq(调度实体结构:struct sched_rt_entity)

rt_rq ←→ rt_rq->active.queue[prio](struct list_head) ←→ struct sched_rt_entity ←[内嵌有]— task_struct
  • • dl_rq(调度实体结构:struct sched_dl_entity)

dl_rq ←→ struct rb_node (in sched_dl_entity) ←→ struct sched_dl_entity ←[内嵌有]— task_struct

调度器通过 container_of() 宏,从调度实体反向定位到所属的 task_struct,从而完成“从队列节点 → 实体 → 任务”的完整链路。

此外,我们注意到:在 rq 结构体中还包含两个特殊的 task_struct 指针:

+0xb10 idle             : Ptr64 task_struct   
+0xb18 stop             : Ptr64 task_struct

它们会参与调度吗?答案是:会的。

  • • Idle 任务
    当前 CPU 的 CFS、RT、DL 队列全为空,即系统空闲时运行,该任务负责使 CPU 进入低功耗状态

  • • Stop 任务
    当有紧急的 per-CPU 操作(例如 CPU 热插拔、任务强制迁移或 TLB 批量刷新)被提交到该 CPU 的 stopper 队列时,rq->stop 任务会被激活。

现在,回到我们的第一个问题:就绪队列从何处获取候选任务?你应该也有了答案。候选任务来源于当前 CPU 的 rq中多个调度子队列以及两个特殊任务。

调度类的优先级顺序

既然就绪队列能从多处获取候选任务,那么又诞生了新的问题:这几处的优先级是如何的?

pick_next_task()中藏着答案(它是调度函数__schedule()中,用于选取下一个任务的函数,你不会忘了吧)。

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{

//【关键!】按调度类优先级从高到低依次尝试:
// 一旦某个调度类返回非空任务,立即返回,不再检查更低优先级类。

   for_each_class(class) {

    /* 调用当前调度类注册的具体选任务函数 */
        p = class->pick_next_task(rq, prev, rf);
        if (p)
            return p;
    }
    BUG();
}

本段的主角——调度类正式登场。首先,让我们认识一下这位核心角色:以下是内核中定义的调度类(sched_class)基类的结构体:

struct sched_class {
    // === 任务队列管理 ===
    void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);   // 入队
    void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);   // 出队

    // === 抢占与调度核心 ===
    void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags); // 检查是否抢占当前任务
    struct task_struct *(*pick_next_task)(struct rq *rq);                       // 【核心】选出下一个运行的任务

    // === 任务切换回调 ===
    void (*put_prev_task)(struct rq *rq, struct task_struct *p);                // 切出前调用
    void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);    // 切入后调用

    // === 主动让出 CPU ===
    void (*yield_task)(struct rq *rq);                                          // 当前任务主动让出
    bool (*yield_to_task)(struct rq *rq, struct task_struct *p);                // 尝试让给指定任务

    // === 时间片与统计 ===
    void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);        // 时钟滴答处理
    void (*update_curr)(struct rq *rq);                                         // 更新当前任务运行统计

    // === 生命周期 ===
    void (*task_fork)(struct task_struct *p);                                   // 任务 fork 后初始化
    void (*task_dead)(struct task_struct *p);                                   // 任务退出前清理

    // === 调度类切换通知 ===
    void (*switched_from)(struct rq *this_rq, struct task_struct *task);        // 任务从此类切出
    void (*switched_to)(struct rq *this_rq, struct task_struct *task);          // 任务切入此类
    void (*prio_changed)(struct rq *this_rq, struct task_struct *task, int oldprio); // 优先级变更

在 Linux 调度器中,每个调度策略(CFS、RT、DL 等)都必须实现一个 sched_class 实例,注册一组回调函数。通用调度框架通过这些函数与具体策略交互,而无需知道内部细节。

以下是各调度类的具体实例说明:

调度类(sched_class)

用途

特点

典型场景

核心数据结构

stop_sched_class

内核高优先级同步操作

优先级最高,立即抢占;每 CPU 一个 stop 任务

CPU 热插拔、任务迁移、TLB 刷新

task_struct

rq->stop

dl_sched_class

强实时任务

基于 EDF,严格保证截止时间

自动驾驶、飞行控制、高清视频流

struct sched_dl_entity
rt_sched_class

实时任务

固定优先级(1–99),FIFO/RR 调度

工业控制、音视频采集

struct sched_rt_entity
fair_sched_class

普通进程

CFS 公平调度,基于 vruntime

桌面应用、Web 服务

struct sched_entity
idle_sched_class

极低优先级任务

仅在无其他可运行任务时执行

日志归档、后台维护

task_struct

rq->idle

调度类也是linux中“面向对象思想”的典型体现。尽管 C 语言本身不支持 class、继承、多态等语法,但内核通过结构体 + 函数指针 + 接口约定,巧妙地实现了面向对象的核心特性。

比如核心函数 pick_next_task():
遍历调度类调用class->pick_next_task(rq),实际行为由具体调度类(如 CFS、RT、DL)的实现决定——这正是运行时多态。新增调度策略只需实现一套回调并注册,无需改动调度核心,实现了高内聚、低耦合与良好扩展性。

调度类中 pick_next_task()用于选择下一个任务,那么遍历调度类的顺序则决定了优先级,顺序在前的调度类,它的pick_next_task()会被优先调用,优先级就更高。

  • • 链接器脚本的SCHED_DATA宏定义了调度类顺序:

/*
 * The order of the sched class addresses are important, as they are
 * used to determine the order of the priority of each sched class in
 * relation to each other.
 */
#define SCHED_DATA                \
    STRUCT_ALIGN();                \
    __sched_class_highest = .;        \
    *(__stop_sched_class)            \
    *(__dl_sched_class)            \
    *(__rt_sched_class)            \
    *(__fair_sched_class)            \
    *(__idle_sched_class)            \
    __sched_class_lowest = .;

这段代码揭示了Linux调度类优先级机制的秘密:优先级不是靠数据结构,而是由链接时的内存布局硬编码实现。在内核链接阶段,各调度类被强制按优先级从高到低(stop → dl → rt → fair → idle)连续排列在内存中。

下面是是一些方便编程的遍历宏:

/* 链接器定义的调度类地址边界(在 vmlinux.lds.h 中) */
extern struct sched_class __sched_class_highest[]; 
extern struct sched_class __sched_class_lowest[];   

/* 从 _from 到 _to遍历调度类 */
#define for_class_range(class, _from, _to) \
    for (class = (_from); class < (_to); class++)

/* 遍历所有调度类:按 stop → dl → rt → fair → idle 顺序 */
#define for_each_class(class) \
    for_class_range(class, __sched_class_highest, __sched_class_lowest)

比如,通过指针遍历(for_each_class)宏,就可以实现“先问高优,有就运行”的调度逻辑。

至此,我们不仅认识了调度类,也清楚了调度优先级的实现原理。

NanoCode插件开发推进

在梳理 Linux 内核调度机制的同时,我也持续推进 NanCode 插件的开发。本次新增的扩展命令为 !ndx.ready6,看到名字大家也能够知道它是为适配内核6而设计的。使用!ndx.ready6 -h可以输出帮助信息。


该命令可直观展示运行队列(rq)的关键统计信息,包括以下内容:

rq 层面的全局状态


字段

说明

nr_running

当前在该 CPU 上处于可运行状态的任务数量

nr_switches

该 CPU 上发生的上下文切换次数

nr_uninterruptible

处于不可中断睡眠状态的进程数

next_balance

下一次负载均衡检查的时间点(单位:jiffies)

curr->pid

当前正在运行的进程的 PID

clock

该 CPU 的调度器时钟(纳秒)

clock_task

该 CPU 上所有任务的累计执行时间

avg_idle

平均空闲时间

(jiffies),用于负载均衡判断

max_idle_balance_cost

触发负载均衡的最大代价阈值(jiffies)


各调度类子队列(cfs_rq、rt_rq、dl_rq)的详细运行数据



以下是 cfs_rqrt_rq 和 dl_rq 三个调度队列的关键字段及其说明的简洁表格:

调度队列

关键字段

说明

cfs_rq

.nr_running

当前可运行的普通任务数量

load_avg

队列的平均负载(反映 CPU 压力)

util_avg

CPU 利用率平均值(0–1024,≈百分比)

min_vruntime

最小虚拟运行时间

,决定下一个被调度的任务

rt_rq

rt_nr_running

当前可运行的实时任务数量

rt_throttled

是否因超过配额被节流(1=是)

rt_runtime

实时任务可用的时间配额(默认 950ms/秒)

dl_rq

dl_nr_running

当前可运行的截止时间任务数量

dl_bw->bw

已分配的截止时间任务带宽(ns)

dl_bw->total_bw

系统为截止时间任务预留的总带宽(ns)


各个 CPU 上正在运行和排队的任务及其基本信息

上图中,4号CPU很忙,有四个线程已经准备好,排队等待运行。就像要蹦极的人,做好准备排好队,一旦轮到自己就开始跳。


因地制宜的优化方案

在做这部分功能时,我们采用了和内核不同的方法,先看内核中print_rq的实现:


内核出于安全性、完整性和实现简洁性的考虑,在 print_rq() 中选择遍历所有任务,并通过判断任务是否属于当前 CPU 来决定是否打印,而非直接遍历各调度类的子队列。这种做法虽然牺牲了一定性能,但能确保打印信息的全面与可靠,而且不干扰调度器工作。

但是在ndb环境下,系统处于中断状态,无需考虑并发安全问题。此外!process命令已能够输出完整的任务列表,保证了信息的完整性。因此,我们改为直接遍历各调度类的子队列(如 cfs_rq、rt_rq、dl_rq),避免全量扫描任务链表。

这一优化将命令执行时间从约 16 秒显著缩短至 3 秒左右。

优化前:


优化后:



实践演练

我在幽兰代码本上指定 CPU 4 运行了 4 个矩阵乘法程序(每个都是单线程),随后通过 NanCode 中断系统并执行命令:

!ndx.ready6 -f 1

结果如下:


如图中cpu[4]中的内容,最左侧的on_cpu为1代表正在运行的进程,此时为PID6023进程。on_cpu为0则为在排队等待运行的进程,此时有三个进程正在排队。


字段

说明

on_cpu

是否正在此 CPU 上执行(1 = 是,0 = 等待)

state

进程状态:R 表示 Runnable(可运行,等待 CPU 时间片)

task

进程名(例如 matmul

PID

进程 ID

tree-key

CFS 调度树中的虚拟运行时间(vruntime),单位为秒;值越小,调度优先级越高

switches

此任务在该 CPU 上被调度的次数(即上下文切换次数)

prio

调度优先级(120 是普通 SCHED_OTHER 进程的默认静态优先级)

wait-time

自进程创建以来,在 runnable 队列中等待 CPU 的累计时间(秒)

sum-exec

实际执行时间总和

(秒),即该任务真正占用 CPU 的时间

sum-sleep

睡眠总时间

(秒),处于 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE(部分)睡眠状态的时间

sum-block

阻塞总时间

(秒),因等待 I/O、锁、信号量等而被阻塞的时间

命令结果中打印了每个CPU的rq地址,可以配合NanoCode中的dt命令查看各个结构体。


例如sched_avg是一个描述运行负载的重要结构体,它相对于rq地址偏移为+0x280(偏移可以通过dt一步步查看),可以使用下面的命令观察它:

dt lK!sched_avg 0xffff0004fd61f200+0x280

内核中 CPU 利用率的最大值是 1024,当前util_avg = 1023。CPU 利用率= 1023/1024≈99.9%,这表示该 CFS 运行队列所占用的 CPU 接近100%,非常忙碌,处于满载状态。

小结

最后,回到开头提出的两个问题。有人可能要问了:无涯,无涯,你说的两个问题,不是只解决了第一个吗?第二个问题:当多个任务就绪时,谁先谁后又是如何确定的?你只说了调度类的优先级,那调度类处理任务的优先级呢?

是的,各个调度类具体如何处理任务,我打算在另一篇文章中详细探讨。各位可以先去查看各调度类的 pick_next_task() 函数,理解不同调度策略的实现逻辑。待我下一篇文章发布时,期待与大家的想法不谋而合。若发现文中存在错误或疏漏,恳请不吝批评指正。

(写文章很辛苦,恳请各位读者点击“在看”,也欢迎转发)

*************************************************

正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生

扫描下方二维码或者在微信中搜索“盛格塾”小程序,可以阅读更多文章和有声读物

Image

也欢迎关注格友公众号

Image

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值