Linux进程调度 - CFS调度器 LoyenWang

背景

  • Read the fucking source code! --By 鲁迅
  • A picture is worth a thousand words. --By 高尔基

说明:

  1. Kernel版本:4.14
  2. ARM64处理器,Contex-A53,双核
  3. 使用工具:Source Insight 3.5, Visio

1. 概述

  • Completely Fair Scheduler,完全公平调度器,用于Linux系统中普通进程的调度。
  • CFS采用了红黑树算法来管理所有的调度实体sched_entity,算法效率为O(log(n))CFS跟踪调度实体sched_entity的虚拟运行时间vruntime,平等对待运行队列中的调度实体sched_entity,将执行时间少的调度实体sched_entity排列到红黑树的左边。
  • 调度实体sched_entity通过enqueue_entity()dequeue_entity()来进行红黑树的出队入队。

老规矩,先上张图片来直观了解一下原理:

Linux进程调度 - CFS调度器 LoyenWang_第1张图片

  • 每个sched_latency周期内,根据各个任务的权重值,可以计算出运行时间runtime
  • 运行时间runtime可以转换成虚拟运行时间vruntime
  • 根据虚拟运行时间的大小,插入到CFS红黑树中,虚拟运行时间少的调度实体放置到左边;
  • 在下一次任务调度的时候,选择虚拟运行时间少的调度实体来运行;

在开始本文之前,建议先阅读下(一)Linux进程调度器-基础

开始探索之旅!

2. 数据结构

2.1 调度类

Linux内核抽象了一个调度类struct sched_class,这是一种典型的面向对象的设计思想,将共性的特征抽象出来封装成类,在实例化各个调度器的时候,可以根据具体的调度算法来实现。这种方式做到了高内聚低耦合,同时又很容易扩展新的调度器。

Linux进程调度 - CFS调度器 LoyenWang_第2张图片

  • 在调度核心代码kernel/sched/core.c中,使用的方式是task->sched_class->xxx_func,其中task表示的是描述任务的结构体struct task_struck,在该结构体中包含了任务所使用的调度器,进而能找到对应的函数指针来完成调用执行,有点类似于C++中的多态机制。

2.2 rq/cfs_rq/task_struct/task_group/sched_entity

  • struct rq:每个CPU都有一个对应的运行队列;
  • struct cfs_rq:CFS运行队列,该结构中包含了struct rb_root_cached红黑树,用于链接调度实体struct sched_entityrq运行队列中对应了一个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调度管理的对象了;

来一张图看看它们之间的组织关系:

Linux进程调度 - CFS调度器 LoyenWang_第3张图片

  • struct sched_entity结构体字段注释如下:
struct sched_entity {
	/* For load-balancing: */
	struct load_weight		load;                   //调度实体的负载权重值
	struct rb_node			run_node;             //用于连接到CFS运行队列的红黑树中的节点
	struct list_head		group_node;          //用于连接到CFS运行队列的cfs_tasks链表中的节点
	unsigned int			on_rq;              //用于表示是否在运行队列中

	u64				exec_start;             //当前调度实体的开始执行时间
	u64				sum_exec_runtime;   //调度实体执行的总时间
	u64				vruntime;           //虚拟运行时间,这个时间用于在CFS运行队列中排队
	u64				prev_sum_exec_runtime;  //上一个调度实体运行的总时间

	u64				nr_migrations;      //负载均衡

	struct sched_statistics		statistics;     //统计信息

#ifdef CONFIG_FAIR_GROUP_SCHED
	int				depth;      //任务组的深度,其中根任务组的深度为0,逐级往下增加
	struct sched_entity		*parent;        //指向调度实体的父对象
	/* rq on which this entity is (to be) queued: */
	struct cfs_rq			*cfs_rq;        //指向调度实体归属的CFS队列,也就是需要入列的CFS队列
	/* rq "owned" by this entity/group: */
	struct cfs_rq			*my_q;      //指向归属于当前调度实体的CFS队列,用于包含子任务或子的任务组
#endif

#ifdef CONFIG_SMP
	/*
	 * Per entity load average tracking.
	 *
	 * Put into separate cache line so it does not
	 * collide with read-mostly values above.
	 */
	struct sched_avg		avg ____cacheline_aligned_in_smp;   //用于调度实体的负载计算(`PELT`)
#endif
};
  • struct cfs_rq结构体的关键字段注释如下:
/* CFS-related fields in a runqueue */
struct cfs_rq {
	struct load_weight load;        //CFS运行队列的负载权重值
	unsigned int nr_running, h_nr_running;  //nr_running:运行的调度实体数(参与时间片计算)

	u64 exec_clock;     //运行时间
	u64 min_vruntime;   //最少的虚拟运行时间,调度实体入队出队时需要进行增减处理
#ifndef CONFIG_64BIT
	u64 min_vruntime_copy;
#endif

	struct rb_root_cached tasks_timeline;   //红黑树,用于存放调度实体

	/*
	 * 'curr' points to currently running entity on this cfs_rq.
	 * It is set to NULL otherwise (i.e when none are currently running).
	 */
	struct sched_entity *curr, *next, *last, *skip; //分别指向当前运行的调度实体、下一个调度的调度实体、CFS运行队列中排最后的调度实体、跳过运行的调度实体

#ifdef	CONFIG_SCHED_DEBUG
	unsigned int nr_spread_over;
#endif

#ifdef CONFIG_SMP
	/*
	 * CFS load tracking
	 */
	struct sched_avg avg;       //计算负载相关
	u64 runnable_load_sum;
	unsigned long runnable_load_avg;        //基于PELT的可运行平均负载
#ifdef CONFIG_FAIR_GROUP_SCHED
	unsigned long tg_load_avg_contrib;      //任务组的负载贡献
	unsigned long propagate_avg;
#endif
	atomic_long_t removed_load_avg, removed_util_avg;
#ifndef CONFIG_64BIT
	u64 load_last_update_time_copy;
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
	/*
	 *   h_load = weight * f(tg)
	 *
	 * Where f(tg) is the recursive weight fraction assigned to
	 * this group.
	 */
	unsigned long h_load;
	u64 last_h_load_update;
	struct sched_entity *h_load_next;
#endif /* CONFIG_FAIR_GROUP_SCHED */
#endif /* CONFIG_SMP */

#ifdef CONFIG_FAIR_GROUP_SCHED
	struct rq *rq;	/* cpu runqueue to which this cfs_rq is attached */     //指向CFS运行队列所属的CPU RQ运行队列

	/*
	 * leaf cfs_rqs are those that hold tasks (lowest schedulable entity in
	 * a hierarchy). Non-leaf lrqs hold other higher schedulable entities
	 * (like users, containers etc.)
	 *
	 * leaf_cfs_rq_list ties together list of leaf cfs_rq's in a cpu. This
	 * list is used during load balance.
	 */
	int on_list;
	struct list_head leaf_cfs_rq_list;
	struct task_group *tg;	/* group that "owns" this runqueue */       //CFS运行队列所属的任务组

#ifdef CONFIG_CFS_BANDWIDTH
	int runtime_enabled;    //CFS运行队列中使用CFS带宽控制
	u64 runtime_expires;    //到期的运行时间
	s64 runtime_remaining;      //剩余的运行时间

	u64 throttled_clock, throttled_clock_task;  //限流时间相关
	u64 throttled_clock_task_time;
	int throttled, throttle_count;      //throttled:限流,throttle_count:CFS运行队列限流次数
	struct list_head throttled_list;    //运行队列限流链表节点,用于添加到cfs_bandwidth结构中的cfttle_cfs_rq链表中
#endif /* CONFIG_CFS_BANDWIDTH */
#endif /* CONFIG_FAIR_GROUP_SCHED */
};

3. 流程分析

整个流程分析,围绕着CFS调度类实体:fair_sched_class中的关键函数来展开。

先来看看fair_sched_class都包含了哪些函数:

/*
 * All the scheduling class methods:
 */
const struct sched_class fair_sched_class = {
	.next			= &idle_sched_class,
	.enqueue_task		= enqueue_task_fair,
	.dequeue_task		= dequeue_task_fair,
	.yield_task		= yield_task_fair,
	.yield_to_task		= yield_to_task_fair,

	.check_preempt_curr	= check_preempt_wakeup,

	.pick_next_task		= pick_next_task_fair,
	.put_prev_task		= put_prev_task_fair,

#ifdef CONFIG_SMP
	.select_task_rq		= select_task_rq_fair,
	.migrate_task_rq	= migrate_task_rq_fair,

	.rq_online		= rq_online_fair,
	.rq_offline		= rq_offline_fair,

	.task_dead		= task_dead_fair,
	.set_cpus_allowed	= set_cpus_allowed_common,
#endif

	.set_curr_task          = set_curr_task_fair,
	.task_tick		= task_tick_fair,
	.task_fork		= task_fork_fair,

	.prio_changed		= prio_changed_fair,
	.switched_from		= switched_from_fair,
	.switched_to		= switched_to_fair,

	.get_rr_interval	= get_rr_interval_fair,

	.update_curr		= update_curr_fair,

#ifdef CONFIG_FAIR_GROUP_SCHED
	.task_change_group	= task_change_group_fair,
#endif
};

3.1 runtime与vruntime

CFS调度器没有时间片的概念了,而是根据实际的运行时间和虚拟运行时间来对任务进行排序,从而选择调度。那么运行时间和虚拟时间分别是什么概念呢,以及基于虚拟时间的调度原理是什么?

本文所述真实时间就是task 霸占cpu的实际运行时间。本文所述虚拟时间就是真实时间通过CFS公式转换而成的时间。

CFS引入了虚拟时间的概念,建立了虚拟时间世界(或说维度),用CFS公式作为桥梁把真实时间与虚拟时间连接在了一起。因此,CFS分成3个部分:虚拟时间世界,CFS公式,真实时间世界。

虚拟时间世界和CPU真实时间世界互不干扰、独立运行,CFS公式是穿越2个世界的钥匙。

1. CFS公式
CFS公式主要作用:把task的真实时间映射为虚拟时间。
每当系统节拍来临(系统驱动定时器),linux会根据CFS公式刷新CFS类的当前task的虚拟时间,并在红黑树上重新排序——以保证最左侧叶子节点放的始终是最小虚拟时间的task。

vruntime = runtime * (NICE_0_LOAD/nice_n_weight)

nice值是很关键的参数,它决定task真实时间转换成虚拟时间的增长率——谁的nice值越大,同样的cpu霸占的真实时间转换成的虚拟时间就越多。于是,可以间接地用nice值来调整CFS类下的各task的实际真实的cpu使用时间。

2. 虚拟时间世界
假设系统中只有CFS类的task,当调度来临时,CFS调度器会比较当前的task与当前红黑树最左侧叶子节点上的task是不是同一个,如果不是,则切换到该叶子节点的task,否则不切换——就这样,在虚拟时间世界里动态地保证了CFS类的各task在虚拟时间上是完全公平的

题外话:linux中不只有CFS调度类的task,还同时存在实时调度类的task(比如deadline、realtime),那么当调度来临时是怎么做的呢?是先从实时调度类里挑选下一个task的,实时调度类里没有,才会从CFS调度器类的红黑树上选task,如果CFS类也没有能运行的task,那么就选idle空闲线程来运行(每个CPU一个)。

3. 真实时间世界
CFS类的各task因为nice值的不同,虽然虚拟世界的时间是相同的,实际霸占cpu的时间其实是不一样的。在真实时间世界里,其实不一定是完全公平的!

4. “完全公平”的意思
CFS英文单词的意思是“完全公平”,但它的“完全公平”指的是在虚拟时间的维度上是“完全公平”的,不能完全保证在真实的cpu时间维度上的“完全公平”(特例:当CFS类的所有task的nice值一样,就能保证真实时间上的完全公平了)。

5.CFS算法比喻
每个CFS调度类的线程 好比一个个的水杯 ,大家高度一样 ,但是直径由nice值决定 。
倒水人是cpu ,倒水的速率一样。倒水人停留在每个水杯的时间是霸占CPU的真实时间。水杯水位线 是CFS的虚拟时间。CFS算法的目标 就是要保证每个水杯的水位线动态上完全公平——哪个水杯水位线最低,倒水人就优先去给它倒水,哎呀,倒着倒着发现当前水杯的水位线不是最低的了,然后倒水人就会跑到最低水位线的水杯去倒水了。这样跑来跑去,就能动态保证每杯的水位线基本差不多高。但是倒水人停留在各个水杯的时间不是公平的 ,这和水杯的直径有关了——nice值越大直径越小,水位线随真实时间上涨的曲线就越陡,即很快就达到某一水位线。——nice值越小,水杯直径越大,即越粗,则要达到同一水位线的话,倒水人停留的时间就越长。——目标高度一样,越粗(nice值越小)的水杯达到同一水位线的时间就越长,越细(nice值越大)的水杯达到同一水位线的时间就越快,霸占时间就越短。
而CFS算法说:我不管,我的目标就是保证水位线高度一致,具体你真实时间我不管。

那么,运行时间和虚拟运行时间是怎么计算的呢?看一下流程调用:

Linux进程调度 - CFS调度器 LoyenWang_第4张图片

  • Linux内核默认的sysctl_sched_latency是6ms,这个值用户态可设。sched_period用于保证可运行任务都能至少运行一次的时间间隔;
  • 当可运行任务大于8个的时候,sched_period的计算则需要根据任务个数乘以最小调度颗粒值,这个值系统默认为0.75ms;
  • 每个任务的运行时间计算,是用sched_period值,去乘以该任务在整个CFS运行队列中的权重占比;
  • 虚拟运行的时间 = 实际运行时间 * NICE_0_LOAD / 该任务的权重;

还是来看一个实例吧,以5个Task为例,其中每个Task的nice值不一样(优先级不同),对应到的权重值在内核中提供了一个转换数组:

const int sched_prio_to_weight[40] = {
 /* -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,
};

图来了:

Linux进程调度 - CFS调度器 LoyenWang_第5张图片

针对实际的虚拟时间vruntime更新方式,计算更新结点主要包含:

  1. TICK_NSEC周期更新vruntime方式,时钟中断触发产生调度流程scheduler_tick---->task_tick_fair---->enqueue_tick----->update_curr

  2. 新创建的进程。假如新进程的vruntime初值为0的话,比老进程的值小很多,那么它在相当长的时间内都会保持抢占CPU的优势,老进程就要饿死了,这显然是不公平的。取父进程vruntime(curr->vruntime) 和 (cfs_rq->min_vruntime + 假设se运行过一轮的值)之间的最大值,赋给新创建进程

  3. idle进程被wakeup。如果休眠进程的 vruntime 保持不变,而其他运行进程的 vruntime 一直在推进,那么等到休眠进程终于唤醒的时候,它的vruntime比别人小很多,会使它获得长时间抢占CPU的优势,其他进程就要饿死了。这显然是另一种形式的不公平。CFS是这样做的:在休眠进程被唤醒时重新设置vruntime值,以min_vruntime值为基础,给予一定的补偿,但不能补偿太多。

  4. task的balance(迁移)。不同cpu的负载时不一样的,所以不同cfs_rq里se的vruntime水平是不一样的。如果进程迁移vruntime不变也是非常不公平的。CFS使用了一个很聪明的做法:在退出旧的cfs_rq时减去旧cfs_rq的min_vruntime,在加入新的cfq_rq时重新加上新cfs_rq的min_vruntime。

  5. 改变task的cpu亲和性
    改变task的优先级
    将task从一个task group设置到新的task group中,如果改变task的rq的话
    一般改变task的行为,都会触发重新计算task的vruntime的数值,也就会导致重新插入

这一套虚拟时间更新算法保证了CFS原则:

  • 随着任务的执行,它的运行时间增加,因此vruntime也会变大,它会在红黑树中向右移动(想象一下这个画面);
  • 计算密集型作业将运行很长时间,因此它将移到最右侧;
  • I/O密集型作业会运行很短的时间,因此它只会稍微向右移动;
  • 对于更重要的任务,也就是nice值较小的(一般是小于0),他们的移动速度相对慢很多。(相对于nice = 0的任务,nice每小一级,CPU usage就会多10%,"10% effect")虚拟时钟的滴答声更慢.

CFS的缺点:也和唤醒抢占特性有关。CFS不能区分交互式进程和主动休眠的进程,主动休眠的进程并不要求快速响应,但也会在唤醒时获得补偿,例如通过调用sleep()、nanosleep()的方式,定时醒来完成特定任务,这有可能会导致其它更重要的应用进程被抢占,有损整体性能。

3.2 CFS调度tick

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

Linux进程调度 - CFS调度器 LoyenWang_第6张图片

主要的工作包括:

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

来一张图,感受一下update_curr函数的相关信息更新吧:

Linux进程调度 - CFS调度器 LoyenWang_第7张图片

3.3 任务出队入队

  • 当任务进入可运行状态时,需要将调度实体放入到红黑树中,完成入队操作;
  • 当任务退出可运行状态时,需要将调度实体从红黑树中移除,完成出队操作;
  • CFS调度器,使用enqueue_task_fair函数将任务入队到CFS队列,使用dequeue_task_fair函数将任务从CFS队列中出队操作。
  • Linux进程调度 - CFS调度器 LoyenWang_第8张图片

  • 出队与入队的操作中,核心的逻辑可以分成两部分:1)更新运行时的数据,比如负载、权重、组调度的占比等等;2)将sched_entity插入红黑树,或者从红黑树移除;
  • 由于dequeue_task_fair大体的逻辑类似,不再深入分析;
  • 这个过程中,涉及到了CPU负载计算task_group组调度CFS Bandwidth带宽控制等,这些都在前边的文章中分析过,可以结合进行理解;

3.3 任务创建

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

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

Linux进程调度 - CFS调度器 LoyenWang_第9张图片

3.4 任务选择

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

Linux进程调度 - CFS调度器 LoyenWang_第10张图片

  • 当需要进程任务切换的时候,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的到期时间设置为调度实体的剩余运行时间;

暂且分析到这吧,CFS调度器涵盖的内容还是挺多的,fair.c一个文件就有将近一万行代码,相关内容的分析也分散在前边的文章中了,感兴趣的可以去看看。

打完收工,洗洗睡了。

在O(n)和O(1)调度器中,时间片是一个很重要的概念,它决定了一个任务能够运行多长时间而不被抢占。对于内核,时间片的机制是这样的,每当系统时钟中断来临,调度器从当前任务的时间片中减去一个时钟周期,直至时间片耗完,这个时候当前任务返回用户空间会,换成其他任务执行,所以时间片的划分是调度器设计上重要的问题。

为了解决上面问题,以往的调度器把动态优先级和时间片绑定在一起,高优先级的进程获得长的时间片,而低优先级的进程获得短的时间片,调度器优先调度具有时间片长的任务。但是这样分配不合理,时间片长的进程并不一定是当下最需要CPU时间的任务。但是这样的算法还是存在一定的问题

高优先级任务的应用会比低优先级任务获得更多的资源,这样会导致一个调度周期内,低优先级的任务可能一直无法得以响应,直到高优先级任务接收。在实际使用的过程中,发现,当一个CPU消耗性任务启动后,那些用户交互程序都可以感觉到明显的延迟。

高优先级的进程将获得更多的执行机会,这是CPU消耗性的任务的期望的,但是,实际上,CPU消耗性进程往往是后台执行,优先级都比较低。例如,我们使用用户交互进程,并设定它的优先级很高,以便它能有更好的用户体验,给它分配一个很大的时间片。实际中,我们发现,这个进程多半是处于阻塞状态的,等待用户的输入。

针对以上问题,linux2.6.23版本引入了CFS(Complete Fair Scheduler),引入了公平性的概念。本章主要是学习CFS调度器的相关原理性内容。

1. CFS调度器基本思想
CFS调度器核心原理很简单,就是使得每个进程都尽可能“公平”地获得运行时间,因此每次都选择过去运行得最少的进程运行,也就是在真实的硬件上实现理想的、精准、完全公平的多任务调度。引入公平性的概念:CFS假设一个理想化的CPU,同时可以运行所有的任务,以动态优先级为权重,那么CFS调度器如何理想化呢?

• CFS调度器和以往得调度器不同之处在于没有时间片得概念,而是分配CPU使用时间的比例
• CFS为了实现公平,必须惩罚当前正在运行的进程,以使得哪些正在等待的进程下次被调度

CFS引入了虚拟运行时间(vruntime),每个调度实体的运行时间,任务的虚拟运行时间越小,意味着任务被访问的时间越短,其对处理器的需求就越高,其核心的思想表现如下:

进程的运行时间相等
对睡眠的进程进行补偿
所以CFS调度器本质上是改进了Round Robin的真是运行时间片的基础上实现了一个虚拟的运行的时间的概念,每个进程都有要给vruntime值,根据不同的权重,vruntime就是该进程的实际运行时间。得到这个vruntime之后,系统将会根据进程的vruntime的排序,基于红黑树,vruntime最小进程会最早得到调度。

2. 关于公平
CFS与以往的调度器不同之处在于没有时间片的概念,而是公平分配CPU使用时间。例如:2个相同优先级的进程在一个CPU上运行,那么每个进程将会分配50%的CPU运行时间,这就是要实现的公平。但是现实是残酷的,必然是有的进程优先级高,有的进程优先级。

针对该问题,CFS调度器引入了权重的概念,有权重代表进程的优先级,各个进程按照权重的比例分配CPU的时间,因此CFS调度器的公平就是保证所有的可运行状态的进程按照权重分配其CPU资源。例如

2个进程A和B,A进程的权重是1024,B进程的权重是2048,那么
A获得CPU的时间比例是1024/(1024+2048) = 33.3%
B获得CPU的时间比例是2048/(1024+2048) = 66.7%

实际运行时间 = 调度周期 * 进程权重 / 所有进程权重之和

CFS调度器用nice值来表示优先级,取值范围是[-20,19],nice值和权重是一一对应的关系。数字越小代表优先级越大,同时意味着权重值越大,而内核本身,选择范围[0,139]在内部表示优先级,同样是数值越低优先级越高。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b130pgXl-1641641082356)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/be1bd92f-ddfc-43a4-8964-5fa9ad07edab/Untitled.png)]

对于普通的进程,可以认为优先级不会发生变化,而实时进程则不然:

static int effective_prio(struct task_struct *p)
{
    p->normal_prio = normal_prio(p);
//如果不是实时进程,返回前面normal_prio的计算结果
    if (!rt_prio(p->prio))
        return p->normal_prio;
    return p->prio;
}

nice值与权重之间是一对一的关系,为了实现普通进程的nice值到CPU时间权重的快速计算,内核预计算好了一个映射数组。对于linux内核的nice和权重之间的转换关系如下所示

Linux进程调度 - CFS调度器 LoyenWang_第11张图片

进程每降低一个nice级别,优先级则提高一个级别,相应的进程多获得10%的CPU时间
进程每提升一个nice级别,优先级则降低一个级别,相应的进程减小得10%的CPU时间
内核预定nice值为0的进程权重为1024,其他nice值对应的权重值可以通过查上述表来获得。同时对于10%的影响也是相对的,累加的。例如一个进程增加10%的CPU时间,则另外一个进程减小10%,因此差距大约是20%,这里使用一个系数1.25来计算。

• 如果进程A和进程B的nice值都为0,那么权重值就是1024,那么它们将获得50%的CPU时间,计算公式为
1024/(1024+1024) = 50%
• 假设进程A增加nice值,此时变成nice = 1,那么进程B理论上将获得55%的CPU时间,而进程A应该获得45%的CPU时间
• 我们利用prio_to_weight表来计算,对于进程A,其获得CPU时间为820/(820+1024)=44.5%
对于进程B,其获得CPU时间为1024/(820+1024)=55.5%

3. 虚拟运行时间
有了进程在CPU运行的权重之后,内核就可以根据权重值计算出进程的虚拟运行时间(virtual runtime)。

什么是“虚拟运行时间”,就是内核根据进程运行的实际时间和权重计算出来的一个时间,CFS调度器只需要保证在同一个CPU上面运行的进程,其虚拟运行时间一致即可。
假设一个CPU调度的周期是6ms,进程A和进程B的权重分别是1024(nice值为0)和820(nice值为1),
• 那么进程A获得的运行时间是6*1024/(1024+820)=3.3ms,进程A的CPU使用比例是3.3/6=55%
• 进程B获得的运行时间是 6 * 1024/(1024+820) = 2.7ms,进程B的CPU使用比例是2.7/6=45%
符合上面说的“进程每降低一个nice值,将多获得10%的CPU时间。

很明显,2个进程的实际执行时间是不相等,但是CFS想保证每个进程运行时间相等。因此CFS引入了虚拟时间的概念。也就是说上面的2.7ms和3.3ms经过一个公式的转换可以得到一个一样的值,这个转换后的值就被称作为虚拟时间。对于CFS只需要保证每个进程运行的虚拟时间是相等的即可,虚拟时间vriture_runtime和时间的时间(wall time)转换公式如下:

virture_runtime = wall_time * NICE_0_LOAD / weight

按照这样的公式,对于上图例子中的进程A和进程B,我们可以看出其虚拟运行时间是相同的

• 进程A的虚拟运行时间:3.3 * 1024 / 1024 = 3.3ms
• 进程B的虚拟运行时间:2.7 * 1024/820 =3.3ms

我们可以看出,尽管A和B进程的权重值不一样,但是计算得到的虚拟时间是一样的。因此CFS就可以保证每个进程获得执行的虚拟时间一致即可。在选择下一个即将运行的进程的时候,只需要找到虚拟时间最小的进程即可,为避免浮点数运算,因此我们采用先放大再缩小的方式以保证计算精度,内核又对公式做了如下转换

Linux进程调度 - CFS调度器 LoyenWang_第12张图片

对于权重的值,已经计算保存在sched_prio_to_weight数组中,所以这个很容易计算出inv_weight,通过后面的公式计算,对于内核使用sched_prio_to_wmult[40],它也是预先计算好的

Linux进程调度 - CFS调度器 LoyenWang_第13张图片

例如当nice值为0的进程,其inv_weight就为:232/NICE_0_LOAD=232/1024=4194304,符合预期
3. vruntime计算
内核使用0139的数值表示进程的优先级,数值越低,优先级越高。优先级099给实时进程使用,100~139给普通进程使用,内核使用load_weight数据结构来记录调度实体的权重信息

struct load_weight {
    unsigned long weight;       /*  存储了权重的信息  */
    u32 inv_weight;                 /*   存储了权重值用于重除的结果 weight * inv_weight = 2^32  */
};

由上面的可以知道,weight代表进程的权重,而Inv_weight等于上面公式计算的值。内核提供了一个函数来查询上面的两个表,然后把这个值放在p->se.load数据结构中,级load_weight数据结构中,详细的代码见

//set_load_weight负责根据非实时进程类型极其静态优先级计算符合权重
//实时进程不需要CFS调度, 因此无需计算其负荷权重值

static void set_load_weight(struct task_struct *p)
{
        //由于数组中的下标是0~39, 普通进程的优先级是[100~139],将静态优先级转换成为数组下标
    //权重值取决于static_prio,减去100而不是120,对应了下面数组下标
        int prio = p->static_prio - MAX_RT_PRIO;
        //task_statuct->se.load获取负荷权重的信息
        struct load_weight *load = &p->se.load;
    
   /*
     * SCHED_IDLE tasks get minimal weight:
     * 必须保证SCHED_IDLE进程的负荷权重最小
     * 其权重weight就是WEIGHT_IDLEPRIO
     * 而权重的重除结果就是WMULT_IDLEPRIO
     */
        if (idle_policy(p->policy)) {
            load->weight = scale_load(WEIGHT_IDLEPRIO);   //IDLE调度策略进程使用固定优先级权重,取最低普通优先级权重的1/5
            load->inv_weight = WMULT_IDLEPRIO;   //取最低普通优先级反转权重的5倍
            return;
        }
        /* 设置进程的负荷权重weight和权重的重除值inv_weight */
        load->weight = scale_load(sched_prio_to_weight[prio]);
        load->inv_weight = sched_prio_to_wmult[prio];
}

有了前面的铺垫,来看内核中对应的实现,内核中使用函数__calc_delta来将实际时间转换为虚拟时间,算法原理就是前面介绍到的公式

static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    if (unlikely(se->load.weight != NICE_0_LOAD))
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

    return delta;
}

按照之前的理论,nice值为0(权重为NICE_0_LOAD)的进程的虚拟时间和实际的时间是相等的,那么此进程对应的虚拟时间就不用计算了,如果不为0,就需要调用__calc_delta计算虚拟时间。

static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw)
{
    u64 fact = scale_load_down(weight);  /*fact等于weight*/
    int shift = WMULT_SHIFT;                          /*WMULT_SHIFT为32*/

    __update_inv_weight(lw);                       /*更新load_weight->inv_weight,一般情况下已经设置,不需要进行操作 */

    if (unlikely(fact >> 32)) {                         /*一般fact>>32为0,所以跳过*/
        while (fact >> 32) {
            fact >>= 1;
            shift--;
        }
    }

    /* hint to use a 32x32->64 mul */
    fact = (u64)(u32)fact * lw->inv_weight;

    while (fact >> 32) {
        fact >>= 1;
        shift--;
    }

    return mul_u64_u32_shr(delta_exec, fact, shift);
}

这里主要考虑两种极限的情况:

当lw->weight特别小,比如为1时,__update_inv_weight(lw)会把lw->inv_weight置为WMULT_CONST(即0x ff ff ff ff), NICE_0_LOAD等于1024相当于将0x ff ff ff ff再右移10位。因此,第二个while循环后shift的值会变为22。mul_u64_u32_shr(delta_exec, fact, shift)相当于delta_exec * (2^32-1) / 2^22变换成delta_exec * (2^10 - 1/2^22),算下来应该与delta_exec * 1024的值差不多。
当lw->weight特别大,甚至超过WMULT_CONST(即0x ff ff ff ff)时,__update_inv_weight(lw)会把lw->inv_weight置为1,两次while循环都不会产生fact右移的情况。fact仍然是NICE_0_LOAD,而shift还是32,故mul_u64_u32_shr(delta_exec, fact, shift)相当于delta_exec >> 22(2^22 = 4,194,304)。
以下将前面的nice值、权重、虚拟运行时间的关系总结如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OvMdncim-1641641082358)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/7cce8ff8-87db-4b05-8bcd-8f3c0f77a162/Untitled.png)]

对于上面的vruntime该如何理解呢?这里面有一个真实事件和虚拟事件的概念,如下图,假设系统中有3个进程A、B、C,

假设它们的nice值都为0,也就是权重值都是1024,它们分配到运行时间相同,即虚拟时间过得跟真实的时间是一样的
假设进程的nice值不为0,并且nice值小的进程优先级高,那么虚拟时间比真实的时间过得慢
假设进程的nice值不为0,并且nice值大的进程优先级低,那么虚拟时间比真实的时间过得快

Linux进程调度 - CFS调度器 LoyenWang_第14张图片
所有CFS调度器抛弃了以前固定时间片和固定调度周期的算法,而是采用进程权重比重来量化和计算实际运行时间。另外引入了虚拟时钟的概念,每个进程的虚拟时间是虚拟运行时间相对nice值为0的权重的比例。

nice值越小的进程,优先级高且权重越大,vruntime值越小,其虚拟时钟比真实时钟跑的慢,也就可以获得比较多的运行时间
nice值越大的进程,优先级低且权重越低,vruntime值越大,其虚拟时钟比真实时钟跑的快,反而获得比较少的运行时间。
CFS调度器总是选择虚拟时钟跑得慢的进程,它像一个多级变速箱,NICE为0的进程是基准齿轮,其他各个进程在不同的变速比下相互追赶,从而达到公正公平

4 调度延迟
传统的调度器无法保证调度延迟,调度的延迟是和系统的负载相关,当负载增加的时候,用户更容易观察到卡顿的现象。

假设CPU的就绪队列上由两个nice value等于0的进程A和B,传统调度器会为每一个进程分配一个固定的时间片100ms,这个时候A先运行,直到100ms的时间耗尽,然后B进行。这两个进程会交替运行,那么调度的延迟是100ms。

但是随着系统的负担越来越重,假如又有两个nice值为0的进程C和D挂到就绪队列,这个时候A→B→C→D交替运行,调度延时就变成了300ms

所以调度延迟就是保证每一个可运行进程都至少运行一次的时间间隔,如果保持调度延时不变,例如固定是6ms,那么系统中如果有两个进程,那么每个进程的运行时间为3ms;但是如果系统中有100个进程,那么每个CPU分配到时间片就为0.06ms,将会导致系统的进程调度太过于频繁,上下文切换时间开销就变大。所以CFS调度器的调度延迟的设定并不是固定的。

当系统就绪态进程个数超过这个值时,我们保证每个进程至少运行一定的时间才让出cpu。这个“至少一定的时间”被称为最小粒度时间。在CFS默认设置中,最小粒度时间是0.75ms。用变量sysctl_sched_min_granularity记录。因此,调度周期是一个动态变化的值。调度周期计算函数是__sched_period()。

static u64 __sched_period(unsigned long nr_running)
{
    if (unlikely(nr_running > sched_nr_latency))
        return nr_running * sysctl_sched_min_granularity;
    else
        return sysctl_sched_latency;
}

5 总结
CFS(完全公平调度器)是Linux内核2.6.23版本开始采用的进程调度器,它的基本原理是这样的:设定一个调度周期(sched_latency_ns),目标是让每个进程在这个周期内至少有机会运行一次,换一种说法就是每个进程等待CPU的时间最长不超过这个调度周期;然后根据进程的数量,大家平分这个调度周期内的CPU使用权,由于进程的优先级即nice值不同,分割调度周期的时候要加权;每个进程的累计运行时间保存在自己的vruntime字段里,哪个进程的vruntime最小就获得本轮运行的权利。

 CFS是内核使用的一种调度器或调度类,它主要负责处理三种调度策略:SCHED_NORMAL、SCHED_BATCH和SCHED_IDLE。调度器的核心在挑选下一个运行的进程,多个调度器核心是适配不同应用场景,不同调度器有优先级之分。实际上系统大多数进程通常都是CFS调度类负责处理的,因此为了优化下一个进程的挑选调度器核心会先判断当前进程是否采用了CFS调度策略,若是,则直接调用CFS代码来挑选下一个进程,若不是或CFS代码未能挑选到一个合适的进程,则会调用各个调度类的挑选函数来寻找一个合适的进程。若CFS代码寻找到了合适的下一个运行的进程,则直接返回该进程的实例而不会再遍历调度类。本文将主要关注下列的CFS活动和行为:

  1. 将一个任务插入运行队列
  2. 从运行队列中挑选一个合适的任务
  3. 从运行队列中移除一个任务

1 调度器的数据结构

linux内核采用进程描述符(PCB)来描述和抽象一个进程,数据结构task_struct用于描述进程的运行状态和控制状态全部信息,其相关结构信息如下:

struct task_struct {
    //当前的运行状态
    volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */
    //SMP会用到
#ifdef CONFIG_SMP
    int on_cpu;                /* 在哪个CPU上运行 */
#ifdef CONFIG_THREAD_INFO_IN_TASK
    unsigned int cpu;         /* current CPU */
#endif
    unsigned int wakee_flips;   /* 用于wakee affine特性 */
    unsigned long wakee_flip_decay_ts;  /* 用于记录上一次wakee_flip时间 */
    struct task_struct *last_wakee;     /* 表示上一次唤醒的是哪个进程 */

    int wake_cpu;                        /* 表示进程唤醒在哪个CPU */
#endif
    /* 用于进程的状态,支持状态如下
    - TASK_ON_RQ_QUEUED:表示进程正在就绪队列中运行
    - TASK_ON_RQ_MIGRATING:表示处于迁移过程中的进程,可能不在就绪队列中 */
    int on_rq;                            
    //进程的动态优先级,静态优先级,给予动态优先级和调度策略计算出来的优先级 
    int prio, static_prio, normal_prio;
    unsigned int rt_priority;          /* 实时进程优先级 */
    const struct sched_class *sched_class;    /*调度类*/
    struct sched_entity se;                      /*普通进程调度类实体 */
    struct sched_rt_entity rt;                  /* 实时进程 */
#ifdef CONFIG_CGROUP_SCHED
    struct task_group *sched_task_group;      /*  组调度 */
#endif
    struct sched_dl_entity dl;                /* deadline调度类实体 */
    int nr_cpus_allowed;                      /* 进程允许运行的CPU个数 */
    cpumask_t cpus_allowed;                   /* 进程允许运行的CPU位图 */
#ifdef CONFIG_SCHED_INFO
    struct sched_info sched_info;              /* 进程调度相关信息 */
#endif
};

static_prio表示进程的静态优先级。静态优先级是进程启动时分配的优先级。它可以用nice和sched_setscheduler系统调用修改,否则在进程运行期间会一直保持恒定。normal_priority表示基于进程的静态优先级和调度策略计算出的优先级。调度器考虑的优先级则保存在prio。由于在某些情况下内核需要暂时提高进程的优先级,因此需要第3个成员来表示。

进程调度有一个非常重要的数据结构sched_entity,被称为调度实体,它描述了进程作为一个调度实体参与进程调度所需要的全部信息,其数据结构定义在include/linux/sched.h

struct sched_entity {
    struct load_weight    load;                /* 调度实体的权重 */
    struct rb_node        run_node;            /* 调度实体作为一个节点插入到CFS的红黑树中 */
      /* 在就绪队列中有一个链表rq->cfs_tasks,调度实体添加到就绪队列后添加到该链表 */
    struct list_head    group_node;            
       /* 进程加入到就绪队列,该位被置1,退出就绪队列,被清0,用于表示是否在运行队列中 */
    unsigned int        on_rq;             
    /* 统计时间信息 */
    u64            exec_start;                    /* 调度实体虚拟时间的起始时间 */
    u64            sum_exec_runtime;            /* 调度实体总的运行时间,实际时间 */
    u64            vruntime;                    /* 调度实体的虚拟时间 */
    u64            prev_sum_exec_runtime;        /* 上一次统计调度实体运行总时间 */

    u64            nr_migrations;                /* 该调度实体发生迁移的次数 */

#ifdef CONFIG_SCHEDSTATS
    struct sched_statistics statistics;        /* 统计信息 */
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
    int            depth;                /* 任务组的深度,其中根任务组的深度为0,逐级往下增加 */
    struct sched_entity    *parent;    /* 指向调度实体的父对象 */
    /* rq on which this entity is (to be) queued: */
    struct cfs_rq        *cfs_rq;    /* 指向调度实体归属的CFS队列,也就是需要入列的CFS队列 */
    /* rq "owned" by this entity/group: */
    struct cfs_rq        *my_q;        /* 指向归属于当前调度实体的CFS队列,用于包含子任务或子的任务组 */
#endif
// 用于调度实体的负载计算(`PELT`)
#ifdef CONFIG_SMP
    struct sched_avg    avg ____cacheline_aligned_in_smp;
#endif
};

rq数据结构是描述CPU通用就绪队列,rq数据结构记录了一个就绪队列所需要的全部信息,包括一个CFS就绪队列数据结构,实时进程调度就绪队列等,其定义在kernel/sched/sched.h,重点的数据成员为

struct rq {
    raw_spinlock_t lock;                        /* 用于保护通用就绪队列的自旋锁 */
    unsigned int nr_running;                    /* 就绪队列中可运行的进程数量 */
    #define CPU_LOAD_IDX_MAX 5
    unsigned long cpu_load[CPU_LOAD_IDX_MAX];    /* 每个就绪队列维护一个cpu_load,在scheduler tick中重新计算,负载更均衡 */
    struct load_weight load;                    /* 就绪队列的权重 */
    unsigned long nr_load_updates;                /* 记录cpu_load的更新次数 */
    u64 nr_switches;                            /* 记录进程切换的次数 */

    struct cfs_rq cfs;                            /* 指向CFS的就绪队列 */
    struct rt_rq rt;                            /* 指向实时进程的就绪队列 */
    struct dl_rq dl;                            /* 指向deadline进程的就绪队列 */
    unsigned long nr_uninterruptible;            /* 统计不可中断的状态进程进入就绪队列的数量 */

    struct task_struct *curr, *idle, *stop;        /* 指向正在运行的进程、idle、系统的stop进程 */
    unsigned long next_balance;                    /* 下一次做负载均衡的时间 */
    struct mm_struct *prev_mm;                    /* 进程切换用于指向前任进程的内存结构描述符mm */

    unsigned int clock_skip_update;                /* 用于更新就绪队列的时钟的标志位 */
    u64 clock;                                    /* 每次时钟节拍到来时候会更新这个时钟 */
    u64 clock_task;                                /* 计算进程vruntime时使用的该时钟 */
    
#ifdef CONFIG_SMP
    struct root_domain *rd;                        /* 调度域的根 */
    struct sched_domain *sd;                    /* 指向CPU对应的最低等级的调度域 */

    unsigned long cpu_capacity;                    /* CPU对应普通进程的量化计算能力 */
    unsigned long cpu_capacity_orig;
    int cpu;                                    /* 用于表示就绪队列运行在哪个CPU上 */
    int online;                                    /* 用于表示CPU处于active状态或online状态 */

    struct list_head cfs_tasks;                    /* 可运行的调度实体会添加到该链表 */
};

系统中每个CPU都有一个就绪队列,它是一个pre-cpu的变量,即每个CPU都有一个rq的数据结构,可以通过this_rq()可获取当前CPU的数据结构rq。

DECLARE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

#define cpu_rq(cpu)        (&per_cpu(runqueues, (cpu)))
#define this_rq()        this_cpu_ptr(&runqueues)
#define task_rq(p)        cpu_rq(task_cpu(p))
#define cpu_curr(cpu)        (cpu_rq(cpu)->curr)
#define raw_rq()        raw_cpu_ptr(&runqueues)

cfs_rq是表示CFS就绪队列的数据结构,其主要定义如下:

struct cfs_rq {
    struct load_weight load;                            /* 就绪队列的总权重 */
    unsigned int nr_running, h_nr_running;                /* 可运行状态的进程数量 和组调度机制下可运行状态的进程数量 */

    u64 exec_clock;                                        /* 统计就绪队列总的运行时间 */
    u64 min_vruntime;                                    /* 用于跟踪CFS就绪队列中红黑树最小vruntime */
#ifndef CONFIG_64BIT
    u64 min_vruntime_copy;                                
#endif

    struct rb_root tasks_timeline;                        /* 红黑树的根 */
    struct rb_node *rb_leftmost;                        /* 指向红黑树中最左边的调度实体 */
    struct sched_entity *curr, *next, *last, *skip;        /* 正在运行的进程,切换的下一个进程 */
#ifdef CONFIG_FAIR_GROUP_SCHED
    struct rq *rq;    /* cpu runqueue to which this cfs_rq is attached */
    int on_list;
    struct list_head leaf_cfs_rq_list;
    struct task_group *tg;    /* group that "owns" this runqueue */
#endif /* CONFIG_FAIR_GROUP_SCHED */
};

网上有一张画的很好的组织关系图

Linux进程调度 - CFS调度器 LoyenWang_第15张图片

struct task_struct:任务的描述符,包含了进程的所有信息,该结构中的struct sched_entity,用于参与CFS的调度
struct sched_entity:每一个调度类并不是直接管理task_struct,而是引入调度实体的概念,这个也是CFS调度管理的对象了,描述了进程作为调度实体参与调度的所需要的所有信息,调度实体会作为一个节点插入到CFS的红黑树中
struct rq:每个CPU都有一个对应的运行队列,rq数据结构记录了一个就绪队列所需要的全部信息,包括一个CFS就绪队列数据结构,实时进程调度就绪队列以及就绪队列的负载权重等信息
struct cfs_rq:CFS运行队列,该结构中包含了struct rb_root_cached红黑树,用于链接调度实体struct sched_entity。rq运行队列中对应了一个CFS运行队列,此外,在task_group结构中也会为每个CPU再维护一个CFS运行队列,所以CFS调度器使用cfs_rq跟踪就绪队列信息以及管理就绪态调度实体,并维护一棵按照虚拟时间排序的红黑树
struct task_group:组调度,Linux支持将任务分组来对CPU资源进行分配管理,该结构中为系统中的每个CPU都分配了struct sched_entity调度实体和struct cfs_rq运行队列,其中struct sched_entity用于参与CFS的调度

2 调度器类

对于CPU资源,本身数量是有限的,而在系统中运行状态的进程数量有很多,就设计到资源的调度,此时就需要设计一种方法,尽可能的保证这种资源被公平的分配到进程中,就涉及到以下的概念

核心调度器:对外提供周期性调度(定时触发)以及主调度器
就绪队列:当所有当前运行的进程都在这个队列中维护,需要选择下一个执行的进程
调度优先级:给不同的进程不同的优先级,这样分配到的实际运行时间片不一样
调度算法:不同类型的进程使用不同的调度算法来选择执行的jinchen
进程调度,就是来管理系统中所有的进程,调度器的工作方式与这些结构的设计密切相关,几个组件在许多方面彼此交互,其如下图所示

Linux进程调度 - CFS调度器 LoyenWang_第16张图片

**激活进程调度(Scheduler Core)层:**可以用两种方式激活调度,一种是直接的,比如进程打算睡眠或者其他原因放弃CPU;另外一种是通过周期性的机制,以固定的频率运行,不时检测是否有必要进行进程切换,这个在**进程管理(二十)----进程调度器有介绍
**上下文切换:**当进程A切换到进程B的时候,如何能正常切换回去,需要保存当时的现场,包含用户空间的页表、用户空间的栈和硬件上下文信息,[进程管理(十九)----linux内核进程上下文(二)]((1条消息) 进程管理(十九)----linux内核进程上下文(二)_奇小葩-CSDN博客)这个有介绍
**调度器类:**当调度器被选用时,它会查询调度器类,从中获取接下来运行哪个进程,同时在选中将要运行的进程之后,必须执行底层的任务切换,就需要跟CPU的紧密交互,这里面会包含很多的调度器类,例如我们熟悉的CFS,实时调度类等
调度器类提供了通用调度方法和各个调度方法之间的关联,调度器类由特定的数据结构中汇集的几个函数指针表示,如下图所示

Linux进程调度 - CFS调度器 LoyenWang_第17张图片

每次调用调度器时,它会挑选具有最高优先级的进程,把CPU提供给该进程,调度器通过将进程在红黑树中排序,跟踪进程的等待时间。

Linux进程调度 - CFS调度器 LoyenWang_第18张图片

所有可运行的进程按照时间在一个红黑树中排序,所谓时间为的等待时间,等待CPU时间最长的进程是最左侧的项,调度器下一次调度会考虑该进程。

**就绪队列:**用于管理活动进程的主要数据结构,各个CPU都有自身的就绪队列,各个活动进程只出现在一个就绪队列中,在各个CPU上同上运行一个进程是不可能的。就绪队列是全局调度器操作的起点,进程不是直接由就绪队列的成员直接管理,还是由CPU的调度类负责,因此各个就绪队列嵌入了特定于调度类的子就绪队列。
**调度类实体:**每个task_struct都嵌入到sched_entity的一个实体中
对于这个软件实现框图如下:

Linux进程调度 - CFS调度器 LoyenWang_第19张图片

内核默认提供了5个调度器,Linux内核使用struct sched_class来对调度器进行抽象:

Stop调度器, stop_sched_class:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占;
Deadline调度器, dl_sched_class:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行;
RT调度器, rt_sched_class:实时调度器,为每个优先级维护一个队列;
CFS调度器, cfs_sched_class:完全公平调度器,采用完全公平调度算法,引入虚拟运行时间概念;
IDLE-Task调度器, idle_sched_class:空闲调度器,每个CPU都会有一个idle线程,当没有其他进程可以调度时,调度运行idle线程;
他们之间的关系如下表

调度器类    描述    调度策略    调度实体    优先级    说明
stop_sched_class                最高优先级,比deadline的优先级更高    1 在每个CPU上实现一个名为”migration/N”内核线程,该内核线程优先级最高,可以抢占任何进程
2 一般用于负载均衡机制中的进程迁移,softlockup检测,CPU热插拔,RCU等                    
dl_sched_class    deadline调度器    SCHED_DEADLINE    sched_dl_entity    最高优先级的实时进程,优先级为-1    用于调度有严格要求的实时进程,如视频编解码等
rt_sched_class    实时调度器    SCHED_FIFO            
SCHED_RR    sched_rt_entity    普通实时进程,优先级为0~99    用于普通的实时进程,如IRQ线程化        
fair_sched_class    完全公平调度器    SCHED_NORMAL            
SCHED_BATCH    sched_entity    普通进程,优先级为100~139    CFS调度        
idle_sched_class    idle调度器    SCHED_IDLE        最低优先级    当就绪队列中没有其他进程时进入idle调度类,idle调度类会让CPU进入低功耗模式
不同的调度器算法,无论内部如何实现,其最终都是从就绪队列中选择下一个可执行的进程来运行。 在这个版本的内核中一共实现了如下几种调度器算法,它们统一由结构体sched_class来表示

Linux进程调度 - CFS调度器 LoyenWang_第20张图片

Linux内核提供了一些调度策略供用户程序来选择调度器,其中Stop调度器和IDLE调度器,仅由内核使用,用户无法进行选择:

SCHED_DEADLINE:限期进程调度策略,使task选择Deadline调度器来调度运行;
SCHED_RR:实时进程调度策略,时间片轮转,进程用完时间片后加入优先级对应运行队列的尾部,把CPU让给同优先级的其他进程;
SCHED_FIFO:实时进程调度策略,先进先出调度没有时间片,没有更高优先级的情况下,只能等待主动让出CPU;
SCHED_NORMAL:普通进程调度策略,使task选择CFS调度器来调度运行;
SCHED_BATCH:普通进程调度策略,批量处理,使task选择CFS调度器来调度运行;
SCHED_IDLE:普通进程调度策略,使task以最低优先级选择CFS调度器来调度运行;

3 核心调度器

核心调度器指的是内核的进程调度框架,由内核来触发调度进程的时机,而如何选择进程的工作,交予调度器类来实现,主要分为周期性和主调度器,我们主要来看概况内容

周期性调度器

周期性调度器的入口函数是scheduler_tick,内核会按照系统频率HZ来自动调用该函数,其主要内容如下:

// kernel/sched/core.c
void scheduler_tick(void)
{
    int cpu = smp_processor_id();
    struct rq *rq = cpu_rq(cpu);
    struct task_struct *curr = rq->curr;
    // 调用调度类对应的task_tick方法,针对CFS调度类该函数是task_tick_fair。
    curr->sched_class->task_tick(rq, curr, 0);
}
可以看到,周期性调度器通过调度器算法的task_tick函数来完成调度工作,后面学习CFS算法时详细分析

主调度器

主调度器的入口函数是schedule,在内核中,当需要将CPU分配给与当前进程不同的另一个进程时,就会调用schedule函数来选择下一个可执行进程。schedule函数最终调用的是__schedule

// kernel/sched/core.c
static void __sched notrace __schedule(bool preempt)
{
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    struct rq_flags rf;
    struct rq *rq;
    int cpu;
    cpu = smp_processor_id();
    rq = cpu_rq(cpu);
    prev = rq->curr;
    switch_count = &prev->nivcsw;
    if (!preempt && prev->state) {
        //1. 如果当前进程处于可中断的睡眠状态,同时现在接收到了信号,那么将再次被提升为可运行进程
        if (unlikely(signal_pending_state(prev->state, prev))) {
            prev->state = TASK_RUNNING;    /* 1 */
        } else {
            //2. 调用deactivate_task函数将当前进程变成不活跃状态
            deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);    /* 2 */
            prev->on_rq = 0;
    }
    //3. 调用pick_next_task函数选择下一个执行的进程
    next = pick_next_task(rq, prev, &rf);    
    //4. 清除当前进程的TIF_NEED_RESCHED标志位,意味着不需要重调度
    clear_tsk_need_resched(prev);    
    clear_preempt_need_resched();
    if (likely(prev != next)) {
        //5. 如果下一个被调度执行进程不是当前进程,调用context_switch函数进行进程上下文切换
        rq = context_switch(rq, prev, next, &rf);    
    }
}

其主要内容就是从调度类中选择下一个进程进行调度,主要是pick_next_task,后面CFS调度器中详细学习

与fork的交互

除了以上两种场景,即周期性调度器以及主调度器之外,fork创建出新进程的时候也会出现与调度器类的交互,其入口函数是sched_fork:

// kernel/sched/core.c
int sched_fork(unsigned long clone_flags, struct task_struct *p)

    if (p->sched_class->task_fork)
        p->sched_class->task_fork(p);
}
sched_fork函数中会调用到对应调度器类的task_fork成员函数来处理,下面讲到CFS调度器的时候再详细分析对应的函数。

4 CFS调度器

了解了优先级、虚拟运行时间、相关数据结构、内核调度框架之后,下面正式进入CFS调度器的学习,CFS调度器内部维护一颗红黑树,红黑树的键值即为进程的虚拟运行时间,虚拟运行时间越小的进程,被调度执行的优先级越高,获得更多的CPU时间。

Linux进程调度 - CFS调度器 LoyenWang_第21张图片

对比两个调度实体在红黑树中的先后顺序,也就只需要对比其中的虚拟运行时间即可

// kernel/sched/fair.c
static inline int entity_before(struct sched_entity *a,
                struct sched_entity *b)
{
    return (s64)(a->vruntime - b->vruntime) < 0;
}
一个进程在刚刚加入到CFS就绪队列的红黑树中时,需要有一个基准值,即这个新加入的进程,应该和什么虚拟运行时间进行对比,找到它在红黑树中的合适位置。这个值由就绪队列中的最小虚拟运行时间来维护,对应的成员是min_vruntime

还需要注意的一点是,既然虚拟运行时间是一直累加的,那么在进程一直运行的情况下,就可能发生数据溢出现象,因此在对比两个虚拟运行时间大小的时候,不是直接比较而是判断的两者的差值(包括上面的entity_before函数也是比较的差值):

// kernel/sched/fair.c
static inline u64 max_vruntime(u64 max_vruntime, u64 vruntime)
{
    s64 delta = (s64)(vruntime - max_vruntime);
    if (delta > 0)
        max_vruntime = vruntime;
    return max_vruntime;
}
那么最小虚拟运行时间的更新?

由于更新进程调度实体以及就绪队列的虚拟运行时间的操作如此重要,所以内核中有一个专门的update_curr函数完成这个工作:

Linux进程调度 - CFS调度器 LoyenWang_第22张图片

其中最后一步是更新CFS就绪队列的最小虚拟运行时间值,来看对应的update_min_vruntime函数

Linux进程调度 - CFS调度器 LoyenWang_第23张图片

Linux进程调度 - CFS调度器 LoyenWang_第24张图片

vruntime值越小的节点,说明虚拟运行时间越少,对应的当前被调度的优先级就越往前,会被更快的调度来执行。

在进程运行的时候,vruntime值稳定增加,于是在红黑树中就是向右边移动。
进程在睡眠时,vruntime值保持不变,而每个队列的min_vruntime时间在增加,那么当睡眠进程被唤醒时(比如等待IO事件),其在红黑树中的位置就靠左,因为其键值变小了,于是会被更快的执行。
place_entity函数属于CFS调度器算法内部使用的一个函数,其作用是调整进程调度实体的虚拟运行时间,传入的第三个参数initial为1的情况下表示是新创建的进程,否则是被唤醒的进程。

Linux进程调度 - CFS调度器 LoyenWang_第25张图片

4.1 将一个任务插入运行队列
进程的创建是通过do_fork()函数完成。新进程的诞生,我们调度核心层会通知调度类,调用特别的接口函数初始化新生儿。我们一路尾随do_fork()函数。do_fork()---->_do_fork()---->copy_process()---->sched_fork()

Linux进程调度 - CFS调度器 LoyenWang_第26张图片

可以看出sched_fork()进行的初始化也比较简单,需要注意的是不同类型的进程会使用不同的调度类,并且也会调用调度类中的初始化函数。在实时进程的调度类中是没有特定的task_fork()函数的,而普通进程使用cfs策略时会调用到task_fork_fair()函数,我们具体看看实现:

Linux进程调度 - CFS调度器 LoyenWang_第27张图片

task_fork_fair函数所完成计算并更新虚拟时间vruntime,到这里新进程关于调度的初始化已经完成,但是还没有被调度器加入到队列中,其是在do_fork()中的wake_up_new_task§;中加入到队列中的,我们具体看看wake_up_new_task()的实现

Linux进程调度 - CFS调度器 LoyenWang_第28张图片

CFS调度类对应的enqueue_task方法函数是enqueue_task_fair()

Linux进程调度 - CFS调度器 LoyenWang_第29张图片

enqueue_task_fair的执行流程如下:

如果通过struct sched_entity的on_rq成员判断进程已经在就绪队列上, 则无事可做.
否则, 具体的工作委托给enqueue_entity完成,其中内核会借机用update_curr更新统计量


enqueue_entity完成了进程真正的入队操作, 其具体流程如下所示

Linux进程调度 - CFS调度器 LoyenWang_第30张图片

更新一些统计统计量, update_curr, update_cfs_shares等
如果进程此前是在睡眠状态, 则调用place_entity中首先会调整进程的虚拟运行时间
最后如果进程最近在运行, 其虚拟运行时间仍然有效, 那么则直接用__enqueue_entity加入到红黑树

Linux进程调度 - CFS调度器 LoyenWang_第31张图片
5 选择下一个进程
调度器schedule函数在进程调度抢占时, 会通过__schedule函数调用全局pick_next_task选择一个最优的进程, 在pick_next_task中我们就按照优先级依次调用不同调度器类提供的pick_next_task方法Linux进程调度 - CFS调度器 LoyenWang_第32张图片

每个调度器类sched_class都必须提供一个pick_next_task函数用以在就绪队列中选择一个最优的进程来等待调度, 而我们的CFS调度器类中, 选择下一个将要运行的进程由pick_next_task_fair函数来完成

Linux进程调度 - CFS调度器 LoyenWang_第33张图片
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct pin_cookie cookie)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    struct task_struct *p;
    int new_tasks;
/* 控制循环来读取最优进程 */
again:
/* 完成组调度下的pick_next选择,返回被选择的调度时实体的指针 */
#ifdef CONFIG_FAIR_GROUP_SCHED
    if (!cfs_rq->nr_running)
        goto idle;
    /* 如果当前进程不是cfs调度器,就用simple的通用调度切换方法 */
    if (prev->sched_class != &fair_sched_class)
        goto simple;

    /*
     * Because of the set_next_buddy() in dequeue_task_fair() it is rather
     * likely that a next task is from the same cgroup as the current.
     *
     * Therefore attempt to avoid putting and setting the entire cgroup
     * hierarchy, only change the part that actually changes.
     */
    /* 这个do while主要是找到最应该被调度的entity,即找到下一个task实体, 
    这里是存在多个cfs_rq的情况 */
    do {
        struct sched_entity *curr = cfs_rq->curr;

        /*
         * Since we got here without doing put_prev_entity() we also
         * have to consider cfs_rq->curr. If it is still a runnable
         * entity, update_curr() will update its vruntime, otherwise
         * forget we've ever seen it.
         */
        if (curr) {
            if (curr->on_rq)    /* 如果当前cfs_rq的task在队列上,更新虚拟时钟 */
                update_curr(cfs_rq);
            else
                curr = NULL;

            /*
             * This call to check_cfs_rq_runtime() will do the
             * throttle and dequeue its entity in the parent(s).
             * Therefore the 'simple' nr_running test will indeed
             * be correct.
             */
             /* 返回true,表示当前cfs已经超出运行时间,不能再进行组内调度*/
            if (unlikely(check_cfs_rq_runtime(cfs_rq)))
                goto simple;
        }
        /* 从cfs_rq获取虚拟时钟最小或者最应该参与调度的task */
        se = pick_next_entity(cfs_rq, curr);
        cfs_rq = group_cfs_rq(se); /* 如果是调度组,会继续while,非调度组的group_cfs_rq返回NULL*/
    } while (cfs_rq);
    /* 获取到调度实体指代的进程信息 */
    p = task_of(se);

    /*
     * Since we haven't yet done put_prev_entity and if the selected task
     * is a different task than we started out with, try and touch the
     * least amount of cfs_rqs.
     */ /* 这里的se就是上面组调度里面,找到的最合适参与调度的task*/
    if (prev != p) { /*这个prev != p里面,主要是更新prev和p相关的组(cfs_cq)内成员的虚拟时钟*/
        struct sched_entity *pse = &prev->se;

        while (!(cfs_rq = is_same_group(se, pse))) {
            int se_depth = se->depth;
            int pse_depth = pse->depth;
            /* 表明pre所在的调度组 深度更深,需要更新prev所在的组的虚拟时钟*/
            if (se_depth <= pse_depth) {
                put_prev_entity(cfs_rq_of(pse), pse);
                pse = parent_entity(pse);
            }
            /* 表明curr所在的调度组更深,需要将curr所在调度组一连串设置成对应cfs_rq的curr*/
            if (se_depth >= pse_depth) {
                set_next_entity(cfs_rq_of(se), se);
                se = parent_entity(se);
            }
        }

        put_prev_entity(cfs_rq, pse);
        set_next_entity(cfs_rq, se);
    }

    if (hrtick_enabled(rq))
        hrtick_start_fair(rq, p);

    return p;
/* 最基础的pick_next函数, 返回被选择的调度时实体的指针*/
simple:
    cfs_rq = &rq->cfs;
#endif
    /* 如果nr_running计数器为0,则当前队列没有可运行的进程,调度idle调度类 */
    if (!cfs_rq->nr_running)
        goto idle;
    /* 将当前进程放入运行队列的合适位置 */
    put_prev_task(rq, prev);
    /* 在没有配置组调度选项(CONFIG_FAIR_GROUP_SCHED)的情况下.group_cfs_rq()返回NULL.
   因此,上函数中的循环只会循环一次*/
    do { /* 选出下一个可执行调度实体(进程) */
        se = pick_next_entity(cfs_rq, NULL);
        /* set_next_entity会调用__dequeue_entity完成此工作 */
        set_next_entity(cfs_rq, se); /*把选中的进程从红黑树移除,更新红黑树*/
        cfs_rq = group_cfs_rq(se); /* 在非组调度情况下, group_cfs_rq返回了NULL*/
    } while (cfs_rq);
    
    p = task_of(se);

    if (hrtick_enabled(rq))
        hrtick_start_fair(rq, p);

    return p;
/* 4. 如果系统中没有可运行的进行, 则需要调度idle进程 */
idle:
    /*
     * This is OK, because current is on_cpu, which avoids it being picked
     * for load-balance and preemption/IRQs are still disabled avoiding
     * further scheduler activity on it and we're being very careful to
     * re-start the picking loop.
     */
    lockdep_unpin_lock(&rq->lock, cookie);
    new_tasks = idle_balance(rq);
    lockdep_repin_lock(&rq->lock, cookie);
    /*
     * Because idle_balance() releases (and re-acquires) rq->lock, it is
     * possible for any higher priority task to appear. In that case we
     * must re-start the pick_next_entity() loop.
     */
    if (new_tasks < 0)
        return RETRY_TASK;

    if (new_tasks > 0)
        goto again;

    return NULL;
}
again标签用于循环的进行pick_next操作
CONFIG_FAIR_GROUP_SCHED宏指定了组调度情况下的pick_next操作, 如果不支持组调度, 则pick_next_task_fair将直接从simple开始执行
simple标签是CFS中最基础的pick_next操作
idle则使得在没有进程被调度时, 调度idle进程
pick_next_entity的函数为选择最佳task的关键算法,实现如下

static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    struct sched_entity *left = __pick_first_entity(cfs_rq);
    struct sched_entity *se;

    /*
     * If curr is set we have to see if its left of the leftmost entity
     * still in the tree, provided there was anything in the tree at all.
     */
  /* left是红黑树里面,拥有最小虚拟时钟的task 
        如果left为NULL或者curr的虚拟时钟比当前最小的left还要小
        那么left就设置成curr,即curr还可以继续获得CPU,不进行任务切换
    */
    if (!left || (curr && entity_before(curr, left)))
        left = curr;
    /* 这个se不是指向curr进程,就是指向红黑树的最小虚拟时钟进程 */
    se = left; /* ideally we run the leftmost entity */

    /*
     * Avoid running the skip buddy, if running something else can
     * be done without getting too unfair.
     */
   /* 发现se指向的task被设置成不参与调度*/
    if (cfs_rq->skip == se) {
        struct sched_entity *second;
     /* 如果这个不参与调度的task就是当前进程,那么就取红黑树的第一个实体 */
        if (se == curr) {
            second = __pick_first_entity(cfs_rq);
        } else {
        /* 个不参与调度的task不是curr进程,那说明这个task是红黑树的最小虚拟时钟task,因此,
        需要从红黑树中找到一个次小的虚拟时钟task*/
            second = __pick_next_entity(se);
            /* 判断这个次小task是否能抢占curr,如果不能,second就还是变为curr*/
            if (!second || (curr && entity_before(curr, second)))
                second = curr;
        }
        /* 判读这个新的task是否能抢占left,如果能,se就为它,
这里的对比条件并没有最开始的严格,这里允许一个最小调度差值*/
        if (second && wakeup_preempt_entity(second, left) < 1)
            se = second;
    }

    /*
     * Prefer last buddy, try to return the CPU to a preempted task.
     */
    /* 发现设置了last值,则优先调度last指向的task*/
    if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
        se = cfs_rq->last;

    /*
     * Someone really wants this to run. If it's not unfair, run it.
     */
    /* 发现设置了last值,则优先调度last指向的task */
    if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
        se = cfs_rq->next;
    /* 清除task的next或last值,以免下次调度的时候,
    又把last和next加入判断,导致不公平*/
    clear_buddies(cfs_rq, se);

    return se;
}
pick_next_entity则从CFS的红黑树中摘取一个最优的进程, 这个进程往往在红黑树的最左端, 即vruntime最小, 但是也有例外, 但是不外乎这几个进程

调用__pick_first_entity函数,取红黑树最左子节点的调度实体,根据我们前面的分析,这是目前虚拟运行时间最小的进程。
如果left为NULL,或者当前进程curr的虚拟运行时间比left节点更小,说明curr进程是主动放弃了执行权力,且其优先级比最左子节点的进程更优,此时将left指向curr。
此时left存储的是目前最优的调度实体指针,se保存下来。
cfs_rq->skip存储了需要调过不参与调度的进程调度实体,如果我们挑选出来的最优调度实体se正好是skip,就需要重新作出选择。
如果前面选择的se指针,正好是当前进程,这样就重新__pick_first_entity拿到当前红黑树的最左子节点。
否则,skip = se = left的情况,调用__pick_next_entity选择se的下一个子节点。
如果second == NULL,说明没有次优的进程,或者curr不为NULL的情况下,且curr进程比second进程更优,就将second指向curr,即curr是最优的进程。
在second不为NULL,且left和second的vruntime的差距是否小于sysctl_sched_wakeup_granularity的情况下,那么second进程能抢占left的执行权。
判断上一个执行的进程能否抢占left的执行权。
判断next的执行权能否抢占left的执行权。
set_next_entity函数用于将调度实体存放的进程做为下一个可执行进程的信息保存下来

static void
set_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    /* 针对即将运行的进程,我们都会从红黑树中删除当前进程*/
    if (se->on_rq) {   /* 如果se尚在rq队列上 */
        /*
         * Any task has to be enqueued before it get to execute on
         * a CPU. So account for the time it spent waiting on the
         * runqueue.
         */
        update_stats_wait_end(cfs_rq, se);
        __dequeue_entity(cfs_rq, se);   /*将se从cfs_rq的红黑树中删除*/
        update_load_avg(se, 1);  /* 更新进程的负载信息。负载均衡会使用*/
    }
   /* 更新调度实体exec_start成员,为update_curr()函数统计时间做准备*/
    update_stats_curr_start(cfs_rq, se); /* 更新就绪队列curr成员 */
    cfs_rq->curr = se;

    /*
     * Track our maximum slice length, if the CPU's load is at
     * least twice that of our own weight (i.e. dont track it
     * when there are only lesser-weight tasks around):
     */
    if (schedstat_enabled() && rq_of(cfs_rq)->load.weight >= 2*se->load.weight) {
        schedstat_set(se->statistics.slice_max,
            max((u64)schedstat_val(se->statistics.slice_max),
                se->sum_exec_runtime - se->prev_sum_exec_runtime));
    }
    /* 更新task上一次投入运行的从时间 */
    se->prev_sum_exec_runtime = se->sum_exec_runtime;
}

6 从运行队列中移除一个任务

Linux进程调度 - CFS调度器 LoyenWang_第34张图片
7 总结
CFS给每个进程安排一个虚拟运行时间vruntime,正在运行的进程vruntime随tick不断增大,没有运行的进程vruntime不变,vruntime小的会被优先运行
对于不同优先级的进程,换算vruntime时优先级高的算少,优先级低的算多,这样优先级高的进程实际运行时间就变多了
调度队列使用红黑树,红黑树的节点是调度实体

Linux进程调度 - CFS调度器 LoyenWang_第35张图片

CFS的队列是一棵红黑树,红黑树的节点是调度实体,每个调度实体都属于一个task_struct,task_struct里面有指针指向这个进程属于哪个调度类
CPU需要找下一个任务执行时,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然rt_sched_class先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务

你可能感兴趣的:(Linux内核-进程调度,linux,cfs,进程调度)