FIFO和RR
SCHED_FIFO(First-In-First-Out scheduling)与SCHED_RR(Round-Robin scheduling),是rt_sched_class调度类中的两种实时调度策略,在linux内核中统称为rt policy。这两种实时调度策略也是比较推荐在实时项目上使用的两种调度策略,而且这两种调度策略在linux内核中的实现大体上也是一致的,所以这里放在一起介绍。
实时任务采用这两种调度策略,虽然没有SCHED_DEADLINE调度顺序高,但是却具有其他显而易见的好处。首先,这两种调度策略,在调度思路上相对简单清晰,设置起来也相对简便,并且更加便于理解。另外,在大多数的应用场景中一般不存在SCHED_DEADLINE任务,所以基本不会影响这两种调度策略的实时性。
1. FIFO及RR介绍
SCHED_FIFO与SCHED_RR,是rt_sched_class调度类中的两种实时调度策略。在Linux内核的调度实现中,也将SCHED_FIFO与SCHED_RR调度策略统称为rt policy。SCHED_FIFO任务和SCHED_RR任务,在初始化、任务唤醒以及调度处理上大体是一致的,并且这两类实时任务唤醒后,都存放在同一个链表形式的rt_rq队列中。
SCHED_FIFO与SCHED_RR这两类调度策略,任务调度都基于实时任务的优先级,都遵循高优先级任务优先,并且可以抢占低优先级任务的原则。两种调度策略的区别主要在于,对处于同一优先级上的任务的调度处理。
采用SCHED_FIFO调度策略的任务,对于同一优先级的任务,采用先进先出的原则,先就绪的任务优先得到调度,并且会一直在cpu上运行,在它运行期间,相同优先级的实时任务无法得到调度,直到它主动让出cpu,其他同优先级任务才会继续根据先进先出的原则,选择下一个任务放到cpu上运行。
而采用SCHED_RR调度策略的任务,同一优先级上的任务,采用时间片轮转的方式,依次进行调度,Linux内核中默认SCHED_RR任务时间片为100ms。
#define RR_TIMESLICE (100 * HZ / 1000)
关于关于这两类任务的初始化、唤醒以及调度,在后文会详细介绍其具体实现。
2. 设置SCHED_FIFO/SCHED_RR任务方法
2.1 通过chrt命令修改任务调度策略
对于已经启动的应用程序,可以通过chrt命令修改其调度策略及优先级。具体可参考下图:
当然也可以通过chrt命令,在任务启动时,修改其调度策略及优先级。如下图:
对于已经编译完成或者运行的程序,可以采用上述chrt命令来修改其调度策略及优先级,那么该如何编写一个rt_sched_class调度类的任务呢?
2.2 编写程序实现
其实实时项目中的开发工作,更多的时候,需要在编写的代码里去实现一个实时任务。这里介绍采用pthread_create的方式,创建一个SCHED_FIFO或者SCHED_RR任务。
首先linux采用的glibc库完整的支持了POSIX标准,支持在创建线程时,设置线程属性。可以将调度策略以及优先级设置到线程属性中,这样在通过pthread_create创建线程时,linux内核中调度器便会将其归类到rt_sched_class调度类中,在任务唤醒及调度的时候便以设置的调度策略来进行。
设置线程属性涉及到的相关的posix接口如下:
int pthread_attr_init (pthread_attr_t *attr)
int pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy)
int pthread_attr_setschedparam(pthread_attr_t *attr, struct sched_param *param)
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
以上POSIX方法的作用和用法,不再一一详细解释了,具体可参考下面创建SCHED_FIFO任务示例中的注解。
创建SCHED_FIFO任务的示例程序主要代码及注解如下:
void *thread_func(void *data)
{
/* Do RT specific stuff here */
return NULL;
}
int main(int argc, char* argv[])
{
struct sched_param param;
pthread_attr_t attr;
pthread_t thread;
int ret;
ret = pthread_attr_init(&attr);
if (ret) {
printf("init pthread attributes failed\n");
goto out;
}
/*
* 设置线程的调度策略为SCHED_FIFO,
* 如要要是用SCHED_RR调度策略,也在此设置
*/
ret = pthread_attr_setschedpolicy(&attr, SCHED_FIFO);
if (ret) {
printf("pthread setschedpolicy failed\n");
goto out;
}
/* 设置线程的优先级为99 */
param.sched_priority = 99;
ret = pthread_attr_setschedparam(&attr, ¶m);
. . .
/* 指定不继承调度策略和优先级,使用自己设置的调度策略和优先级 */
ret = pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED);
. . .
/* 使用设置的attr创建线程 */
ret = pthread_create(&thread, &attr, thread_func, NULL);
. . .
return ret;
}
3. 调度策略的具体实现
前文介绍过,linux内核中将SCHED_FIFO和SCHED_RR两种调度策略统称为rt policy。这两种调度策略在linux内核中的实现上大部分也是一体的,只是针对两种策略的差异,根据判断单独做了处理。
为了更清楚的了解SCHED_FIFO和SCHED_RR两种调度策略的具体实现,这里分别从任务的创建、唤醒、和调度三个过程进行详细说明。
3.1 任务创建
应用层通过pthread_create()方法创建实时任务,pthread_create()方法会通过sched_setscheduler系统调用,将设置到线程属性中的调度策略和优先级,传到linux内核调度器中,这样调度器在进行任务调度时,才能知道以哪种调度策略进行。
sched_setscheduler()系统调用的具体实现,在内核源码kerenl/sched/core.c中。它并未做太多的操作,主要调用了_sched_setscheduler()方法,将线程属性中的调度策略及优先级保存到sched_attr结构体中,然后调用__sched_setscheduler进行后续的工作。
_sched_setscheduler()的实现如下:
static int _sched_setscheduler(struct task_struct *p, int policy,
const struct sched_param *param, bool check)
{
struct sched_attr attr = {
.sched_policy = policy,
.sched_priority = param->sched_priority,
.sched_nice= PRIO_TO_NICE(p->static_prio),
};
. . .
return __sched_setscheduler(p, &attr, check, true);
}
__sched_setscheduler实现比较复杂,里面包含各种判断和检查识别工作,比如优先级及调度策略的设置是否正确等,这里只关注其核心内容,即设置任务的调度类和优先级。
static int __sched_setscheduler(struct task_struct *p,
const struct sched_attr *attr,
bool user, bool pi)
{
. . .
/*
* 当修改任务参数时(调度策略、优先级等),也会调用此方法
* 判断任务是否已经在就绪队列中或者是当前正在运行中,
* 如果是作相应处理
*/
queued = task_on_rq_queued(p);
running = task_current(rq, p);
if (queued)
dequeue_task(rq, p, queue_flags);
if (running)
put_prev_task(rq, p);
/* 设置任务的相关属性 */
__setscheduler(rq, p, attr, pi);
__setscheduler_uclamp(p, attr);
/* 设置完后,对任务状态进行恢复 */
if (queued) {
. . .
enqueue_task(rq, p, queue_flags);
}
if (running)
set_next_task(rq, p);
. . .
}
Linux内核调度器中,任务的调度策略和优先级的初始设置和修改,最终都是在方法__setscheduler()中完成的,__setscheduler()主要完成两个工作:
-
设置任务的调度策略及优先级。
-
根据调度策略及优先级,指定其所属调度类。
__setscheduler()主要实现代码如下:
static void __setscheduler(struct rq *rq, struct task_struct *p,
const struct sched_attr *attr, bool keep_boost)
{
. . .
/*
* 设置任务的调度策略及优先级属性到任务描述结构体struct task_struct中
* 调度策略存储在任务描述结构体struct task_struct的policy成员中
* 优先级存储在任务描述结构体struct task_struct的rt_priority成员中
*/
__setscheduler_params(p, attr);
/*
* 根据任务调度策略,计算出p->prio。
* deadline任务:p->prio < 0,
* rt policy任务:0 <= p->prio < 100,
* 普通任务:100 <= p-> < 140, fair
*/
p->prio = normal_prio(p);
. . .
/*
* 设置任务对应的调度类
* SCHED_FIFO和SCHED_RR任务,都是 rt_sched_class
*/
if (dl_prio(p->prio))
p->sched_class = &dl_sched_class;
else if (rt_prio(p->prio))
p->sched_class = &rt_sched_class;
else
p->sched_class = &fair_sched_class;
}
任务调度策略和优先级的保存,最终在__setscheduler_params()中完成。
static void __setscheduler_params(struct task_struct *p,
const struct sched_attr *attr)
{
int policy = attr->sched_policy;
p->policy = policy;
/*
* SCHED_DEADLINE和普通任务做相应处理
* SCHED_DEADLINE任务,后文Deadline小节会有详细介绍
* 普通任务,将nice值进行转换,后续会存储到任务结构体的prio中
*/
if (dl_policy(policy))
__setparam_dl(p, attr);
else if (fair_policy(policy))
p->static_prio = NICE_TO_PRIO(attr->sched_nice);
/* 保存任务的rt_priority */
p->rt_priority = attr->sched_priority;
p->normal_prio = normal_prio(p);
set_load_weight(p, true);
}
创建任务时,我们设置的任务优先级为rt_priority,并不是内核调度器调度使用的prio,需要进行转换,这个工作实在normal_prio()中完成的。normal_prio()根据任务的调度策略,对优先级进行转换,将rt policy任务的优先级rt_priority转换为了prio。
static inline int normal_prio(struct task_struct *p)
{
int prio;
if (task_has_dl_policy(p))
prio = MAX_DL_PRIO-1; /* deadline任务 */
else if (task_has_rt_policy(p))
prio = MAX_RT_PRIO-1 - p->rt_priority; /* rt policy 任务 */
else
prio = __normal_prio(p); /* 普通任务 */
return prio;
}
关于SCHED_FIFO和SCHED_RR任务创建过程,这边介绍的稍微多了些,这样做主要是为了,在阅读下文任务唤醒和任务调度时,更容易理解。
任务创建过程简单来说主要有以下三个工作:
-
将调度策略SCHED_FIFO和SCHED_RR设置到任务描述结构体struct task_struct的policy;
-
将任务的优先级保存到描述结构体struct task_struct的rt_prority中;
-
对任务的rt_priority进行转换保存到struct task_struct中的prio,以供后续调度使用。
任务创建完成后,必须要唤醒放到相关的就绪队列,才能得到cpu调度。
3.2 任务唤醒
任务创建完成后,如果想要得到调度放到cpu上运行,首先要处于就绪状态,即需要唤醒放到调度器中对应的就绪队列中,rt policy任务在调度器中的就绪队列为rt_rq。实时调度类rt_sched_class的就绪队列rt_rq内,以链表形式维护了100个任务队列,分别用于存放优先级0~99的RT任务。
rt policy任务的唤醒过程,就是将根据任务的优先级,将实时任务放入就绪队列rt_rq中对应的链表队列,并将其状态置为TASK_RUNNING的过程。
Linux内核中任务唤醒的统一接口为try_to_wake_up(),它最主要的工作有两个:
-
通过activate_task()将任务加入对应的就绪队列,其实现是根据任务所属的调度类,调用对应调度类的enqueue_task()实现;
-
通过ttwu_do_wakeup(),检查是否可以抢占当前运行任务,并且将任务状态置为TASK_RUNNING。
这里主要介绍实时任务加入就绪队列的过程,即activate_task()的实现,activate_task()直接执行enqueue_task(),调用对应rt_sched_class调度类中enqueue_task_rt()方法将实时任务加入实时任务就绪队列,其主要工作如下:
-
找到将要唤醒实时任务的调度实体rt_se;
-
根据任务优先级prio,将任务插入到rt_rq中对应的链表中;
-
根据当前cpu是否在运行rt任务以及是否满足迁移条件,决定是否进行任务迁移。
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;
. . .
/* 根据任务prio,将任务插入rt_rq中对应的链表 */
enqueue_rt_entity(rt_se, flags);
/*
* 如果当前rt_rq上有任务正在占用cpu运行,并且满足迁移条件
* 将任务放到pushable队列,用于任务迁移
*/
if (!task_current(rq, p) && p->nr_cpus_allowed > 1)
enqueue_pushable_task(rq, p);
}
关于实时任务的迁移,既然是实时任务,肯定希望实时任务能够尽可能快的得到调度,在当前cpu core上不能立马得到调度的情况下,便会尝试将任务迁移到其他能够立刻得到调度的cpu core上。
实时任务就绪队列rt_rq中,存在100个链表形式的任务队列,具体将任务放到哪个链表队列中是在enqueue_rt_entity()方法中通过调用__enqueue_rt_entity()中实现的。
其主要代码摘出解释如下:
static void __enqueue_rt_entity(struct sched_rt_entity *rt_se, unsigned int flags)
{
/* 获取任务实例存放的就绪队列rt_rq,其中维护这100个任务链表队列 */
struct rt_rq *rt_rq = rt_rq_of_se(rt_se);
struct rt_prio_array *array = &rt_rq->active;
/* 根据RT任务优先级prio找到对应的链表 */
struct list_head *queue = array->queue + rt_se_prio(rt_se);
. . .
/* 根据flag,决定将任务插入链表头还是链表尾部 */
if (move_entity(flags)) {
WARN_ON_ONCE(rt_se->on_list);
if (flags & ENQUEUE_HEAD)
list_add(&rt_se->run_list, queue);
else
list_add_tail(&rt_se->run_list, queue);
/* 设置标志位,表明相关任务链表里有任务等待调度 */
__set_bit(rt_se_prio(rt_se), array->bitmap);
rt_se->on_list = 1;
}
rt_se->on_rq = 1;
inc_rt_tasks(rt_se, rt_rq);
}
至此,rt policy任务已经存放到就绪队列rt_rq中对应的链表中了,但是任务唤醒工作并没有到此结束。后续还需要判断当前任务是否可以抢占目前CPU上的任务,这个判断对实时任务调度尤为重要,另外还要将任务描述结构体struct task_struct中的state状态置为TASK_RUNNING,表明任务已经就绪或者正在运行状态。这两个工作实在ttwu_do_wakeup()方法中完成的,其主要实现如下:
static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
struct rq_flags *rf)
{
/* 检查是否可以进行抢占,如果可以,将当前任务设置TIF_NEED_RESCHED标志 */
check_preempt_curr(rq, p, wake_flags);
/* 设置任务状态为TASK_RUNNING */
p->state = TASK_RUNNING;
. . .
}
到这里,任务唤醒工作基本结束了,任务唤醒后便处于就绪状态,只有处于就绪状态的任务才能够得到调度,才能被CPU执行。
3.3 任务调度
任务唤醒后,便处于就绪状态,下一步便是等待调度器调度,放到CPU上运行。任务调度的过程就是从cpu core上所有就绪的任务中,找出最合适的任务放到CPU上运行的过程。
Linux内核中任务调度时机主要有两种,一种是主动显式的调用schedule();另一种是从内核返回用户空间或者从中断返回时,检查到TIF_NEED_RESCHED标志被设置后的被动任务切换和调度。
另外有两种情况可以设置任务TIF_NEED_RESCHED标志,触发被动的任务切换和调度,分别是:任务唤醒时触发抢占时的设置TIF_NEED_RESCHED标志;scheduler_tick()周期性检查需要切换运行任务时,设置TIF_NEED_RESCHED标志。
Linux调度器中的任务调度接口为__schedule(),不管主动的还是被动的任务调度,最终都需要调用__schedule()完成,其实现比较复杂,但其最重要的工作有以下两个:
-
通过pick_next_task()找到下一个将要运行的任务;
-
通过context_switch()进行上下文切换后,开始在cpu上被执行。
各个调度类任务的调度中,上下文切换工作并无太大区别,这里不做过多说明,感兴趣的,可自行阅读context_switch()的代码实现。这里主要介绍rt_sched_class调度类中从其rt_rq就绪队列中找到下一个任务的过程,即pick_next_task_rt()的实现。
pick_next_task_rt()的定义在kernel/sched/rt.c中,主要代码如下:
static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct task_struct *p;
. . .
p = _pick_next_task_rt(rq);
. . .
return p;
}
pick_next_task_rt()主要调用了_pick_next_task_rt()继续后面的工作,而_pick_next_task_rt()方法又是执行了pick_next_rt_entity()来完成具体的查找任务工作,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;
struct sched_rt_entity *next = NULL;
struct list_head *queue;
int idx;
/* 通过标志位,找到有任务等待的最高优先级任务链表 */
idx = sched_find_first_bit(array->bitmap);
BUG_ON(idx >= MAX_RT_PRIO);
queue = array->queue + idx;
/* 从链表中拿出任务,即为就绪实时任务中优先级最高的任务 */
next = list_entry(queue->next, struct sched_rt_entity, run_list);
return next; /* next 为即将被执行的任务 */
}
找到下一个即将被执行的任务后,首先需要清除当前正在被执行任务的TIF_NEED_RESCHED标志,然后通过context_switch()进行上下文切换后,完成CPU上运行任务的切换。
3.4 FIFO和RR区别实现
前面介绍了SCHED_FIFO任务和SCHED_RR任务的创建、唤醒和调度过程,linux内核调度器中,SCHED_FIFO任务和SCHED_RR任务的创建,唤醒加入就绪队列以及调度查找下一任务过程都是一致的,那么FIFO和RR两种调度策略的区别体现在哪里呢?
首先,FIFO和RR调度策略都是高优先级任务优先得到调度,并且可以抢占低优先级任务,他们的区别是,SCHED_FIFO任务一旦放到cpu上运行,便会一直运行,直到它主动让出CPU,而SCHD_RR任务时基于时间片轮转的,每个SCHED_RR任务时间片耗完后,都会重新入队,等待下一次调度。Linux内核中SCHDE_RR任务的默认时间片为100ms。
FIFO和RR的区别主要体现在任务已经得到调度,放到CPU上运行后的处理上。
Linux调度器内,每个时钟tick都会通过scheduler_tick()方法调用,对当前运行的任务进行一些更新判断操作,通过调用curr->sched_class->task_tick(rq, curr, 0),调用CPU当前运行任务对应调度类的task_tick方法进行。rt_sched_class调度类中的实现为task_tick_rt(),而SCHED_FIFO任务和SCHED_RR任务的调度区别也在其中,其具体实现如下:
static void task_tick_rt(struct rq *rq, struct task_struct *p, int queued)
{
/* 获取任务调度实体 */
struct sched_rt_entity *rt_se = &p->rt;
. . .
/*
* 判断任务是否为SCHED_RR任务,因为SCHED_RR任务基于时间片轮转
* 如果不是,直接返回
* 否则判断SCHED_RR任务时间片是否耗完,决定是否让出CPU,重新入队
*/
if (p->policy != SCHED_RR)
return;
/* 每次时钟tick,rt.time_slice减一,当rt.time_slice=0,说明时间片用完 */
if (--p->rt.time_slice)
return;
/* 重新赋值SCHED_RR任务的时间片时间为100ms(100 * HZ / 1000) */
p->rt.time_slice = sched_rr_timeslice;
/*
* 当SCHED_RR任务时间片用完后,将任务插入就绪队列中的链表尾部
* 设置当前任务TIF_NEED_RESCHED标志,让出CPU
*/
for_each_sched_rt_entity(rt_se) {
if (rt_se->run_list.prev != rt_se->run_list.next) {
requeue_task_rt(rq, p, 0); /* 重新入队 */
resched_curr(rq); /* 设置标志,让出CPU */
return;
}
}
}
通过上述代码分析,可以看到SCHED_FIFO和SCHED_RR调度策略在实现上的具体区别,当SCHED_FIFO任务正在运行时,每个时钟tick,并不会对任务时间片有相关判断操作,而SCHED_RR任务,每个时钟tick都会对任务的时间片数值rt.time_slice进行减一操作,当任务时间片消耗完后,重新将其加入到链表队列的尾部,并且让出CPU进行任务切换。
最后,linux内核调度器FIFO和RR的调度策略实现中,不同优先级的任务,存放在不同的链表队列中。优先级相同的SCHED_FIFO任务和SCHED_RR任务,存放在同一个链表队列中。所以如果链表队列中同时存在SCHED_FIFO和SCHED_RR任务时,两种调度策略的任务遵循,先入先出的策略,即排在链表头部的任务会优先得到调度,而对于单独的SCHED_RR任务和SCHED_RR任务,仍然遵循各自的调度算法,即SCHED_FIFO任务会一直占用CPU运行,直到它主动让出CPU,SCHED_RR任务得到调度后,在耗完分配给它的时间片后,需要让出cpu,重新加入链表队列尾部,等待下一次调度。