Linux内核学习——进程管理之调度器基本原理(基于CFS调度器)

1.进程描述符

Linux通过struct task_struct结构体描述每一个进程,进程围绕一个名为task_struct的数据结构建立,该结构体定义在include/linux/sched.h中。在阐述调度器的实现之前,了解一下Linux管理进程的方式是很有必要的。

//注:文章代码分析基于Linux-4.18.0。

struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
	/*
	 * For reasons of header soup (see current_thread_info()), this
	 * must be the first element of task_struct.
	 */
	struct thread_info		thread_info;
#endif
	/* -1 unrunnable, 0 runnable, >0 stopped: */
	volatile long			state;//程序运行状态

	randomized_struct_fields_start

	void				*stack;
	atomic_t			usage;
	/* Per task flags (PF_*), defined further below: */
	unsigned int			flags;
	unsigned int			ptrace;

#ifdef CONFIG_SMP
	struct llist_node		wake_entry;
	int				on_cpu;
#ifdef CONFIG_THREAD_INFO_IN_TASK
	/* Current CPU: */
	unsigned int			cpu;
#endif
	unsigned int			wakee_flips;
	unsigned long			wakee_flip_decay_ts;
	struct task_struct		*last_wakee;

	int				recent_used_cpu;
	int				wake_cpu;
#endif
	int				on_rq;

	int				prio;
	int				static_prio;
	int				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;
	/* ... ,代码均为省略版代码*/
};

se、rt、dl分别对应CFS调度器、RT调度器、Deadline调度器的调度实体。

2.调度类结构体

调度器(scheduler)是一个操作系统的核心部分,是CPU时间的管理员,负责选择最适合的就绪进程来执行。一个系统中可以共存多个调度器,将调度器模块化,可以提高其扩展性。在Linux中,将调度器公共的部分抽象,使用struct sched_class结构体描述一个具体的调度类。系统核心调度代码会通过结构体的成员调用具体调度类的核心算法,先介绍下struct sched_class部分成员作用。

struct sched_class {
	const struct sched_class *next; /* 1 */
	void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags); /* 2 */
	void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags); /* 3 */
	void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags); /* 4 */
	struct task_struct * (*pick_next_task)(struct rq *rq, struct task_struct *prev, */struct rq_flags *rf); /* 5*/
    /* ... */
}; 
  1. next:next成员指向下一个调度类(比自己低一个优先级)。高优先级调度类管理的进程会优先获得CPU的使用权。
  2. enqueue_task:向该调度器管理的runqueue中添加一个进程。我们把这个操作称为入队。
  3. dequeue_task:向该调度器管理的runqueue中删除一个进程。我们把这个操作称为出队。
  4. check_preempt_curr:当一个进程被唤醒或者创建的时候,需要检查当前进程是否可以抢占当前cpu上正在运行的进程,如果可以抢占需要标记TIF_NEED_RESCHED flag。
  5. pick_next_task:从runqueue中选择一个最适合运行的task。

3.具体调度类

Linux中主要包含以下5种具体的调度类,fair_sched_class是完全公平调度类,也称CFS调度类。本篇文章主要集中在Linux CFS调度器源码解析。

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;

4.调度器、调度类、调度策略之间的关系

3.具体调度类可知,Linux中主要包含stop_sched_class、dl_sched_class、rt_sched_class、fair_sched_class及idle_sched_ class等调度类。每一个进程都对应一种调度策略,每一种调度策略又对应一种调度类(每一个调度类可以对应多种调度策略)。针对不同的调度策略,选择的调度器也是不一样的。
stop_sched_class 优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断。
在这里插入图片描述

调度类调度策略调度器描述
dl_sched_classSCHED_DEADLINEDeadline调度器
rt_sched_classSCHED_RR 、SCHED_FIFORT实时调度器
fair_sched_classSCHED_NORMAL、SCHED_BATCH、SCHED_IDLECFS调度器
idle_sched_class空闲进程的调度策略IDLE Task调度器

针对以上调度类,系统中有明确的优先级概念。每一个调度类利用next成员构建单项链表。优先级从高到低示意图如下:

sched_class_highest----->stop_sched_class
                         .next---------->dl_sched_class
                                         .next---------->rt_sched_class
                                                         .next--------->fair_sched_class
                                                                        .next----------->idle_sched_class
                                                                                         .next = NULL 

5.虚拟时间(virtual time)

CFS调度类是一个针对普通进程的调度类,它引入了权重的概念,权重代表着进程的优先级。CFS调度器针对优先级又提出了nice值的概念,其实和权重是一一对应的关系。进程的nice值取值范围是[-20, 19]。数值越小代表优先级越高,同时也意味着权重值越大,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,
};

数组的值可以看作是公式:weight = 1024 / 1.2 5 n i c e 1.25^nice 1.25nice计算得到。公式中以1024权重为基准值计算得来,1024权重对应nice值为0,其权重被称为NICE_0_LOAD。默认情况下,大部分进程的权重基本都是NICE_0_LOAD。CFS调度器的目标是保证每一个进程的完全公平调度。例如,调度周期是6ms,系统一共2个相同优先级的进程A和B,那么每个进程都将在6ms周期时间内内各运行3ms。如果进程A和B,他们的权重分别是1024和820(nice值分别是0和1)。进程A获得的运行时间是6x1024/(1024+820)=3.3ms,进程B获得的执行时间是6x820/(1024+8 20)=2.7ms。2个进程的实际执行时间是不相等的,但是CFS想保证每个进程运行时间相等。因此CFS引入了虚拟时间的概念,也就是说上面的2.7ms和3.3ms经过一个公式的转换可以得到一样的值,这个转换后的值称作虚拟时间。这样的话,CFS只需要保证每个进程运行的虚拟时间是相等的即可。虚拟时间vriture_runtime和实际时间(wall time)转换公式如下:


                                 NICE_0_LOAD                                                                         2^32
vriture_runtime = wall_time * ---------------- =(wall_time * NICE_0_LOAD * inv_weight) >> 32       (inv_weight = ------------ )               
                                    weight                                                                          weight                                                      

进程A的虚拟时间3.3 * 1024 / 1024 = 3.3ms,我们可以看出nice值为0的进程的虚拟时间和实际时间是相等的。进程B的虚拟时间是2.7 * 1024 / 820 = 3.3ms。我们可以看出尽管A和B进程的权重值不一样,但是计算得到的虚拟时间是一样的。因此CFS主要保证每一个进程获得执行的虚拟时间一致即可。在选择下一个即将运行的进程的时候,只需要找到虚拟时间最小的进程即可。

权重weight的值已经计算保存到sched_prio_to_weight数组中,根据这个数组我们可以很容易计算inv_weight的值。内核中使用sched_pr io_to_wmult数组保存inv_weight的值。

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,
}; 

系统中使用struct load_weight结构体描述进程的权重信息。weight代表进程的权重,inv_weight等于 2 32 2^{32} 232/weight。

struct load_weight {
	unsigned long		weight;
	u32			        inv_weight;
}; 

将实际时间转换成虚拟时间的实现函数是calc_delta_fair()。

static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
	if (unlikely(se->load.weight != NICE_0_LOAD))     //进程的权重是NICE_0_LOAD,进程对应的虚拟时间就不用计算
		delta = __calc_delta(delta, NICE_0_LOAD, &se->load);  //调用__calc_delta()函数
 
	return delta;
} 

calc_delta_fair()通过调用__calc_delta()函数进行虚拟时间计算。

static u64 __calc_delta(u64 delta_exec, unsigned long weight, struct load_weight *lw)
{
	u64 fact = scale_load_down(weight);
	int shift = 32;
 
	__update_inv_weight(lw);
 
	if (unlikely(fact >> 32)) {
		while (fact >> 32) {
			fact >>= 1;
			shift--;
		}
	}
 
	fact = (u64)(u32)fact * lw->inv_weight;
 
	while (fact >> 32) {
		fact >>= 1;
		shift--;
	}
 
	return mul_u64_u32_shr(delta_exec, fact, shift);
} 

虚拟时间vriture_runtime和实际时间(wall time)对应的__calc_delta()函数参数转换公式如下:


                               weight(也就是传递的NICE_0_LOAD)                                                                            2^32
__calc_delta() = delta_exec * ------------------------------ =(delta_exec * weight  * lw->inv_weight) >> 32       (lw->inv_weight = ------------ )               
                                        lw->weight                                                                                   lw->weight                                                      

和上面计算虚拟时间计算公式对比发现,如果需要计算进程的虚拟时间,这里的weight只需要传递参数NICE_0_LOAD,lw参数是进程对应的struct load_weight结构体。

6.目标延迟(target latency)

CFS为完美多任务中的无限小调度周期的近似值设立了一个目标,而这个目标称作“目标延迟”。越小的调度周期将带来越好的交互性,同时也更接近完美的多任务,但是你必须承受更高的切换代价和更差的系统总吞吐能力。让我们假定目标延迟值是20ms,我们有两个同样优先级的可运行任务(无论这些任务的优先级是多少),每个任务在被其他任务抢占前运行10ms。如果我们有4个这样的任务,则每个只能运行5ms。进一步设想,如果有20个这样的任务,那么每个仅仅只能获得1ms的运行时间。随着进程的增加,每个进程分配的时间在减少,进程调度过于频繁,上下文切换时间开销就会变大。因此,CFS调度器的目标延迟时间的设定并不是固定的,调度周期计算函数是__sched_period()。当系统处于就绪态的进程数量nr_running少于一个定值(sched_nr_latency,默认值8)的时候,目标延迟也是固定一个值(sysctl_sched_latency,默认值6ms)不变。当系统就绪态进程个数超过这个值(8)时,我们保证每个进程至少运行一定的时间才让出cpu,这个“至少一定的时间”被称为最小粒度时间。在CFS默认设置中,最小粒度时间是0.75ms,用变量sysctl_sched_min_granulari ty记录。因此,调度周期是一个动态变化的值。

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

7.CFS调度实体

struct sched_entity结构体描述CFS调度实体,包括struct load_weight用来记录权重信息。除此以外我们一直关心的时间信息,肯定也要一起记录。struct sched_entity结构体简化后如下:

struct sched_entity {
	/* For load-balancing: */
	struct load_weight		load;/* 1 */
	unsigned long			runnable_weight;
	struct rb_node			run_node;/* 2*/
	struct list_head		group_node;
	unsigned int			on_rq;/* 3 */

	u64				exec_start;
	u64				sum_exec_runtime;/* 4 */
	u64				vruntime;/* 5 */
	u64				prev_sum_exec_runtime;
};

struct rb_node {
	unsigned long  __rb_parent_color;//红黑树颜色
	struct rb_node *rb_right;//右
	struct rb_node *rb_left;//左
} __attribute__((aligned(sizeof(long))));
  1. load:权重信息,在计算虚拟时间的时候会用到inv_weight成员。
  2. run_node:CFS调度器的每个就绪队列维护了一颗红黑树,上面挂满了就绪等待执行的task,run_node就是挂载点。
  3. on_rq:调度实体se加入就绪队列后,on_rq置1。从就绪队列删除后,on_rq置0。
  4. sum_exec_runtime:调度实体已经运行实际时间总合。
  5. vruntime:调度实体已经运行的虚拟时间总合。

8.就绪队列(runqueue)

系统中每个CPU都会有一个全局的就绪队列(cpu runqueue),使用struct rq结构体描述。每一个调度类也有属于自己管理的就绪队列。例如,struct cfs_rq是CFS调度类的就绪队列,管理就绪态的struct sched_entity调度实体,后续通过pick_next_task接口从就绪队列中选择最适合运行的调度实体(虚拟时间最小的调度实体)。

struct rq {
    struct cfs_rq cfs;//CFS调度类的就绪队列
	struct rt_rq rt;
	struct dl_rq dl;
};
 
struct rb_root_cached {
	struct rb_root rb_root;//红黑树的根
	struct rb_node *rb_leftmost;//红黑树的最左边节点
};
 
struct cfs_rq {
	struct load_weight load;/* 1 */
	unsigned int nr_running;/* 2 */
	u64 min_vruntime;/* 3 */
	struct rb_root_cached tasks_timeline;/* 4 */
}; 
  1. load:就绪队列权重,就绪队列管理的所有调度实体权重之和。
  2. nr_running:就绪队列上调度实体的个数。
  3. min_vruntime:跟踪就绪队列上所有调度实体的最小虚拟时间。
  4. tasks_timeline:用于跟踪调度实体按虚拟时间大小排序的红黑树的信息(包含红黑树的根以及红黑树中最左边节点)。

9.选择最适合运行的调度实体

Linux调度核心在选择下一个合适的task运行的时候,会按照优先级的顺序遍历调度类的pick_next_task函数。因此,SCHED_FIF O调度策略的实时进程永远比SCHED_NORMAL调度策略的普通进程优先运行,pick_next_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;
 
	for_each_class(class) {          /* 按照优先级顺序遍历所有的调度类,通过next指针便利单链表 */
		p = class->pick_next_task(rq, prev, rf);
		if (p)
			return p;
	}
} 

10.文末总结

Linux通过struct task_struct结构体描述每一个进程,task_struct包含很多进程相关的信息。每一个调度类并不是直接管理task_struct,而是引入调度实体的概念,CFS调度器的调度实体是sched_entity,每个就绪态的调度实体sched_entity包含插入红黑树中使用的节点rb_n ode,同时vruntime成员记录已经运行的虚拟时间。每个CPU都会有一个全局的就绪队列rq,每一个调度类也有属于自己管理的就绪队列。CFS调度器使用cfs_rq跟踪就绪队列信息以及管理就绪态调度实体,并维护一棵按照虚拟时间排序的红黑树。tasks_timeline->rb_lef tmost指向红黑树中最左边的调度实体,即虚拟时间最小的调度实体(为了更快的选择最适合运行的调度实体,因此rb_leftmost相当于一个缓存)。函数调用如图所示:
在这里插入图片描述

参考文献

1.http://www.wowotech.net/process_management/447.html
2.《深入Linux内核架构》

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值