‌CFS调度算法

‌CFS调度算法(Completely Fair Scheduler)是一种完全公平调度算法,旨在确保所有进程在调度周期内获得公平的执行时间。‌‌

1,介绍

CFS(Completely Fair Scheduler,完全公平调度器)用于Linux系统中普通进程的调度。它给cfs_rq(cfs的run queue)中的每一个进程设置一个虚拟时钟,vruntime。如果一个进程得以执行,随着时间的增长(一个个tick的到来),其vruntime将不断增大。没有得到执行的进程vruntime不变。调度器总是选择vruntime跑得最慢的那个进程来执行。这就是所谓的“完全公平”。为了区别不同优先级的进程,优先级高的进程vruntime增长得慢,以至于它可能得到更多的运行机会。

CFS不区分具体的cpu算力消耗型进程,还是io消耗型进程,统一采用红黑树算法来管理所有的调度实体sched_entity,算法效率为O(log(n))。每个进程都由一个struct task_struct来表示,为什么这里又定义了一个sched_entity?这是由于调度需要一些更详细的信息,比如当前运行时间或上次运行时间,因此就搞出了一个sched_entity的概念,为了存储一些调度相关的信息,给调度器用。CFS跟踪调度实体sched_entity的虚拟运行时间vruntime,平等对待运行队列中的调度实体sched_entity,将执行时间少的调度实体sched_entity排列到红黑树的最左边。调度实体sched_entity通过enqueue_entity()和dequeue_entity()来进行红黑树的出队入队。

2,框架

CFS调度器框架
  • 调度周期:为了保证每个任务都在合理的期限内运行, 我们把时间分成一块块调度周期。而在每个调度周期中, 让每个任务分到同样多的 vruntime。这样的话,至多经过一个调度周期,大家又能运行了。而且任务切换不至于过于频繁。对于单 CPU 而言, 默认设置是小于 8 个就绪任务时就按照 6ms,大于 8 个就绪任务时,每个任务给 0.75 ms的调度粒度。如果 CPU 个数大于 1 个,那么调度周期就算长一点,用户感觉的延迟也会不那么高。因此对于多个CPU 而言,调度粒度还会乘上1+[log2(number of cpus)],拿8核cpu为例,调度粒度是0.75*4=3ms,在调度粒度的时间内进程是不能被抢占的。
  • 调度延迟:从进程加入到运行队列,到被放到cpu上执行经历的时间,和调度周期有关,当运行个数小于等于8时,调度延迟等于调度周期。调度延迟的default值6ms,对于8核cpu,调度延迟=4*6=24ms。当运行队列上的进程数小于等于8时,每个进程至少分到3ms=24/8。
  • 在每个sched_latency内,根据各task的权重值,可以计算出运行时间runtime =调度周期 * 进程权重 / 所有进程权重之和。
  • 根据虚拟运行时间vruntime将各个调度实体用红黑树管理,最左边的节点对应着最小的vruntime。
  • 在调度的时候选择最小vruntime对应的进程进行调度。
  • 虚拟时间vruntime的计算如下:vruntime = runtime*nice_0_weight/seight。

3,数据结构

CFS调度器相关的数据结构

运行队列、cfs运行队列,任务task、任务组、调度实体结构体简介:

  • struct rq:每个CPU都有一个对应的运行队列。
  • struct cfs_rq:CFS运行队列,该结构中包含了struct rb_root_cached红黑树,用于链接调度实体struct sched_entity。rq运行队列中对应了一个CFS运行队列,此外,在task_group结构中也会为每个CPU再维护一个CFS运行队列。
  • struct task_struct:任务的描述符,包含了进程的所有信息,该结构中的struct sched_entity,用于参与CFS的调度。
  • struct task_group:组调度,Linux支持将任务分组来对CPU资源进行分配管理,该结构中为系统中的每个CPU都分配了struct sched_entity调度实体和struct cfs_rq运行队列,其中struct sched_entity用于参与CFS的调度。
  • struct sched_entity:调度实体,这个也是CFS调度管理的对象。

CFS调度类需要实现的所有接口定义在 struct sched_class 里。下面对其中最重要的一些调度类接口做简单的介绍:

  • enqueue_task:将待运行的任务插入到per-cpu rq。向就绪队列中添加一个任务,当某个任务进入可运行状态时,调用这个函数。典型的场景就是内核里的唤醒函数,将被唤醒的任务插入rq然后设置任务运行态为 TASK_RUNNING。对 CFS 调度器来说,则是将任务插入红黑树,给 nr_running 增加计数。
  • dequeue_task:将非运行态任务移除出per-cpu rq。将一个任务从就绪队列中删除,典型的场景就是任务调度引起阻塞的内核函数,把任务运行态设置成 TASK_INTERRUPTIBLE 或
  • TASK_UNINTERRUPTIBLE,然后调用 schedule 函数,最终触发dequeue_task的操作。对 CFS 调度器来说,则是将不在处于运行态的任务从红黑树中移除,给 nr_running 减少计数。
  • yield_task:处于运行态的任务主动让出 CPU。典型的场景就是处于运行态的应用调用sched_yield系统调用,直接让出 CPU。此时系统调用 sched_yield 系统调用先调用 yield_task 申请让出 CPU,然后调用 schedule 去做上下文切换。对 CFS 调度器来说,如果 nr_running 是 1,则直接返回,最终 schedule 函数也不产生上下文切换。否则,任务被标记为skip 状态。调度器在红黑树上选择待运行任务时肯定会跳过该任务。之后,因为 schedule 函数被调用,pick_next_task 最终会被调用。其代码会从红黑树中最左侧选择一个任务,然后把要放弃运行的任务放回红黑树,然后调用上下文切换函数做任务上下文切换。
  • yield_to_task:让处于运行态的任务主动放弃CPU,并执行指定的任务。
  • check_preempt_curr:用于在待运行任务插入rq后,检查是否应该抢占正在CPU上运行的当前任务。Wakeup Preemption 的实现逻辑主要在这里。对 CFS 调度器而言,主要是在是否能满足调度时延和是否能保证足够任务运行时间之间来取舍。CFS 调度器也提供了预定义的 Threshold 允许做 Wakeup Preemption 的调
  • pick_next_task:选择下一个最适合调度运行的任务,将其从rq移除。并且如果前一个任务还保持在运行态,即没有从rq移除,则将当前的任务重新放回到rq。内核 schedule 函数利用它来完成调度时任务的选择。对CFS调度器而言,大多数情况下,下一个调度任务是从红黑树的最左侧节点选择并移除。如果前一个任务是其它调度类,则调用该调度类的 put_prev_task 方法将前一个任务做正确的安置处理。但如果前一个任务如果也属于CFS调度类的话,为了效率,跳过调度类标准方法 put_prev_task,但核心逻辑仍旧是 put_prev_task_fair 的主要部分。
  • put_prev_task:将前一个正在CPU上运行的任务从CPU上拿下的处理。如果任务还在运行态则将任务放回rq,否则,根据调度类要求做简单处理。此函数通常是 pick_next_task 的密切关联操作,是 schedule 实现的关键部分。如果前一个任务属于CFS调度类,则使用CFS调度类的具体实现 put_prev_task_fair。此时,如果任务还是 TASK_RUNNING 状态,则被重新插入到红黑树的最右侧。如果这个任务不是 TASK_RUNNING 状态,则已经从红黑树移除过了,只需要修改CFS 当前任务指针 cfs_rq->curr 即可。
  • select_task_rq:为给定的任务选择一个最优的CPU就绪队列rq,返回rq所属的CPU号。典型的使用场景是唤醒,fork/exec 进程时,给进程选择一个rq,这也给调度器一个CPU负载均衡的机会。对CFS调度器而言,主要是根据传入的参数要求找到符合亲和性要求的最空闲的CPU所属的rq。
  • set_curr_task:当任务改变自己的调度类或者任务组时,该函数被调用。用户进程可以使用 sched_setscheduler系统调用,通过设置自己新的调度策略来修改自己的调度类。对CFS调度器而言,当任务把自己调度类从其它类型修改成CFS调度类,此时需要把该任务设置成正当前CPU正在运行的任务。例如把任务从红黑树上移除,设置 CFS 当前任务指针 cfs_rq->curr 和调度统计数据等。
  • task_tick:每次周期性时钟到的时候,这个函数被调用,可能触发调度。这个函数通常在系统周期性 (per-tick) 的时钟中断上下文调用,调度类可以把per-tick处理的事务交给该方法执行。例如,调度器的统计数据更新,Tick Preemption的实现逻辑主要在这里。Tick Preemption主要判断是否当前运行任务需要Preemption来被强制剥夺运行。对CFS调度器而言,Tick Preemption 主要是在是否能满足调度时延和是否能保证足够任务运行时间之间来取舍。CFS调度器也提供了预定义的Threshold允许做Tick Preemption的调优。
  • task_dead:进程结束时调用。
  • switched_from:用于切换调度类。
  • switched_to:切换到下一个进程来运行。
  • prio_changed:改变进程优先级。

4,流程分析

整个CFS的核心就是基于cfs调度类实现的各个函数,接下来的流程分析也基于这些核心函数。

4.1 vruntime的计算

CFS调度器没有时间的概念,而是根据虚拟运行时间对任务进行排序,选择虚拟运行时间最小的进程进行调度。虚拟运行时间的运算流程如下:

vruntime的计算

计算vruntime是调用sched_vslice实现的,计算的时候依赖调度周期。具体公式:vruntime =(runtime * weight * lw->inv_weight) >> WMULT_SHIFT。其中weight为要调度进程的权重,lw->inv_weight为cfs队列加上要调度进程的总权重,其中runtime的计算是通过sched_slice实现的,sched_slice根据当前进程的权重来计算在CFS就绪队列总权重中可以分到多少调度时间。

Sched_slice中的slice被复用了,首先被表示为调度周期,接下来的for循环中,slice的值 = 调度周期 * 进程权重/ 运行队列的权重。

4.2 任务创建​

在父进程通过fork创建子进程的时候,task_fork_fair函数会被调用。这个函数的传入参数是子进程的task_struct。该函数的主要作用,就是确定子任务的vruntime,因此也能确定子任务的调度实体在红黑树中的位置。

task_fork_fair本身比较简单,流程如下图:

任务创建

4.3 任务出队入队

  • 当任务进入可运行状态时,需要将调度实体放入到红黑树中,完成入队操作。
  • 当任务退出可运行状态时,需要将调度实体从红黑树中移除,完成出队操作。

CFS调度器使用enqueue_task_fair函数将任务入队到CFS队列,使用dequeue_task_fair函数将任务从CFS队列中出队操作。

任务出队入队
  • dequeue_task_fair与enqueue_task_fair大体的逻辑类似,这里不再深入分析。
  • 出队与入队的操作中,核心的逻辑可以分成两部分:
    • 1)更新运行时的数据,比如负载、权重、组调度的占比等等。
    • 2)将sched_entity插入红黑树,或者从红黑树移除。
  • Enqueue task过程中,涉及到了CPU负载计算、task_group组调度、CFS Bandwidth带宽控制等。

4.4 任务选择

每当进程任务切换的时候,也就是schedule函数执行时,调度器需要选择下一个将要执行的任务。在CFS调度器中,是通过pick_next_task_fair函数完成的,流程如下:

任务选择
  • 当需要进程任务切换的时候,pick_next_task_fair函数的传入参数中包含了需要被切换出去的任务,也就是pre_task。
  • 当pre_task不是普通进程时,也就是调度类不是CFS,那么它就不使用sched_entity的调度实体来参与调度,因此会执行simple分支,通过put_pre_task函数来通知系统当前的任务需要被切换,而不是通过put_prev_entity函数来完成。
  • 当pre_task是普通进程时,调用pick_next_entity来选择下一个执行的任务,这个选择过程实际是有两种情况:当调度实体对应task时,do while()遍历一次,当调度实体对应task_group时,则需要遍历任务组来选择下一个执行的任务了。
  • put_prev_entity,用于切换任务前的准备工作,更新运行时的统计数据,并不进行dequeue的操作,其中需要将CFS队列的curr指针置位成NULL。
  • set_next_entity,用于设置下一个要运行的调度实体,设置CFS队列的curr指针。
  • 如果使能了hrtimer,则将hrtimer的到期时间设置为调度实体的剩余运行时间。

4.5 cfs调度tick

CFS调度器中的tick函数为task_tick_fair,系统的每个调度tick都会调用到,此外如果使用了hrtimer,也会调用到这个函数。流程如下:

CFS调度tick

主要的工作包括:

  • 更新运行时的各类统计信息,比如vruntime, 运行时间、负载值、权重值等。
  • 检查是否需要抢占,主要是比较运行时间是否耗尽,以及vruntime的差值是否大于运行时间等。

上面的多个流程中都调用到update_curr函数,我们看一下这个函数具体更新了哪些信息。

update_curr更新的信息

转载 自 linux CFS调度

Linux CFS 调度器:原理、设计与内核实现

posted @   星辰大海浩瀚宇宙呀  阅读(17)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示