Linux进程管理(二)进程调度

Linux进程管理

Linux进程管理(一)进程数据结构

Linux进程管理(二)进程调度

Linux进程管理(三)进程调度之主动调度

Linux进程管理(四)进程调度之抢占式调度

Linux进程管理(二)进程调度

一、进程调度解决什么问题?

我们在使用电脑的时候,比如打开一个视频剪辑器,一个文本编辑器,可以认为它们都是一个进程。假如CPU是单核的,那么在同一时间只能运行一个进程,但是给我们的感觉是视频剪辑器和文本编辑器好像是同时运行的,也就是视频剪辑器在剪辑视频的时候,我们同时可以使用文本编辑器,这是怎么实现的呢?

其实这只是我们从宏观上感觉它们是并行运行的,而微观上它们是串行运行的。也就是说,可以认为这两个进程在做频繁的切换,比如视频剪辑器运行10ms,然后文本编辑器运行10ms,如此交替,这样子它们其实串行运行的,但由于我们的反应没那么快,所以觉得它们是并行运行的,如下图所示

在这里插入图片描述

一般操作系统的进程的进程数会非常的多,而一个CPU同一时间只能运行一个进程,这些进程可能是视频剪辑器,可能是文本编辑器等等。例如文本编辑器大多数时间在等待我们按下按键,并不需要占用太多CPU运行时间,而每当我们按下键盘上的按键的时候,它需要快速响应我们的操作并且将字符显示在屏幕。而视频剪辑器在剪辑视频的时候非常耗费CPU,但是它并不需要像文本编辑器那么频繁地与用户交互。也就是文本编辑器它可以占用更少地CPU运行时间,但是它需要快速响应用户操作,而视频编辑器它需要占用更多地CPU运行时间,但是它不需要快速响应用户操作,如下图所示

在这里插入图片描述

为了提高用户体验和系统性能,要解决的问题就是决定什么时候应该运行哪一个进程,该进程应该运行多久。也就是我们上面举的例子,每当我们操作文本编辑器的时候,要快速让文本编辑器处于运行状态,在我们没有操作文本编辑器的时候,应该尽量让视频剪辑器运行

这就是进程调度解决的问题,这也是衡量一个操作系统的优秀与否的一个重要指标

本篇文章讲解Linux如何管理进程,进程调度是怎么转起来的,为了实现进程调度维护了哪些数据结构,实现了哪些算法

至于一个进程如何实现抢占,进程调度的时机等细节将放到后面的文章讲解

二、进程调度整体框架

在设计到内核具体的代码之前,我先来给你讲解一下进程调度的大体框架,让你明白进程调度是怎么转起来的,在你明白每一个部分的含义之后,再深入讲解内核的实现

操作系统管理非常多的进程,这些进程当前可能处于可运行状态或者睡眠状态。进程调度解决的是当前应该运行哪一个进程,它关心的对象是当前可运行状态的进程,内核为了管理这些可运行的进程,准备了一个运行队列,如下图所示

在这里插入图片描述

对于多CPU处理器,每一个CPU都有属于它的运行队列

我们将CPU当前正在运行的进程称为 current 进程,current 进程是不在运行队列中的,如下图所示

在这里插入图片描述

接下来要解决的是,current进程什么时候应该被其它进程抢占,以及如何抢占?

进程切换一般分为两步

  • 第一步对current进程设置需要重新调度标志

  • 第二步在系统调用返回或中断返回时等时机检查current进程是否设置了需要重新调度标志,如果需要,则调用schedule发生进程切换(具体的时机将在后面的文章详细讨论,这里暂且这么认为就行)

    什么是系统调用返回和中断返回?

    如果你对这两个概念不了解也无大碍,我这里简单地讲解。你可以理解成,当CPU在运行某一个进程的时候,发生系统调用或者中断,会暂停进程的运行,然后去执行特定的处理程序,在执行完处理程序想要恢复进程运行的这个时候,就是系统调用返回或中断返回的时机

    中断是由硬件触发的,系统调用是进程运行时触发的,可能有很多硬件频繁地产生中断,许多进程频繁地触发系统调用,所以对于操作系统来说,系统调用返回和中断返回这样的时机是随机又频繁地产生地,所以我们有很多个时机可以去检查current进程是否需要被切换

    另外,你可以这样理解真正发生进程切换都是通过调用schedule函数完成的

首先我们来解决第一步,设置current进程需要重新调度的标志

我们通过什么机制来设置current进程需要重新调度的标志呢?

硬件电路中有一个硬件定时器,它负责周期性的产生时钟中断(一般为10ms),我们称它为滴答定时器,可以认为,它就是操作系统的心脏。每当产生定时器中断的时候,CPU就会执行中断处理程序

在这里插入图片描述

在滴答定时器的中断处理中,我们会判断current进程是否需要被抢占,怎么判断?

很明显,这一部分需要具体的调度算法来实现,Linux将调度算法的实现抽象成调度类

在滴答定时器的中断处理中,通过调度类去实现相应的计算,然后判断current进程是否需要被抢占,如果需要被抢占,那么就在current进程设置需要重新调度的标志,如下图所示

在这里插入图片描述

实时上,Linux内核的调度类不仅仅只有一个,因为内核同时实现了多种调度算法,但是我们这里强调总体框架,暂不讨论这里细节问题

到此,进程切换的第一步设置current进程需要重新调度标志部分已经讲解完

接下看第二步,进程真正的切换

实现进程真正的切换总是调用schedule函数,而schedule函数被调用的一般时机是系统调用返回或者是中断返回时。在系统调用返回或者是中断返回中,会检查current进程是否设置了需要重新调度标志,如果设置了,那么就调用schedule函数

系统调用返回或者是中断返回这样的时机对于操作系统整体来说,总是随机且频繁地产生,如下图所示

在这里插入图片描述

如果current进程设置了需要重新调度标志,那么就会调用schedule函数。schedule函数会通过调度类,从运行队列中选取下一个要运行的进程,然后抢占current进程,成为新的current进程,如下图所示

在这里插入图片描述

到这里,你应该明白了整个进程调度机制是怎么运行起来的,以及为了实现进程调度,实现了哪些数据结构,下面适当地总结一下

  • 首先进程调度处理的对象是可运行的进程,所以准备了一个运行队列来管理当前可运行的进程,如果是多CPU处理器,那么每一个CPU都有它对应的一个运行队列
  • CPU当前正在运行的进程成为current进程,进程调度解决的问题就是合理地切换current进程
  • 进程发生切换需要两步,第一步在current进程设置需要重新调度的标志。第二步是在中断返回或系统调用返回时,检查是否current进程是否设置两类需要重新调度标志,如果设置了,那么就调用schedule函数来发生进程抢占(换言之,进程真正发生切换总是通过调用schedule函数发生的)
  • 在硬件电路有一个滴答定时器,每隔10ms产生一次中断,CPU就处理一次中断。在滴答定时器中断处理中,通过调度类来检查current进程是否需要被切换,如果需要就设置需要重新调度的标志
  • 对于整个操作系统来说,中断和系统调用总是随机且频繁地产生,在中断返回或者系统调用返回地时候,会检查current进程是设置了需要重新调度地标志。如果设置了,就会调用schedule函数发生进程抢占,切换current进程
  • schedule函数通过调度类,从运行队列中获取下一个运行的进程,然后用它来抢占current进程,从而切换进程运行

下面我们继续深入讲解各个部分

三、优先级与调度策略

在内核中,肯定不能对所有的进程一视同仁,有的进程需要优先运行,有的进程需要运行更长的时间

为了更好地实现进程调度,每个进程都有自己的优先级调度策略

所谓优先级,就是表示这个进程的重要性,优先级高的自然会被更好的对待

那调度策略又是什么呢?想一想,进程调度其实是一个非常复杂的问题,想使用一种算法来实现良好的进程调度是不可能的,Linux内核实现了好几种调度算法。所谓调度策略,你可以理解为使用哪种算法来管理进程

每个进程都使用 task_struct 结构来表示,在这个结构体中,关于调度策略的定义如下

unsigned int policy;

policy 表示该进程采用哪种调度策略,内核提供了以下几种调度策略

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

#define SCHED_NORMAL		0
#define SCHED_FIFO		1
#define SCHED_RR		2
#define SCHED_BATCH		3
#define SCHED_IDLE		5
#define SCHED_DEADLINE		6

Linux内核的进程大概可分为两类,一类是普通进程,一类是实时进程

其中属于实时进程的调度策略是 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE

属于普通进程的调度策略是 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE

下面我来跟你详细讲解每个调度策略代表什么

  • SCHED_DEADLINE:这是实时进程的调度策略,它是按照任务的deadline来调度的,当产生一个调度点的时候,总会选取距离deadline最近的进程来运行

  • SCHED_FIFO:这是实时进程的一种调度策略,FIFO表示先进先出机制,在使用该调度策略的进程被选中运行后,它可以运行任意长时间,直到更高优先级的进程抢占或者自己让出CPU

  • SCHED_RR:这也是实时进程的调度策略,RR是时间片轮转调度,每个使用该调度策略的进程都有自己的时间片,进程运行直到时间片耗尽,再将其添加到运行队列尾部,如此循环

  • SCHED_NORMAL:表示普通进程的调度策略,内核大多数进程都属于普通进程,普通进程使用完全公平调度算法实现调度

  • SCHED_BATCH:是用于非交互,CPU使用密集的批处理进程,它和普通进程都是使用完全公平调度算法来实现。内核中在某时刻可以去唤醒某个进程,如果这个进程的调度策略是SCHED_BATCH,那它就不会去抢占当前正在运行的进程

  • SCHED_IDLE:是用于特别空闲的进程使用的调度策略

讲完调度策略,我们来将优先级

task_struct 中关于优先级的定义如下

int prio, static_prio, normal_prio;
unsigned int rt_priority;

是的,内核使用了四个变量来表示优先级,这四个变量之间的关系相当复杂,不过没关系,我会尽量地解释清楚

  • prio:动态优先级,进程调度中判断一个进程的优先级都是使用此变量
  • normal_prio:这个变量也表示动态优先级,它表示正常的优先级。最初的时候 prio 是等于 normal_prio 的,只不过有的时候进程的优先级需要临时改变,所以会改变prio,但是 normal_prio 是不会变的。在创建子进程的时候,子进程继承的优先级是normal_prio,而不是prio
  • static_prio:表示进程优先级,进程启动的时候赋值的,内核不会去改变它,只能用户通过nice和sched_setscheduler 系统调用来设置
  • rt_priority:只有实时进程才会用到的优先级,其值范围是0~99,最低优先级是0,最高优先级是99

这四个变量有什么联系呢?

prio 和 normal_prio 最初的值是相等的,它们都是基于 static_prio 或者 rt_priority 计算的(至于基于 staticc_prio 还是 rt_priority,取决于调度策略)

下面来看一看内核的代码

内核中将0139的优先级划分为两个范围,099表示实时进程优先级,100~139的优先级表示普通进程的优先级,数值越小表示优先级越高

在这里插入图片描述

首先我们讲static_prio,进程启动的时候会设置好静态优先级。如果需要修改,可以通过nice系统调用来设置,nice的范围是-2019,最终映射到优先级为100139的部分,如下所示

在这里插入图片描述

内核中定义如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

/* nice的范围 */
#define MAX_NICE	19
#define MIN_NICE	-20
#define NICE_WIDTH	(MAX_NICE - MIN_NICE + 1) //20

#define DEFAULT_PRIO		(MAX_RT_PRIO + NICE_WIDTH / 2) //120

#define NICE_TO_PRIO(nice)	((nice) + DEFAULT_PRIO) //100~139

void set_user_nice(struct task_struct *p, long nice)
{
    ...
    p->static_prio = NICE_TO_PRIO(nice);
	...
}

rt_priority 动态优先级又是怎么指定的呢?

用户层可以通过 sched_setscheduler,将普通进程更改为实时进程,通过更改进程的调度策略,同时设置 rt_priority,也就是说它的值可以是应用程序指定的,范围是0~99

在清楚 static_priort_priority 怎么得来之后,我们来看看 normal_prioprio 这两个变量是怎么计算的

内核中通过下面的代码来设置

p->prio = effective_prio(p);

看一下 effective_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;
}

可以看到,通过这条指令 p->prio = effective_prio§,会同时设置 prio 和 normal_prio,下面来看看 normal_prio 函数的定义,这个函数也是解开这几个变量之间关系的关键

#define MAX_DL_PRIO		0

static inline int normal_prio(struct task_struct *p)
{
	int prio;

	if (task_has_dl_policy(p)) //deadline调度策略
		prio = MAX_DL_PRIO-1; //-1
	else if (task_has_rt_policy(p)) //FIFO或者RR的调度策略
		prio = MAX_RT_PRIO-1 - p->rt_priority; //99-rt_priority
	else //普通进程调度策略(NORMAL、BATCH、IDLE)
		prio = __normal_prio(p);
	return prio;
}

normal_prio 根据进程不同的调度策略,使用不同的方法来设置进程的优先级

  • 如果进程采用 SCHED_DEADLINE 调度策略,那么优先级就等于-1,这可不在正常的0~139范围内,可见SCHED_DEADLINE 调度策略的优先级是极高的

  • 如果进程采用 SCHED_FIFO 或者 SCHED_RR 调度策略,那么优先级就等于 99 - rt_priority,rt_priority 的范围是0~99。当rt_priority 的越大,优先级数值越小,优先级就越高。这也就是为什么动态优先级 rt_priority 越大,优先级越大

  • 如果是采用 SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE 调度策略,那么就采用 __normal_prio 来计算,其定义如下

    static inline int __normal_prio(struct task_struct *p)
    {
        /* 直接返回static_prio */
    	return p->static_prio;
    }
    

将上述的关系整理下表

进程类型 static_prio normal_prio prio
非实时进程(普通进程) static_prio static_prio static_prio
优先级提高的非实时进程 static_prio static_prio 不变
实时进程 static_prio MAX_RT_PRIO-1-rt_priority 不变

优先级和调度策略都存在于 task_struct 中,它们都是描述进程的信息,它们具体有什么用,我们下面将会介绍

四、调度类

进程指定了调度策略,表明要使用哪种调度算法,那么总要有一个地方来实现这个算法吧

你还记得我们讲的进程调度基本框架吗,看一下下面这张图

在这里插入图片描述

我们说过,其中的调度类就是实现了某种调度算法,实际上内核中不止一个调度类,因为需要实现好几种算法,所以内核有好几个调度类。但是不一定每种调度算法都对应一个唯一的调度类,一个调度类可以实现多种算法

内核中定义以下几个调度类

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

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;
  • stop_sched_class:这个调度类实现的是一直让一个进程运行,不允许被抢占
  • dl_sched_class:这个调度类实现的是deadline调度算法
  • rt_sched_class:这个调度实现了FIFO和RR调度算法
  • fair_sched_class:这个调度类实现了完全公平调度算法(CFS)
  • idle_sched_class:非常空闲的任务会使用此调度类

调度类是全局的,在 task_struct 中,定义以下变量来指向相应的调度类

const struct sched_class	*sched_class;

一个进程具体使用什么调度类,取决于它所选择的调度策略,调度策略和调度类之间是有映射关系的,它们的映射关系如下

调度策略 调度类
stop_sched_class
SCHED_DEADLINE dl_sched_class
SCHED_FIFO、SCHED_RR rt_sched_class
SCHED_NORMAL、SCHED_BATCH fair_sched_class
SCHED_IDLE idle_sched_class

这些调度类,从上到下,优先级从高到低。它们被按照这个顺序串成一个链表,如下所示

在这里插入图片描述

在进程调度基本框架中,我们说过,schedule 会通过调度类去运行队列中挑选下一个要运行的进程,然后抢占 current 进程。现在这里需要再进一步详细说明,应该说,schedule 会按照这个优先级顺序去遍历调度类,使用相应的调度类去尝试从运行队列中获取下一个要运行的进程,只要能够获取到一个进程,那么就会返回

所以使用实时进程使用了更高优先级的调度类,它们总是比使用 fair_sched_class 的普通进程更优先被调用

在 schedule 函数中会通过 pick_next_task,来挑选下一个运行的进程,其定义如下

#define sched_class_highest (&stop_sched_class)

#define for_each_class(class) \
   for (class = sched_class_highest; class; class = class->next)

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	...
     
	/* 遍历调度类来挑选进程 */
	for_each_class(class) {
    	p = class->pick_next_task(rq, prev, rf);
        if (p) {
            return p;
        }
    }
}

其中 for_each_class 就是遍历调度类链表

下面来看一看调度类的定义,看它究竟需要实现什么功能,定义如下(我已经删除其中的一部分成员了)

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

struct sched_class {
	const struct sched_class *next;

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

	void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

	struct task_struct * (*pick_next_task)(struct rq *rq,
					       struct task_struct *prev,
					       struct rq_flags *rf);
	void (*put_prev_task)(struct rq *rq, struct task_struct *p);

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

	void (*update_curr)(struct rq *rq);
};
  • next:指针,就像上面所说,所有的调度类会被串成一个链表
  • enqueue_task:将一个可运行的进程放入运行队列中
  • dequeue_task:enqueue_task 的反操作,将一个进程从运行队列中删除
  • yield_task:如果当前进程想自愿放弃CPU,可以通过 sched_yield 系统调用,最终会调用到这个函数
  • check_preempt_curr:在必要的情况下,会调用check_preempt_curr,用一个新唤醒的进程来抢占当前进程,例如使用 wake_up_new_task 来唤醒新进程,就会调用到此函数
  • pick_next_task:从运行队列中选取下一个要运行的进程
  • put_prev_task:用另一个进程代替当前运行的进程之前调用
  • set_curr_task:当进程的调度策略发生变化的时候,就会调用此函数
  • task_tick:在滴答定时器中断处理中,周期性调度器会调用此函数
  • update_curr:用于更行运行队列的统计信息

五、运行队列和调度实体

上面我们讲的时候,都是说调度类从运行队列中挑选下一个任务,如下图所示

在这里插入图片描述

想一想,调度算法如此复杂,而且不同的调度类它们肯定需要维护自己的数据结构,所以这个队列肯定不是数据结构中简单的队列。你可以理解为这个运行队列只不过是一个统称,它内部还包含了各种各样的数据结构,我之所以这么画,只是为了让你好理解,下面我们来揭开它神秘的面纱

内核中对运行队列的定义如下(我省略了其中许多统计信息)

struct rq {
	...
    struct cfs_rq		cfs;
    struct rt_rq		rt;
    struct dl_rq		dl;
	...
};

struct rq 中,对于不同的调度类,定义了不同的子队列,选取不同的调度类的可运行进程,会被调度类放到相应的子队列中

  • cfs:fair_sched_class 调度类在运行队列中对应的子队列
  • rt:rt_sched_class 调度类在运行队列中对应的子队列
  • dl:dl_sched_class 调度类在运行队列中对应的子队列

这里你需要明白一个概念,调度类只是实现了某种算法,运行队列是用来存放可运行的进程

你可能会发现 stop_sched_class 和 idle_sched_class 并没有对应的子队列,是的,因为这两种调度方式都属于极端的情况,内核只有在某些特殊情况下才会让进程使用这两个调度类,所以并没有维护它们的子队列

所以关于运行队列,我们可以使用下图描述

在这里插入图片描述

调度类只会去操作其对应的调度类,一个可运行状态的进程同一时间只运行存放在运行队列的一个子队列中,具体存放在哪一个,取决于它所选择的调度类

为了配合相应的队列使用,在 task_struct 也定义了相应的调度实体,你可以理解成它是相应队列中的一个节点,如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

struct task_struct {
    ...
	struct sched_entity		se;
	struct sched_rt_entity		rt;
	struct sched_dl_entity		dl;
    ...
};

在这里我们至少稍微介绍了调度队列二和调度实体,关于详细的讲解,将在下面具体的调度算法讲解中说明

到此,内核进程调度所维护的数据结构以及各个模块之间的关系大体介绍完了,这里还未涉及到内核调度具体的算法实现,此外进程优先级级的作用还谈到,其实它的作用将在调度算法中体现

进程调度算法的实现都在调度类中,其中 rt_sched_class 实现了 FIFO 和 RR 调度算法,fair_sched_class 实现了完全公平调度算法,我们下面也将讨论这两个调度类,其它调度类这里就不讲解了

六、FIFO 和 RR 调度算法

FIFO 和 RR 调度算法,这两种调度算法都属于实时进程的调度方式

FIFO 表示先进先出的调度算法,使用该调度策略的进程会一直运行,直到被更高优先级的进程抢占或者自愿让出CPU

RR 表示时间片轮转调度算法,也就是说每个进程都有相应的时间片,当时间片运行完之后,会将进程放入放到队列尾,如此轮转

rt_sched_class 调度类中实现了这两种算法,下面我们将来分析它的运行逻辑

在讨论 rt_sched_class 之前,我们先来分析其对应的队列以及调度实体是怎样的

运行队列

rt_sched_class 其对应的调度队列为 struct rt_rq,其定义如下

struct rt_rq {
    /* 用来存放可运行的进程 */
	struct rt_prio_array	active;
	...
    /* 指向对应的运行队列 */
    struct rq		*rq;

	...
};

我省略了许多成员变量,其中最重要的成员就是 struct rt_prio_array active,其定义如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

#define MAX_USER_RT_PRIO	100
#define MAX_RT_PRIO		MAX_USER_RT_PRIO

struct rt_prio_array {
	DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); /* 使用位图标记哪个队列中有数据 */
	struct list_head queue[MAX_RT_PRIO]; /* 每种优先级有自己的队列 */
};

rt_prio_array 中定义了一个位图 bitmap

还有一个数组 queue,数组有 MAX_RT_PRIO(100) 个元素,每个元素都是一个链表头,表示一个队列

数组的下表表示优先级,也就是说使用 rt_sched_class 调度类的可运行进程会根据其优先级添加到相应的队列中。数组有 100 个元素,也就是 100 个优先级,刚好对应实时进程的优先级范围(0-99)

在这里插入图片描述

因为一般不是每个队列上都有进程,所以使用一个 bitmap 来标记,方便快速查找

所以 rt_rq 的真正形式是下面这样子的

在这里插入图片描述

调度实体

看完运行队列后,下面再来看看对应的调度实体

在 task_struct 中,定义了这样的成员

struct task_struct {
    ...
	struct sched_rt_entity		rt;
    ...
};

sched_rt_entity 就是 rt_rq 队列对应的调度实体,你可以将其理解为它是队列中的一个节点

sched_rt_entity 的定义如下(我只保留了最重要的成员)

struct sched_rt_entity {
	struct list_head		run_list;
    ...
	unsigned int			time_slice;
	...
    unsigned short			on_rq;
	...
} __randomize_layout;
  • run_list:链表节点,用于插入运行队列中
  • time_slice:时间片,此变量只用于RR调度策略,对于FIFO不使用
  • on_rq:表示当前进程是否在运行队列上

下面我们开始来分析 rt_sched_class 调度类,为了弄清楚 rt_sched_class 是如何工作的,我们只需要分析三个方法

  • 将进程添加到运行队列
  • 从运行队列挑选下一个任务
  • 周期性调度器检查是否需要重新调度

将进程添加到运行队列

首先来看如何将进程添加到运行队列中

在内核中要将一个进程添加进运行队列,总是通过下面的方法

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

可见 enqueue_task 总是转而调用进程所指的调度类的 enqueue_task

对于 rt_sched_class 的 enqueue_task,对应的就是 enqueue_task_rt,定义如下

static void
enqueue_task_rt(struct rq *rq, struct task_struct *p, int flags)
{
    /* 取得调度实体 */
    struct sched_rt_entity *rt_se = &p->rt;
    ...
	/* 将进程添加到运行队列中 */
    enqueue_rt_entity(rt_se, flags);
	...
}

首先取得进程的调度实体,这里使用的是 rt_sched_class,所以它的调度实体肯定是 sched_rt_entity,然后通过 enqueue_rt_entity 将其添加到运行队列中,下面我们来看 enqueue_rt_entity 的实现

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

static void enqueue_rt_entity(struct sched_rt_entity *rt_se, unsigned int flags)
{
    ...
    __enqueue_rt_entity(rt_se, flags);
    ...
}
static void __enqueue_rt_entity(struct sched_rt_entity *rt_se, unsigned int flags)
{
    struct rt_rq *rt_rq = rt_rq_of_se(rt_se); //获取运行队列
    struct rt_prio_array *array = &rt_rq->active;
    struct list_head *queue = array->queue + rt_se_prio(rt_se); //根据优先级找到对应的队列
    
    list_add_tail(&rt_se->run_list, queue); //添加到指定队列中
    __set_bit(rt_se_prio(rt_se), array->bitmap); //设置bitmap
    
    rt_se->on_rq = 1; //设置进程在运行队列上的标志
}

首先得到对应的运行队列 rt_rq,还记得我们上面讲的 rt_rq,它的形式如下

在这里插入图片描述

然后会根据优先级找到 rt_rq 中对应的队列,再将调度实体添加到对应队列中,然后再设置bitmap标记存在进程的队列

进程的优先级是通过 rt_se_prio 函数获取的,我们来看看它的定义

static inline int rt_se_prio(struct sched_rt_entity *rt_se)
{
    return rt_task_of(rt_se)->prio;
}

它返回了 task_struct 中的 prio

好了,到这里你应该知道了 rt_sched_class 如何将一个进程添加到运行队列中,下面我们再来看看它是如何挑选下一个可运行的进程的

从运行队列挑选下一个任务

上面我们说过,在 schedule 函数中,会调用 pick_next_task,按照调度类的优先级遍历调度,获取下一个可运行的进程,如下

/*
 * 本文作者:_JT_
 * 博客地址:https://blog.csdn.net/weixin_42462202
 */

static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
	...
     
	/* 遍历调度类来挑选进程 */
	for_each_class(class) {
    	p = class->pick_next_task(rq, prev, rf);
        if (p) {
            return p;
        }
    }
}

对于 rt_sched_class ,它的 pick_next_task 就是 pick_next_task_rt,其定义如下(这里我省略了许多内容,只保留了主要的逻辑)

static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
    ...
	p = _pick_next_task_rt(rq);
    ...
	return p;
}

我们再来看 _pick_next_task_rt 的定义

static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
    struct sched_rt_entity *rt_se;
    struct rt_rq *rt_rq  = &rq->rt;
    ...
    rt_se = pick_next_rt_entity(rq, rt_rq);
	...
	p = rt_task_of(rt_se);
    p->se.exec_start = rq_clock_task(rq);
    
    return p;
}

首先从运行队列 struct rq 中取得 struct rt_rq,然后通过 pick_next_rt_entity 来获取队列中下一个运行的进程,返回的是调度实体,最后通过 rt_task_of 将调度实体转化为进程描述符 task_struct,然后再设置进程开始运行的时间

其中 pick_next_rt_entity 的定义如下

static struct sched_rt_entity *pick_next_rt_entity(struct rq *rq,
						   struct rt_rq *rt_rq)
{
	struct rt_prio_array *array = &rt_rq->active;
	idx = sched_find_first_bit(array->bitmap);
    
    queue = array->queue + idx;
    next = list_entry(queue->next, struct sched_rt_entity, run_list);
    
    return next;
}

首先通过 sched_find_first_bit,使用 bitmap 查找 rt_rq 第一个存在进程的队列,然后返回队列的第一个进程

从这里可以看出,优先级越高的进程会优先被挑选

到这里如果使用 rt_sched_class 调度类挑选下一个进程已经分析完了

周期性调度器检查是否需要重新调度

我们先来回顾一下下面这张图

在这里插入图片描述

我们说过,滴答定时器会周期性产生定时器中断,然后CPU会去处理定时器中断,中断函数会定时的检查是否需要重新调度,如果需要重新调度,那么就会在current进程设置需要重新调度标志

在内核中这个中断处理函数为 scheduler_tick,其定义如下

void scheduler_tick(void)
{
    int cpu = smp_processor_id();
    struct rq *rq = cpu_rq(cpu);
    struct task_struct *curr = rq->curr;
    ...
    curr->sched_class->task_tick(rq, curr, 0);
    ...
}

首先获取当前CPU对应的运行队列,然后调用current进程对应的调度类的 task_tick 函数

对于 rt_sched_class,对应的就是 task_tick_rt,其定义如下

static void task_tick_rt(struct rq *rq, struct task_struct *p, int queued)
{
    ...
	/* 如果调度策略不是RR,那么就退出 */
	if (p->policy != SCHED_RR)
        return;
	...
	/* 消耗时间片 */
	if (--p->rt.time_slice)
        return; //时间片没有用完
    
    /* 时间片用完了,重新设置时间片 */
    p->rt.time_slice = sched_rr_timeslice;
    
    ...
    /* 将进程放到队列尾 */
    requeue_task_rt(rq, p, 0);
    
    /* 设置需要重新调度标志 */
    resched_curr(rq);
}

首先如果进程的调度策略不是 SCHED_RR,也就是说现在的调度策略是 SCHED_FIFO,那就直接退出

这也对应了 FIFO 算法,进程在运行的时候是不会被抢占的,只能自己让出CPU

如果进程的调度策略是 SCHED_RR,那么就减少时间片,如果时间片没用完,那么就退出

如果时间片用完了,那么就重新设置时间片,然后将进程重新放到队列尾部,再在current进程设置需要重新调度的标志

这也对应了RR算法,每个进程都有时间片,一旦时间片用完后,会再次将进程添加到队列尾部

下面看看 requeue_task_rt 的调用过程

requeue_task_rt
	requeue_rt_entity

static void
requeue_rt_entity(struct rt_rq *rt_rq, struct sched_rt_entity *rt_se, int head)
{
    struct rt_prio_array *array = &rt_rq->active;
    struct list_head *queue = array->queue + rt_se_prio(rt_se);
    
    list_move_tail(&rt_se->run_list, queue);
}

看 resched_curr 是如何在current进程设置需要重新调度标志的

void resched_curr(struct rq *rq)
{
    ...
    set_tsk_need_resched(curr); 
	...
}
static inline void set_tsk_need_resched(struct task_struct *tsk)
{
	set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
}

最终将会在进程的 thread_info 的 flag 设置 TIF_NEED_RESCHED 标志,至于 thread_info 是什么,请看上一篇文章

在设置了需要重新调度的标志后,在系统调用或者中断返回的时候,会检查该标志,如果设置了,会调用 schedule 函数发生进程切换

关于 rt_sched_class 调度类的实现,到此告一段落,接下来分析及其重要的完全公平调度算法

七、完全公平调度算法(CFS)

完全公平调度算法的英文全称为 Completely Fair Scheduler,所以简称为 CFS

在内核中,大多数进程都是普通进程,只有少部分是实时进程,所以对于普通进程的调度算法的实现尤为重要

CFS 称为完全公平调度算法,听起来就非常公平,那么它是怎么实现的呢?下面我将为你一一揭晓

CFS算法的原理

在 CFS 中,已经没有时间片的概念了,转而变成每个进程都有自己的运行时间,我们称之为 vruntime

如果进程在运行,那么 vruntime 就会增加,如果进程在睡眠,那么 vruntime 不会增加。CFS算法的目的就是确保每个进程的 vruntime 一样,也就是确保每个进程运行一样长的时间

CFS 每次都会挑选队列中 vruntime 最小的进程来运行,然后随着时间的增加,进程的 vruntime 也在增加,当它不是当前可运行进程中 vruntime 最小的进程的时候,那么它就会被抢占

以上就是CFS算法的基本原理

现在思考一个问题,如果 CFS 只是上述那样简单的实现,那也就意味着所有的进程都被平等对待,这样子进程的优先级也就没有意义了,所以 CFS 不可能如此简单地实现

实现上对于每个进程,还多了一个权重的概念,权重是和进程的优先级是息息相关的,优先级越高(优先级数值越小),权重就越大,进程运行的实际时间就越长

那么具体是怎么实现的呢?

其实 CFS 最基本的想法还是去保持每个进程的 vruntime 相等,只是 vruntime 并不是表示实际的运行时间,而是虚拟运行时间。vruntime 的计算是和权重息息相关的,在内核中是通过下面方法计算的

vruntime += 实际运行时间 * NICE_0_LOAD / 权重

其中的 NICE_0_LOAD 是一个常量,所以从这个式子中可以看到,进程的权重越大,那么 vruntime 的增加就越慢。而CFS保持的是所有进程的 vruntime 相等,所以优先级越高的进程,权重越大,vruntime 增加得越慢,自然其实际运行时间就越长,看进程的优先级在此就发挥作用了

权重的设置

下面来看看权重和进程的优先级有什么关联(下面的代码其实我省略了很多内容,甚至省略了调用过程,不过无伤大雅,这样足以为你展示这个函数的真正逻辑)

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, //nice=0时,权重等于1024
 /*   5 */       335,       272,       215,       172,       137,
 /*  10 */       110,        87,        70,        56,        45,
 /*  15 */        36,        29,        23,        18,        15,
};

static void set_load_weight(struct task_struct *p, bool update_load)
{
    int prio = p->static_prio - MAX_RT_PRIO;
    ...
	load->weight = sched_prio_to_weight[prio];
	...
}

可以看到,设置权重其实就是根据优先级从已经计算好数值的数组中挑选一项,数据刚好有40项,就是对应nice的范围

在这里插入图片描述

这些权重的值有什么关系呢?

其实相邻两个数之间是 1.25 倍的关系,如果优先级减1,也就是进程的优先级变高,那么进程占CPU份额就多10%,下面我来计算给你看

上面已经提到了,vruntime 的增量可以有下面公式计算

vruntime += 实际运行时间 * NICE_0_LOAD / 权重

其中的 NICE_0_LOAD 表示的是 nice 为0的权重,从数组中看到的是 1024

我们可以将公式变换一下,可以得到实际的运行时间和虚拟时间增量的关系如下

实际运行时间 = vruntime增量 * 权重 / NICE_0_LOAD

我们现在取两个相邻的优先级,取 nice=0 和 nice = 1 的情况,假设vruntime增量是10,可以计算得下面关系

nice 权重 vruntime增量 实际运行时间
0 1024 10 10
-1 1277 10 12.4

即后者比前者多运行了 2.4 的时间,约占总的时间的 10%,也就是多占了10%的CPU份额

实现CFS算法的调度是 fair_sched_class,在讨论它之前,我们先来来看看它对应的运行队列和调度实体

运行队列

CFS要求快速地选出当前队列中 vruntime 最小的进程,Linux内核采用了红黑树这种数据结构,如果你不了解红黑树也没关系,只需要知道它支持快速插入和删除,自动排序,树的最左边节点的值最小就行

fair_sched_class 的队列是以 vruntime 为键值实现的红黑树,所以对于 cfs_rq,它其实是下面这样子的

在这里插入图片描述

cfs_rq 在内核中的定义如下(我省略了绝大多数成员)

struct rb_root_cached {
	struct rb_root rb_root; //红黑树根节点
	struct rb_node *rb_leftmost; //缓存最左端节点
};

struct cfs_rq {
    ...
	struct rb_root_cached	tasks_timeline;
	...
};

rb_root_cached 成员中有两个变量,一个为红黑树的根节点,一个变量用于缓存红黑树最左边的节点

调度实体

下面来看看调度实体,其包含在 task_struct 中,如下

struct task_struct {
	...
	struct sched_entity		se; 
	...
};

sched_entity 的定义如下(同样的,我也省略了大部分成员)

struct sched_entity {
	struct load_weight		load; //权重
    ...
    struct rb_node			run_node; //红黑树节点
    unsigned int			on_rq; //进程是否在运行队列上标记
    ...
	u64				vruntime; //虚拟运行时间
    ...
};

我想在这里看到这些变量你应该很熟悉了吧,这些在我们上面介绍CFS算法的时候,都有涉及到

下面我们开始分析内核对CFS算法的实现,其对应的调度类为 fair_sched_class,这里我们将讨论它的以下方法

  • 入队列
  • 挑选下一个运行进程
  • 周期调度器处理函数

入队列操作

fair_sched_class 调度类的入队列操作为 enqueue_task_fair,其定义如下

static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
    ...
    enqueue_entity(cfs_rq, se, flags);
    ...
}
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    __enqueue_entity(cfs_rq, se);
}
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    /* 找到插入位置 */
 	while (*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);
}

关于红黑树的插入过程这里不会讨论,我们来看看它是如何找到插入位置的,从上面代码中可以看到,它是通过 entity_before 来判断的,entity_before 的定义如下

static inline int entity_before(struct sched_entity *a,
				struct sched_entity *b)
{
	return (s64)(a->vruntime - b->vruntime) < 0;
}

其实就是比较两个节点的 vruntime,所以这棵红黑树是以 vruntime 作为键值的

挑选下一个运行进程

下面我们来看看 fair_sched_class 是如何挑选下一个任务的,其对应的函数为 pick_next_task_fair,定义如下

static 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;
    ...
	se = pick_next_entity(cfs_rq, curr);
    ...
	p = task_of(se);
    ...
	return p;
}

可以看到,是通过 pick_next_entity 来获取下一个调度实体的,

static struct sched_entity *
pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    struct sched_entity *left = __pick_first_entity(cfs_rq); //试图获取缓存的最左端节点
    struct sched_entity *se;
    
    /* 如果红黑树中已经没有节点了,或者获取的节点 vruntime 大于current进程 */
    if (!left || (curr && entity_before(curr, left)))
        left = curr;

    se = left;
    ...
    return se;
}

首先试图去获取红黑树最左端的节点,如果红黑树中没有节点了,或者获取的节点的 vruntime 大于current进程,那么就返回current进程对应的调度实体,否则返回获取到的节点的调度实体

周期调度器处理函数

在介绍实时进程的调度算法的时候,我们已经说过每次滴答时钟中断都会调用 scheduler_tick 进行处理,这个函数会转而调用current进程指向的调度类的 task_tick,对于 fair_sched_class,就是 task_tick_fair,其定义如下

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &curr->se;
    
    cfs_rq = cfs_rq_of(se); //获取cfs的运行队列
    entity_tick(cfs_rq, se, queued);
    ...
}

entity_tick 的定义如下

static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
	/* 更新运行时间统计 */
    update_curr(cfs_rq);

    /* 如果运行队列上的进程个数大于1,那就检查是否需要发生抢占 */
    if (cfs_rq->nr_running > 1)
        check_preempt_tick(cfs_rq, curr);
}

首先会调用 update_curr 来更新运行的统计时间,然后如果运行队列上的进程大于1,那么就调用 check_preempt_tick 来决定是否需要发生抢占

我们先来看一看 update_curr,其定义如下

static void update_curr(struct cfs_rq *cfs_rq)
{
    u64 now = rq_clock_task(rq_of(cfs_rq)); //现在的时间
    delta_exec = now - curr->exec_start; //实际运行时间
    ...
    curr->exec_start = now; //更新进程开始运行的时间
    ...
    curr->vruntime += calc_delta_fair(delta_exec, curr); //由实际运行时间计算虚拟运行时间
    ...
}

从注释中你应该可以看得很清楚了,其中的主要操作就是更新 vruntime,我这里就不详细说明了

下面再看看 check_preempt_tick 的定义

static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    ...
    se = __pick_first_entity(cfs_rq); //获取红黑树最左端的节点
    
    
    delta = curr->vruntime - se->vruntime;
    if (delta < 0) //如果current进程的 vruntime 小于红黑树最左端节点
        return; //那么就退出
    
    /* 否则,设置需要重新调度的标志 */
    resched_curr(rq_of(cfs_rq));
}

首先从红黑树中挑选出最左端的节点,然后和current进程的 vruntime 进行对比,如果大于,那么就退出;如果小于,就调用 resched_curr,再 current 进程设置需要重新调度的标志

至此,进程调度的内容基本就将完了,下面来总结以下

八、总结

  • 在学习进程调度之前,你必须先明确进程调度解决什么问题
  • 在学习Linux内核进程调度机制之前,我希望你先把进程调度的基本框架搞明白,如果搞明白了,学起来就轻松许多
  • 内核中有多种调度策略可以选择,每种调度策略都表示相应的调度算法
  • 内核中使用调度类来实现相应的算法,相应的调度策略会映射到相应的调度类

最后,上面的图由于是我边画边截的图,所以可能有点模糊,下面给你一张高清点的图

在这里插入图片描述

发布了107 篇原创文章 · 获赞 197 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/weixin_42462202/article/details/102887008