1、 引言
这学期选修了孟老师和李老师共同教授的《Linus操作系统分析》这门课。孟老师上课提到过很多非常有meaning有启发的观点,比如第一节课就提到了当时非常火的DeepSeek。DeepSeek以其非常低的训练成本以及如此强大的gpt能力而震惊世界,普遍观点是它使用了很多特殊的原创性的推理加速方式,从硬件上进行了非常大的优化。但是当时孟老师确实站在训练的数据集的角度来分析这件事,因为对于llm来说,他们的输入都是所谓的Token,而对于中文和英文这两种人类语言而言,同样的意思,所需要的文字和单词数量是有很大差异的,中文显得非常的简洁凝练,因此所需的输入上下文的Token自然也就会少很多,这样对于llm训练来说,很明显训练成本就会降低很多。当时听到这个观点时候还是非常震撼的,原来语言学还能有这个用处hhh。以及后面几个课程实验都让人非常地想去动手跟着教程做一遍,大大提高了动手能力。当时这些都是题外话了,这篇学习心得我主要是想分享的是Linux系统中一个非常重要的topic:进程调度,聚焦于Linux提供的完全公平调度器 (CFS),来分享一下我在学习CFS中的一些体会和心得。
2、 进程调度:操作系统的核心引擎
Linux 内核作为现代操作系统的核心,其进程调度机制对系统性能、响应能力和资源公平性至关重要。在 Linux 2.6.23 版本中引入的完全公平调度器 (Completely Fair Scheduler, CFS) 取代了之前的 O(1) 调度器,成为默认的普通进程调度器,其设计哲学和实现机制深刻影响了 Linux 的性能表现。以下是对 Linux 进程调度机制,尤其是 CFS 的深入分析:
- 核心目标:
- 最大化 CPU 利用率: 尽可能让 CPU 保持忙碌状态,避免空闲浪费。
- 提供交互式响应: 确保用户交互式任务(如桌面应用、键盘鼠标输入响应)获得低延迟的 CPU 时间,提升用户体验。
- 保证公平性: 在多个竞争进程之间,按照某种策略(如优先级、权重)公平地分配 CPU 时间片。
- 支持多任务并发: 在单个或多个 CPU 核心上,营造多个进程同时运行的假象。
- 支持优先级和策略: 允许高优先级任务更快获得 CPU,支持不同调度策略(如实时、批处理)。
- 低调度开销: 调度决策本身消耗的 CPU 时间要尽可能小。
- 触发调度的时机:
- 主动让出 (Yield): 进程主动调用
sched_yield()或执行阻塞操作(如 I/O 请求、等待信号量、睡眠)。 - 时间片耗尽: 进程用完其分配的 CPU 时间片(在 CFS 中是虚拟时间片概念)。
- 更高优先级进程就绪: 一个更高优先级(或更应运行)的进程变为可运行状态。
- 中断返回: 当中断处理程序执行完毕,返回用户态或内核态时,可能触发调度检查。
- 系统调用返回: 某些系统调用(特别是可能阻塞的)返回用户空间时。
- 主动让出 (Yield): 进程主动调用
- 调度策略 (Scheduling Policies):
Linux 采用基于调度类 (Scheduling Classes) 的模块化设计,每个调度类实现不同的调度策略,并按照严格的优先级顺序组织:SCHED_DEADLINE(截止时间调度): 最高优先级。用于有严格时间限制的实时任务。任务声明其运行时间 (runtime)、周期 (period) 和截止时间 (deadline)。调度器保证任务在每个周期内获得其声明的运行时间,并在截止时间前完成。SCHED_FIFO(先进先出实时调度): 固定优先级实时调度。相同优先级的任务按就绪队列顺序执行,直到主动让出或被更高优先级任务抢占。没有时间片概念,一旦运行,除非阻塞或让出,否则一直占用 CPU。SCHED_RR(轮转实时调度): 带时间片的实时调度。在SCHED_FIFO的基础上,为每个任务分配一个时间片。当时间片用完,任务被放到同优先级队列尾部,轮到下一个任务运行。保证同优先级任务间的公平性。SCHED_NORMAL/SCHED_OTHER(普通调度): CFS 调度器管理的策略。 用于绝大多数普通非实时进程。核心目标是公平性和良好的交互性。SCHED_BATCH(批处理调度): 适用于非交互式的 CPU 密集型批处理作业。调度器会轻微降低其调度优先级,倾向于让其运行较长时间片以减少上下文切换开销,但会显著牺牲交互性。SCHED_IDLE(空闲调度): 优先级最低。只有当系统空闲时才会运行的任务,不会抢占任何其他策略的任务。
3、 完全公平调度器 (CFS) 的诞生与哲学
- 前身 O(1) 调度器的局限:
- 基于固定优先级和静态时间片分配。
- 复杂的启发式算法用于区分交互式进程和批处理进程,难以调优且易出错。
- 在高负载或复杂场景下,公平性表现不佳。低优先级任务可能面临严重的“饥饿”问题。
- 交互式响应有时不够理想。
- CFS 的设计目标:
- 完全公平 (Truly Fair): 在理论上保证每个可运行进程获得与其权重 (Weight) 成比例的 CPU 时间份额。
- 确定性 (Deterministic): 保证在给定的时间间隔内,每个任务获得的 CPU 时间是确定且可预测的(基于其权重)。
- 低开销 (Low Overhead): 尽管追求公平性,但调度决策的时间复杂度仍需高效(理想为 O(1) 或 O(log N))。
- 提升交互性 (Improved Interactivity): 自然地优待交互式进程(它们通常短暂运行后主动让出 CPU,从而在 CFS 的公平模型下更快地再次获得 CPU)。
- 简化设计 (Simplicity): 避免 O(1) 调度器中复杂的启发式规则,核心算法更清晰。
- 核心思想:虚拟运行时间 (Virtual Runtime, vruntime)
CFS 摒弃了固定时间片和绝对优先级的传统概念,引入了革命性的vruntime概念:- 每个可运行任务 (
task_struct) 维护一个关键的成员sched_entity.se.vruntime(单位为纳秒)。 vruntime的增长速度 = 实际运行时间 * (NICE_0_LOAD / 任务权重)实际运行时间:任务在 CPU 上实际消耗的时间。NICE_0_LOAD:一个基准权重常量(通常是 1024),代表nice=0进程的权重。任务权重 (Weight):一个根据进程的nice值计算得出的数值。nice值越低(优先级越高),权重越大。
- 核心公式:
vruntime += delta_exec * (NICE_0_LOAD / weight) - 物理含义:
vruntime衡量了一个任务“应该运行”的时间在理想公平 CPU 下的虚拟流逝时间。 权重大的任务(高优先级),其vruntime增长得慢;权重小的任务(低优先级),其vruntime增长得快。
- 每个可运行任务 (
4、 CFS 的核心机制详解
- 权重 (Weight) 与 Nice 值:
- 用户通过
nice值(范围通常为 -20 到 +19)影响进程的优先级。nice值越低,优先级越高。 - CFS 使用一个预定义的转换表将
nice值映射到具体的权重 (weight)值。 - 关键特性:
nice值每相差 1,进程应获得的 CPU 时间比例相差约 10%(实际因子约为 1.25)。例如:nice=0(weight=1024) 的进程应获得 100% 的基准 CPU 时间。nice=1(weight≈820) 的进程应获得大约 1024/(1024+820) ≈ 55.5% 的 CPU(如果与nice=0竞争)。nice=-1(weight≈1277) 的进程应获得大约 1277/(1024+1277) ≈ 55.5% 的 CPU(如果与nice=0竞争)。可见,nice值改变 1,CPU 份额比例变化约 1.25 倍(≈10% 相对变化)。
- 公式体现:
vruntime增长速率与weight成反比。高weight(高优先级) ->vruntime增长慢 -> 在红黑树中位置靠左 -> 更快被调度。
- 用户通过
- 红黑树 (Red-Black Tree, rbtree):调度队列的实现
- CFS 使用一个按
vruntime排序的红黑树 (cfs_rq->tasks_timeline) 来管理所有可运行的普通进程 (SCHED_NORMAL,SCHED_BATCH,SCHED_IDLE)。 - 键值 (Key): 任务的
sched_entity.se.vruntime。 - 排序规则: 最小的
vruntime位于树的最左侧 (rb_leftmost)。 - 调度决策: CFS 总是选择红黑树中具有最小
vruntime的任务(即最左边的任务)来运行。这个任务是最“亏欠”CPU 时间的任务。 - 优势:
- 高效查找最小节点: 红黑树是自平衡二叉搜索树,查找最小节点 (
rb_leftmost) 的时间复杂度是 O(1) (因为内核会缓存这个节点)。 - 高效插入/删除: 插入 (
enqueue_task_fair) 和删除 (dequeue_task_fair) 一个任务的平均和最坏时间复杂度是 O(log N) (N 是可运行任务数),这在绝大多数实际场景下都是高效的。 - 公平性保障: 按
vruntime排序直接体现了 CFS 的公平调度目标。
- 高效查找最小节点: 红黑树是自平衡二叉搜索树,查找最小节点 (
- CFS 使用一个按
- 调度过程:
pick_next_task_fair(选择下一个任务):- 从当前 CPU 的运行队列 (
struct rq) 中获取 CFS 调度队列 (struct cfs_rq)。 - 从 CFS 红黑树中取出具有最小
vruntime的调度实体 (sched_entity)(即cfs_rq->rb_leftmost指向的实体)。 - 返回该调度实体对应的任务 (
task_struct)。
- 从当前 CPU 的运行队列 (
task_tick_fair(时钟中断处理):- 在每次时钟中断 (
tick) 时,更新当前运行任务的vruntime(update_curr):- 计算自上次更新后实际运行的时间
delta_exec。 - 应用公式:
curr->se.vruntime += delta_exec * (NICE_0_LOAD / curr->se.load.weight)
- 计算自上次更新后实际运行的时间
- 检查当前任务是否应该被抢占:
- CFS 没有传统意义上的固定时间片。抢占发生在:
- 当前任务阻塞(主动让出)。
- 一个具有更小
vruntime的新任务被唤醒(唤醒抢占wakeup_preemption)。 - 当前任务已经运行了足够长的时间,导致其
vruntime不再是最小的(通过check_preempt_tick检查)。
check_preempt_tick检查:- 计算当前任务运行的实际时间
delta_exec。 - 计算一个目标延迟 (
sched_latency) 期间,该任务理想应运行的时间:ideal_runtime = (sched_latency_ns * se->load.weight) / cfs_rq->load.weight - 如果
delta_exec > ideal_runtime,则设置TIF_NEED_RESCHED标志,请求重新调度。
- 计算当前任务运行的实际时间
- CFS 没有传统意义上的固定时间片。抢占发生在:
- 在每次时钟中断 (
enqueue_task_fair(任务入队): 当任务从睡眠/阻塞状态变为可运行时:- 更新该任务的调度实体 (
sched_entity) 的vruntime(可能会进行一些补偿place_entity,防止其vruntime过小导致过度抢占)。 - 根据其当前的
vruntime,将其插入到相应 CPU CFS 运行队列的红黑树中。 - 更新 CFS 运行队列的负载权重 (
cfs_rq->load.weight += se->load.weight)。 - 检查这个新加入的任务
vruntime是否小于当前运行任务的vruntime。如果是,则触发抢占 (resched_curr)。
- 更新该任务的调度实体 (
dequeue_task_fair(任务出队): 当任务阻塞、终止或被迁移时:- 将其从 CFS 红黑树中移除。
- 更新 CFS 运行队列的负载权重 (
cfs_rq->load.weight -= se->load.weight)。
put_prev_task_fair(放回上一个任务): 在切换出当前任务前调用,将其放回红黑树(如果它仍然是可运行的)。
- 目标延迟 (Scheduling Latency /
sched_latency_ns):- 这是一个可调参数(通常默认在几毫秒到几十毫秒量级,如 6ms)。
- 核心作用: 定义了 CFS 试图在多长时间间隔内,让所有可运行的普通进程至少有机会运行一次。
- 如何工作:
- 在目标延迟
S内,如果有N个可运行的普通进程,那么每个进程期望获得的 CPU 时间份额是S / N。 ideal_runtime的计算 (ideal_runtime = S * (task_weight / total_weight)) 正是基于这个目标延迟和进程权重。
- 在目标延迟
- 动态调整: 当可运行任务数
N非常大时,S / N会变得非常小(可能小于一次时钟中断周期或上下文切换开销)。为了避免过多的无效切换,CFS 引入了最小粒度 (sched_min_granularity_ns)。当一个任务的ideal_runtime小于最小粒度时,它会获得至少最小粒度的运行时间。这实际上是在任务数过多时,隐式地拉长了目标延迟,牺牲了一点理论上的短期公平性来换取整体吞吐量和效率。
- 处理交互性 (Handling Interactivity):
- CFS 不显式区分交互式和批处理进程。
- 自然优待机制:
- 交互式进程通常执行模式是:短时间运行 -> 等待 I/O (阻塞) -> 被唤醒 -> 再次短时间运行。
- 当交互式进程阻塞后唤醒 (
enqueue_task_fair):- 内核会执行
place_entity函数,尝试给唤醒的任务一点vruntime补偿。 - 具体做法:将任务的
vruntime设置为max(se->vruntime, cfs_rq->min_vruntime - some_threshold)。cfs_rq->min_vruntime是队列中所有任务vruntime的下限(单调递增)。 - 效果: 唤醒的任务的
vruntime被设置得相对较小(比当前队列中大多数任务都小),使其在红黑树中位置靠左,从而能更快地被调度执行,满足了交互式任务对低响应延迟的需求。
- 内核会执行
- 批处理进程: 通常是 CPU 密集型,长时间运行而不主动让出。这会导致其
vruntime持续增长,最终在红黑树中的位置向右移动。当它最终让出 CPU 时,其vruntime已经很大,再次就绪时需要等待队列中vruntime较小的任务(通常是交互式任务)先运行,这体现了公平性。
- 组调度 (Group Scheduling / CFS Bandwidth Control):
- 解决的问题: 防止单个用户或进程组 (
cgroup) 内的进程过度消耗 CPU,影响其他用户或组的公平性。 - 核心概念:
cpuCGroup 子系统。cpu.shares: 设置 CGroup 的相对权重。类似于进程的nice值,但作用于整个组。根 CGroup 的shares通常是 1024。子 CGroup 的shares决定了它们之间相对于父 CGroup 的 CPU 份额比例。例如,两个子 CGroup 的shares都设为 512,它们将平分父 CGroup 的 CPU 份额。cpu.cfs_period_us: 指定一个周期长度(微秒),默认通常是 100ms (100,000us)。cpu.cfs_quota_us: 指定在该周期 (cfs_period_us) 内,该 CGroup 中所有任务最多能使用的 CPU 时间总量(微秒)。例如,quota=50,000us,period=100,000us表示该组最多使用 50% 的 CPU。如果设为-1,表示无限制。
cfs_bandwidth机制实现:- 每个受带宽控制的 CGroup 有一个关联的
cfs_bandwidth结构。 - 包含一个配额池 (
quota) 和一个周期计时器 (period_timer)。 - 当一个任务开始运行时,会从所属 CGroup 的配额池中扣除时间。
- 当配额池耗尽时,该 CGroup 的所有任务会被限流 (
throttled):它们会被移出运行队列,放入一个throttled链表,直到下一个周期开始(计时器到期)时配额池被重新填充 (refill_cfs_bandwidth_runtime),它们才能被重新加入运行队列。 - 如果任务在配额耗尽前就主动让出 CPU,剩余的配额时间会归还给配额池(部分实现)。
- 每个受带宽控制的 CGroup 有一个关联的
- 效果: 实现了对 CPU 资源的硬性上限控制,确保了不同租户/应用之间的资源隔离和公平性,是云计算环境的关键特性。
- 解决的问题: 防止单个用户或进程组 (
5、 CFS 的高级特性与优化
- 唤醒抢占 (Wakeup Preemption):
- 当一个任务被唤醒 (
try_to_wake_up) 时,会检查其vruntime是否显著小于当前正在运行任务的vruntime(考虑唤醒开销)。 - 如果是,则立即请求重新调度 (
resched_curr),让新唤醒的(可能更急需 CPU 的,如交互式任务)任务尽快运行,减少唤醒延迟。
- 当一个任务被唤醒 (
- 新任务处理 (Fork Handling):
- 新创建的子进程 (
fork) 在初始时如何设置vruntime? - CFS 的策略:
sched_fork()中,子进程的vruntime通常被设置为父进程当前的vruntime。 - 目的: 避免新进程因为
vruntime=0而立即抢占 CPU,获得不公平的初始优势。让子进程从父进程的“历史”位置开始参与调度,更公平。
- 新创建的子进程 (
- 多核负载均衡 (Load Balancing):
- CFS 负责单个 CPU 核心上的公平调度。
- 多核系统(SMP/NUMA)的负载均衡由独立的机制处理(
load_balance,通常由migration内核线程执行)。 - 负载均衡器会:
- 定期检查 CPU 负载是否不均衡。
- 尝试从繁忙队列 (
busiest runqueue) 迁移任务到空闲队列 (this runqueue)。 - 迁移时考虑任务亲和性 (
affinity)、缓存热度 (cache hotness)、NUMA 局部性。
- CFS 与负载均衡协同:迁移任务时,会将其
vruntime根据目标 CPU 的运行队列的min_vruntime进行调整 (post_init_entity_util_avg),使其在新队列中能公平地参与竞争,避免因min_vruntime差异导致任务在新 CPU 上长时间得不到调度或立即抢占。
- 调度域与调度组 (Scheduling Domains & Groups):
- 用于构建 CPU 拓扑层次结构(Sockets, Cores, Hyper-Threads, NUMA nodes)。
- 帮助负载均衡器更智能地工作:优先在同一 NUMA 节点内、同一物理核的不同超线程间迁移任务,减少远程内存访问和缓存失效的开销。
- CPU 带宽控制扩展:
- 除了基础的
cpu,cfs_period_us和cpu.cfs_quota_us,cpuCGroup 控制器还提供:cpu.stat:统计信息(使用时间、被限流次数等)。cpu.rt_period_us/cpu.rt_runtime_us:用于实时任务的带宽控制。cpu.max:cgroup v2中统一设置最大资源限制的接口。
- 除了基础的
6、 CFS 的性能与权衡
- 优势:
- 卓越的公平性: 在绝大多数场景下,能非常精确地按权重分配 CPU 时间。
- 优异的交互性: 对交互式任务的响应延迟低且稳定。
- 良好的吞吐量: O(log N) 的调度开销在绝大多数负载下是可接受的。红黑树查找最小节点是 O(1)。
- 设计简洁清晰: 核心围绕
vruntime和红黑树,逻辑相对 O(1) 调度器更易懂。 - 强大的扩展性: 通过组调度和 CGroup 轻松支持资源隔离和限制。
- 低配置需求: 通常使用默认参数即可获得良好性能,不像 O(1) 需要复杂的交互性启发式调优。
- 挑战与权衡:
- O(log N) 开销: 在极端高负载下(成千上万个可运行任务),红黑树的插入/删除操作 O(log N) 的开销可能变得显著。内核持续优化(如缓存
leftmost节点)。 - 缓存局部性: 红黑树节点在内存中可能不连续,频繁的入队/出队操作可能影响 CPU 缓存效率。相比简单的 FIFO 队列开销略高。
- 最小粒度 vs 目标延迟: 在任务数极多时,为了控制切换开销,最小粒度的存在会拉长实际的目标延迟,牺牲短时间窗口内的绝对公平性。
- 实时任务优先级: CFS 管理的普通任务总是低于实时任务 (
SCHED_FIFO/SCHED_RR/SCHED_DEADLINE)。设计不当的实时任务可能“饿死”普通任务。 - NUMA 优化: 在 NUMA 架构下,负载均衡决策(迁移任务)需要仔细权衡 CPU 负载均衡和内存访问局部性,避免频繁的远程内存访问抵消了负载均衡的收益。CFS 本身不直接处理 NUMA,需与负载均衡协同。
- O(log N) 开销: 在极端高负载下(成千上万个可运行任务),红黑树的插入/删除操作 O(log N) 的开销可能变得显著。内核持续优化(如缓存
7、 总结
Linux 的 CFS 调度器是一个工程杰作,它通过创新的虚拟运行时间 (vruntime) 概念和高效的红黑树数据结构,在理论公平性、实际交互性、吞吐量和实现简洁性之间取得了卓越的平衡。它摒弃了复杂的启发式规则,用统一、简洁的模型 (vruntime 增长速率反比于权重) 自然地实现了对不同优先级 (nice)、不同类型(交互式/批处理)进程的公平调度和良好响应。模块化的调度类设计确保了实时任务能够得到及时处理。组调度 (cfs_bandwidth) 的引入则完美适应了云计算和容器化时代对资源隔离和精确控制的需求。
尽管存在 O(log N) 复杂度和缓存局部性等挑战,但持续的优化(如 leftmost 缓存、高效负载均衡)确保了 CFS 在各种工作负载下都能提供出色的性能。理解 CFS 的工作原理,对于开发高性能 Linux 应用、进行系统调优(如调整 sched_latency_ns, min_granularity_ns, 设置 CGroup 参数)、诊断性能问题(如调度延迟、CPU 饥饿)都至关重要。它不仅是 Linux 内核稳定高效运行的基石,也是操作系统调度理论成功应用于实践的典范。
参考资料:
《庖丁解牛Linux操作系统分析》:https://gitee.com/mengning997/linuxkernel

5963

被折叠的 条评论
为什么被折叠?



