第 1-2 课:进程与调度:CPU 空出来时,内核选谁?

第 02 课 · 进程与调度:CPU 空出来时,内核选谁?

本课环境

  • 源码:D:\Linux\rk3568_linux_sdk\kernel(Linux 5.10.160,RK3568 / ARM64,4 × Cortex-A55)
  • 主线文件:kernel/sched/core.c、kernel/sched/fair.c、kernel/sched/sched.h、kernel/fork.c
  • 关键配置:CONFIG_PREEMPT_NONE=y(内核不可抢占)
  • 阅读:约 35--45 分钟 | 实验:10 分钟,不需要板子
  • 配套速查表:进程与调度速查表(字段索引 / 五类 / __schedule 五步 / CFS 常量)

承接上一课

你已经知道 PID 1 / PID 2 是怎么被创建的、initcall 是怎么被遍历的。

但"创建 task"只是把它放进系统。真正的问题是:

CPU 只有一个,下一条指令该执行谁的?

这一课把 task_struct、运行队列、调度类和 CFS 串成一条完整的决策链。全课只回答一个问题:下一个运行的 task,凭什么被选中?


本文速览

  • 一个结构体两种身份 :task_struct 既是"资源柜子"又是"调度实体"
  • 三步入队 :dup_task_struct → sched_fork → wake_up_new_task,"创建"不等于"运行"
  • 五个调度类 :优先级不靠数组,靠链接顺序 ;for_each_class() 从高地址往下问
  • __schedule() 五步:更新时钟 → prev 出队 → 选 next → 切地址空间与寄存器 → 收尾
  • CFS 记账 :vruntime += delta_exec × 1024 / weight,永远选最小者
  • 三份真实实验输出:nice −5/0/5/10 的 68/22.3/7.3/2.4,以及睡眠者的"有限补偿"
  • 本机特色 :这棵 SDK 内核带 69 处 Android RVH 厂商钩子 ,sched_fork() 里就有

一、两分钟热身:上一课的三个问题

先凭记忆回答,再展开。间隔检索比重新阅读更能形成长期记忆。

Q1:为什么 start_kernel() 里 mm_init() 必须早于 sched_init()?

因为调度器及后续初始化需要分配内存(页表、task 缓存、运行队列相关结构)。没有页分配器,任何 kmalloc 都无从谈起。顺序表达的是"后者假设前者已经可用"。

Q2:内建驱动的 module_init() 等价于哪一级 initcall?

device_initcall,即 level 6。可加载模块则在 insmod 时执行 init_module,级别差异消失。

Q3:"level 6 的驱动 initcall 执行完"是否等于"驱动 probe 完成"?

不等于。initcall 通常只注册驱动;probe 由驱动核心在设备与驱动匹配后调用,资源未就绪时还会 -EPROBE_DEFER 延迟重试。


二、task_struct:一个结构体,两种身份

内核里没有"进程对象"和"线程对象"两套东西。调度器、文件系统、内存管理看到的都是同一个

struct task_struct(include/linux/sched.h:656,已核对)。

它可以被理解成一个"资源柜子 ",同时又是一个"可被调度的实体":

text 复制代码
                    struct task_struct  (sched.h:656)
        ┌──────────────────────────┬──────────────────────────┐
        │  身份 A:资源容器          │  身份 B:调度实体          │
        │  "我能看到什么、拥有什么"   │  "我何时、在哪个 CPU 上跑"  │
        ├──────────────────────────┼──────────────────────────┤
        │  mm / active_mm   777/778│  state            665     │
        │  fs                   980│  on_rq            700     │
        │  files                983│  prio/static/normal 702--704│
        │  nsproxy              990│  rt_priority      705     │
        │  signal / sighand 993/994│  sched_class      707     │
        │  cred                 953│  se / rt / dl  708/709/713│
        │  thread              1397│  policy           741     │
        │                          │  cpus_mask        744     │
        └──────────────────────────┴──────────────────────────┘
                                   │
                      ┌────────────┴────────────┐
                      ▼                         ▼
              资源由 CLONE_* 决定共享       由 sched_class 决定归属
               → 进程 / 线程的分界            → 哪个调度类

2.1 身份 A:资源容器

字段 行号 含义
mm / active_mm 777 / 778 用户地址空间;内核线程只有 active_mm
fs 980 cwd、root
files 983 打开文件描述符表
nsproxy 990 namespace 集合
signal / sighand 993 / 994 信号处理与共享状态
cred 953 uid/gid、capability
thread 1397 架构相关的寄存器/FPU 上下文

2.2 身份 B:调度实体

字段 行号 含义
state 665 可运行 / 睡眠 / 停止 / 僵尸
on_rq 700 是否在运行队列上
prio / static_prio / normal_prio 702--704 动态 / 静态 / 归一化优先级
rt_priority 705 实时优先级
sched_class 707 指向所属调度类的 vtable
se / rt / dl 708 / 709 / 713 三种调度实体
policy 741 SCHED_NORMAL / FIFO / RR / DEADLINE...
cpus_mask 744 允许运行的 CPU 集合

💡 进程与线程的区别,不在调度器眼里

调度器只认识 task_struct。 "进程"和"线程"的差别,是创建时 CLONE_* 标志决定共享哪些资源:

共享 mm、files、fs、sighand 的 task 属于同一线程组,tgid 相同。

对调度器来说,它们都是一次调度决策的候选者。

2.3 state 不是"正在运行"

这是最容易被误读的字段之一。TASK_RUNNING(include/linux/sched.h:82)的准确含义是可运行:

text 复制代码
        ┌─────────────────────────────────────────────┐
        │  TASK_RUNNING = "可运行"                     │
        │    ├── 正在 CPU 上跑   ← 由 rq->curr 表示     │
        │    └── 在运行队列里排队 ← 由 on_rq 表示       │
        └─────────────────────────────────────────────┘

真正的"正在运行"由 rq->curr 表示,不是由 state 表示。 其他常见状态:

状态 含义
TASK_INTERRUPTIBLE 可被信号唤醒的睡眠
TASK_UNINTERRUPTIBLE 不可打断的睡眠(D 状态,ps 里最难杀)
TASK_STOPPED 被 SIGSTOP 停住
EXIT_ZOMBIE 已退出、等父进程收尸
EXIT_DEAD 连收尸都完成了

三、从 fork() 到运行队列:三步

fork()、vfork()、clone() 最终都走 copy_process()(kernel/fork.c:1900,已核对)。它不直接"运行"新 task,而是完成三件事:

text 复制代码
   fork() / clone() / vfork()
            │
            ▼
 ┌──────────────────────────────────────────────────────┐
 │ ① dup_task_struct()      kernel/fork.c:886           │
 │    复制 task_struct、分配内核栈、复制 thread_info      │
 │    → arch_dup_task_struct()  fork.c:871              │
 │    此时新 task 状态 = TASK_NEW(不可运行)             │
 └──────────────────────┬───────────────────────────────┘
                        ▼
 ┌──────────────────────────────────────────────────────┐
 │ ② copy_process()         kernel/fork.c:1900          │
 │    按 CLONE_* 复制/共享 mm、files、fs、sighand、       │
 │    signal、nsproxy ...                                  │
 │    → sched_fork()        fork.c:2133 → core.c:3469    │
 └──────────────────────┬───────────────────────────────┘
                        ▼
 ┌──────────────────────────────────────────────────────┐
 │ ③ wake_up_new_task()     kernel/sched/core.c:3594    │
 │    state = TASK_RUNNING            (:3602)           │
 │    选 CPU                          (:3614)           │
 │    activate_task() 入队            (:3621)           │
 │    check_preempt_curr() 判断抢占   (:3623)           │
 │    → enqueue_task_fair() fair.c:5557                 │
 │        → enqueue_entity()  fair.c:4259               │
 │            → __enqueue_entity() fair.c:581  插入红黑树 │
 └──────────────────────────────────────────────────────┘
                        ▼
              它才真正成为"调度候选者"

3.1 sched_fork() 决定了它属于哪个调度类

sched_fork()(3469 行)先调用 __sched_fork()(3307 行)清理调度实体,

然后设置 TASK_NEW(3479),继承父进程优先级(3484),再按优先级选类(3510 起):

c 复制代码
/* kernel/sched/core.c:3510 */
if (dl_prio(p->prio))
        return -EAGAIN;                 /* deadline:拒绝通过 fork 继承 */
else if (rt_prio(p->prio))
        p->sched_class = &rt_sched_class;
else
        p->sched_class = &fair_sched_class;

这三行解释了两件事:

  1. 普通进程默认落在 CFS(fair) ------因为绝大多数进程 prio 落在普通区间;
  2. deadline 任务不能靠 fork 继承 ------必须显式设置,否则 fork 直接返回 -EAGAIN。

⚠️ 注意:选择调度类 和真正被调度 是两件事。后者要等它入队并被 pick_next_task() 选中。

3.2 一个"本机特色":Android RVH 钩子

如果你去翻这段源码,会看到一行不属于上游 5.10 的代码:

c 复制代码
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
        trace_android_rvh_sched_fork(p);        /* :3471  ← 厂商钩子 */
        ...
}

这是 Rockchip/Android SDK 的 RVH(vendor hook)机制 。我统计了一下:kernel/sched/ 下共有 69 处 trace_android_rvh_* 调用。

为什么值得知道?

  • 这些钩子是 SDK 厂商补丁 ,不在 kernel.org 的 5.10 里------对照 Bootlin Elixir 时会对不上号;
  • 它们提供了"不改内核代码就能挂回调"的扩展点,Android 的性能/功耗调优大量依赖它;
  • 排障时如果看到陌生的 android_rvh 符号,先想到"这是厂商加料",不要怀疑自己读错了。

3.3 新任务不会"立刻"运行

wake_up_new_task() 只是让它可运行:

  • 它可能立刻抢占当前 task(如果优先级更高);
  • 也可能在队列里等到下一次调度点。

"创建"与"运行"之间,隔着一次调度决策。


四、运行队列与五个调度类

每个 CPU 有一个 struct rq(kernel/sched/sched.h:918),它是该 CPU 的"待办清单"。

CFS 任务放在 rq->cfs,这是一个 struct cfs_rq(:532):

字段 行号 作用
tasks_timeline 544 红黑树根(按 vruntime 排序)
min_vruntime 539 "虚拟时间的进度条",单调递增
text 复制代码
                    struct rq  (sched.h:918)   ← 每 CPU 一个
        ┌──────────────────────┬──────────────────────┐
        │  rq->cfs             │  rq->rt / rq->dl     │
        │  struct cfs_rq :532  │  实时 / deadline 队列 │
        │  ┌────────────────┐  │                      │
        │  │ rb tree        │  │  rq->curr  ────────▶ 当前在跑的 task
        │  │ (tasks_timeline│  │  rq->idle            │
        │  │   按 vruntime) │  │                      │
        │  └────────────────┘  │                      │
        │  min_vruntime :539   │                      │
        └──────────────────────┴──────────────────────┘

4.1 sched_class 是调度器的"插槽"

struct sched_class(sched.h:1838)是一张函数指针表:

c 复制代码
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);
        struct task_struct *(*pick_next_task)(struct rq *rq, ...);
        void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
        void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
        ...
};

每种调度策略实现同一套接口,内核按优先级依次询问。

4.2 五个调度类:从高到低

优先级 类 定义位置(已核对) 段名 典型策略
最高 stop_sched_class stop_task.c:112 __stop_sched_class 停机/热插拔;普通任务永不进入
高 dl_sched_class deadline.c:2521 __dl_sched_class SCHED_DEADLINE,按截止时间与运行预算
中 rt_sched_class rt.c:2551 __rt_sched_class SCHED_FIFO / SCHED_RR,优先级 0--99
低 fair_sched_class fair.c:11512 __fair_sched_class SCHED_NORMAL / BATCH / IDLE(本课重点)
最低 idle_sched_class idle.c:491 __idle_sched_class 没别的任务可跑时运行

4.3 优先级不是数组,是链接顺序

这是内核里非常漂亮的一个设计。链接脚本把五个类按固定顺序放进同一个段

(include/asm-generic/vmlinux.lds.h:131--139,已核对):

c 复制代码
/* vmlinux.lds.h:131  SCHED_DATA */
__begin_sched_classes = .;
*(__idle_sched_class)        /* 地址最低 */
*(__fair_sched_class)
*(__rt_sched_class)
*(__dl_sched_class)
*(__stop_sched_class)        /* 地址最高 */
__end_sched_classes = .;

于是:

c 复制代码
/* kernel/sched/sched.h:1913 */
#define sched_class_highest (__end_sched_classes - 1)

/* sched.h:1919-1920 */
#define for_each_class(class) \
        for_class_range(class, sched_class_highest, sched_class_lowest)

"地址更大"就等于"优先级更高" ,for_each_class() 从高地址往低地址遍历。

内核还在启动时做了一次自检(core.c:7476--7480):

c 复制代码
BUG_ON(&idle_sched_class + 1 != &fair_sched_class ||
       &fair_sched_class + 1 != &rt_sched_class ||
       &rt_sched_class + 1   != &dl_sched_class);
BUG_ON(&dl_sched_class + 1 != &stop_sched_class);

如果哪天有人改了链接顺序,内核会在启动时直接 BUG------用一行断言把"布局假设"变成"硬约束"。

text 复制代码
   地址:低 ────────────────────────────────────▶ 高
        idle    fair     rt      dl      stop
         │       │        │       │        │
         └───────┴────────┴───────┴────────┘
              for_each_class 从右往左问
   优先级:低 ◀──────────────────────────────── 高

4.4 pick_next_task():从高到低问一遍

c 复制代码
/* kernel/sched/core.c:4606,省略快速路径 */
for_each_class(class) {
        p = class->pick_next_task(rq);
        if (p)
                return p;
}

/* idle 类必须永远有任务可返回,否则内核 BUG */
BUG();

这段循环是调度器的"总开关":先问 stop,再问 dl、rt、fair,最后 idle。

只要高优先级类有可运行任务,低优先级类根本没有机会被问到。

💡 实时任务为什么会"饿死"普通任务?

因为 for_each_class() 先问 RT。只要 RT 队列里一直有可运行任务,fair 类就没被问到。

这不是 bug,而是优先级语义。防止饥饿要靠 sched_rt_runtime_us 之类的带宽限制,而不是 CFS。

4.5 本课的范围

我们只展开 CFS 在单 CPU 上如何选下一个 task。

stop / dl / rt 的内部策略、SMP 负载均衡(load_balance())、cgroup 调度与带宽控制,

留到后续专题;exec、exit、wait4 的完整生命周期也会另开一课。

本课先把"从创建到入队,再到被选中"的决策链走通。


五、__schedule():决策与上下文切换

pick_next_task() 只负责"选"。真正完成切换的是 __schedule()(4685 行),由 schedule()(4875 行)等入口调用。

text 复制代码
 ① update_rq_clock()  更新运行队列时钟
    prev_state = prev->state              (:4737)
            │
 ② prev 不是 TASK_RUNNING?
    → deactivate_task() 把它移出运行队列   (:4761)
            │
 ③ next = pick_next_task(rq, prev, &rf)   (:4771)
    清除 TIF_NEED_RESCHED
            │
 ④ context_switch()                        (:3992)
    处理 mm / lazy TLB
    → switch_to(prev, next, prev)          (:4043)
            │
 ⑤ finish_task_switch()                    (:3841)
    解锁运行队列、mmdrop、task_work

5.1 ARM64 上,switch_to 到底切了什么?

通用宏在 include/asm-generic/switch_to.h:21,它调用架构的 __switch_to()。ARM64 分两层:

层 位置 干什么
架构状态 arch/arm64/kernel/process.c:636 处理 FPU、MTE、SCTLR 等,然后调 cpu_switch_to()
寄存器与栈 arch/arm64/kernel/entry.S:1102 保存/恢复被调用者保存寄存器(x19--x28、x29/fp、sp、lr),并 msr sp_el0, x1(entry.S:1122)

最后那条指令特别值得记:

在 ARM64 Linux 上,current 就是通过 sp_el0 获取的。

所以上下文切换不只是跳到一个函数:它同时切换了 寄存器、内核栈、地址空间(mm)和架构状态。

进程切换比线程切换贵,主要贵在地址空间和 TLB ------线程共享 mm,context_switch() 里可以跳过页表切换(lazy TLB 优化)。

5.2 本配置没有内核抢占

本机 .config 是 CONFIG_PREEMPT_NONE=y。这意味着:

  • 内核代码不会因为时间片用完而被强制切走;
  • TIF_NEED_RESCHED 只在返回用户态(kernel/entry/common.c:159)、显式 schedule()、阻塞点或 cond_resched() 处被检查;
  • preempt_enable() 在这种配置下只减少抢占计数,不会触发调度(include/linux/preempt.h:207)。
text 复制代码
   PREEMPT_NONE(本机)          PREEMPT(未启用)
   ─────────────────────         ─────────────────────
   内核代码一路跑到底             内核代码可在任意点被打断
   只有"出内核"时才可能切换        调度延迟更小
   调度延迟取决于最长内核路径      吞吐略降

这对嵌入式很重要:一个长时间不睡眠的内核线程(或驱动里的忙等)可能显著拉高其他任务的调度延迟 。

后面学中断和驱动时,我们会反复回到这一点。


六、CFS:用 vruntime 把 CPU 时间记账

CFS(Completely Fair Scheduler)的目标不是"每个任务运行同样长",而是按权重分配 CPU 时间 。

它用一个虚拟时钟 vruntime 来记账:

text 复制代码
        vruntime += delta_exec × NICE_0_LOAD / weight

        NICE_0_LOAD = 1024(逻辑权重)
        weight      = sched_prio_to_weight[nice + 20]
        delta_exec  = 本次实际运行时间

核心规则只有一句:永远选 vruntime 最小的那个。

6.1 源码路径非常短

步骤 位置
update_curr() 在每次 tick / 调度点更新当前实体 kernel/sched/fair.c:862
执行累加 fair.c:883 curr->vruntime += calc_delta_fair(delta_exec, curr)
换算 calc_delta_fair()(fair.c:679)→ __calc_delta()(fair.c:235)

6.2 为什么公式里是 1024?

这是 nice 0 的逻辑 权重。64 位内核会把权重左移 SCHED_FIXEDPOINT_SHIFT(10 位)以提升精度,

所以实际的 NICE_0_LOAD = 1 << 20,se->load.weight 也是放大后的值。

分子分母同比例放大,比值不变------所以讲原理时用逻辑值 1024 就够了。

6.3 权重表:nice 改的是权重,不是"优先级数字"

sched_prio_to_weight[40](kernel/sched/core.c:8913)覆盖 nice −20...19:

c 复制代码
/* -20 */  88761,  71755,  56483,  46273,  36291,
/* -15 */  29154,  23254,  18705,  14949,  11916,
/* -10 */   9548,   7620,   6100,   4904,   3906,
/*  -5 */   3121,   2501,   1991,   1586,   1277,
/*   0 */   1024,    820,    655,    526,    423,
/*   5 */    335,    272,    215,    172,    137,
/*  10 */    110,     87,     70,     56,     45,
/*  15 */     36,     29,     23,     18,     15,

关键比值:

nice 权重 相对 nice 0
−5 3121 3.05×
0 1024 1.00×
5 335 0.33×
10 110 0.11×

所以 nice −5 的任务长期大约拿到 nice 0 的 3 倍 CPU 时间。

⚠️ nice 越大,权重越小,优先级越低------和"数字越大越优先"的直觉正好相反。

6.4 时间片怎么算?

c 复制代码
/* fair.c:42 */  sysctl_sched_latency         = 6000000ULL;  /* 6 ms  */
/* fair.c:64 */  sysctl_sched_min_granularity =  750000ULL;  /* 0.75 ms */
  • __sched_period()(fair.c:695):任务数 ≤ 8 时是 6 ms ;超过 8 个时拉长为 nr_running × 0.75 ms,避免时间片过碎;
  • sched_slice()(fair.c:709):slice = period × weight / total_weight,且不小于 0.75 ms。

6.5 选择:红黑树最左节点

text 复制代码
                    cfs_rq (sched.h:532)
                    tasks_timeline (:544)
                            │
                    ┌───────┴───────┐
                 vruntime=12    vruntime=40
                ┌───┴───┐           │
             vr=8    vr=15      vr=55
              ▲
              └── 最左节点 = vruntime 最小 = 下一个运行
                  __pick_first_entity()  fair.c:618

pick_next_entity()(fair.c:4506)还会考虑 last / next / skip 这几个"buddy",

用于缓存亲和和唤醒抢占,而不是纯数学最小。

6.6 两个容易忽略的修正

修正一:tick 抢占。 check_preempt_tick()(fair.c:4420)比较当前任务已运行时间与 ideal_runtime;

超了就设置 TIF_NEED_RESCHED。

修正二:唤醒补偿。 place_entity()(fair.c:4172)给睡眠者最多**半个 latency(默认 3 ms)**的虚拟时间信用,

让它醒来后能"追赶"一点,但不会无限透支。这就是 GENTLE_FAIR_SLEEPERS。

⚠️ 不要把 vruntime 当成真实时间

vruntime 是归一化后的虚拟时间。不同权重的任务,同样运行 1 ms,vruntime 的增长量完全不同。

这正是"按权重公平"的实现方式。

同样跑 1 ms,三种 nice 的记账差异:

text 复制代码
  nice 0  (weight 1024) → vruntime += 1.000 ms
  nice -5 (weight 3121) → vruntime += 0.328 ms   ← 增长慢,所以更容易被再次选中
  nice 5  (weight  335) → vruntime += 3.058 ms   ← 增长快,靠边站

七、动手实验:用真实权重表跑一遍 CFS

脚本 assets/cfs_sim.py 会从本机 kernel/sched/core.c 里解析真实的 sched_prio_to_weight[40],

然后用一个固定量子模型模拟"选最小 vruntime"的过程。

⚠️ 这是教学模型,不是内核本身

它忽略了:最小粒度、唤醒抢占、SMP 负载均衡、cgroup 调度、RT/DL 类。

它忠实还原的只有 CFS 的核心思想:选最小 vruntime,份额收敛到权重比。

脚本启动时会自己声明数据来源,这也是判断"它是不是真的读了你的源码"的依据:

text 复制代码
Source: D:/Linux/rk3568_linux_sdk/kernel/kernel/sched/core.c
Parsed 40 weights (nice -20..19); nice 0 = 1024
Quantum: 1.0 ms, quanta: 2000, total: 2000 ms

任务 1:基线

bash 复制代码
python -I assets/cfs_sim.py

本机实测输出:

text 复制代码
Task   nice  weight   runtime(ms)    share   weight-share
---------------------------------------------------------
A        -5    3121        1360.0   68.00%         68.00%
B         0    1024         446.0   22.30%         22.31%
C         5     335         146.0    7.30%          7.30%
D        10     110          48.0    2.40%          2.40%

Runtime vs weight ratios (relative to the second task):
  A/B: runtime 3.05x, weight 3.05x
  C/B: runtime 0.33x, weight 0.33x
  D/B: runtime 0.11x, weight 0.11x

First picks: A B C D A A A B A A

看三个地方:

  1. share 与 weight-share 几乎完全相同 (68.00% vs 68.00%)------这不是巧合,而是 vruntime 记账的必然结果;
  2. A/B 的运行时间比 3.05×,权重比也是 3.05× ------CFS 的"公平"是按权重的公平,不是平均主义;
  3. First picks 里 A 连续跑了三拍 ------这是模型的简化痕迹:脚本每次只跑固定 1 ms 量子,而 A 的 vruntime 增长最慢,所以会被连续选中。真实 CFS 会按权重算时间片(sched_slice()),通常每个任务每轮只跑一次、权重大的时间片更长,但长期份额与模型一致。

任务 2:先预测,再验证

把 nice 改成 -10, 0, 10。先写下你的预测:A 会拿到多少份额?

bash 复制代码
python -I assets/cfs_sim.py --nice=-10,0,10 --quanta 1200 --trace 0

本机实测输出:

text 复制代码
Task   nice  weight   runtime(ms)    share   weight-share
---------------------------------------------------------
A       -10    9548        1072.0   89.33%         89.38%
B         0    1024         115.0    9.58%          9.59%
C        10     110          13.0    1.08%          1.03%

Runtime vs weight ratios (relative to the second task):
  A/B: runtime 9.32x, weight 9.32x
  C/B: runtime 0.11x, weight 0.11x
  • A 约 89.3% ,B 约 9.6% ,C 约 1.1%;
  • A/B 权重比是 9548/1024 ≈ 9.32,模拟运行时间比也是 9.32。

再想一个问题 :如果把 nice 改成 0, 0, 0,三者份额会怎样?为什么?

答案 :三者权重相同(都是 1024),vruntime 增长速度相同,长期份额各约 1/3 。

CFS 在同权重下追求均分------这就是"完全公平"四个字的来源。

任务 3:睡眠者醒来

bash 复制代码
python -I assets/cfs_sim.py --wakeup-demo --quanta 2000 --trace 0

本机实测输出:

text 复制代码
Wakeup demo: the last task sleeps for the first half, then wakes with at most
             half a latency of vruntime credit.

Task   nice  weight   runtime(ms)    share   weight-share
---------------------------------------------------------
A        -5    3121        1233.0   61.65%         55.59%
B         0    1024         405.0   20.25%         18.24%
C         5     335         133.0    6.65%          5.97%
D        10     110          44.0    2.20%          1.96%
E         0    1024         185.0    9.25%         18.24%

Runtime vs weight ratios (relative to the second task):
  A/B: runtime 3.04x, weight 3.05x
  E/B: runtime 0.46x, weight 1.00x   ← E 明显没拿到它的权重份额

E 睡了前半程,所以整段时间 只拿到 9.25% (它的权重份额是 18.24%);

但在醒来后的后半程,它拿到了约 18.5%,与权重份额基本一致。

这就是 place_entity() 的"有限补偿":

text 复制代码
   时间轴 ──────────────────────────────────────────▶
   E:  [ 睡眠 1000 ms ]  [ 运行 1000 ms ]
                         │
                         ├── 醒来时获得 ≤ 3 ms(半 latency)的 vruntime 信用
                         │   → 能"追赶"一点,但补不回整段睡眠
                         ▼
   若补偿无限:E 一醒来就霸占 CPU,长期份额失真  ← 所以必须限制

为什么不能无限补偿?

如果内核给睡眠者无限补偿,它会一醒来就霸占 CPU,长期份额失真。

所以补偿被限制在半个 latency(默认 3 ms)。

任务 4:回到源码核对

bash 复制代码
K=/d/Linux/rk3568_linux_sdk/kernel
grep -n "sched_prio_to_weight\[40\]" -A 10 $K/kernel/sched/core.c
grep -n "vruntime += calc_delta_fair" -A 2 $K/kernel/sched/fair.c
grep -n "sched_class_highest" -A 10 $K/kernel/sched/sched.h
grep -n "for_each_class(class)" -A 6 $K/kernel/sched/core.c | head -10

确认三件事:

  1. 权重表真实存在(core.c:8913);
  2. vruntime 更新公式与模拟器一致(fair.c:883);
  3. 调度类顺序由链接布局决定(sched.h:1913 + vmlinux.lds.h:131)。

任务 5(可选,需要板子)

bash 复制代码
ps -eo pid,ni,pri,psr,stat,comm | head -20
nice -n 10 sleep 30 &
taskset -c 0 yes > /dev/null &
# 实时任务需要 root:
chrt -f 50 sleep 5
  • ni 是 nice,pri 是内核看到的动态优先级(普通任务通常是 120 + nice ),psr 是当前所在 CPU;
  • 用 chrt 起一个 FIFO 任务,观察它是否压过普通任务------这是 4.4 节"先问 RT"的现场验证。

实验检查点

  • 我能解释 vruntime 公式中每个量的含义
  • 我能说明 nice −5 为什么约等于 3 倍 CPU 份额
  • 我能说清五个调度类的优先级与遍历顺序
  • 我能区分"新任务入队"与"新任务开始运行"
  • 我能解释睡眠者为什么只得到有限的 vruntime 补偿
  • 我能说清 PREEMPT_NONE 对调度延迟的影响

八、检索练习

先不要翻回上文。凭记忆作答。

Q1. task_struct 中哪个字段直接指向它所属的调度类?

A. state | B. policy | C. sched_class | D. on_rq

Q2. wake_up_new_task() 的关键动作是什么?

A. 把 task 从运行队列删除 | B. 把 task 插入运行队列并检查抢占 | C. 把 task 的 vruntime 清零 | D. 把 task 的地址空间复制一份

Q3. 五个调度类从高到低的正确顺序是?

A. stop → dl → rt → fair → idle | B. idle → fair → rt → dl → stop | C. rt → stop → dl → fair → idle | D. dl → stop → rt → idle → fair

Q4. CFS 中把一个任务的 nice 从 0 改为 −5,会发生什么?

A. vruntime 增长更快,CPU 份额更大 | B. vruntime 增长更快,CPU 份额更小 | C. vruntime 增长更慢,CPU 份额更小 | D. vruntime 增长更慢,CPU 份额更大

Q5. CFS 在同一调度类内选择下一个任务的主要依据是?

A. 最大 vruntime 的任务 | B. 最小 vruntime 的任务 | C. 优先级数字最大的任务 | D. 最近一次运行的任务


答案与解析

Q1 → C。 sched_class(include/linux/sched.h:707)直接指向调度类的函数表。

policy 是用户态策略,最终会通过 sched_setscheduler() 反映到 sched_class 与优先级上。

Q2 → B。 wake_up_new_task()(core.c:3594)把状态设为 TASK_RUNNING,选择 CPU,调用 activate_task() 入队,再用 check_preempt_curr() 判断是否需要抢占当前任务。

地址空间复制发生在更早的 copy_process() 里。

Q3 → A。 链接脚本按 idle → fair → rt → dl → stop 放置(vmlinux.lds.h:131--139),

for_each_class() 从高地址向低地址遍历(sched.h:1919--1920),所以优先级是 stop → dl → rt → fair → idle。

Q4 → D。 nice 从 0 降到 −5,权重从 1024 升到 3121。权重越大,vruntime 增长越慢,任务被选中得越频繁,CPU 份额越大。

注意 nice 越小优先级越高,和普通"数字越大越优先"的直觉相反。

Q5 → B。 CFS 把可运行实体按 vruntime 放进红黑树,取最左节点(__pick_first_entity(),fair.c:618),

也就是虚拟时间最小的任务。真实实现还会用 last / next / skip buddy 做缓存亲和与唤醒抢占修正。


九、四个常见误区

误区 真相
"nice 值越大越优先。" 反了。nice 越大,权重越小,优先级越低;普通任务的动态优先级通常是 120 + nice。
"CFS 给每个任务相同时间片。" 不是。时间片按权重切分(sched_slice());同权重才近似均分。
"fork 完新任务马上运行。" 它只是被放进运行队列;是否立即抢占,由 check_preempt_curr() 和调度点决定。
"调度器随时能抢占内核代码。" 本配置是 PREEMPT_NONE,只在返回用户态、阻塞点或显式调度点切换。

十、本课速查

10.1 一图流

text 复制代码
   task_struct ──┐
                 ├─→ 按 CLONE_* 决定共享 → 进程 / 线程
                 │
                 └─→ sched_fork() 选类 → fair_sched_class
                            │
                            ▼
                     rq->cfs (cfs_rq) 红黑树,按 vruntime 排序
                            │
                            ▼
        __schedule() → pick_next_task() → for_each_class()
                            │              stop→dl→rt→fair→idle
                            ▼
                    context_switch() → switch_to()
                            │
                            ▼
                 ARM64: __switch_to() → cpu_switch_to()
                        (msr sp_el0, next  → 切换 current)

10.2 关键源码索引(逐条核对过)

位置 内容
include/linux/sched.h:656 struct task_struct
include/linux/sched.h:707 / 708 sched_class / se
kernel/fork.c:886 / 1900 dup_task_struct() / copy_process()
kernel/sched/core.c:3307 / 3469 __sched_fork() / sched_fork()
kernel/sched/core.c:3594 wake_up_new_task()
kernel/sched/core.c:4606 / 4685 / 4875 pick_next_task() / __schedule() / schedule()
kernel/sched/core.c:8913 sched_prio_to_weight[40]
kernel/sched/sched.h:918 / 532 / 539 struct rq / struct cfs_rq / min_vruntime
kernel/sched/sched.h:1913--1920 sched_class_highest / for_each_class
kernel/sched/fair.c:862 / 883 / 679 update_curr() / vruntime 累加 / calc_delta_fair()
kernel/sched/fair.c:42 / 64 / 695 / 709 latency 6 ms / min_granularity 0.75 ms / __sched_period() / sched_slice()
kernel/sched/fair.c:581 / 618 / 4172 / 4420 / 4506 入树 / 取最左 / place_entity() / check_preempt_tick() / pick_next_entity()
include/asm-generic/vmlinux.lds.h:131--139 SCHED_DATA(五类链接顺序)
arch/arm64/kernel/process.c:636 __switch_to()
arch/arm64/kernel/entry.S:1102 / 1122 cpu_switch_to / msr sp_el0
kernel/entry/common.c:159 返回用户态时检查 TIF_NEED_RESCHED

10.3 常量与小工具

项 值
nice 范围 −20 ... 19
nice 0 权重 1024(逻辑)/ 1 << 20(实际定点)
nice −5 / 5 / 10 权重 3121 / 335 / 110
SCHED_IDLE 权重 3(WEIGHT_IDLEPRIO,sched.h:1791)
sysctl_sched_latency 6 ms
sysctl_sched_min_granularity 0.75 ms
睡眠者补偿上限 3 ms(latency / 2)

结语

调度不是"谁更重要"的口号,而是一套记账系统:

CFS 用 vruntime 记录每个 task 已经消耗的加权 CPU 时间,每次选择虚拟时间最小的那个。
nice 改的是权重,不是某个神秘的优先级数字。

这一课如果你只带走一句话,我希望是这句:

"创建"不等于"运行";"注册到类"不等于"被选中"。中间隔着一次调度决策。

下一篇:第 03 课《物理内存:一页内存从哪里来?》

从 memblock → 伙伴系统 → SLUB,回答三个问题:kmalloc 的一页内存到底从哪来?

为什么"空闲页够"仍可能分配失败?kmalloc(100) 落进哪个缓存?(剧透:kmalloc-128,因为本机 ARCH_DMA_MINALIGN=128。)


本课所有行号均已在本机 D:\Linux\rk3568_linux_sdk\kernel(Linux 5.10.160)源码上逐条核对;三份实验输出为 assets/cfs_sim.py 在本机的真实运行结果(权重表由脚本解析 kernel/sched/core.c:8913 得到)。本机内核为 RK3568 SDK 构建,带 69 处 Android RVH 厂商钩子,与上游 5.10 存在少量差异。

相关推荐
Soneswy1 小时前
Linux系统篇--用户切换与文件权限
linux·服务器
Ruiery1 小时前
Linux 6.6内核内存管理深度解析(三·补):页表项(PTE)结构与地址翻译
linux·运维·服务器
五块钱的方便面1 小时前
ubuntu下networkmanager不让管理解决办法
linux·运维·ubuntu
叼烟扛炮1 小时前
Linux 学习笔记(三十四):线程同步——条件变量与生产消费者模型
linux·运维·服务器·笔记·学习·1024程序员节
根目录下的猫2 小时前
ArkTS 代码中如何添加作者信息
linux·运维·ubuntu
Linux界最忧郁之人2 小时前
OpenStack Victoria双节点实战|Packstack一键部署(CentOS‑Stream‑8)+华为云ECS实操
linux·运维·服务器·centos·华为云·openstack
DevNote_2 小时前
1C1G 服务器从零配置实录(附完整命令)
linux·运维·服务器·javascript·adb·ssh·gmssh
haermosi2 小时前
Ubuntu 下 Flameshot 截图一直闪屏,已找到问题并解决
linux·ubuntu·gnome·问题排查·flameshot·linux 使用记录