2. CFS调度器

[TOC]

  • 1 权重计算

    • 1.1 nice,优先级,权重,vruntime

    • 1.2 CPU的负载

      • 1.2.1 调度实体类的负载
  • 2 进程创建

  • 3 进程调度

  • 4 scheduler tick

  • 5 组调度

  • 6 PELT算法改进

  • 7 小结

本节思考题:

  1. 请简述对进程调度器的理解,早期Linux内核调度器(包括0(N)和0(1))是如何工作的?
  2. 请简述进程优先级、nice值和权重之间的关系。
  3. 请简述CFS调度器是如何工作的。
  4. CFS调度器中vruntime是如何计算的?
  5. vmntime是何时更新的?
  6. CFS调度器中的min_vruntime有什么作用?
  7. CFS调度器对新创建的进程和刚唤醒的进程有何关照?
  8. 如何计算普通进程的平均负载load_avg_contrib?runnable_avg_sum和runnable_avg_period分别是什么含义?
  9. 内核代码中定义了若干个表,请分别说出它们的含义,比如prio_to_weight、prio_to_wmult、runnable_avg_yN_inv、runnable_avg_yN_sum.
  10. 如果一个普通进程在就绪队列里等待了很长时间才被调度,那么它的平均负载该如何计算?

Linux内核作为一个通用操作系统,需要兼顾各种各样类型的进程,包括实时进程、交互式进程、批处理进程等。每种类型进程都有其特别的行为特征,总结如下。

  • 交互式进程:与人机交互的进程,和鼠标、键盘、触摸屏等相关的应用,例如vim编辑器等,它们一直在睡眠同时等待用户召唤它们。这类进程的特点是系统响应时间越快越好,否则用户就会抱怨系统卡顿。

  • 批处理进程:此类进程默默地工作和付出,可能会占用比较多的系统资源,例如编译代码等。

  • 实时进程:有些应用对整体时延有严格要求,例如现在很火的VR设备,从头部转动到视频显示需要控制到19毫秒以内,否则会使人出现眩晕感。对于工业控制系统,不符合要求的时延可能会导致严重的事故。

本节主要讲述普通进程的调度,包括交互进程批处理进程等。在CFS调度器出现之前,早期Linux内核中曾经出现过两个调度器,分别是0(N)和0(1)调度器

0(N)调度器发布于1992年,该调度器算法比较简洁,从就绪队列中比较所有进程的优先级,然后选择一个最高优先级的进程作为下一个调度进程。每个进程有一个固定时间片,当进程时间片使用完之后,调度器会选择下一个调度进程,当所有进程都运行一遍后再重新分配时间片。这个调度器选择下一个调度进程前需要遍历整个就绪队列,花费0(N)时间。

Linux2.6.23内核之前有一款名为0(1)的调度器,优化了选择下一个进程的时间。它为每个CPU维护一组进程优先级队列,每个优先级一个队列,这样在选择下一个进程时,只需要查询优先级队列相应的位图即可知道哪个队列中有就绪进程,所以查询时间为常数0(1)。

0(1)调度器在处理某些交互式进程时依然存在问题,特别是有一些测试场景下导致交互式进程反应缓慢,另外对NUMA支持也不完善,因此大量难以维护和阅读的代码被加入该调度器中。

后来产生了CFS调度算法.不同进程采用不同的调度策略,目前Linux内核中默认实现了4种调度策略,分别是deadlinerealtimeCFSidle,它们分别使用struct sched_class来定义调度类

deadlinerealtimeCFS这三个调度策略对应的调度类通过进程优先级来区分的.

  • 普通进程的优先级: 100~139.
  • 实时进程的优先级: 0~99.
  • Deadline进程优先级: -1.

4种调度类通过next指针串联在一起,用户空间程序可以使用调度策略API函数(sched_setscheduler())来设定用户进程的调度策略。其中,SCHED_NORMAL和SCHED_BATCH使用CFS调度器,SCHED_FIFO和SCHED_RR使用realtime调度器,SCHED_IDLE指idle调度,SCHED_DEADLINE指deadline调度器。

[include/uapi/linux/sched.h]
/*
 * 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 */
#define SCHED_IDLE		5
#define SCHED_DEADLINE		6

注: sched_setscheduler(),sched_getscheduler()---------用户空间程序系统调用API设置和获取内核调度器的调度策略和参数。

1 权重计算

1.1 nice,优先级,权重,vruntime

内核使用0〜139的数值表示进程的优先级,数值越低优先级越高。优先级0〜99给实时进程使用,100〜139给普通进程使用。另外在用户空间有一个传统的变量nice(用户空间的一个值!!!)值映射到普通进程的优先级(只是普通进程,也就是CFS调度策略对应的进程!!!),即100〜139。

进程PCB描述符struct task_struct数据结构中有3个成员描述进程的优先级

[include/linux/sched.h]

struct task_struct {
    ...
    int prio;
    int static_prio;
    int normal_prio;
    unsigned int rt_priority;
    ...
};

static_prio是静态优先级,在进程启动时分配。内核不存储nice值,取而代之的是static_prio。内核中的宏NICE_TO_PRIO()实现由nice值转换成static_prio(差值是120)。它之所以被称为静态优先级是因为它不会随着时间而改变,用户可以通过nice或sched_setscheduler等系统调用来修改该值

[include/linux/sched/prio.h]
#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

// MAX_PRIO = 140
#define MAX_PRIO		(MAX_RT_PRIO + NICE_WIDTH)
//DEFAULT_PRIO = 120
#define DEFAULT_PRIO		(MAX_RT_PRIO + NICE_WIDTH / 2)
/*
 * Convert user-nice values [ -20 ... 0 ... 19 ]
 * to static priority [ MAX_RT_PRIO..MAX_PRIO-1 ],
 * and back.
 */
#define NICE_TO_PRIO(nice)	((nice) + DEFAULT_PRIO)
#define PRIO_TO_NICE(prio)	((prio) - DEFAULT_PRIO)

normal_prio是基于static_prio和调度策略计算出来的优先级,在创建进程时会继承父进程的normal_prio。对于普通进程来说,normal_prio等同于static_prio,对于实时进程,会根据rt_priority重新计算normal_prio,详见effective_prio()函数。

prio保存着进程的动态优先级,是调度类考虑的优先级,有些情况下需要暂时提高进程优先级,例如实时互斥量等。

rt_priority是实时进程的优先级。

内核使用struct load_weight数据结构来记录调度实体的权重信息(weight).

[include/linux/sched.h]
struct load_weight {
	unsigned long weight;
	u32 inv_weight;
};

其中,weight是调度实体的权重,inv_weight是inverse weight的缩写,它是权重的一个中间计算结果,稍后会介绍如何使用。调度实体的数据结构中己经内嵌了struct load_weight结构体,用于描述调度实体的权重。

[include/linux/sched.h]
struct sched_entity {
	struct load_weight	load;		/* for load-balancing */
	...
}

因此代码中经常通过p->se.load来获取进程p的权重信息

nice值的范围是从-20〜19(nice值转priority是120,也可见nice值对应的是普通进程的,不是实时进程或deadline进程或idle进程!!!),进程默认的nice值为0。这些值含义类似级别,可以理解成有40个等级,nice值越高,则优先级越低(优先级数值越大,nice值和优先级值线性关系),反之亦然。例如一个CPU密集型的应用程序nice值从0增加到1,那么它相对于其他nice值为0的应用程序将减少10%的CPU时间。因此进程每降低一个nice级别优先级提高一个级别,相应的进程多获得10%的CPU时间;反之每提升一个nice级别,优先级则降低一个级别,相应的进程少获得10%的CPU时间。为了计算方便,内核约定nice值为0的权重值为1024,其他nice值对应的权重值可以通过查表的方式来获取,内核预先计算好了一个表prio_to_weight[40],表下标对应nice值[-20〜19]。

[kernel/sched/sched.h]
static const int 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,
};

前文所述的10%的影响是相对及累加的,例如一个进程增加了10%的CPU时间,则另外一个进程减少10%,那么差距大约是20%,因此这里使用一个系数1.25来计算的。举个例子,进程A和进程Bnice值都为0,那么权重值都是1024,它们获得CPU的时间都是50%,计算公式为1024/(1024+1024)=50%。假设进程A增加一个nice值,即nice=1, 进程B的nice值不变,那么进程B应该获得55%的CPU时间,进程A应该是45%。我们利用prio_to_weight[]表来计算,进程A=820/(1024+820)=45%,而进程B=1024/(1024+820)=55%,注意是近似等于。

内核中还提供另外一个表prio_to_wmult[40],也是预先计算好的。

[kernel/sched/sched.h]
static const u32 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,
};

prio_to_wmult[]表的计算公式如下:

config

其中,inv_weight是inverse weight的缩写,指权重被倒转了,作用是为后面计算方便。

内核提供一个函数来查询这两个表,然后把值存放在p->se.load数据结构中,即struct load_weight结构中。

[kernel/sched/core.c]
static void set_load_weight(struct task_struct *p)
{
	int prio = p->static_prio - MAX_RT_PRIO;
	struct load_weight *load = &p->se.load;

	/*
	 * SCHED_IDLE tasks get minimal weight:
	 */
	if (p->policy == SCHED_IDLE) {
		load->weight = scale_load(WEIGHT_IDLEPRIO);
		load->inv_weight = WMULT_IDLEPRIO;
		return;
	}

	load->weight = scale_load(prio_to_weight[prio]);
	load->inv_weight = prio_to_wmult[prio];
}

prio_to_wmult[]表有什么用途呢?

在CFS调度器中有一个计算虚拟时间的核心函数calc_delta_fair(),它的计算公式为:

config

其中,vruntime表示进程虚拟的运行时间,delta_exec表示实际运行时间,nice_0_weight表示nice为0的权重值,weight表示该进程的权重值

vruntime该如何理解呢?如图3.4所示,假设系统中只有3个进程A、B和C,它们的NICE都为0, 也就是权重值都是1024。它们分配到的运行时间相同,即都应该分配到1/3的运行时间。如果A 、B 、C 三个进程的权重值不同呢?

2. CFS调度器_第1张图片

CFS调度器抛弃以前固定时间片固定调度周期的算法,而采用进程权重值的比重来量化和计算实际运行时间。另外引入虚拟时钟的概念,每个进程的虚拟时间实际运行时间相对NICE值为0的权重的比例值。进程按照各自不同的速率比物理时钟节拍内前进

  • NICE值小的进程,优先级高权重大,vruntime越小,其虚拟时钟比真实时钟跑得慢,但是可以获得比较多的运行时间
  • 反之,NICE值大的进程,优先级低,权重也低,vruntime越大,其虚拟时钟比真实时钟跑得快,反而获得比较少的运行时间。

CFS调度器总是选择虚拟时钟跑得慢的进程,它像一个多级变速箱NICE为0的进程是基准齿轮,其他各个进程在不同的变速比下相互追赶,从而达到公正公平(具体原因计算参照后面!!!)。

假设某个进程nice值为1,其权重值为820,delta_exec=10ms,导入公式计算vruntime=(10*1024)/820,这里会涉及浮点运算。为了计算高效,函数calc_delta_fair()的计算方式变成乘法和移位运行公式如下(函数真实的计算过程如下!!!):

vruntime = (delta_exec * nice_0_weight * inv_weight) >> shift

把 inv_weight带入计算公式后,得到如下计算公式:

config

这里巧妙地运用prio_to_wmult[]表预先做了除法,因此实际的计算只有乘法和移位操作,2^32是为了预先做除法和移位操作。calc_delta_fair()函数等价于如下代码片段:

[kernel/sched/fair.c]
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;
}

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

	__update_inv_weight(lw);

	if (unlikely(fact >> 32)) {
		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);
}

以上讲述了进程权重、优先级和vruntime的计算方法。

1.2 CPU的负载

下面来关注CPU的负载计算问题

计算一个CPU的负载(负载!!!),最简单的方法是计算CPU上就绪队列上所有进程的权重。仅考虑优先级权重是有问题的,因为没有考虑该进程的行为,有的进程使用的CPU是突发性的,有的是恒定的,有的是CPU密集型,也有的是IO密集型进程调度(调度时候,不是计算负载!!!)考虑优先级权重的方法可行,但是如果延伸到多CPU之间的负载均衡就显得不准确了,因此从Linux3.8内核05以后进程的负载计算不仅考虑权重,而且跟踪每个调度实体的负载情况,该方法称为PELT(Pre-entity Load Tracking,详见详见 https://lwn.net/Articles/531853/ )。

CPU负载计算从两方面考虑:

  • CPU的就绪队列上的所有进程的权重
  • CPU上每个调度实体的负载

1.2.1 调度实体类的负载

调度实体数据结构sched_entity中有一个struct sched_avg用于描述进程的负载

[include/linux/sched.h]
// 负载类
struct sched_avg {
	/*
	 * These sums represent an infinite geometric series and so are bound
	 * above by 1024/(1-y).  Thus we only need a u32 to store them for all
	 * choices of y < 1-2^(-32)*1024.
	 */
	// runnable状态总时间
	u32 runnable_avg_sum;
	// fork后的系统中所有时间
	u32 runnable_avg_period;
	// 用于计算时间间隔
	u64 last_runnable_update;
	s64 decay_count;
	// 负载的贡献值
	unsigned long load_avg_contrib;
};

[include/linux/sched.h]
// 调度实体类
struct sched_entity {
...
// 注意在SMP情况下才有用
#ifdef CONFIG_SMP
	/* Per-entity load-tracking */
	struct sched_avg	avg;
#endif
};

注: SMP情况下,进程的负载才考虑.

runnable_avg_sum表示该调度实体就绪队列里(se->on_rq=1,调度实体的on_rq属性值为1代表在就绪队列!!!)可运行状态(runnable,包括就绪态和运行态时间!!!)的总时间调度实体就绪队列中的时间包括两部分,一是正在运行的时间,称为running时间,二是在就绪队列中等待的时间runnable包括上述两部分时间。在后续Linux内核版本演变中,会计算进程运行的时间(running time),但在Linux 4.0内核中暂时还没有严格区分。进程进入就绪队列时(调用enqueue_emity),on_rq会设置为1,但是该进程因为睡眠等原因退出就绪队列时(调用dequeue_entity())on_rq会被清0,因此runnable_avg_sum就是统计进程在就绪队列的时间(注意该时间不完全等于进程运行的时间,因为还包括在就绪队列里排队的时间)。

runnable_avg_period可以理解为该调度实体系统中的总时间,之所以称为period是因为以1024微秒为一个周期periodlast_runnable_update用于计算时间间隔.当一个进程fork出来之后,对于该进程来说,无论它是否在就绪队列中,还是被踢出就绪队列,runnable_avg_period—直在递增(fork出来后就开始增加!!!)。

考虑到历史数据对负载的影响,采用衰减系数计算平均负载

  • runnable_avg_sum: 调度实体在就绪队列里可运行状态下总的衰减累加时间。
  • runnable_avg_period: 调度实体在系统中总的衰减累加时间。

load_avg_contrib进程平均负载的贡献度,后续会详细讲述该值如何计算。

对于那些长时间不活动而突然短时间访问CPU的进程或者访问磁盘被阻塞等待的进程,它们的load_avg_contrib要比CPU密集型的进程小很多,例如做矩阵乘法运算的密集型进程。对于前者,runnable_avg_sum时间要远远小于runnable_avg_period可获得的时间,对于后者,它们几乎是相等的。

下面用经典的电话亭例子来说明问题。假设现在有一个电话亭(好比是CPU),有4个人要打电话(好比是进程),电话管理员(好比是内核调度器)按照最简单的规则轮流给每个打电话的人分配1分钟的时间,时间截止马上把电话亭使用权给下一个人,还需要继续打电话的人只能到后面排队(好比是就绪队列)。那么管理员如何判断哪个人是电话的重度使用者呢?可以使用如下式:

config

电话的使用率计算公式就是每个分配到电话的使用者使用电话的时间除以分配时间。使用电话的时间和分配到时间是不一样的,例如在分配到的1分钟时间里,一个人查询电话本用了20秒,打电话只用了40秒,那么active_use_time是40秒,period是60秒。因此电话管理员通过计算一段统计时间里的每个人的电话平均使用率便可知道哪个人是电话重度使用者。

类似的情况有很多,例如现在很多人都是低头族,即手机重度使用者,现在你要比较在过去24小时内身边的人谁是最严重的低头族。那么以1小时为一个period,统计过去24个period周期内的手机使用率相加,再比较大小,即可知道哪个人是最严重的低头族。runnable_avg_period好比是period的总和runnable_avg_sum好比是一个人在每个period里使用手机的时间总和

cfs_rq数据结构中的成员runnable_load_avg用于累加在该就绪队列所有调度实体load_avg_contrib总和,它在SMP负载均衡调度器中用于衡量CPU是否繁忙。另外内核还记录阻塞睡眠进程负载,当一个进程睡眠时,它的负载会记录在blocked_load_avg成员中。

如果一个长时间运行的CPU密集型的进程突然不需要CPU了,那么尽管它之前是一个很占用CPU的进程,此刻该进程的负载是比较小的。

我们把1毫秒(准确来说是1024微秒,为了方便移位操作)的时间跨度算成一个周期,称为period,简称PI。一个调度实体(可以是一个进程,也可以是一个调度组)在一个PI周期内对系统负载的贡献除了权重外,还有在PI周期内可运行的时间(runnablejime),包括运行时间和等待CPU时间。一个理想的计算方式是:统计多个实际的PI周期,并使用一个衰减系数来计算过去的PI周期对负载的贡献。假设Li是一个调度实体在第i个周期内的负载贡献,那么一个调度实体的负载总和计算公式如下:

config

这个公式用于计算调度实体最近的负载过去的负载也是影响因素,它是一个衰减因子。因此调度实体的负载需要考虑时间的因素,不能只考虑当前的负载,还要考虑其在过去一段时间的表现衰减的意义类似于信号处理中的采样,距离当前时间点越远,衰减系数越大,对总体影响越小。其中,y是一个预先选定好的衰减系数,y^32约等于0.5,因此统计过去第32个周期的负载可以被简单地认为负载减半

该计算公式还有简化计算方式,内核不需要使用数组来存放过去PI个周期的负载贡献,只需要用过去周期贡献总和乘以衰减系数y,并加上当前时间点的负载L0即可。内核定义了表runnable_avg_yN_inv[]来方便使用衰减因子

[kernel/sched/fair.c]
/* Precomputed fixed inverse multiplies for multiplication by y^n */
static const u32 runnable_avg_yN_inv[] = {
	0xffffffff, 0xfa83b2da, 0xf5257d14, 0xefe4b99a, 0xeac0c6e6, 0xe5b906e6,
	0xe0ccdeeb, 0xdbfbb796, 0xd744fcc9, 0xd2a81d91, 0xce248c14, 0xc9b9bd85,
	0xc5672a10, 0xc12c4cc9, 0xbd08a39e, 0xb8fbaf46, 0xb504f333, 0xb123f581,
	0xad583ee9, 0xa9a15ab4, 0xa5fed6a9, 0xa2704302, 0x9ef5325f, 0x9b8d39b9,
	0x9837f050, 0x94f4efa8, 0x91c3d373, 0x8ea4398a, 0x8b95c1e3, 0x88980e80,
	0x85aac367, 0x82cd8698,
};

为了处理器计算方便,该表对应的因子乘以2^32,计算完成后再右移32位。在处理器中,乘法运算比浮点运算快得多,其公式等同于:

config

其中,除以2^32可以用右移32位来计算。runnable_avg_yN_inv[]相当于提前计算了公式中的(2^32)/B的值。runnable_avg_yN_inv[]表包括32个下标,对应过去0〜32毫秒的负载贡献的衰减因子

举例说明,假设当前进程的负载贡献度是100,要求计算过去第32毫秒的负载。首先查表得到过去32毫秒时间周期的衰减因子:runnable_avg_yN_inv[31]。计算公式为:Load = (100 * runnable_avg_yN_inv[31] >> 32)(过去第32毫秒的负载,这是计算方式!!!),最后计算结果为 51。

下面是我换算后的衰减因子.runnable_avg_yN_inv[]是为了CPU计算方便然后乘以了2^32。由runnable_avg_yN_inv[]推导回runnable_avg_yN_org[],计算公式可以是:((1000 * runnable_avg_yN_inv[]>>32)/1000。

[kernel/sched/fair.c]
衰减因子:(只保留小数点3位数)
static const u32 runnable_avg_yN_org[] = {
    0.999, 0.978, 0.957, 0.937, 0.917, 0.897,
    0.878, 0.859, 0.840, 0.822, 0x805, 0.787,
    ...
    ...
    ...
    0.522, 0.510,
};

内核中的decay_load()函数用于计算第n个周期的衰减值

[kernel/sched/fair.c]
/*
 * Approximate:
 *   val * y^n,    where y^32 ~= 0.5 (~1 scheduling period)
 */
static __always_inline u64 decay_load(u64 val, u64 n)
{
	unsigned int local_n;

	if (!n)
		return val;
	else if (unlikely(n > LOAD_AVG_PERIOD * 63))
		return 0;

	/* after bounds checking we can collapse to 32-bit */
	local_n = n;

	/*
	 * As y^PERIOD = 1/2, we can combine
	 *    y^n = 1/2^(n/PERIOD) * y^(n%PERIOD)
	 * With a look-up table which covers y^n (n= LOAD_AVG_PERIOD)) {
		val >>= local_n / LOAD_AVG_PERIOD;
		local_n %= LOAD_AVG_PERIOD;
	}

	val *= runnable_avg_yN_inv[local_n];
	/* We don't use SRR here since we always want to round down. */
	return val >> 32;
}

参数val表示n个周期后的负载值,n表示第n个周期,其计算公式,即第n个周期的衰减值为val*y^n,计算y^n釆用查表的方式,因此计算公式变为:

(val * runnable_avg_yN_inv[n]) >> 32

因为定义了32毫秒的衰减系数为1/2,每增加32毫秒都要衰减1/2,因此如果period太大,衰减后值会变得很小几乎等于0。所以函数代码中,当period大于2016就直接等于0。处理period值在32~2016范围的情况,每增加32毫秒就要衰减1/2,相当于右移一位,见代码。

runnable_avg_yN_inv[]表为了避免CPU做浮点运算,把实际的一组浮点类型数值乘以2^32,CPU做乘法和移位要比浮点运算快得多

为了计算更加方便,内核又维护了一个表runnable_avg_yN_sum[],己预先计算好如下公式的值。

config

其中,n取1〜32。为什么系数是1024呢?因为内核的runnable_avg_yN_sum[]表通常用于计算时间的衰减,准确地说是周期period,一个周期是1024微秒。例如n= 2 时,sum = 1024*( runnable_avg_yN[1] + runnable_avg_yN[2]) = 1024 X (0.978 + 0.957) = 1981.44,即约等于 runnable_avg_yN_sum[2],详见 runnable_avg_yN_sum[]表.

[kernel/sched/fair.c]
/*
 * Precomputed \Sum y^k { 1<=k<=n }.  These are floor(true_value) to prevent
 * over-estimates when re-combining.
 */
static const u32 runnable_avg_yN_sum[] = {
	    0, 1002, 1982, 2941, 3880, 4798, 5697, 6576, 7437, 8279, 9103,
	 9909,10698,11470,12226,12966,13690,14398,15091,15769,16433,17082,
	17718,18340,18949,19545,20128,20698,21256,21802,22336,22859,23371,
};

__compute_runnable_contrib()会使用该表来计算连续n个PI周期负载累计贡献值

[kernel/sched/fair.c]
static u32 __compute_runnable_contrib(u64 n)
{
	u32 contrib = 0;

	if (likely(n <= LOAD_AVG_PERIOD))
		return runnable_avg_yN_sum[n];
	else if (unlikely(n >= LOAD_AVG_MAX_N))
		return LOAD_AVG_MAX;

	/* Compute \Sum k^n combining precomputed values for k^i, \Sum k^j */
	do {
		contrib /= 2; /* y^LOAD_AVG_PERIOD = 1/2 */
		contrib += runnable_avg_yN_sum[LOAD_AVG_PERIOD];

		n -= LOAD_AVG_PERIOD;
	} while (n > LOAD_AVG_PERIOD);

	contrib = decay_load(contrib, n);
	return contrib + runnable_avg_yN_sum[n];
}

__compute_runnable_contrib()函数中的参数n表示PI周期的个数。如果n小于等于LOAD_AVG_PERIOD(32个周期),那么直接查表runnable_avg_yN_sum[]取值,如果n大于等于LOAD_AVG_MAX_N(345个周期),那么直接得到极限值LOAD_AVG_MAX(47742)。如果n的范围为32〜345,那么每次递进32个衰减周期进行计算,然后把不能凑成32个周期的单独计算并累加,见do-while代码。

下面来看计算负载中的一个重要函数__update_entity_runnable_avg().

[kernel/sched/fair.c]
static __always_inline int __update_entity_runnable_avg(u64 now,
							struct sched_avg *sa,
							int runnable)
{
	u64 delta, periods;
	u32 runnable_contrib;
	int delta_w, decayed = 0;

	delta = now - sa->last_runnable_update;
	/*
	 * This should only happen when time goes backwards, which it
	 * unfortunately does during sched clock init when we swap over to TSC.
	 */
	if ((s64)delta < 0) {
		sa->last_runnable_update = now;
		return 0;
	}

	/*
	 * Use 1024ns as the unit of measurement since it's a reasonable
	 * approximation of 1us and fast to compute.
	 */
	delta >>= 10;
	if (!delta)
		return 0;
	sa->last_runnable_update = now;

	/* delta_w is the amount already accumulated against our next period */
	delta_w = sa->runnable_avg_period % 1024;
	if (delta + delta_w >= 1024) {
		/* period roll-over */
		decayed = 1;

		/*
		 * Now that we know we're crossing a period boundary, figure
		 * out how much from delta we need to complete the current
		 * period and accrue it.
		 */
		delta_w = 1024 - delta_w;
		if (runnable)
			sa->runnable_avg_sum += delta_w;
		sa->runnable_avg_period += delta_w;

		delta -= delta_w;

		/* Figure out how many additional periods this update spans */
		periods = delta / 1024;
		delta %= 1024;
        // 重点1
		sa->runnable_avg_sum = decay_load(sa->runnable_avg_sum,
						  periods + 1);
		// 重点2
		sa->runnable_avg_period = decay_load(sa->runnable_avg_period,
						     periods + 1);

		/* Efficiently calculate \sum (1..n_period) 1024*y^i */
		// 重点3
		runnable_contrib = __compute_runnable_contrib(periods);
		if (runnable)
			sa->runnable_avg_sum += runnable_contrib;
		sa->runnable_avg_period += runnable_contrib;
	}

	/* Remainder of delta accrued against u_0` */
	if (runnable)
		sa->runnable_avg_sum += delta;
	sa->runnable_avg_period += delta;

	return decayed;
}

__update_entity_runnable_avg()函数参数now表示当前的时间点,由就绪队列rq->clock_task得到,sa表示该调度实体的struct sched_avg数据结构,runnable表示该进程是否在就绪队列上接受调度(se->on_rq)。

delta表示上一次更新到本次更新的时间差,单位是纳秒。delta时间转换成微秒,注意这里为了计算效率右移10位,相当于除以1024。runnable_avg_period记录上一次更新时的总周期数(一个周期是1毫秒,准确来说是1024微秒),第28行代码,delta_w是上一次总周期数中不能凑成一个周期(1024微秒)的剩余的时间,如图3.5所示的T0时间。第29〜59行代码,表示如果上次剩余delta_w加上本次时间差delta大于一个周期,那么就要进行衰减计算。第 62〜64行 代 码 ,如果不能凑成一个周期,不用衰减计算,直接累加runnable_avg_sum和runnable_avg_period的值,最后返回是否进行了衰减运算。

2. CFS调度器_第2张图片

下面来看衰减计算的情况,第38行代码计算的delta_w是图3.5中的T1,这部分时间是上次更新中不满一个周期的剩余时间段,将直接累加到runnable_avg_sum和runnable_avg_period中。第46行代码,periods是指本次更新与上次更新经历周期period的个数,第47行代码,delta如图3.5中的T2时间段。第49〜51行代码,分别对调度实体的mnnable_avg_sum和 runnable_avg_period执行衰减计算,为什么要单独执行衰减计算呢?因为这时的 sa->runnable_avg_sum和sa->runnable_avg_period的值己经是periods个周期之前的值。第55行代码,计算调度实体在periods周期内的累加衰减值。第 56〜58行代码,把之前的两个计算值累加。第 61〜64行代码,把 T2 时间段也添力上。__update_entity_runnable_avg()函数的计算公式可以简单归纳如下:

config

其中,period是指上一次统计到当前统计经历的周期个数,prev_avg_sum是指上一次统计时runnable_avg_sum值在period+1个周期的衰减值,decay指period个周期的衰减值和。runnable_avg_period计算方法类似。如果一个进程在就绪队列里等待了很长时间才被调度,那么该如何计算它的负载呢?假设该进程等待了1000个period,即1024毫秒,之前sa->runnable_avg_sum和sa->runnable_avg_period值为48000,唤醒之后在__update_entity_runnable_avg()函数中的第49〜51行代码,因为period值很大,decay_load()函数计算结果为0,相当于sa->runnable_avg_sum和sa->runnable_avg_period值被清0了。第55行代码,__compute_runnable_contrib()函数计算整个时间的负载贡献值,因 为period大于LOAD_AVG_MAX_N,直接返回LOAD_AVG_M A X 。当 period比较大时,衰减后的可能变成0,相当于之前的统计值被清0 了。

[kernel/sched/fair.c]
/* Update a sched_entity's runnable average */
static inline void update_entity_load_avg(struct sched_entity *se,
					  int update_cfs_rq)
{
	struct cfs_rq *cfs_rq = cfs_rq_of(se);
	long contrib_delta;
	u64 now;

	/*
	 * For a group entity we need to use their owned cfs_rq_clock_task() in
	 * case they are the parent of a throttled hierarchy.
	 */
	if (entity_is_task(se))
		now = cfs_rq_clock_task(cfs_rq);
	else
		now = cfs_rq_clock_task(group_cfs_rq(se));

	if (!__update_entity_runnable_avg(now, &se->avg, se->on_rq))
		return;

	contrib_delta = __update_entity_load_avg_contrib(se);

	if (!update_cfs_rq)
		return;

	if (se->on_rq)
		cfs_rq->runnable_load_avg += contrib_delta;
	else
		subtract_blocked_load_contrib(cfs_rq, -contrib_delta);
}

update_entity_load_avg()函数计算进程最终负载贡献度load_avg_contrib

首先通过__update_entity_runnable_avg()函数计算runnable_avg_sum这个可运行时间的累加值。注意__update_entity_runnable_avg()函数如果返回0,表示上次更新到本次更新的时间间隔不足1024微秒,不做衰减计算,那么本次不计算负载贡献度。然后通过__update_entity_load_avg_contrib()函数计算本次更新的贡献度,最后累加CFS运行队列的cfs_rq->runnable_load_avg 中。

[kernel/sched/fair.c]
static inline void __update_task_entity_contrib(struct sched_entity *se)
{
	u32 contrib;

	/* avoid overflowing a 32-bit type w/ SCHED_LOAD_SCALE */
	contrib = se->avg.runnable_avg_sum * scale_load_down(se->load.weight);
	contrib /= (se->avg.runnable_avg_period + 1);
	se->avg.load_avg_contrib = scale_load(contrib);
}

load_avg_contrib的计算公式如下:

config

可见一个调度实体的平均负载和以下3 个因素相关。

  • 调度实体的权重值weight
  • 调度实体的可运行状态下的总衰减累加时间runnnable_avg_sum。
  • 调度实体在调度器中的总衰减累加时间runnable_avg_period。

runnable_avg_sum越接近runnable_avg_period,则平均负载越大,表示该调度实体一直在占用CPU。

2 进程创建

进程的创建通过do_fork()函数来完成,do_fork()在执行过程中就参与了进程调度相关的初始化

2.1 进程调度相关的初始化

进程调度有一个非常重要的数据结构struct sched_entity, 称为调度实体,该数据结构描述进程作为一个调度实体参与调度所需要的所有信息,例如load表示该调度实体的权重run_node表示该调度实体在红黑树中的节点,on_rq表示该调度实体是否在就绪队列中接受调度,vruntime 表示虚拟运行时间。exec_start、sum_exec_runtime 和 prev_sum_exec_runtime是计算虚拟时间需要的信息,avg表示该调度实体的负载信息

[include/linux/sched.h]

struct sched_entity {
    // 权重
	struct load_weight	load;		/* for load-balancing */
	// 该调度实体在红黑树中的节点
	struct rb_node		run_node;
	struct list_head	group_node;
	// 是否在就绪队列中接受调度
	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;
	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;
#endif

#ifdef CONFIG_SMP
	/* Per-entity load-tracking */
	struct sched_avg	avg;
#endif
};

__sched_fork()函数会把新创建进程调度实体se相关成员初始化为0,因为这些值不能复用父进程,子进程将来要加入调度器中参与调度,和父进程“分道扬镳”。

[do_fork()->sched_fork()->_sched_fork()]
[kernel/sched/core.c]
static void __sched_fork(unsigned long clone_flags, struct task_struct *p)
{
	p->on_rq			= 0;

	p->se.on_rq			= 0;
	p->se.exec_start		= 0;
	p->se.sum_exec_runtime		= 0;
	p->se.prev_sum_exec_runtime	= 0;
	p->se.nr_migrations		= 0;
	p->se.vruntime			= 0;
#ifdef CONFIG_SMP
	p->se.avg.decay_count		= 0;
#endif
	INIT_LIST_HEAD(&p->se.group_node);
}

继续看sched_fork()函数,设置子进程运行状态为TASK_RUNNING,这里不是真正开始运行,因为还没添加到调度器里。

[do_fork() ->sched_fork()]

[kernel/sched/core.c]
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
	unsigned long flags;
	int cpu = get_cpu();
    // 重点1
	__sched_fork(clone_flags, p);
	p->state = TASK_RUNNING;
	p->prio = current->normal_prio;

	if (dl_prio(p->prio)) {
		put_cpu();
		return -EAGAIN;
	} else if (rt_prio(p->prio)) {
		p->sched_class = &rt_sched_class;
	} else {
		p->sched_class = &fair_sched_class;
	}

	if (p->sched_class->task_fork)
		p->sched_class->task_fork(p);
	set_task_cpu(p, cpu);
	put_cpu();
	return 0;
}

根据新进程的优先级确定相应的调度类.每个调度类都定义了一套操作方法集,调用CFS调度器task_fork方法做一些fork相关的初始化

CFS调度器调度类定义的操作方法集如下:

[kernel/sched/fair.c]
/*
 * 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_waking		= task_waking_fair,
#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_move_group	= task_move_group_fair,
#endif
};

task_fork方法实现在kernel/fair.c文件中。

[do_fork()->sched_fork()->task_fork_fair()]
[kernel/sched/fair.c]
static void task_fork_fair(struct task_struct *p)
{
	struct cfs_rq *cfs_rq;
	struct sched_entity *se = &p->se, *curr;
	int this_cpu = smp_processor_id();
	struct rq *rq = this_rq();
	unsigned long flags;

	raw_spin_lock_irqsave(&rq->lock, flags);

	update_rq_clock(rq);

	cfs_rq = task_cfs_rq(current);
	curr = cfs_rq->curr;

	/*
	 * Not only the cpu but also the task_group of the parent might have
	 * been changed after parent->se.parent,cfs_rq were copied to
	 * child->se.parent,cfs_rq. So call __set_task_cpu() to make those
	 * of child point to valid ones.
	 */
	rcu_read_lock();
	// 重点
	__set_task_cpu(p, this_cpu);
	rcu_read_unlock();
    
    // 重点
	update_curr(cfs_rq);

	if (curr)
		se->vruntime = curr->vruntime;
	place_entity(cfs_rq, se, 1);

	if (sysctl_sched_child_runs_first && curr && entity_before(curr, se)) {
		/*
		 * Upon rescheduling, sched_class::put_prev_task() will place
		 * 'current' within the tree based on its new key value.
		 */
		swap(curr->vruntime, se->vruntime);
		resched_curr(rq);
	}

	se->vruntime -= cfs_rq->min_vruntime;

	raw_spin_unlock_irqrestore(&rq->lock, flags);
}

task_fork_fair()函数的参数p表示新创建的进程。进程task_struct数据结构中内嵌了调度实体struct sched_entity结构体,因此由task_struct可以得到该进程的调度实体

smp_processor_id()从当前进程thread_info结构中的cpu成员获取当前CPU id。系统中每个CPU有一个就绪队列(runqueue),它是Per-CPU类型,即每个CPU有一个struct rq数据结构。this_rq()可以获取当前CPU的就绪队列数据结构struct rq

[kernel/sched/sched.h]
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)

struct rq数据结构是描述CPU的通用就绪队列,rq数据结构中记录了一个就绪队列所需要的全部信息,包括一个CFS调度器就绪队列数据结构struct cfs_rq、一个实时进程调度器就绪队列数据结构struct rt_rq和一个deadline调度器就绪队列数据结构struct dl_rq,以及就绪队列的权重load等信息。

struct rq重要的数据结构定义如下:

[kernel/sched/sched.h]
// 就绪队列,每个CPU有一个
struct rq {
	unsigned int nr_running;
	// 就绪队列的权重
	struct load_weight load;
	unsigned long nr_load_updates;
	u64 nr_switches;
	
    // CFS调度器就绪队列
	struct cfs_rq cfs;
	// 实时进程调度器就绪队列
	struct rt_rq rt;
	// deadline调度器就绪队列
	struct dl_rq dl;

	struct task_struct *curr, *idle, *stop;
	u64 clock;
	// 每次滴答tick到来时候更新,可以用于计算实际运行时间delta_exec
	u64 clock_task;

	int cpu;
	int online;
    ...
};

struct cfs_rq是CFS调度器就绪队列的数据结构,定义如下:

[include/1inux/sched.h]
struct cfs_rq {
	struct load_weight load;
	unsigned int nr_running, h_nr_running;

	u64 exec_clock;
	u64 min_vruntime;
	struct sched_entity *curr, *next, *last, *skip;

	unsigned long runnable_load_avg, blocked_load_avg;
    ...
};

内核中调度器相关数据结构的关系如图3.6所示,看起来很复杂,其实它们是有关联的。

2. CFS调度器_第3张图片

回到task_fork_fair()函数中,se表示新进程的调度实体,由current变量(当前进程task_struct数据结构!!!)通过函数task_cfs_rp()取得当前进程对应的CFS调度器就绪队列的数据结构(cfs_rq )。调度器代码中经常有类似的转换,例如取出当前CPU的通用就绪队列struct rq 数据结构,取出当前进程对应的通用就绪队列,取出当前进程对应的CFS调度器就绪队列等。

task_cfs_rq()函数可以取出当前进程对应的CFS就绪队列

#define task_thread_info(task)  ((struct thread_info *)(task)->stack)

static inline unsigned int task_cpu(const struct task_struct *p)
{
    return task_thread_info(p)->cpu;
}

#define cpu_rq(cpu)  (&per_cpu(runqueues, (cpu)))
#define task_rq(p)  cpu_rq(task_cpu(p))

static inline struct cfs_rq *task_cfs_rq(struct task_struct *p)
{
    return &task_rq(p)->cfs;
}

task_fork_fair()函数中__set_task_cpu()把当前CPU绑定到该进程中(struct task_struct中的stack的成员cpu!!!),p->wake_cpu在后续唤醒该进程时会用到这个成员。

[kernel/sched/sched.h]
static inline void __set_task_cpu(struct task_struct *p, unsigned int cpu)
{
	set_task_rq(p, cpu);
#ifdef CONFIG_SMP
	smp_wmb();
	// 当前CPU绑定到该进程中
	task_thread_info(p)->cpu = cpu;
	p->wake_cpu = cpu;
#endif
}

update_curr()函数是CFS调度器中比较核心的函数(该函数很重要!!!)。

[kernel/sched/fair.c]
static void update_curr(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	// 获取当前CPU的通用就绪队列保存的clock_task值,
	// 该变量在每次时钟滴答(tick)到来时更新。
	u64 now = rq_clock_task(rq_of(cfs_rq));
	u64 delta_exec;

	if (unlikely(!curr))
		return;
    // 上一次调用update_curr()函数到现在的时间差,即实际运行的时间.
    // 权重大的
    // 权重值大的后期该值会变大
	delta_exec = now - curr->exec_start;
	if (unlikely((s64)delta_exec <= 0))
		return;
    // 开始执行时间设为现在
	curr->exec_start = now;
	curr->sum_exec_runtime += delta_exec;
	
	// 虚拟运行时间不断增加
	curr->vruntime += calc_delta_fair(delta_exec, curr);
	update_min_vruntime(cfs_rq);
    ...
}

update_curr()函数的参数是当前进程对应的CFS就绪队列curr指针指向的调度实体是当前进程,即父进程。这个函数主要更新的就是当前进程(父进程!!!)结构体相关成员.

rq_clock_task()获取当前就绪队列(每个CPU对应的通用就绪队列)保存的clock_task值,该变量在每次时钟滴答(tick)到来时更新。

calc_delta_fair()函数计算过程:

config

delta_exec计算该进程从上次调用update_curr()函数到现在的时间差(实际运行的时间!!!)。

calc_delta_fair()使用delta_exec时间差来计算该进程的虚拟时间vruntime

[kernel/sched/fair.c]
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;
}

调度实体struct sched_entity数据结构中有一个成员weight,用于记录该进程的权重。calc_delta_fair()首先判断该调度实体的权重是否为NICE_0_LOAD,如果是,则直接使用该delta时间。NICE_0_LOAD类似参考权重,__calc_delta()利用参考权重来计算虚拟时间。把nice值为0的进程作为一个参考进程,系统上所有的进程都以此为参照物,根据参考进程权重和权重的比值作为速率向前奔跑。nice值范围是-20〜19,nice值越大,优先级越低。优先级越低的进程,其权重也越低。因此按照vruntime的计算公式,进程权重小,那么vruntime值反而越大;反之,进程优先级高,权重也大,vruntime值反而越小

CFS总是在红黑树中选择vruntime最小的进程进行调度,优先级高的进程总会被优先选择,随着vruntime增长(权限级高的后期vruntime值会变大),优先级低的进程也会有机会运行

回到task_fork_fair()函数中的place_entity()函数.

[kernel/sched/fair.c]
static void
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
	u64 vruntime = cfs_rq->min_vruntime;

	/*
	 * The 'current' period is already promised to the current tasks,
	 * however the extra weight of the new task will slow them down a
	 * little, place the new task so that it fits in the slot that
	 * stays open at the end.
	 */
	// 重点1
	if (initial && sched_feat(START_DEBIT))
	    // vruntime增加了一个惩罚值
		vruntime += sched_vslice(cfs_rq, se);

	/* sleeps up to a single latency don't count. */
	if (!initial) {
		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;

		vruntime -= thresh;
	}

	/* ensure we never gain time by being placed backwards. */
	se->vruntime = max_vruntime(se->vruntime, vruntime);
}

place_entity()参数cfs_rq指父进程对应的cfs就绪队列,se是新进程的调度实体,initial值为1。每个cfs_rq就绪队列中都有一个成员min_vruntime。min_vruntime其实是单步递增的,用于跟踪整个CFS就绪队列中红黑树里的最小vruntime值

重点1处,如果当前进程用于fork新进程,那么这里会对新进程的vruntime做一些惩罚,因为新创建了一个进程导致CFS运行队列的权重发生了变化。惩罚值通过sched_vslice()函数来计算。

[kernel/sched/fair.c]
unsigned int sysctl_sched_latency = 6000000ULL;
static unsigned int sched_nr_latency = 8;
/*
 * Minimal preemption granularity for CPU-bound tasks:
 * (default: 0.75 msec * (1 + ilog(ncpus)), units: nanoseconds)
 */
unsigned int sysctl_sched_min_granularity = 750000ULL;
unsigned int normalized_sysctl_sched_min_granularity = 750000ULL;

static u64 __sched_period(unsigned long nr_running)
{
	u64 period = sysctl_sched_latency;
	unsigned long nr_latency = sched_nr_latency;

	if (unlikely(nr_running > nr_latency)) {
		period = sysctl_sched_min_granularity;
		period *= nr_running;
	}

	return period;
}

首先,__sched_period()函数会计算CFS就绪队列中一个调度周期的长度,可以理解为一个调度周期的时间片,它根据当前运行的进程数目(调度周期时间片根据运行的进程数目确定!!!)来计算。CFS调度器有一个默认调度时间片,默认值为6毫秒,详见sysctl_sched_latency变量。当运行中的进程数目大于8时,按照进程最小的调度延时(sysctl_sched_min_granularity,0.75毫秒)乘以进程数目来计算调度周期时间片,反之用系统默认的调度时间片,即 sysctl_sched_latency。

[kernel/sched/fair.c]
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    // 一个调度周期的时间片
	u64 slice = __sched_period(cfs_rq->nr_running + !se->on_rq);

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

		cfs_rq = cfs_rq_of(se);
		load = &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);
	}
	return slice;
}

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

[kernel/sched/fair.c]
static u64 sched_vslice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	return calc_delta_fair(sched_slice(cfs_rq, se), se);
}

sched_vslice()根据sched_slice()计算得到的当前进程的调度时间来计算可以得到多少虚拟时间

回到place_entity()函数,新创建的进程(initial为1)会得到惩罚,惩罚的时间(vruntime增加的值)根据新进程的权重由sched_vslice()函数计算虚拟时间。最后新进程调度实体的虚拟时间是在调度实体的实际虚拟时间和CFS运行队列中min_vruntime中取最大值,详细见代码。

static void task_fork_fair(struct task_struct *p)
{
    ...
    place_entity(cfs_rq, se, 1);
    ...
    se->vruntime -= cfs_rq->min_vruntime;
    ...
}

回到task_fork_fair()函数,为何通过place_entity()函数计算得到的se->vruntime(新进程的调度实体对应的vruntime)要减去min_vruntime呢?难道不用担心该vruntime变得很小恶意占用调度器吗?新进程还没有加入到调度器中,加入调度器时会重新增加min_vruntime值。换个角度来思考,新进程在place_entity()函数中得到了一定的惩罚,惩罚的虚拟时间由sched_vslice()计算,在某种程度上也是为了防止新进程恶意占用CPU时间。

再回到do_fork()函数中,新进程创建完成后需要由wake_up_new_task()把它加入到调度器中。

[do_fork()->wake_up_new_task()]
[kernel/sched/core.c]
void wake_up_new_task(struct task_struct *p)
{
	unsigned long flags;
	struct rq *rq;

	raw_spin_lock_irqsave(&p->pi_lock, flags);
#ifdef CONFIG_SMP
	/*
	 * Fork balancing, do it here and not earlier because:
	 *  - cpus_allowed can change in the fork path
	 *  - any previously selected cpu might disappear through hotplug
	 */
	// 重点1
	set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
#endif

	/* Initialize new task's runnable average */
	init_task_runnable_average(p);
	rq = __task_rq_lock(p);
	// 重点2
	activate_task(rq, p, 0);
	p->on_rq = TASK_ON_RQ_QUEUED;
	trace_sched_wakeup_new(p, true);
	check_preempt_curr(rq, p, WF_FORK);
#ifdef CONFIG_SMP
	if (p->sched_class->task_woken)
		p->sched_class->task_woken(rq, p);
#endif
	task_rq_unlock(rq, p, &flags);
}

在前文中sched_fork()函数(copy_process()中调用的)己经设置了父进程的CPU子进程thread_info->cpu中,为何这里要重新设置呢?因为在fork新进程的过程中,cpus_allowed有可能发生变化,另外一个原因是之前选择的CPU有可能被关闭了,因此重新选择CPU。select_task_rq()函数会调用CFS调度类select_task_rq()方法来选择一个合适的调度域中最悠闲的CPU。select_task_rq()方法将在第3.3节中再详细介绍。

activate_task()调用 enqueue_task()函数。

[kernel/sched/core.c]
void activate_task(struct rq *rq, struct task_struct *p, int flags)
{
	if (task_contributes_to_load(p))
		rq->nr_uninterruptible--;

	enqueue_task(rq, p, flags);
}

static void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
	update_rq_clock(rq);
	p->sched_class->enqueue_task(rq, p, flags);
}

update_rq_clock()更新rq->clock_task.

[kernel/sched/fair.c]
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;

    // 重点1
	for_each_sched_entity(se) {
		if (se->on_rq)
			break;
		cfs_rq = cfs_rq_of(se);
		// 重点2
		enqueue_entity(cfs_rq, se, flags);

		/*
		 * end evaluation on encountering a throttled cfs_rq
		 *
		 * note: in the case of encountering a throttled cfs_rq we will
		 * post the final h_nr_running increment below.
		*/
		if (cfs_rq_throttled(cfs_rq))
			break;
		cfs_rq->h_nr_running++;

		flags = ENQUEUE_WAKEUP;
	}

	for_each_sched_entity(se) {
		cfs_rq = cfs_rq_of(se);
		cfs_rq->h_nr_running++;

		if (cfs_rq_throttled(cfs_rq))
			break;

		update_cfs_shares(cfs_rq);
		// 重点3
		update_entity_load_avg(se, 1);
	}

	if (!se) {
		update_rq_runnable_avg(rq, rq->nr_running);
		add_nr_running(rq, 1);
	}
	hrtick_update(rq);
}

eriqueue_task_fair()把新进程添加到CFS就绪队列中。

重点1处,for循环对于没有定义FAIR_GROUP_SCHED的系统来说,其实是调度实体se

重点2,enqueue_entity()把调度实体se添加到cfs_rq就绪队列中。

重点3处,update_rq_runnable_avg()更新该调度实体的负载load_avg_contrib就绪队列的负载runnable_load_avg

下面来看 enqueue_entity() 函数。

[kernel/sched/fair.c]
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
	/*
	 * Update the normalized vruntime before updating min_vruntime
	 * through calling update_curr().
	 */
	// 重点1
	if (!(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_WAKING))
		se->vruntime += cfs_rq->min_vruntime;

	/*
	 * Update run-time statistics of the 'current'.
	 */
	// 重点2
	update_curr(cfs_rq);
	// 重点3
	enqueue_entity_load_avg(cfs_rq, se, flags & ENQUEUE_WAKEUP);
	account_entity_enqueue(cfs_rq, se);
	update_cfs_shares(cfs_rq);
    
    // 重点4
	if (flags & ENQUEUE_WAKEUP) {
		place_entity(cfs_rq, se, 0);
		enqueue_sleeper(cfs_rq, se);
	}

	update_stats_enqueue(cfs_rq, se);
	check_spread(cfs_rq, se);
	if (se != cfs_rq->curr)
	    // 重点5
		__enqueue_entity(cfs_rq, se);
	// 重点6
	se->on_rq = 1;

	if (cfs_rq->nr_running == 1) {
		list_add_leaf_cfs_rq(cfs_rq);
		check_enqueue_throttle(cfs_rq);
	}
}

重点1处,新进程是刚创建的,因此该进程的vruntime要加上min_vruntime。回想之前在task_fork_fair()函数里vruntime减去min_vruntime,这里又添加回来,因为task_fork_fair()只是创建进程还没有把该进程添加到调度器,这期间min_vnmtime已经发生变化,因此添加上min_vruntime是比较准确的。

重点2处,update_curr()更新当前进程的vruntime(父进程!!!)和该CFS就绪队列的min_vruntime。

重点3处,计算该调度实体se(新进程)的平均负载load_avg_contrib,然后添加到整个CFS就绪队列的总平均负载cfs_rq->runnable_load_avg中。

重点4处,处理刚被唤醒的进程,place_entity()对唤醒进程有一定的补偿,最多可以补偿一个调度周期的一半(默认值sysctl_sched_latency/2,3 毫秒),即vruntime减去半个调度周期时间

重点5处,__enqueue_emity()把该调度实体添加到CFS就绪队列的红黑树中。

重点6处,设置该调度实体的on_rq成员为1,表示已经在CFS就绪队列中。se->on_rq经常会被用到,例如update_entity_load_avg()函数。

[kernel/sched/fair.c]
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	struct rb_node **link = &cfs_rq->tasks_timeline.rb_node;
	struct rb_node *parent = NULL;
	struct sched_entity *entry;
	int leftmost = 1;

	/*
	 * Find the right place in the rbtree:
	 */
	while (*link) {
		parent = *link;
		entry = rb_entry(parent, struct sched_entity, run_node);
		/*
		 * We dont care about collisions. Nodes with
		 * the same key stay together.
		 */
		if (entity_before(se, entry)) {
			link = &parent->rb_left;
		} else {
			link = &parent->rb_right;
			leftmost = 0;
		}
	}

	/*
	 * Maintain a cache of leftmost tree entries (it is frequently
	 * used):
	 */
	if (leftmost)
		cfs_rq->rb_leftmost = &se->run_node;

	rb_link_node(&se->run_node, parent, link);
	rb_insert_color(&se->run_node, &cfs_rq->tasks_timeline);
}

3 进程调度

__schedule()是调度器的核心函数,其作用是让调度器选择和切换到一个合适进程运行。调度的时机可以分为如下3种。

  • 阻塞操作:互斥量(mutex)、信号量(semaphore)、等待队列(waitqueue) 等
  • 中断返回前系统调用返回用户空间时,去检查TIF_NEED_RESCHED标志位以判断是否需要调度。
  • 将要被唤醒的进程(Wakeups)不会马上调用schedule()要求被调度,而是会被添加到CFS就绪队列中,并且设置TIF_NEED_RESCHED标志位。那么唤醒进程什么时候被调度呢?这要根据内核是否具有可抢占功能(CONFIG_PREEMPT=y)分两种情况。

如果内核可抢占,则:

  • 如果唤醒动作发生在系统调用或者异常处理上下文中,在下一次调用preempt_enable()时会检查是否需要抢占调度;
  • 如果唤醒动作发生在硬中断处理上下文中,硬件中断处理返回前夕会检查是否要抢占当前进程。

如果内核不可抢占,则:

  • 当前进程调用cond_resched()时会检查是否要调度;
  • 主动调度调用schedule();
  • 系统调用或者异常处理返回用户空间时;
  • 中断处理完成返回用户空间时。

前文提到的硬件中断返回前夕硬件中断返回用户空间前夕是两个不同的概念。前者是每次硬件中断返回前夕都会检查是否有进程需要被抢占调度,不管中断发生点是在内核空间,还是用户空间;后者是只有中断发生点在用户空间才会检查。

[kernel/sched/core.c]
static void __sched __schedule(void)
{
	struct task_struct *prev, *next;
	unsigned long *switch_count;
	struct rq *rq;
	int cpu;

	preempt_disable();
	cpu = smp_processor_id();
	rq = cpu_rq(cpu);
	rcu_note_context_switch();
	prev = rq->curr;

	schedule_debug(prev);

	if (sched_feat(HRTICK))
		hrtick_clear(rq);

	/*
	 * Make sure that signal_pending_state()->signal_pending() below
	 * can't be reordered with __set_current_state(TASK_INTERRUPTIBLE)
	 * done by the caller to avoid the race with signal_wake_up().
	 */
	smp_mb__before_spinlock();
	raw_spin_lock_irq(&rq->lock);

	rq->clock_skip_update <<= 1; /* promote REQ to ACT */

	switch_count = &prev->nivcsw;
	// 重点1
	if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
		if (unlikely(signal_pending_state(prev->state, prev))) {
			prev->state = TASK_RUNNING;
		} else {
			deactivate_task(rq, prev, DEQUEUE_SLEEP);
			prev->on_rq = 0;

			/*
			 * If a worker went to sleep, notify and ask workqueue
			 * whether it wants to wake up a task to maintain
			 * concurrency.
			 */
			if (prev->flags & PF_WQ_WORKER) {
				struct task_struct *to_wakeup;

				to_wakeup = wq_worker_sleeping(prev, cpu);
				if (to_wakeup)
					try_to_wake_up_local(to_wakeup);
			}
		}
		switch_count = &prev->nvcsw;
	}

	if (task_on_rq_queued(prev))
		update_rq_clock(rq);

	next = pick_next_task(rq, prev);
	clear_tsk_need_resched(prev);
	clear_preempt_need_resched();
	rq->clock_skip_update = 0;

	if (likely(prev != next)) {
		rq->nr_switches++;
		rq->curr = next;
		++*switch_count;

		rq = context_switch(rq, prev, next); /* unlocks the rq */
		cpu = cpu_of(rq);
	} else
		raw_spin_unlock_irq(&rq->lock);

	post_schedule(rq);

	sched_preempt_enable_no_resched();
}

__schedule()函数调用pick_next_task()让进程调度器从就绪队列中选择一个最合适的进程next,然后context_switch()切换到next进程运行。

prev指当前进程。thread_info数据结构中的preempt_count成员用于判断当前进程是否可以被抢占,preempt_count的低8位用于存放抢占引用计数(preemption count),除此之外,还有一个比特位用于PREEMPT_ACTIVE,它只有在内核抢占调度中会被置位,详见preempt_schedule()函数。

[preempt_schedule() -> preempt_schedule_common()]
[kernel/sched/core.c]
static void __sched notrace preempt_schedule_common(void)
{
	do {
	    // preempt_count中比特位PREEMPT_ACTIVE置位
		__preempt_count_add(PREEMPT_ACTIVE);
		// 调度
		__schedule();
		// preempt_count中比特位PREEMPT_ACTIVE清除
		__preempt_count_sub(PREEMPT_ACTIVE);

		/*
		 * Check again in case we missed a preemption opportunity
		 * between schedule and now.
		 */
		barrier();
	} while (need_resched());
}

重点1处判断语句基于以下两种情况来考虑。

  • 不处于正在运行状态下的当前进程清除出就绪队列TASK_RUNNING的状态值为0,其他状态值都非0。
  • 中断返回前夕的抢占调度的情况。

如果当前进程在之前发生过抢占调度preempt_schedule(),那么在preempt_schedule()->__schedule()时它不应该被清除出运行队列。为什么这里做这样的判断呢?下面以睡眠等待函数wait_event()为例,当前进程调用wait_event函数,当条件(condition)不满足时,就会把当前进程加入到睡眠等待队列wq中,然后schedule()调度其他进程直到满足condition。

wait_event()函数等价于如下代码片段:

0 #define __wait_event (wq, condition) \
1 do {  \
2     DEFINE_WAIT(_wait);
3 for (;;) {  \
4           wait->private = current; \
5           list_add(&_wait->task_list, &wq->task_list); \
6           set_current_state(TASK_UNINTERRUPTIBLE); \
7           if (condition)  \  < = 发生中断
8           break;  \
9           schedule();  \
10 }  \
llset_current_state(TASK_RUNNING); \
121ist_del_init (&_wait->task_list); \
13} while (0)

这里需要考虑以下两种情况。

  • 进程p在for循环中等待condition条件发生,另外一个进程A设置condition条件来唤醒进程p,假设系统中只触发一次condition条件。第6行代码设置当前进程p的状态为TASKJJNINTERRUPTIBLE之后发生了一个中断,并且中断处理返回前夕判断当前进程p是可被抢占的。如果当前进程p的thread_info的preempt_count中没有置位PREEMPT_ACTIVE,那么根据_schedule()函数中第23〜31行代码的判断逻辑,当前进程会被清除出运行队列。如果此后再也没有进程来唤醒进程P,那么进程p 再也没有机会被唤醒了。

你可能感兴趣的:(2. CFS调度器)