Bootstrap

Linux-5.9.10内核调度器分析(一)

Linux的调度分析

参考:
《深入理解linux内核架构》
《公众号LoyenWang》
《一些网络博客》

一些问题

早期Linux内核调度器O(1)和O(N)是如何工作?

进程priority和nice值和权重weight之间的关系?

CFS中vruntime如何计算并何时更新

简述CFS工作机制

CFS中min_vruntime有何作用?

CFS also maintains the rq->cfs.min_vruntime value, which is a monotonic increasing value tracking the smallest vruntime among all tasks in the runqueue. The total amount of work done by the system is tracked using min_vruntime; that value is used to place newly activated entities on the left side of the tree as much as possible

min_vruntime是单调递增的数值,用于记录cfs的红黑树中所有任务中最小的vruntime。作用是尽量将新激活的任务排到红黑树的左边。

CFS对新建进程和刚唤醒的进程如何处理?

如何计算普通进程的平均负载?

一、就绪队列

内核为每个CPU创建一个进程就绪队列,该队列上的进程均有该CPU执行。

per-cpu变量在每个CPU上都有一个副本,对它的访问几乎不需要锁,因为每个CPU都在自己的副本上工作。

/*在 kernel/sched/core.c中定义*/
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

/* 宏定义展开在include/linux/percpu-defs.h 
 * 静态分配per_cpu数组,数组名为name,结构类型为type
 */
#define DEFINE_PER_CPU_SHARED_ALIGNED(type, name)			\
	DEFINE_PER_CPU_SECTION(type, name, PER_CPU_SHARED_ALIGNED_SECTION) \
	____cacheline_aligned_in_smp

每个数组元素就是一个就绪队列,对应一个cpu。

/*
 * This is the main, per-CPU runqueue data structure.
 */
struct rq {
	[...代码省略...]

	struct cfs_rq		cfs;                /* 嵌入普通进程的cfs调度队列 */
	struct rt_rq		rt;					/* 实时进程的实时调度策略调度队列 */
	struct dl_rq		dl;					/* 空闲进程的调度队列 */

	[...代码省略...]
}
1.0 CPU负载均衡
1.0.1 什么是CPU负载(load)

CPU负载是一个很容易和CPU利用率(utility)混淆的概念。CPU利用率是CPU忙闲的比例,例如在一个周期为1000ms的窗口中观察CPU的情况,如果500ms的时间在执行任务,500ms的时间处于idle状态,那么在这个窗口中CPU的利用率是50%。

在CPU利用率没有达到100%的时候,利用率基本上等于负载,一旦当CPU利用率达到了100%的时候,利用率其实是无法给出CPU负载的状况,因为大家的利用率都是100%,利用率相等,但是并不意味着CPUs的负载也是相等的,因为这时候不同CPU上runqueue中等待执行的任务数目不同,直觉上runque上挂着10任务的CPU承压比挂着5个任务的CPU的负载要更重一些。因此,早期的CPU负载是使用runqueue深度来描述的。

1.1 普通进程cfs就绪队列

cfs_rq和rt_rq以及dl_rq都定义在kernel/sched/sched.h中。

cfs_rq成员描述
load运行队列所有调度实体总负载。se入队时update_load_add增加负载;se出队时通过update_load_sub减去负载
nr_runningCFS运行队列调度实体数量,se入队时加1,se出队时减1
h_nr_runningh指hierarchy。在支持组调度机制时,该值表示CFS就绪队列里中包含组调度里所有可运行状态的进程数量
idle_h_nr_running记录idle调度实体数量
exec_clock统计就绪队列的总运行时间
min_vruntime记录 CFS运行队列红黑树中最小的vruntime值,保持单步递增。该值非常重要,在对唤醒进程和fork进程vruntime做补偿时使用
tasks_timelineCFS运行队列红黑树根,所有调度实体都依据se->vruntime(红黑树的KEY)大小加入到红黑树接受调度
curr记录CFS运行队列中当前正在运行的se
next记录CFS运行队列中急需运行的se,wakeup唤醒进程时可能将被唤醒的进程赋值给next。pick_next_entity时会优先选择cfs_rq->next
last记录CFS运行队列中当前运行的se,与curr不同,curr一定会记录当前运行se,而last只会记录执行wakeup操作的se。pick_next_entity时会次优先选择cfs_rq->last。这样有利于重复利用cache。就是当唤醒进程抢占了当前进程时,last指向这个当前进程
skip记录CFS运行队列中跳过运行的se,系统调用sched_yield会将当前实体赋值到cfs_rq->skip。pick_next_entity时如发现选择的se是cfs_rq->skip时,会重新选择se
avg基于PELT算法的负载计算值
/* CFS-related fields in a runqueue */
struct cfs_rq {
	struct load_weight	load;						/* load维护了所有这些进程的累积负荷值 */
	unsigned int		nr_running;          		/* nr_running计算了队列上可运行进程的数目 */
	unsigned int		h_nr_running;      			/* SCHED_{NORMAL,BATCH,IDLE} */
	unsigned int		idle_h_nr_running; 			/* SCHED_IDLE */

	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;						/* curr指向当前正运行的实体 */
	struct sched_entity	*next;						/* next指向将被唤醒的进程 */
	struct sched_entity	*last;						/* last指向唤醒next进程的进程 */
	struct sched_entity	*skip;

	[...代码省略...]
};

其中rb_root_cached包含了两个成员:

一个是红黑树根rb_root

另一个是rb_node,其总是设置为指向树最左边的结点,即最需要被调度的进程;

/*
 * Leftmost-cached rbtrees.
 *
 * We do not cache the rightmost node based on footprint
 * size vs number of potential users that could benefit
 * from O(1) rb_last(). Just not worth it, users that want
 * this feature can always implement the logic explicitly.
 * Furthermore, users that want to cache both pointers may
 * find it a bit asymmetric, but that's ok.
 */
struct rb_root_cached {
	struct rb_root rb_root;
	struct rb_node *rb_leftmost;
};
1.2 实时进程rt就绪队列
/* Real-Time classes' related field in a runqueue: */
struct rt_rq {
	struct rt_prio_array	active;
	unsigned int		rt_nr_running;
	unsigned int		rr_nr_running;
#if defined CONFIG_SMP || defined CONFIG_RT_GROUP_SCHED
	struct {
		int		curr; /* highest queued rt task prio */
#ifdef CONFIG_SMP
		int		next; /* next highest */
#endif
	} highest_prio;
#endif
#ifdef CONFIG_SMP
	unsigned long		rt_nr_migratory;
	unsigned long		rt_nr_total;
	int			overloaded;
	struct plist_head	pushable_tasks;

#endif /* CONFIG_SMP */
	int			rt_queued;

	int			rt_throttled;
	u64			rt_time;
	u64			rt_runtime;
	/* Nests inside the rq lock: */
	raw_spinlock_t		rt_runtime_lock;

#ifdef CONFIG_RT_GROUP_SCHED
	unsigned long		rt_nr_boosted;

	struct rq		*rq;
	struct task_group	*tg;
#endif
};

二、调度实体

2.1 普通进程调度实体–CFS

由于调度器可以操作比进程更一般的实体,因此需要一个适当的数据结构来描述此类实体

struct sched_entity {
	/* For load-balancing: */
	struct load_weight		load;						/* 用于负载均衡 */
	struct rb_node			run_node;					/* run_node是标准的树结点,使得实体可以在红黑树上排序 */
	struct list_head		group_node;
	unsigned int			on_rq;						/* on_rq表示该实体当前是否在就绪队列上接受调度 */

	u64				exec_start;
	u64				sum_exec_runtime;
	u64				vruntime;							/* 在进程执行期间虚拟时钟上流逝的时间数量由vruntime统计 */
	u64				prev_sum_exec_runtime;

	u64				nr_migrations;

	struct sched_statistics		statistics;

#ifdef CONFIG_FAIR_GROUP_SCHED
	int				depth;
	struct sched_entity		*parent;
	/* rq on which this entity is (to be) queued: */
	struct cfs_rq			*cfs_rq;
	/* rq "owned" by this entity/group: */
	struct cfs_rq			*my_q;
	/* cached value of my_q->h_nr_running */
	unsigned long			runnable_weight;
#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;
#endif
};

struct sched_entity该结构体有两个作用:

(1) 包含有进程调度的信息(比如进程的运行时间,睡眠时间等等,调度程序参考这些信息决定是否调度进程)

(2) 使用该结构体来组织进程

  • struct load_weight load 指定了权重,决定了各个实体占队列总负荷的比例。计算负荷权重是调度器的一项重任,因为CFS所需的虚拟时钟的速度最终依赖于负荷。
  • run_node是红黑树节点,因此struct sched_entity调度实体将被组织成红黑树的形式,同时意味着普通进程也被组织成红黑树的形式。
  • 在进程运行时,我们需要记录消耗的CPU时间,以用于完全公平调度器。sum_exec_runtime即用于该目的。跟踪运行时间是由update_curr不断累积完成的。调度器中许多地方都会调用该函数,例如,新进程加入就绪队列时,或者周期性调度器中。每次调用时,会计算当前时间和exec_start之间的差值,exec_start则更新到当前时间。差值则被加到sum_exec_runtime
  • 在进程被撤销CPU时,其当前sum_exec_runtime值保存到prev_exec_runtime。此后,在进程抢占时又需要该数据。但请注意,在prev_exec_runtime中保存sum_exec_runtime的值,并不意味着重置sum_exec_runtime!原值保存下来,而um_exec_runtime则持续单调增长

sched_entity结构体的重要数据成员有:

membertypedescription
loadstruct load_weight调度实体的权重
runable_weightunsigned long进程在可运行(runnable)状态的权重,这个值等于进程的权重
run_nodestruct rb_node调度实体作为一个节点插入CFS的红黑树里面
group_nodestruct list_head在就绪队列rq的结构体里有一个链表rq->cfs_tasks,CFS调度实体添加到rq后会加到该链表中
on_rqunsigned int进程进入就绪队列,on_rq会被置1(调用enqueue_task()时)
进程退出就绪队列,on_rq被清0(调用dequeue_task()时)
exec_startu64计算调度实体虚拟时间的起始时间
sum_exec_runtimeu64调度实体的总运行时间,这是真实时间
vruntimeu64调度实体的虚拟时间
prev_sum_exec_runtimeu64上一次统计调度实体运行的总时间
nr_migrationsu64该调度实体发生迁移的次数
statisticsstruct sched_statistics统计信息
avgstruct sched_avg负载相关的信息
2.2 实时进程调度实体–rt

用于组织实时进程的调度。

struct sched_rt_entity {
	struct list_head		run_list;
	unsigned long			timeout;
	unsigned long			watchdog_stamp;
	unsigned int			time_slice;
	unsigned short			on_rq;
	unsigned short			on_list;

	struct sched_rt_entity		*back;
#ifdef CONFIG_RT_GROUP_SCHED
	struct sched_rt_entity		*parent;
	/* rq on which this entity is (to be) queued: */
	struct rt_rq			*rt_rq;
	/* rq "owned" by this entity/group: */
	struct rt_rq			*my_q;
#endif
} __randomize_layout;

struct list_head run_list表明rt_entity是由双链表管理,而不是红黑树。

三、调度类

3.1 调度类sched_class

调度类整体关系:

img

kernel/sched/sched.h中声明了调度类。

sched_class中定义了一堆函数指针,指针指向的函数就是调度策略的具体实现,所有和进程调度有关的函数都直接或者间接调用了这些成员函数,来实现进程调度。此外,每个进程描述符中都包含一个指向该结构体类型的指针sched_class,指向了所采用的调度类。

struct sched_class {

#ifdef CONFIG_UCLAMP_TASK
	int uclamp_enabled;
#endif

	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 (*yield_task)   (struct rq *rq);
	bool (*yield_to_task)(struct rq *rq, struct task_struct *p);

	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);

#ifdef CONFIG_SMP
	int (*balance)(struct rq *rq, struct task_struct *prev, struct rq_flags *rf);
	int  (*select_task_rq)(struct task_struct *p, int task_cpu, int sd_flag, int flags);
	void (*migrate_task_rq)(struct task_struct *p, int new_cpu);

	void (*task_woken)(struct rq *this_rq, struct task_struct *task);

	void (*set_cpus_allowed)(struct task_struct *p,
				 const struct cpumask *newmask);

	void (*rq_online)(struct rq *rq);
	void (*rq_offline)(struct rq *rq);
#endif

	void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
	void (*task_fork)(struct task_struct *p);
	void (*task_dead)(struct task_struct *p);

	/*
	 * The switched_from() call is allowed to drop rq->lock, therefore we
	 * cannot assume the switched_from/switched_to pair is serliazed by
	 * rq->lock. They are however serialized by p->pi_lock.
	 */
	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);

	unsigned int (*get_rr_interval)(struct rq *rq,
					struct task_struct *task);

	void (*update_curr)(struct rq *rq);

#define TASK_SET_GROUP		0
#define TASK_MOVE_GROUP		1

#ifdef CONFIG_FAIR_GROUP_SCHED
	void (*task_change_group)(struct task_struct *p, int type);
#endif
} __aligned(STRUCT_ALIGNMENT); /* STRUCT_ALIGN(), vmlinux.lds.h */
3.2 CFS调度策略类

kernel/sched/fair.c中定义并初始化了完全公平调度策略的调度类fair_sched_class

/*
 * All the scheduling class methods:
 */
const struct sched_class fair_sched_class
	__attribute__((section("__fair_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,
	.set_next_task          = set_next_task_fair,

#ifdef CONFIG_SMP
	.balance		= balance_fair,
	.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

	.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

#ifdef CONFIG_UCLAMP_TASK
	.uclamp_enabled		= 1,
#endif
};

四、进程描述符

process control block,PCB表示进程描述符。

task_struct结构分析

task_struct中包含了很多重要的元素,如进程状态、栈内存指针、进程优先级(动态、静态、普通、实时)、进程所属调度类、进程调度实体(普通和实时)、调度策略等等。

image-20210128152153801

task_structinclude/linux/sched.h中声明,值得好好分析一下。其中和调度器相关的成员:

名称类型说明
statevolatile long进程的当前状态-1 unrunnable
0 runnable
>0 stopped
on_cpuint表示进程正处于运行(running)状态0
1
cpuint表示进程正运行在哪个CPU上
wakee_flipsunisgned int用于wake affine特性
wakee_flip_decay_tsunsigned long用于记录上一次wakee_flips的时间
last_wakeestruct task_struct*上一次唤醒的是哪个进程
wake_cpuint进程上一次运行在哪个cpu上
on_rqint用于设置进程的状态0:
1:TASK_ON_RQ_QUEUED进程正在就绪队列运行
2:TASK_ON_RQ_MIGRATING处于迁移过程中的进程,可能不在就绪队列里面
prioint进程动态优先级prio 值越小,表明进程的优先级越高。prio 值的取值范围是 0 ~139
根据调度策略不同,又分两个区间:
(1)实时进程,0~99
(2)非实时进程:100~139
static_prioint进程静态优先级static_prio 值的范围是 100 ~ 139。 static_prio 的值越小,表明进程的静态优先级越高
static_prio = MAX_RT_PRIO + nice +20
normal_prioint基于static_prio和调度策略计算出来的优先级非实时进程,normal_prio 的值就等于静态优先级值 static_prio;
对于实时进程,normal_prio = MAX_RT_PRIO-1 - p->rt_priority
rt_priorityunsigned int实时进程优先级实时优先级(rt_priority)的值越大,意味着进程优先级越高
sched_classconst struct sched_class*调度类进程使用的哪个调度器,有fair、rt、idle等
sestruct sched_entitycfs调度实体
rtstruct sched_rt_entityrt调度实体
dlstruct sched_idle_entityidle调度实体
nr_cpus_allowedint进程允许运行的CPU个数
cpus_allowedcpumask_t进程允许运行的CPU位图
sched_infostruct sched_info调度相关信息
policyunsigned int调度策略

五、调度器

5.1 周期性调度器

周期性调度器在scheduler_tick中实现。如果系统正在活动中,内核会按照频率HZ自动调用该函数。如果没有进程在等待调度,那么在计算机电力供应不足的情况下,也可以关闭该调度器以减少电能消耗。

kernel/sched/core.c中定义

/*
 * This function gets called by the timer code, with HZ frequency.
 * We call it with interrupts disabled.
 */
void scheduler_tick(void)
{
	int cpu = smp_processor_id();								/* 获取当前cpu号 */
	struct rq *rq = cpu_rq(cpu);								/* 获取cpu就绪队列rq(每个cpu都有一个就绪队列) */
	struct task_struct *curr = rq->curr;						/* 从rq中获取当前运行进程的描述符 */
	struct rq_flags rf;
	unsigned long thermal_pressure;								/* 5.7内核后的新特性,CPU热压过高后会限频,但调度器并不知道,所以需要让调度器感知CPU频率被限制住,这样更好的调度任务*/

	arch_scale_freq_tick();
	sched_clock_tick();

	rq_lock(rq, &rf);

	update_rq_clock(rq);				/* 更新就绪队列中的clock和clock_task成员值,代表当前的时间,一般我们会用到clock_task*/
	thermal_pressure = arch_scale_thermal_pressure(cpu_of(rq));
	update_thermal_load_avg(rq_clock_thermal(rq), rq, thermal_pressure);
	curr->sched_class->task_tick(rq, curr, 0);	/*进入当前进程的调度类的task_tick函数中,更新当前进程的时间片,不同调度类的该函数实现不同*/
	calc_global_load_tick(rq);
	psi_task_tick(rq);

	rq_unlock(rq, &rf);

	perf_event_task_tick();

#ifdef CONFIG_SMP
	rq->idle_balance = idle_cpu(cpu);			/* 判断cpu是否空闲 */
	trigger_load_balance(rq);					/* 挂起SCHED_SOFTIRQ软中断函数,去做周期性的负载平衡操作 */
#endif
}

该函数主要作用:

(1) 管理内核中与整个系统和各个进程的调度相关的统计量。其间执行的主要操作是对各种计数器加1,我们对此没什么兴趣。
(2) 激活负责当前进程的调度类的周期性调度方法。

5.2 主调度器

在内核中的许多地方,如果要将CPU分配给与当前活动进程不同的另一个进程,都会直接调用主调度器函数(schedule)。在从系统调用返回之后,内核也会检查当前进程是否设置了重调度标志TIF_NEED_RESCHED,例如,前述的scheduler_tick就会设置该标志。如果是这样,则内核会调用schedule。该函数假定当前活动进程一定会被另一个进程取代。

5.2.1 __sched前缀
/* Attach to any functions which should be ignored in wchan output. */
#define __sched		__attribute__((__section__(".sched.text")))

将相关函数的代码编译之后,放到目标文件的一个特定的段中,即.sched.text中。该信息使得内核在显示栈转储或类似信息时,忽略所有与调度有关的调用。

5.2.2 schedule函数
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)

static __always_inline bool need_resched(void)		/* 该函数用于判断TIF_NEED_RESCHED标志位看是否需要重新调度 */
{
	return unlikely(tif_need_resched());
}

schedule()函数

asmlinkage __visible void __sched schedule(void)
{
	struct task_struct *tsk = current;			/* 获取当前进程的结构体*/

	sched_submit_work(tsk);						/* 防止死锁问题 */
	do {
		preempt_disable();						/* 关闭抢占 */
		__schedule(false);
		sched_preempt_enable_no_resched();	 	/* 开启抢占 */
	} while (need_resched());					/* 如果需要重新调度,则循环? */
	sched_update_worker(tsk);
}
EXPORT_SYMBOL(schedule);

__schedule()函数

/*
 * __schedule()是主要的调度函数.
 * 所谓的主要函数是指推动调度,因此进入该函数的原因有:
 * 1. 明显的阻塞:锁、信号、等待队列等等;
 * 2. TIF_NEED_RESCHED标志被中断和用户空间返回路劲检测到;
 * 3. 唤醒wakeup并没有真正的进入schedule(),唤醒只是将进程加入run-queue
 * The main means of driving the scheduler and thus entering this function are:
 * WARNING: must be called with preemption disabled!  调用时必须禁用抢占
 */
static void __sched notrace __schedule(bool preempt)
{
	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();							/* 获取当前cpu号 */
	rq = cpu_rq(cpu);									/* 获取当前cpu的runqueue */
	prev = rq->curr;									/* 将当前进程的描述符指针保存在prev变量中 */

	[...代码省略...]

	next = pick_next_task(rq, prev, &rf);			/* 将下一个被调度的进程描述符指针存放在next变量中 */
	clear_tsk_need_resched(prev);					/* 清除当前进程的TIF_NEED_RESCHED标志位 */
	clear_preempt_need_resched();					/* 清除PREEMPT_NEED_RESCHED */

	if (likely(prev != next)) {
		rq->nr_switches++;
        
		[...代码省略...]
        
		++*switch_count;

		psi_sched_switch(prev, next, !task_on_rq_queued(prev));

		trace_sched_switch(preempt, prev, next);					/* event事件 sched_switch */

		/* Also unlocks the rq: */
		rq = context_switch(rq, prev, next, &rf); 			/* 当前进程和下一个进程的上下文进行切换*/
	} else {
		rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
		rq_unlock_irq(rq, &rf);
	}

	balance_callback(rq);
}

上下文切换context_switch()

上下文切换一般分为两个,一个是硬件上下文切换(指的是cpu寄存器,要把当前进程使用的寄存器内容保存下来,再把下一个程序的寄存器内容恢复),另一个是切换进程的地址空间(说白了就是程序代码)。进程的地址空间(程序代码)主要保存在进程描述符中struct mm_struct结构体中,因此该函数主要是操作这个结构体。

/*
 * context_switch - switch to the new MM and the new thread's register state.
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
	       struct task_struct *next, struct rq_flags *rf)
{
	prepare_task_switch(rq, prev, next);

	[...代码省略...]

	/* Here we just switch the register state and the stack. */
	switch_to(prev, next, prev);
	barrier();

	return finish_task_switch(prev);
}

switch_to()函数

#define switch_to(prev, next, last)					\
	do {								\
		((last) = __switch_to((prev), (next)));			\
	} while (0)
  • 为何需要三个参数
  • switch_to()函数后面的代码(finish_task_switch(prev))到底是由哪个进程执行的?何时执行的?

image-20210506112342485

如上图所示,switch_to()函数的代码被分成了两部分,A0和A1,这两部分是属于同一个进程的。

假设进程A在CPU0上主动进程进程切换到B,进程A执行了**“代码A0”,然后通过switch_to()函数换到了B,此时CPU0已经切换到了进程B的硬件上下文,而进程A睡眠了,这个时间点上“代码A1”**没有被运行,last参数执行进程A。

既然last是指向进程A的,那么为什么不直接使用switch_to()函数的第一个参数prev呢?

因为在switch_to()执行之前,prev确实指向进程A,可switch_to()运行后,CPU0已经在运行进程B,此时内核栈已经从进程A的切换到进程B的了,读取prev参数变成读取进程B的prev参数了,而不是进程A的,那么它就不一定执行进程A了。switch_to()函数返回的task_struct保留了切换进来的进程,作为last参数。

过一段时间,在某个CPUn上,假设是进程X执行了switch_to(),刚好切换回进程A,此时进程A开始执行**“代码A1”**,就是finish_task_switch(last)了,就是来帮进程X进行清理工作,然后再去继续执行进程A的自己的其余代码。

总结就是:next进程运行它自己的finish_task_switch()函数代码去帮助切换CPU给它的进程last进行收尾工作,通常last等于prev(什么情况下last不等于prev?)。

5.2.3 linux-5.9.10调度器框架

调度类的顺序

/* vmlinux.lds.h文件中定义
 * 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.这里固定死了调度类的优先级顺序,stop > deadline > rt > fair > idle
 */
#define SCHED_DATA				\
	STRUCT_ALIGN();				\
	__begin_sched_classes = .;		\
	*(__idle_sched_class)			\
	*(__fair_sched_class)			\
	*(__rt_sched_class)			\
	*(__dl_sched_class)			\
	*(__stop_sched_class)			\
	__end_sched_classes = .;
/*kernel/sched/sched.h中定义*/
/* Defined in include/asm-generic/vmlinux.lds.h */
extern struct sched_class __begin_sched_classes[];
extern struct sched_class __end_sched_classes[];

#define sched_class_highest (__end_sched_classes - 1)
#define sched_class_lowest  (__begin_sched_classes - 1)  /*此处为何是begin -1 ? 难道不是 +1 ?*/

#define for_class_range(class, _from, _to) \
	for (class = (_from); class != (_to); class--)

#define for_each_class(class) \
	for_class_range(class, sched_class_highest, sched_class_lowest) /*从stop遍历至idle*/

extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;
schedule
pick_next_task
先看下一个任务是否CFS不是在遍历所有调度类

linux5.9.10中调度器的主框架core.c是如何与CFS、实时rt、deadline、idle等具体的调度器实现整合起来的?

/*
 * Scheduling policies
 */
#define SCHED_NORMAL		0
#define SCHED_FIFO		1
#define SCHED_RR		2
#define SCHED_BATCH		3
/* SCHED_ISO: reserved but not implemented yet */   				/*MuQSS使用的是该policy*/
#define SCHED_IDLE		5
#define SCHED_DEADLINE		6

调度器框架和具体调度器代码联系-pick_next_task

5.2.4 sched_class连接调度器框架和调度器

scheduler-sched_class关系

六、CFS

我感觉好多中文书籍翻译的还是不够准确,每次遇到新内容,可以在看过中文书对整体框架有了了解后再去读一下kernel documention原汁原味的英文版。

以下是kernel文档中关于CFS的。

(1)overview

CFS的80%的设计思想可以归纳为一句话:CFS basically models an “ideal, precise multi-tasking CPU” on real hardware。就是想尽可能的公平、理想化的实现多任务处理。

因为在实际物理机上,cpu一次只能执行一个任务,CFS为了实现公平的多任务处理,引入“virtual runtime”的概念,看看官方怎么解释这个virtual runtime的:

The virtual runtime of a task specifies when its next timeslice would start execution on the ideal multi-tasking CPU described above. In practice, the virtual runtime of a task is its actual runtime normalized to the total number of running tasks.

这句话好好理解一下:

一个进程的vruntime是指它在理想化多任务CPU上的下一个时间片(timeslice)的开始执行时间。实际上,vruntime是根据队列中待运行任务的总数而标准化出来的实际运行时间

这里解释的vruntime是个时间戳、时间点,并不是指一段时间。

(2)简要的实现细节

CFS中,每个进程的vruntime是通过它的p->vruntime(单位ns)来跟踪记录的。vruntime可以精确的记录时间戳以及计算一个任务应该获得CPU时间。

Small detail: on “ideal” hardware, at any time all tasks would have the same p->se.vruntime value — i.e., tasks would execute simultaneously and no task would ever get “out of balance” from the “ideal” share of CPU time.
#这里是说,理想的系统下,所有任务的p->vruntime应该相同,也就是多任务应该同时开始处理
CFS’s task picking logic is based on this p->se.vruntime value and it is thus very simple: it always tries to run the task with the smallest p->se.vruntime value (i.e., the task which executed least so far). CFS always tries to split up CPU time between runnable tasks as close to “ideal multitasking hardware” as possible.

CFS选择哪个进程去执行的逻辑:

总是选择p->vruntime值最小的那个进程(vruntime最小,也就是目前为止执行最少的那个进程。<这里改如何理解?vruntime小,执行怎么就少了?难道这里把vruntime理解为执行的时间长度?看到这里,似乎明白了代码update_curr()中为何有curr->vruntime += calc_delta_fair(delta_exec, curr);这里delta_exec简单情况下就是当前时间和开始执行时间的差这个代码,vruntime看来并不只是时间戳,同时也是一段时间?这个理解还有待再验证确认>)

CFS的大部分设计都是围绕这个核心逻辑来设计的,通过一些辅助手段如nice值,多进程处理以及识别睡眠进程的各种算法

(3)红黑树

CFS的设计是激进的,使用时序红黑树,按vruntime为关键之排序。

rq->cfs.min_vruntime是个单调递增的值,用于记录rq中所有任务中最小的vruntime。(**但是看代码,update_min_vruntime()函数中,cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);它是根据cfs->curr是否有来确定该行中vruntime的取值,这里会出现原来的cfs_rq->min_vruntime大于vruntime的情况然后导致cfs_rq->min_vruntime就不需要改变了的情况么?**这里没能理解,为何这里是求max_vruntime而不是求最小值,难道和min_vruntime是个一直累积增加的值有关系?)。

rq->cfs.load是rq上所有任务的权重之和

CFS工作方式:

运行一个任务,当该进程调度时(或者周期性调度tick发生了),该进程占CPU的使用是如何计算的呢:该进程刚刚消耗CPU的时间(一般短小)会增加到p->se.vruntime上,一旦p->se.vruntime增大以至于红黑树的最左子树换成了其他进程(这里要根据到红黑树leftmost的距离加上针对计算密集型进程的最小抢占时间间隔granularity,这个granularity是加在谁的头上)

it runs a task a bit, and when the task schedules (or a scheduler tick happens) the task’s CPU usage is “accounted for”: the (small) time it just spent using the physical CPU is added to p->se.vruntime. Once p->se.vruntime gets high enough so that another task becomes the “leftmost task” of the time-ordered rbtree it maintains (plus a small amount of “granularity” distance relative to the leftmost task so that we do not over-schedule tasks and trash the cache), then the new leftmost task is picked and the current task is preempted.

(4)CFS的一些特点

CFS中没有时间片timeslice之说,为什么呢?

CFS使用纳秒颗粒度进行计算,并不依赖于jiffies或者其他HZ

CFS的nice值、SMP负载均衡等特性。

(5) 调度策略

CFS的调度策略有:SCHED_NORMAL(普通进程),SCHED_BATCH(批处理),SCHED_IDLE(比nice19还要弱)

(6) CFS的组调度

目标比如说首先要让一个OS上的用户尽量平均获得CPU时间,然后又要让该用户的所有任务尽量平均或者CPU时间。

CONFIG_CGROUP_SCHED直接组调度,尽量按groups进行均分CPU时间
CONFIG_RT_GROUP_SCHED实时进程组调度(SCHED_FIFO/SCHED_RR)
CONFIG_FAIR_GROUP_SCHEDCFS进程组调度(SCHED_NORMAL/ SCHED_BATCH)

这些都需要CONFIG_CGROUPS支持,cgroups系统支持。

进程调度过程分为两部分,一是对进程信息进行修改,主要是修改和调度相关的信息,比如进程的运行时间,睡眠时间,进程的状态,cpu的负荷等等,二是进程的切换。和进程调度相关的所有函数中,只有schedule函数是用来进行进程切换的,其他函数都是用来修改进程的调度信息。

rq、cfs_rq、task_group、task_struct之间的关系:

img

6.1 进程优先级

程的优先级和调度关系密切,计算进程的虚拟运行时间要用到优先级,优先级决定进程权重,权重决定进程虚拟时间的增加速度,最终决定进程可运行时间的长短。权重越大的进程可以执行的时间越长。

6.1.0 用户态nice值

nice值越大,说明很nice,很好说话,获得cpu的排队就得往后靠;nice值越小,越不好讲话,优先要调用。

nice会映射到内核的优先级100139(普通进程)。优先级的099是实时进程使用的。

6.1.1 优先级

在用户空间可以通过nice命令设置进程的静态优先级,这在内部会调用nice系统调用。 进程的nice值在-20至+19之间(包含)。值越低,表明优先级越高。

image-20210121151646932

内核使用一个简单些的数值范围,从0到139(包含),用来表示内部优先级。同样是值越低,优先级越高。从0到99的范围专供实时进程使用。nice值[-20, +19]映射到范围100到139。实时进程的优先级总是比普通进程更高

#define MAX_NICE	19
#define MIN_NICE	-20
#define NICE_WIDTH	(MAX_NICE - MIN_NICE + 1)

#define MAX_USER_RT_PRIO	100
#define MAX_RT_PRIO		MAX_USER_RT_PRIO

#define MAX_PRIO		(MAX_RT_PRIO + NICE_WIDTH)
#define DEFAULT_PRIO		(MAX_RT_PRIO + NICE_WIDTH / 2)   //默认优先级是120,也就是nice=0的

获取进程p的有效优先级prio

static int effective_prio(struct task_struct *p)
{
	p->normal_prio = normal_prio(p);
	/*如果是实时进程或已经提高到实时优先级,则保持优先级不变。否则,返回普通优先级:*/
	if (!rt_prio(p->prio))
		return p->normal_prio;
	return p->prio;
}

该函数用于设置进程的优先级,该函数设计的有一定技巧性,函数的返回值是用来设置进程的活动优先级,但是在函数体中也把进程的普通优先级设置了。

假定我们在处理普通进程,不涉及实时调度。在这种情况下,normal_prio只是返回静态优先级。结果很简单:所有3个优先级都是同一个值,即静态优先级!

static inline int normal_prio(struct task_struct *p)  /* 获取普通优先级 */
{
	int prio;

	if (task_has_dl_policy(p))  /* 判断当前进程是否空闲进程,是则设置进程的普通优先级-1*/
		prio = MAX_DL_PRIO-1;
	else if (task_has_rt_policy(p))  /* 判断是否实时进程,是则设置实时进程普通优先级0-99(越小优先级越高)*/
		prio = MAX_RT_PRIO-1 - p->rt_priority;
	else
		prio = __normal_prio(p);		/* 普通进程的普通优先级等于其静态优先级 */
	return prio;
}

其中,第8行,看到这块减去了p->rt_priority,比较奇怪,这是因为实时进程描述符的rt_priority成员中事先存放了它自己的优先级(数字也是0-99,但在这里数字越大,优先级越高),因此往p->prio中倒换的时候,需要处理一下,MAX_RT_PRIO值为100,因此MAX_RT_PRIO-1-(0,99)就倒换成了(99,0),这仅仅是个小技巧。

6.1.2 权重以及vruntime

进程的重要性不仅是由优先级指定的,而且还需要考虑保存在task_struct->se.load的负荷权重。

include/linux/sched.h中定义了权重的结构体:

task_struct
sched_entity
load_weight

这里为何一个是unsigned long一个是u32类型?

struct load_weight {
	unsigned long			weight;	/*调度实体的权重*/
	u32				inv_weight;	/*inverse_weight,权重中间计算结果*/
};
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,
};

进程每降低一个nice值,则多获得10%的CPU时间,每升高一个nice值,则放弃10%的CPU时间。对内核使用的范围[0, 39]中的每个nice级别,该数组中都有一个对应项。各数组之间的乘数因子是1.25。(这个因子1.25也只是大约等于,参考下面有个权重和nice值的曲线图,当初设计的时候这一组数据为何这么设计?)

sched_prio_to_weight权重值与nice值的对应曲线关系图:

weight-nice

下图是绘制的某个进程nice值对应的权重与进程的nice为0时的1024权重加起来的占比,比如

nice值为-20的进程,占比为:88761/(88761+1024)

权重相对于nice0进程的占cpu比

nice值和为0的两个进程的占CPU比(就是权重对称着的两个进程):

比如权重88761和权重15的进程组合,权重71755和权重18的进程组合:

权重对称曲线图

vruntime与nice的关系图:

用默认值(6ms*1024/weight)计算,可以看出,nice值越低,vruntime越接近0。

vruntime-nice曲线图

设置权重

static void set_load_weight(struct task_struct *p, bool update_load)
{
	int prio = p->static_prio - MAX_RT_PRIO;
	struct load_weight *load = &p->se.load;   /* 权重保存在task_struct的se.load中 */

	/*SCHED_IDLE进程得到的权重最小:*/
	if (task_has_idle_policy(p)) {
		load->weight = scale_load(WEIGHT_IDLEPRIO);
		load->inv_weight = WMULT_IDLEPRIO;
		return;
	}

	/*
	 * SCHED_OTHER tasks have to update their load when changing their
	 * weight
	 */
	if (update_load && p->sched_class == &fair_sched_class) {
		reweight_task(p, prio);			//CFS比其他调度器多了一个reweight_entity()操作
	} else {
		load->weight = scale_load(sched_prio_to_weight[prio]);
		load->inv_weight = sched_prio_to_wmult[prio];
	}
}

虚拟运行时间vruntime

为啥权重那里需要两个表?

虚拟运行时间计算公式:

image-20210305170744257

image-20210305172408088

inverse_weight值就存放于在sched_prio_to_wmult表中,weight值存放于sched_prio_to_weight表中,这样计算虚拟时间就只有乘法和移位操作;

权重值反转inverse值(2^32/weight,方便运算,事先根据sched_prio_to_weight表计算好)

/*
 * Inverse (2^32/x) values of the sched_prio_to_weight[] array, precalculated.
 */
const u32 sched_prio_to_wmult[40] = {
 /* -20 */     48388,     59856,     76040,     92818,    118348,
 /* -15 */    147320,    184698,    229616,    287308,    360437,
 /* -10 */    449829,    563644,    704093,    875809,   1099582,
 /*  -5 */   1376151,   1717300,   2157191,   2708050,   3363326,
 /*   0 */   4194304,   5237765,   6557202,   8165337,  10153587,
 /*   5 */  12820798,  15790321,  19976592,  24970740,  31350126,
 /*  10 */  39045157,  49367440,  61356676,  76695844,  95443717,
 /*  15 */ 119304647, 148102320, 186737708, 238609294, 286331153,
};

image-20210121162118285

所有的可运行进程都按时间在一个红黑树中排序,所谓时间即其等待时间。等待CPU时间最长的进程是最左侧的项,调度器下一次会考虑该进程。等待时间稍短的进程在该树上从左至右排序。

完全公平调度算法依赖于虚拟时钟,用以度量等待进程在完全公平系统中所能得到的CPU时间。

所有与虚拟时钟有关的计算都在update_curr中执行,该函数在系统中各个不同地方调用,包括周期性调度器之内

/*
 * Update the current task's runtime statistics.
 */
static void update_curr(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	u64 now = rq_clock_task(rq_of(cfs_rq));     /* 从就绪队列rq的clock_task成员中获取当前时间 */
	u64 delta_exec;

	if (unlikely(!curr))
		return;

	delta_exec = now - curr->exec_start;	 /*当前时间减去进程上次时钟中断tick中开始时间得到进程运行的时间间隔*/
	if (unlikely((s64)delta_exec <= 0))
		return;

	curr->exec_start = now;     			/* 当前时间赋值给进程新的开始时间 */

	schedstat_set(curr->statistics.exec_max,
		      max(delta_exec, curr->statistics.exec_max));

    /*将进程运行的时间间隔delta_exec累加到调度实体的sum_exec_runtime成员中,该成员代表进程到目前为止运行了多长时间*/
	curr->sum_exec_runtime += delta_exec;
	schedstat_add(cfs_rq->exec_clock, delta_exec);  /*将进程运行的时间间隔delta_exec也累加到公平调度就绪队列cfs_rq的exec_clock成员中*/

    /*calc_delta_fair函数很关键,它将进程执行的真实运行时间转换成虚拟运行时间,然后累加到调度实体的vruntime域中*/
	curr->vruntime += calc_delta_fair(delta_exec, curr);
	update_min_vruntime(cfs_rq); /*更新cfs_rq队列中的最小虚拟运行时间min_vruntime,该时间是就绪队列中所有进程包括当前进程的已运行的最小虚拟时间,只能单调递增*/

	if (entity_is_task(curr)) {
		struct task_struct *curtask = task_of(curr);

		trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
		cgroup_account_cputime(curtask, delta_exec);
		account_group_exec_runtime(curtask, delta_exec);
	}

	account_cfs_rq_runtime(cfs_rq, delta_exec);
}

每个cfs_rq队列均有一个min_vruntime成员,装的是就绪队列中所有进程包括当前进程已运行的虚拟时间中最小的那个时间。update_min_vruntime用于更新该时间。

队列中的min_vruntime成员非常重要,用于在睡眠进程被唤醒后以及新进程被创建好时,进行虚拟时间补偿或者惩罚

static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	struct rb_node *leftmost = rb_first_cached(&cfs_rq->tasks_timeline);

	u64 vruntime = cfs_rq->min_vruntime;

	if (curr) {
		if (curr->on_rq)
			vruntime = curr->vruntime;
		else
			curr = NULL;
	}

	if (leftmost) { /* non-empty tree */  /*就绪队列中有下一个要被调度的进程,则进入下一个调度实体*/
		struct sched_entity *se;
		se = rb_entry(leftmost, struct sched_entity, run_node);

        /*从当前进程和下个被调度进程中,选择最小的已运行虚拟时间,保存到vruntime中*/
		if (!curr)
			vruntime = se->vruntime;
		else
			vruntime = min_vruntime(vruntime, se->vruntime);
	}

    /*从当前队列的min_vruntime域和vruntime变量中,选最大的保存到队列的min_vruntime域中,完成更新*/
	/* ensure we never gain time by being placed backwards. */
	cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
#ifndef CONFIG_64BIT
	smp_wmb();
	cfs_rq->min_vruntime_copy = cfs_rq->min_vruntime;
#endif
}
问题:

虚拟运行时间到底怎么一回事?

我认为应该翻译为虚拟运行时间戳:

On real hardware, we can run only a single task at once, so we have to introduce the concept of “virtual runtime.” The virtual runtime of a task specifies when its next timeslice would start execution on the ideal multi-tasking CPU described above. In practice, the virtual runtime of a task is its actual runtime normalized to the total number of running tasks.
In CFS the virtual runtime is expressed and tracked via the per-task p->se.vruntime (nanosec-unit) value. This way, it’s possible to accurately timestamp and measure the “expected CPU time” a task should have gotten

Linux内核文档中对virtual runtime的解释是:该进程下一次的时间片从何时开始运行,vruntime落脚点是一个时间点,而不是一段时间。这样理解的话下面的问题就好解释了(2021.04.09, 17:30)。

为何nice值低(优先级高)的虚拟运行时间比实际运行时间还小了呢,这该如何理解?

sched_vslice函数计算虚拟时间

img

先简单说一下CFS调度算法的思想:理想状态下每个进程都能获得相同的时间片,并且同时运行在CPU上,但实际上一个CPU同一时刻运行的进程只能有一个。也就是说,当一个进程占用CPU时,其他进程就必须等待。CFS为了实现公平,必须惩罚当前正在运行的进程,以使那些正在等待的进程下次被调度。

具体实现时,CFS通过每个进程的虚拟运行时间(vruntime)来衡量哪个进程最值得被调度。CFS中的就绪队列是一棵以vruntime为键值的红黑树,虚拟时间越小的进程越靠近整个红黑树的最左端。因此,调度器每次选择位于红黑树最左端的那个进程,该进程的vruntime最小。

/*
 * Targeted preemption latency for CPU-bound tasks:		CPU计算密集型进程的目标抢占延时
 *
 * NOTE: this latency value is not the same as the concept of
 * 'timeslice length' - timeslices in CFS are of variable length
 * and have no persistent notion like in traditional, time-slice
 * based scheduling concepts.
 *
 * (to see the precise effective timeslice length of your workload,
 *  run vmstat and monitor the context-switches (cs) field)
 *
 * (default: 6ms * (1 + ilog(ncpus)), units: nanoseconds)
 */
unsigned int sysctl_sched_latency			= 6000000ULL;		//默认6ms
static unsigned int normalized_sysctl_sched_latency	= 6000000ULL;

/*
 * Minimal preemption granularity for CPU-bound tasks:
 *
 * (default: 0.75 msec * (1 + ilog(ncpus)), units: nanoseconds)
 */
unsigned int sysctl_sched_min_granularity			= 750000ULL;
static unsigned int normalized_sysctl_sched_min_granularity	= 750000ULL;

/*
 * This value is kept at sysctl_sched_latency/sysctl_sched_min_granularity
 */
static unsigned int sched_nr_latency = 8;			//这里固定值8个任务数,6ms/0.75ms=8


/*
 * The idea is to set a period in which each task runs once.
 *
 * When there are too many tasks (sched_nr_latency) we have to stretch
 * this period because otherwise the slices get too small.
 *
 * p = (nr <= nl) ? l : l*nr/nl
 */
static u64 __sched_period(unsigned long nr_running)
{
	if (unlikely(nr_running > sched_nr_latency))	//cfs队列中任务数大于8,为何就要用任务数*0.75ms作为delta_exec呢?任务数的时候多反而进程一次执行时间长了呢? 
       	/*2021.04.08解答:计算vruntime时的runtime(实际运行时间)其实理解为保证让每个任务都执行一遍的最大调度延迟,和等有关系shced_latency_ns,任务数越多,则需要成比例放大,以nr_running * sysctl_sched_min_granularity 值为准*/
		return nr_running * sysctl_sched_min_granularity;
	else
		return sysctl_sched_latency;
}

/*
 * We calculate the wall-time slice from the period by taking a part
 * proportional to the weight.
 *
 * s = p*P[w/rw]
 */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq); //计算runtime,为何se->on_rq非操作?这里sched_entity中的on_rq取值是{0,1},进入就绪队列置1,移出就绪队列置0)

	for_each_sched_entity(se) {
		struct load_weight *load;
		struct load_weight lw;

		cfs_rq = cfs_rq_of(se);
		load = &cfs_rq->load;

        /*如果se不在队列里,将se的权重weight加到cfs_rq队列的load记录中*/
		if (unlikely(!se->on_rq)) {
			lw = cfs_rq->load;

			update_load_add(&lw, se->load.weight);
			load = &lw;
		}
		slice = __calc_delta(slice, se->load.weight, load); //计算出runtime
	}
	return slice;
}

/*
 * We calculate the vruntime slice of a to-be-inserted task.
 *
 * vs = s/w
 */
static u64 sched_vslice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	return calc_delta_fair(sched_slice(cfs_rq, se), se);   //根据runtime(delta,俗称实际运行时间)计算出vruntime
}

/*
 * delta /= w
 */
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;
}

//数学公式就是 (delat_exec * nice_0_weight / 实际weight); 换成移位操作
/*
 * delta_exec * weight / lw.weight
 *   OR
 * (delta_exec * (weight * lw->inv_weight)) >> WMULT_SHIFT
 *
 * Either weight := NICE_0_LOAD and lw \e sched_prio_to_wmult[], in which case
 * we're guaranteed shift stays positive because inv_weight is guaranteed to
 * fit 32 bits, and NICE_0_LOAD gives another 10 bits; therefore shift >= 22.
 *
 * Or, weight =< lw.weight (because lw.weight is the runqueue weight), thus
 * weight/lw.weight <= 1, and therefore our shift will also be positive.
 */
static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw)
{
	u64 fact = scale_load_down(weight);
	int shift = WMULT_SHIFT;		//WMULT_SHIFT = 32

	__update_inv_weight(lw);

	if (unlikely(fact >> 32)) {			//这后面都看不懂,看代码应该是和32位或64位系统有关系,实际就是weight右移32位再乘以delta_exec
		while (fact >> 32) {
			fact >>= 1;
			shift--;
		}
	}

	fact = mul_u32_u32(fact, lw->inv_weight);

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

	return mul_u64_u32_shr(delta_exec, fact, shift);
}

函数调用关系图如下:

__sched_period()函数计算CFS就绪队列中一个调度周期,可以理解为一个调度周期的总时间片;

sched_slice()则根据当前进程的权重计算在CFS就绪队列总权重中可以分到的调度时间;

sched_vslice()计算虚拟时间;

image-20210409155527824

调度延迟

内核有一个固定的概念, 称之为良好的调度延迟, 即表示一个运行队列所有进程运行一次的周期. 它在sysctl_sched_latency给出, 可通过/proc/sys/kernel/sched_latency_ns控制。

第二个控制参数sched_nr_latency, 控制在一个延迟周期中处理的最大活动进程数目. 如果挥动进程的数目超过该上限, 则延迟周期也成比例的线性扩展.sched_nr_latency可以通过sysctl_sched_min_granularity间接的控制, 后者可通过/procsys/kernel/sched_min_granularity_ns设置. 默认值是4000000纳秒, 即4毫秒, 每次sysctl_sched_latency/sysctl_sched_min_granularity之一改变时, 都会重新计算sched_nr_latency.

__sched_period确定延迟周期的长度, 通常就是sysctl_sched_latency, 但如果有更多的进程在运行, 其值有可能按比例线性扩展. 在这种情况下, 周期长度是

__sched_period = sysctl_sched_latency * nr_running / sched_nr_latency

问题:

那么问题来了,如果用

delta_exec(runtime) * nice_0_weight / weight  ##weight取值按照nice:[-20,19]遍历
计算出所有的vruntime加起来是不是应该大约还等于delta_exec(runtime) * nice_0_weight

不能这么理解,每一次进行sched_period计算时,计算的delta_exec可能是不一样的,比如nr超过了sched_latency_ns/sched_min_granularity_ns,那么period间隔就是nr*sched_min_granularity_ns。

/proc/sys/kernel下可用sysctl控制的有scheduler的一些参数及含义

namevaluedescription
sched_autogroup_enabled0:禁止
1:开启
启用后,内核会创建任务组来优化桌面程序的调度。它将把占用大量资源的应用程序放在它们自己的任务组,这有助于性能提升
sched_cfs_bandwidth_slice_us5mscfs带宽控制,全局时间池;值越大,传输开销越大,值越小,细粒度的消耗也越大
sched_child_runs_first0:先调度父进程
1:先调度子进程
表示在创建子进程的时候是否让子进程抢占父进程,即使父进程的vruntime小于子进程,这个会减少公平性,但是可以降低write_on_copy,具体根据系统应用情况来考量使用哪种方式(见task_fork_fair过程)
sched_energy_aware
sched_latency_ns20ms表示一个运行队列所有进程运行一次的周期(运行周期计算与当前队列的进程数有关)。
<1> nr_running > sched_nr_latency的情况 (sched_nr_latency变量不能通过proc设置,其值是 (sched_latency_ns + sched_min_granularity_ns - 1) / sched_min_granularity_ns ),那么调度周期就是nr_running * sched_min_granularity_ns;
<2> nr_running不大于sched_nr_latency,则调度周期就是shced_latency_ns
sched_migration_cost_ns500000ns用来判断一个进程是否还是hot,如果进程的运行时间(now - p->se.exec_start)小于它,那么内核认为它的code还在cache里,所以该进程还是hot,那么在迁移的时候就不会考虑它
sched_min_granularity_ns表示进程最少运行时间,防止频繁的切换,对于交互系统(如桌面),该值可以设置得较小,这样可以保证交互得到更快的响应(见周期调度器的check_preempt_tick过程)
sched_nr_migrate32在多CPU情况下进行负载均衡时,一次最多移动多少个进程到另一个CPU上
sched_rr_timeslice_ms100ms设置在RR实时调度策略情况下,时间片轮转的单位时间
sched_rt_period_us1s置实时进程的周期时间,这个时间通常是1秒
sched_rt_runtime_us0.95s设置实时进程一个周期时间内的最大可运行时间,这个时间默认是0.95秒
sched_schedstats
sched_tunable_scaling0或1或2当内核试图调整sched_min_granularity,sched_latency和sched_wakeup_granularity这三个值的时候所使用的更新方法,0为不调整,1为按照cpu个数以2为底的对数值进行调整,2为按照cpu的个数进行线性比例的调整
sched_util_clamp_max
sched_util_clamp_min
sched_wakeup_granularity_ns4000000ns表示进程被唤醒后至少应该运行的时间的基数(delta_exec入参),它只是用来判断某个进程是否应该抢占当前进程,并不代表它能够执行的最小时间(sysctl_sched_min_granularity),如果这个数值越小,那么发生抢占的概率也就越高(见wakeup_gran、wakeup_preempt_entity函数)
sched_domain文件夹和cpu相关的调度域信息
6.1.3 负载均衡

内核中计算CPU负载的方法是PELT(Per-Entity Load Tracing),不仅考虑进程权重,而且跟踪每个调度实体的负载情况。

sched_entity结构中有一个struct sched_avg用于描述进程的负载

struct sched_avg {
	u64				last_update_time;
	u64				load_sum;
	u64				runnable_sum;
	u32				util_sum;
	u32				period_contrib;
	unsigned long			load_avg;
	unsigned long			runnable_avg;
	unsigned long			util_avg;
	struct util_est			util_est;
} ____cacheline_aligned;
6.1.4 选择下一个进程
/*
 * Pick up the highest-prio task:
 */
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	const struct sched_class *class;
	struct task_struct *p;

	if (likely(prev->sched_class <= &fair_sched_class &&
		   rq->nr_running == rq->cfs.h_nr_running)) {

		p = pick_next_task_fair(rq, prev, rf);
		if (unlikely(p == RETRY_TASK))
			goto restart;

		/* Assumes fair_sched_class->next == idle_sched_class */
		if (!p) {
			put_prev_task(rq, prev);
			p = pick_next_task_idle(rq);
		}

		return p;
	}

restart:
	put_prev_task_balance(rq, prev, rf);

	for_each_class(class) {
		p = class->pick_next_task(rq);
		if (p)
			return p;
	}

	/* The idle class should always have a runnable task: */
	BUG();
}





/**主要是pick_next_task_fair函数**/
struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	struct cfs_rq *cfs_rq = &rq->cfs;
	struct sched_entity *se;
	struct task_struct *p;
	int new_tasks;

again:
	if (!sched_fair_runnable(rq))
		goto idle;

#ifdef CONFIG_FAIR_GROUP_SCHED
	if (!prev || prev->sched_class != &fair_sched_class)
		goto simple;

	do {
		struct sched_entity *curr = cfs_rq->curr;

		if (curr) {
			if (curr->on_rq)
				update_curr(cfs_rq);
			else
				curr = NULL;

			if (unlikely(check_cfs_rq_runtime(cfs_rq))) {
				cfs_rq = &rq->cfs;

				if (!cfs_rq->nr_running)
					goto idle;

				goto simple;
			}
		}

		se = pick_next_entity(cfs_rq, curr);
		cfs_rq = group_cfs_rq(se);
	} while (cfs_rq);  /*对所有的调度组进行遍历,从中选择下一个可调度的进程,而不只局限在当前队列的当前组*/

	p = task_of(se);

	[...代码省略...]

	return NULL;
}
6.1.5就绪队列的入队和出队

enqueue_task_fair()函数

CFS的enqueue_task钩子函数是enqueue_task_fair()函数:

/**nr_running是cfs_rq结构体中的成员,计数所有就绪的进程数包括cfs_rq中以及正在运行的进程**/
static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
	struct cfs_rq *cfs_rq;
	struct sched_entity *se = &p->se;		/*获取进程p的调度实体*/
	int idle_h_nr_running = task_has_idle_policy(p);	/*判断进程 p->policy == SCHED_IDLE,有何作用?/

	util_est_enqueue(&rq->cfs, p);	/*cfs的负载估算,占用cpu的算力负载估算*/

	if (p->in_iowait)
		cpufreq_update_util(rq, SCHED_CPUFREQ_IOWAIT);

    /*这段代码在做什么?*/
	for_each_sched_entity(se) {
		if (se->on_rq) /*判断进程是否已经在队列里,on_rq为1则不需要再加入队列了,已经存在队列里*/
			break;
		cfs_rq = cfs_rq_of(se);
		enqueue_entity(cfs_rq, se, flags);   /*将调度实体加入队列*/

		cfs_rq->h_nr_running++;				/*计数增加*/
		cfs_rq->idle_h_nr_running += idle_h_nr_running;		

		/* end evaluation on encountering a throttled cfs_rq */
		if (cfs_rq_throttled(cfs_rq))
			goto enqueue_throttle;

		flags = ENQUEUE_WAKEUP;
	}

    /*为什么需要两次循环,操作不同在什么地方呢?*/
	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);

		update_load_avg(cfs_rq, se, UPDATE_TG);
		se_update_runnable(se);
		update_cfs_group(se);

		cfs_rq->h_nr_running++;
		cfs_rq->idle_h_nr_running += idle_h_nr_running;

		/* end evaluation on encountering a throttled cfs_rq */
		if (cfs_rq_throttled(cfs_rq))
			goto enqueue_throttle;

               /*
                * One parent has been throttled and cfs_rq removed from the
                * list. Add it back to not break the leaf list.
                */
               if (throttled_hierarchy(cfs_rq))
                       list_add_leaf_cfs_rq(cfs_rq);
	}

	/* At this point se is NULL and we are at root level*/
	add_nr_running(rq, 1);

	if (flags & ENQUEUE_WAKEUP)
		update_overutilized_status(rq);

enqueue_throttle:
	if (cfs_bandwidth_used()) {
		for_each_sched_entity(se) {
			cfs_rq = cfs_rq_of(se);

			if (list_add_leaf_cfs_rq(cfs_rq))
				break;
		}
	}

	assert_list_leaf_cfs_rq(rq);

	hrtick_update(rq);
}

enqueue_task_fair主要职责:

1)更新运行时的数据,比如负载、权重、组调度的占比等等;

2)将sched_entity插入红黑树;

enqueue_task_fair

将调度实体入队红黑树。

static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node;  /*获取就绪队列中红黑树的根节点*/
	struct rb_node *parent = NULL;		/* 用于指向树根*/
	struct sched_entity *entry;
	bool leftmost = true;

	while (*link) {
		parent = *link;
		entry = rb_entry(parent, struct sched_entity, run_node); /*获得树根节点的调度实体*/
        
		/*比较要入队的实体中的已运行虚拟时间和树根实体中的该信息,如果前者小的话,就要插入到树的左子树上(link指向树根的左孩子,再次进入循环,类似于递归),否则就要插入到树的右子树上(同上)。这块就将进程的调度策略展现的淋漓尽致:根据进程已运行的虚拟时间来决定进程的调度,红黑树的左子树比右子树要先被调度,已运行的虚拟时间越小的进程越在树的左侧*/
		if (entity_before(se, entry)) {
			link = &parent->rb_left;
		} else {
			link = &parent->rb_right;
			leftmost = false;
		}
	}

	rb_link_node(&se->run_node, parent, link); /*红黑树重新着色*/
	rb_insert_color_cached(&se->run_node,
			       &cfs_rq->tasks_timeline, leftmost);
}

疑问:

【1】为何enqueue_task_fair中需要两次for循环遍历se?而且h_nr_running每个循环中都加了?

在未开启group调度器情况下,我觉得实际上应该就执行了第一个for循环,因为执行完第一个循环for_each_sched_entity(se)后,se已经为NULL,就不会开始第二个for循环

dequeue_task_fair()函数

static void dequeue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
	struct cfs_rq *cfs_rq;
	struct sched_entity *se = &p->se;
	int task_sleep = flags & DEQUEUE_SLEEP;
	int idle_h_nr_running = task_has_idle_policy(p);
	bool was_sched_idle = sched_idle_rq(rq);

	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);
		dequeue_entity(cfs_rq, se, flags);

		cfs_rq->h_nr_running--;
		cfs_rq->idle_h_nr_running -= idle_h_nr_running;

		/* end evaluation on encountering a throttled cfs_rq */
		if (cfs_rq_throttled(cfs_rq))
			goto dequeue_throttle;

		/* Don't dequeue parent if it has other entities besides us */
		if (cfs_rq->load.weight) {
			/* Avoid re-evaluating load for this entity: */
			se = parent_entity(se);
			/*
			 * Bias pick_next to pick a task from this cfs_rq, as
			 * p is sleeping when it is within its sched_slice.
			 */
			if (task_sleep && se && !throttled_hierarchy(cfs_rq))
				set_next_buddy(se);
			break;
		}
		flags |= DEQUEUE_SLEEP;
	}

	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);

		update_load_avg(cfs_rq, se, UPDATE_TG);
		se_update_runnable(se);
		update_cfs_group(se);

		cfs_rq->h_nr_running--;
		cfs_rq->idle_h_nr_running -= idle_h_nr_running;

		/* end evaluation on encountering a throttled cfs_rq */
		if (cfs_rq_throttled(cfs_rq))
			goto dequeue_throttle;

	}

	/* At this point se is NULL and we are at root level*/
	sub_nr_running(rq, 1);

	/* balance early to pull high priority tasks */
	if (unlikely(!was_sched_idle && sched_idle_rq(rq)))
		rq->next_balance = jiffies;

dequeue_throttle:
	util_est_dequeue(&rq->cfs, p, task_sleep);
	hrtick_update(rq);
}

dequeue_task_fair的主要工作内容和enqueue其实类型:

1)更新运行时间、负载等;

2)将实体移出红黑树队列;

dequeue_task_fair

出队列

static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	rb_erase_cached(&se->run_node, &cfs_rq->tasks_timeline);
}

static inline void rb_erase_cached(struct rb_node *node,
				   struct rb_root_cached *root)
{
	if (root->rb_leftmost == node)   /*判断要出队的实体是不是红黑树最左侧的孩子(rb_leftmost所指向的)*/
		root->rb_leftmost = rb_next(node); /*是最左子树的话需要找出下一个*/
	rb_erase(node, &root->rb_root);
}
6.1.6 睡眠进程被唤醒后抢占当前进程

当某个资源空出来后,等待该资源的进程就会被唤醒,唤醒后也许就要抢占当前进程。

该函数会唤醒睡眠中的指定p的进程。

static int
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
   [...]
}

唤醒一个刚被创建的进程

void wake_up_new_task(struct task_struct *p)
{
    [...]
}

检查唤醒进程是否能抢占当前进程.

static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
    [...]
}
6.1.7 fork的处理

该函数在do_fork—>copy_process函数中调用,用来设置新创建进程的虚拟时间信息。

static void task_fork_fair(struct task_struct *p)
{
	struct cfs_rq *cfs_rq;
	struct sched_entity *se = &p->se, *curr;
	struct rq *rq = this_rq();
	struct rq_flags rf;

	rq_lock(rq, &rf);
	update_rq_clock(rq);

	cfs_rq = task_cfs_rq(current);
	curr = cfs_rq->curr;
	if (curr) {
		update_curr(cfs_rq);
		se->vruntime = curr->vruntime;  	/*当前进程(父进程)的虚拟运行时间拷贝给新进程(子进程)*/
	}
	place_entity(cfs_rq, se, 1);  			/*完成新进程的“时间片”计算以及虚拟时间惩罚,之后将新进程加入红黑树中*/

    /*如果设置了子进程先于父进程运行的标志并且当前进程不为空且当前进程已运行的虚拟时间比新进程小,则执行if体*/
	if (sysctl_sched_child_runs_first && curr && entity_before(curr, se)) {
		/*交换当前进程和新进程的虚拟时间(新进程的虚拟时间变小,就排在了红黑树的左侧,当前进程之前,下次就能被调度)*/
		swap(curr->vruntime, se->vruntime);
		resched_curr(rq); /*设置重新调度标志*/
	}

	se->vruntime -= cfs_rq->min_vruntime; /*给新进程的虚拟运行时间减去队列的最小虚拟时间来做一点补偿(因为在上边的place_entity函数中给新进程的虚拟时间加了一次min_vruntime,所以在这里要减去)*/
	rq_unlock(rq, &rf);
}

看下place_entity函数,该函数完成新进程的“时间片”计算和虚拟时间惩罚,并且将新进程加入就绪队列。

static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
	u64 vruntime = cfs_rq->min_vruntime;

	/*如果initial标志为1的话(说明当前计算的是新进程的时间),将计算出的新进程的虚拟时间片累加到vruntime中,累加到原因是调度系统要保证先把就绪队列中的所有的进程执行一遍之后才能执行新进程*/
	if (initial && sched_feat(START_DEBIT))
		vruntime += sched_vslice(cfs_rq, se);

	/* sleeps up to a single latency don't count. */
	if (!initial) {  			/*如果当前计算的不是新进程(睡眠的进程),把一个延迟周期的长度sysctl_sched_latency(6ms)赋给thresh*/
		unsigned long thresh = sysctl_sched_latency; 

		/*
		 * Halve their sleep time's effect, to allow
		 * for a gentler effect of sleepers:
		 */
		if (sched_feat(GENTLE_FAIR_SLEEPERS))
			thresh >>= 1;			/*thresh减半*/

		vruntime -= thresh; /*睡眠进程的虚拟运行时间减去减半后的thresh,因为睡眠进程好长时间未运行,因此要进行虚拟时间补偿,把它已运行的虚拟时间减小一点,使得它能多运行一会*/
	}

	/* ensure we never gain time by being placed backwards. */
	se->vruntime = max_vruntime(se->vruntime, vruntime);			/*将设置好的虚拟时	间保存到进程调度实体的vruntime域*/
}

为什么要对新进程进行虚拟时间惩罚,其实原因只有一个,就是调度系统要保证将就绪队列中现有的进程执行一遍之后再执行新进程,那么就必须使新进程的 vruntime=cfs_rq->min_vruntime+新进程的虚拟时间片,才能使得新进程插入到红黑树的右边,最后参与调度,不然无法保证所有进程在新进程之前执行。

check_preemp_curr()函数

check_preempt_curr

6.1.8 让出cpu

两个yield_task相关函数:

  • yield()让出cpu给其他线程
  • yield_to()让出cpu给同一线程去的其他线程(是否是同一队列?)

yield_task

6.19 CFS弱化timeslice那怎么判断时间用尽了

curr->exec_start有何作用?怎么用?

exec_start是记录虚拟运行时间的起始点,

check_preempt_tick()函数用于检查时间是否用尽:

/*
 * Preempt the current task with a newly woken task if needed:
 */
static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    unsigned long ideal_runtime, delta_exec;
    struct sched_entity *se;
    s64 delta;

    /*  计算curr的理论上应该运行的时间  */
    ideal_runtime = sched_slice(cfs_rq, curr);

    /*  计算curr的实际运行时间
     *  sum_exec_runtime: 进程执行的总时间
     *  prev_sum_exec_runtime:进程在切换进CPU时的sum_exec_runtime值  */
    delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;

    /*  如果实际运行时间比理论上应该运行的时间长
     *  说明curr进程已经运行了足够长的时间
     *  应该调度新的进程抢占CPU了  */
    if (delta_exec > ideal_runtime)
    {
        resched_curr(rq_of(cfs_rq));
        /*
         * The current task ran long enough, ensure it doesn't get
         * re-elected due to buddy favours.
         */
        clear_buddies(cfs_rq, curr);
        return;
    }

    /*
     * Ensure that a task that missed wakeup preemption by a
     * narrow margin doesn't have to wait for a full slice.
     * This also mitigates buddy induced latencies under load.
     */
    if (delta_exec < sysctl_sched_min_granularity)   //这里保证了最小运行时间片
        return;

    se = __pick_first_entity(cfs_rq);
    delta = curr->vruntime - se->vruntime;   //这里需要和红黑树中最左节点的虚拟运行时间进行差值计算,如果差值大于当前进程的理想运行时间了,说明需要最左节点饥饿了,需要调度给它

    if (delta < 0)
        return;

    if (delta > ideal_runtime)
        resched_curr(rq_of(cfs_rq));
}

check_preempt_tick函数的目的在于, 判断是否需要抢占当前进程. 确保没有哪个进程能够比延迟周期中确定的份额运行得更长. 该份额对应的实际时间长度在sched_slice中计算.

而上一节我们提到, 进程在CPU上已经运行的实际时间间隔由sum_exec_runtime - prev_sum_runtime给出.

在set_next_entity函数的最后, 将选择出的调度实体se的sum_exec_runtime保存在了prev_sum_exec_runtime中, 因为该调度实体指向的进程, 马上将抢占处理器成为当前活动进程, 在CPU上花费的实际时间将记入sum_exec_runtime, 因此内核会在prev_sum_exec_runtime保存此前的设置. 要注意进程中的sum_exec_runtime没有重置. 因此差值sum_exec_runtime - prev_sum_runtime确实标识了在CPU上执行花费的实际时间.

在处理周期性调度时, 这个差值就显得格外重要

因此抢占决策很容易做出决定, 如果检查发现当前进程运行需要被抢占, 那么通过resched_task发出重调度请求. 这会在task_struct中设置TIF_NEED_RESCHED标志, 核心调度器会在下一个适当的时机发起重调度.

其实需要抢占的条件有下面两种可能性

  • curr进程的实际运行时间delta_exec比期望的时间间隔ideal_runtime长

此时说明curr进程已经运行了足够长的时间

  • curr进程与红黑树中最左进程left虚拟运行时间的差值大于curr的期望运行时间ideal_runtime

此时说明红黑树中最左结点left与curr节点更渴望处理器, 已经接近于饥饿状态, 这个我们可以这样理解, 相对于curr进程来说, left进程如果参与调度, 其期望运行时间应该域curr进程的期望时间ideal_runtime相差不大, 而此时如果curr->vruntime - se->vruntime > curr.ideal_runtime, 我们可以初略的理解为curr进程已经优先于left进程多运行了一个周期, 而left又是红黑树总最饥渴的那个进程, 因此curr进程已经远远领先于队列中的其他进程, 此时应该补偿其他进程。

如果检查需要发生抢占, 则内核通过resched_curr(rq_of(cfs_rq))设置重调度标识, 从而触发延迟调度

注意:那么我们自己设计的最简单调度器(就按照固定的时间片比如10ms执行,执行完再切换),就只需要做两个地方:

  • 期望运行时间idea_runtime,则是计算固定时间片sched_slice()是固定的,比如10ms;
  • 实际运行时间delta_exec,主要问题就在这,通过类似update_curr()的来更新时间戳,prev_sun_exec_runtime记录上次运行的时间?怎么操作呢?
6.19.1 prev_sum_exec_time作用
static void
set_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	/* 'current' is not kept within the tree. */
	if (se->on_rq) {
		update_stats_wait_end(cfs_rq, se);
		__dequeue_entity(cfs_rq, se);
		update_load_avg(cfs_rq, se, UPDATE_TG);
	}

	update_stats_curr_start(cfs_rq, se);
	cfs_rq->curr = se;

    [...] //省略部分代码
    
	se->prev_sum_exec_runtime = se->sum_exec_runtime;
}

prev_sum_exec_runtime是在set_next_entity()函数中被记录的,而set_next_entity只有在两个地方会被调用:

image-20210508112154473

set_next_entity作用:用来将下一个即将被调度的进程移出红黑树;

put_prev_entity作用:用于将上一个被切换下来的进程放进红黑树就绪队列中;

七、MuQSS调度器

查看MuQSS调度器是否启用。

image-20210201141303000

全称:The Multiple Queue Skiplist Scheduler

疑问1:这里的skiplist是什么?

7.1 skiplist跳表

skiplist是一种数据结构,类似CFS的红黑树,但比红黑树简单,比双链表更高效。

跳跃表使用概率均衡技术而不是使用强制性均衡,因此,对于插入和删除结点比传统上的平衡树算法更为简洁高效。

跳表参考这篇博客:https://blog.csdn.net/ict2014/article/details/17394259

7.2 MuQSS简介

参考这篇博客https://cloud.tencent.com/developer/article/1517909

BFS虽然简单,但是两个问题却非常明显:

  1. 遍历查找的O(n)问题。链表为什么不基于Virtual Deadline进行预排序呢?
  2. 多CPU操作全局链表的锁问题。

我们看看BFS的算法简单到何种程度:

  • task插入:直接将task插入链表末尾。
  • task选择:冒泡选择Virtual Deadline最小的task。 【在遍历过程中会有trick,发现当前jiffies大于task的VD,就退出,这像极了Linux内核的timer处理】

最终,Con Kolivas认为:

  1. 在task数量并不太大的情况下,O(n)算法没有任何问题。
  2. 在CPU数量保持在16个以内时,争锁的开销可以忽略。

MuqSS零代价解决了BFS存在的两个问题:

  1. 遍历查找的O(n)问题。 引入Skiplist数据结构替换双向链表,在O(logn)的插入代价下将查找的时间复杂度降为O(1)。 【关于Skiplist,可以参考我的另一篇文章: https://blog.csdn.net/dog250/article/details/46997155】
  2. 多CPU操作全局链表的锁问题。引入每CPU链表,避免全局争锁。同时以trylock代替lock,以损失准确性为代价实现无锁操作。

Con Kolivas在 保持简单 这个约束下设计了MuqSS,其要点是:

  • Skiplist的作用类似主线Linux内核CFS中的红黑树,但比红黑树简单得多。
  • 选择task的算法遍历所有CPU的Skiplist表头,选择当前全局最优task。
  • 锁粒度细化到每个CPU的Skiplist。
  • 遍历过程针对每CPU锁采用trylock,失败则继续下一个CPU,实现无锁化。

时间复杂度同样都是O(n),但MuqSS的n指的是CPU数量而非task数量

image-20210127173724016

7.3 MuQSS关键因子
proc参数默认值含义
iso_cpu70该值设置了无特权的SCHED_ISO进程可以以实施优先级运行的cpu百分占比,即在整个系统(即所有cpu)上滚动5秒的平均cpu百分比。
SCHED_ISO在linux-5.9.10中保留了,并未实现。
MuQSS独有
kexec_load_disabled0ROM/Flash boot loader
rr_interval6MuQSS独有;该值是任何cpu调度单元可以运行的最小时间长度。增加该值可以提高计算密集型任务的吞吐量,但会增加延迟;同样,减少该值,牺牲吞吐量,降低了平均和最大延迟。
该值是ms级别,可设置范围为1-1000,一般默认值是根据调度器初始化时可用的cpu数量来决定,一般最小为6;
MuQSS独有
sched_energy_aware1softlockup threshold,是看门狗threshold的两倍大。如果将该值设置为0,则会关闭lockup探测。
yield_type1该值决定了sched_yield函数调用时会怎么表现
0:不放弃cpu
1:只放弃cpu给更高优先级的进程
2:耗尽时间片并重新计算deadline
MuQSS独有
7.4 MuQSS的源码实现
7.4.1 skip_list分析

MuQSS的skip_list主要增加了两个文件include/linux/skip_list.h 以及 kernel/skip_list.c

skip_list.h中:

typedef u64 keyType;
typedef void *valueType;
typedef struct nodeStructure skiplist_node;

struct nodeStructure {
	int level;	/* Levels in this structure */
	keyType key;
	valueType value;
	skiplist_node *next[8];   /*这里一共是8个,和后面的MaxNumberOfLevels对应么?*/
	skiplist_node *prev[8];
};  //定义跳表节点

typedef struct listStructure {
	int entries;   /*记录元素个数,每次插入加1,每次删除减1*/
	int level;	/* Maximum level of the list
			(1 more than the number of levels in the list) */
	skiplist_node *header; /* pointer to header */
} skiplist;

skiplist_node节点结构体中有个level, skiplist表结构体中也有个level,这两个level有何区别:

skip_list.c中:

初始化一个slnode节点:

void skiplist_node_init(skiplist_node *node)
{
	memset(node, 0, sizeof(skiplist_node)); /*内存置零,就是准备留给新对象使用*/
}

初始化一个跳表:

  • 跳表最大级数是8;
  • 初始化next和prev指向节点本身;
//MaxNumberOfLevels是跳表的级数,这里定义最多8级
#define MaxNumberOfLevels 8
#define MaxLevel (MaxNumberOfLevels - 1)

void skiplist_init(skiplist_node *slnode)
{
	int i;

	slnode->key = 0xFFFFFFFFFFFFFFFF;
	slnode->level = 0;
	slnode->value = NULL;
	for (i = 0; i < MaxNumberOfLevels; i++)
		slnode->next[i] = slnode->prev[i] = slnode; /*初始slnode的next和prev指自己*/
}

个人理解:这里的MaxNumberOfLevels级数不是指下图中的黄色底标的数字,是指从左到右的箭头的层数。

img

创建一个新的空表:

skiplist *new_skiplist(skiplist_node *slnode)
{
	skiplist *l = kzalloc(sizeof(skiplist), GFP_ATOMIC);

	BUG_ON(!l);
	l->header = slnode;
	return l;
}

销毁一张表:

void free_skiplist(skiplist *l)
{
	skiplist_node *p, *q;

	p = l->header;
	do {
		q = p->next[0];
		p->next[0]->prev[0] = q->prev[0];
		skiplist_node_init(p);
		p = q;
	} while (p != l->header);
	kfree(l);
}

插入一个节点:

void skiplist_insert(skiplist *l, skiplist_node *node, keyType key, valueType value, unsigned int randseed)
{
	skiplist_node *update[MaxNumberOfLevels];
	skiplist_node *p, *q;
	int k = l->level;

    /*步骤1,从最高层一层一层往下找,并更新update数组,update数组中保存的是每次降一层时的节点*/
	p = l->header;
	do {
		while (q = p->next[k], q->key <= key)
			p = q;
		update[k] = p;
	} while (--k >= 0);

	++l->entries;
    /*步骤2,产生一个随机层数level,如果新生成的层数比跳表的层数大,则设置k为跳表当前level大1的层数,并更新update中k层指向header*/
	k = randomLevel(randseed); /*需要插入的层*/
	if (k > l->level) {
		k = ++l->level;
		update[k] = l->header;
	}

    /*步骤3,将待插入的节点一层一层的插入*/
	node->level = k; /*node当中的level记录了该节点从哪一层被插入*/
	node->key = key;
	node->value = value;
	do {
		p = update[k];    /*这里逐层往下插入到update[k]之后一个元素*/
		node->next[k] = p->next[k];
		p->next[k] = node;
		node->prev[k] = p;
		node->next[k]->prev[k] = node;
	} while (--k >= 0);
}

/*步骤2中为何不用担心k超过MaxNumberOfLevels?应该是这个计算随机数时会保证在MaxLevel范围内*/
static inline unsigned int randomLevel(const long unsigned int randseed)
{
	return find_first_bit(&randseed, MaxLevel) / 2;
}

下图中假设要插入的值是25:

步骤1

我们需要对于每一层进行遍历并保存这一层中下降的节点(其后继节点为NULL或者后继节点的key大于等于要插入的key),如下图, 节点中有白色星花标识的节点保存到update数组。

步骤2

通过一个随机算法产生一个随机的层数,但是当这个随机产生的层数level大于当前跳表的最大层数时,我们此时需要更新当前跳表最大层数到level之间的update内容,这时应该更新其内容为跳表的头节点head,想想为什么这么做? 然后就是更新跳表的最大层数。

这么做update[k]=l->header是为啥,是因为多加了一层时,只有header么?

对,我觉得答案就是这样,当计算随机数k已经大于当前list的最大level了,则向上取一层,这一层是新的,里面没有元素,所以update[k]需要从header处开始插入

还有为什么会算出来k大于了当前跳表的最大层数?那样每次都 k == ++l->level 会不会k超过了最大跳表值?

函数randomLevel中应该限制了MaxLevel(是MaxNumberOfLevel-1)

步骤3

根据update[k]开始逐层往下插入节点

删除跳表节点:

void skiplist_delete(skiplist *l, skiplist_node *node)
{
	int k, m = node->level; /*这里的node-level在插入时设置了*/

    /*for循环从0层开始一直到node所在插入层遍历,将node节点的next和prev移除*/
	for (k = 0; k <= m; k++) {
		node->prev[k]->next[k] = node->next[k];
		node->next[k]->prev[k] = node->prev[k];
	}
	skiplist_node_init(node);  /*只是把node节点内存置空,并未释放这部分内存啊?*/
	/*如果m刚好是最顶层,删除节点后需要检查下是否m层只剩下header,如果是删除m层*/
    if (m == l->level) { 
		while (l->header->next[m] == l->header && l->header->prev[m] == l->header && m > 0)
			m--;
		l->level = m;
	}
	l->entries--; /*这个entries计数有何用?待解答*/
}
7.4.2 MuQSS详细设计

为何设计MuQSS?

BFS中是所有的CPU共享一个runqueue,这会导致什么呢?会导致每个CPU都需要去搜索整个runqueue去寻找拥有最早的deadline的进程来调度,并且不用管该进程原来是哪个CPU调度的,从而导致BFS的延时会因processed和CPUs的数量增加而增加。并且,单个runqueue会导致CPU之间的锁竞争,当CPU数量超过16个后,lock contention就很严重了。

MuQSS是BFS的一种进化方案,改进在哪里?

  • 每个CPU都有自己的runqueue
  • skiplist跳表取代链表

那么,当初BFS为何只用一个runqueue呢?

是因为有multiple runqueues会需要复杂的交互,因为每个runqueue都只会负责它自己队列的调度延时和公平性,这边需要一个复杂的交互系统来保证低延时和公平性,任何增加CPU本地进程调度吞吐量带来优势的同时也会带来劣势,这是因为需要一个复杂的平衡系统,来保证绑定同一个进程到同一个CPU的低延时效果,而不是同一个进程被不同的CPU调度运行。

MuQSS怎么解决多个runqueue带来的劣势问题?

MuQSS通过跳表优先级排序、创新的使用了无锁检查(当它需要因为降低延时需求或者CPU平衡等理由来从其他队列中获取更早的deadline的任务时)。MuQSS仍然没有balancing系统,选择允许下一个任务调度决策和任务唤醒CPU来实现平衡。

详细设计

1. 定制的skiplist实现

MuQSS使用固定的8 level跳表,不是动态分配的,这样使得每个队列仅可将O(logN)扩展为64k个任务(这个地方没搞懂),但是呢,每个CPU都有一个runqueue的话,这样O(logN)最多可扩展64k * CPUs个任务,和CPU数量相关了

2. 任务插入

MuQSS任务插队就是一个O(logN)的插入skiplist操作。

3. Niffies

jiffies是记录系统启动后到现在的时钟中断次数,它取决于系统的时钟评率,比如1000Hz,那么产生时钟中断是没1/1000s一次,也即1ms一次。

niffies和jiffies不同,niffies是一直单调递增的定时器,纳秒单位,Niffies是根据高分辨率TSC计时器针对每个运行队列计算的,并且为了保持公平性,每当两个运行队列同时锁定时,CPU之间就会进行同步

4. virtual deadline

虚拟期限?,MuQSS中保证低延时、调度公平性、优先级的关键核心机制是virtual deadline machanism

rr_interval: roud robin interval,该参数可通过proc系统调节,作用是:当两个任务具有相同的nice级别时(普通进程SCHED_NORMAL或者SCHED_OTHER),该进程能够运行的最大时间;或者换个角度说,两个相同优先级任务的最大延迟时间。

当一个任务需要CPU时间,它被配置的**时间片(time_slice)**等于一个rr_interval和一个virtual deadline,(这里如何理解?),virtual deadline如何计算:

niffies + (prio_ratio * rr_interval)

其中:

  • prio_ratio:优先级,是和nice -20的基线进行比的比率,每增加一个nice level,prio_ratio增加10%;

  • deadline: (deadline调度器是根据deadline来选择调度的,最先到达截止时间点的进程被有优先调度);截止时间点,是个虚拟时间,用于比较接下来调度运行那个任务。

    选择哪个进程该运行,通常有三种情况:

    • 时间片耗尽,进程会被重新调度,时间片也会被分配,deadline也会按照上面的公式进行重计算;
    • 进程进入睡眠sleep状态,会让出CPU,这个过程中,time_slice时间片和deadline不会改变,该进程下次被调度时还会恢复;
    • 抢占,一个新的任务比当前正在运行的任务有更高的优先级,可以抢占。

    在前两种情况中,deadline是选择下一个运行任务的关键要素点。

The CPU proportion of different nice tasks works out to be approximately the
(prio_ratio difference)^2
The reason it is squared is that a task’s deadline does not change while it is running unless it runs out of time_slice. Thus, even if the time actually passes the deadline of another task that is queued, it will not get CPU time unless the current running task deschedules, and the time “base” (niffies) is constantly moving.

5. 任务查找

由于在skiplist中,任务已经预先根据调度的预期顺序排序了,通常选择下一个待运行的任务就是选择0 level的第一个entry入口任务

查找的时间复杂度是O(k),这里的k是CPU个数。

6. 延时

通过使用虚拟期限来控制正常任务的调度顺序,可以确保每个运行队列的队列到激活延迟都受rr_interval可调参数约束,该参数默认设置为6ms。 这意味着与CPU绑定的任务等待的最长时间将与正在运行的任务的数量成正比,在通常情况下,每个CPU 0-2个正在运行的任务,将低于7ms的阈值(人类能感到抖动的阈值)。

7.4.3 源码流程概括

MuQSS.c直接将core.c原本的框架修改了,直接不涉及调度类等,集成在MuQSS.c中

MuQSS.c简要流程

八、实时调度器rt

【待整理】

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;