引入
- 对于操作系统来讲,它面对的CPU的数量是有限的,但是进程数目远远超过CPU的数目,因而就需要进行进程的调度,有效的分配CPU的时间,既要保证进程的最快响应,也要保证进程之间的公平,这是一个非常复杂的、需要平衡的事情。
- 一个CPU中同一时刻只能运行一个进程,内核可以通过快速切换CPU上的进程造成多个进程同时运行的假象。但是切换线程是有代价的,这涉及到进程状态的保存与恢复。另外,内核还需要考虑下次切换时运行哪个进程,什么时候切换等等。
- 什么时候切换: 内核认为当前在CPU上运行的线程已经运行的足够久了,就会把这个进程换掉,让另外一个线程来执行。
这个时候我们就需要进程调度了。
调度策略和调度类
在Linux里面,进程大概可以分为两种:
- 一种叫做实时进程,也就是需要尽快执行返回结果的那种,优先级一般比较高
- 一种叫做普通进程,大部分进程其实都是这种,按照正常流程完成即可
很显然,对于这两种进程,我们的调度策略肯定是不同的。
- 在task_struct中,有一个成员变量,叫做调度策略:
unsigned int policy;
- 它有下面几个定义:
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
- 配合调度策略的,有刚刚提到的优先级,也在task_struct中:
int prio, static_prio, normal_prio;
unsigned int rt_priority;
- 优先级其实就是一个数值:
- 对于实时进程,优先级的范围是0~99
- 对于普通进程,优先级是100~139
- 数值越小,优先级越高。从上面也可以看出,所有的实时进程都比普通进程的优先级高
实时调度策略
对于调度策略,其中,SCHED_FIFO、SCHED_RR、SCHED_DEADLINE
都是实时进程的调度策略:
SCHED_FIFO
:高优先级的进程可以抢占低优先级的进程;而相同优先级的进程先来先服务SCHED_RR
轮流调度算法:高优先级的进程可以抢占低优先级的进程;而相同优先级的任务采用时间片算法,当相同优先级的任务用完时间片会被放到队列外部,以保证公平性SCHED_DEADLINE
:按照任务的deadline进行调度。当产生一个调度点的时候,DL调度器总是选择其deadline具体当前时间点最近的那个任务执行
普通调度策略
对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。
SCHED_NORMAL
:普通进程SCHED_BATCH
:后台进程,这些进程可以默默执行,不要影响需要交互的进程,可以降低它的优先级SCHED_IDLE
:特别空闲的时候才跑的进程
上面无论是policy还是priority,都设置了异构变量,变量仅仅表示了它应该这样干,但是事情总要有人去干,谁呢?在task_struct里面,还有这样的成员变量:
const struct sched_class *sched_class;
调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。
sched_class有几种实现:
stop_sched_class
:优先级最高的任务会使用这种策略,会中断所有其他线程,而且不会被其他任务打断dl_sched_class
:对应上面的 deadline 调度策略;rt_sched_class
:就对应 RR 算法或者 FIFO 算法的调度策略,具体调度策略由进程的task_struct->policy 指定;fair_sched_class
:普通进程的调度策略idle_sched_class
: 就是空闲进程的调度策略。
我们平常都是普通进程,也就是说fair_sched_class用的最多。fair_sched_class,顾名思义,对普通进程来讲,公平是最重要的
完全公平调度算法
在linux里面,实现了一个基于CFS(Completely Fair Scheduling,完全公平调度)的调度算法。其实现原理是:
- 首先,先记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断(也叫做时钟滴答tick)。
- CFS会为每一个进程安排一个虚拟进行时间vruntime。
- 如果一个进程在执行,随着时间的增长,也就是一个个tick的到来,进程的vruntime将不断增大。没有得到执行的进程vruntime不变
- 显然,那些 vruntime 少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。
- 这有点像让你把一筐球平均分到 N 个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。
那如何给优先级高的进程多分配时间呢?
- 这个简单,就相当于N个口袋,优先级高的袋子大,优先级低的袋子小
- 这样求就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,但是也可以认为是公平的
调度队列与调度实体
从上面可以推导出,CFS需要一个数据结构来对vruntime进行排序,找出最小的那个。
- 这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能快速的调整排序,要知道vruntime可是经常在变的,变了再插入这个数据结构,就需要重新排序。
- 能够平衡查询和更新速度的树,一般用的是红黑树。
- 红黑树的节点是应该包括vruntime的,称为调度实体
不光 CFS 调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。在 task_struct 中有这样的成员变量:
struct sched_entity se; // 完全公平算法调度实体 sched_entity
struct sched_rt_entity rt; // 实时调度实体
struct sched_dl_entity dl; // Deadline 调度实体
而进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是个普通进程,则通过sched_entity,将自己挂载在这颗红黑树上。
对于普通进程的调度实体定义如下,这里面包含了 vruntime 和权重 load_weight,以及对于运行时间的统计
struct sched_entity {
struct load_weight load;
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;
struct sched_statistics statistics;
......
};
下图是一个红黑树的例子
- 所有可运行的进程通过不断的插入操作最终都存储在以时间为顺序的红黑树中,vruntime最小的在树的左侧,vruntime最多的在树的右侧。
- CFS调度策略会选择红黑树最左边的叶子节点作为下一个将获得cpu的任务
那这颗红黑树放在哪里呢?一个任务队列中
- 每个CPU都有自己的struct rq结构,用于描述在此CPU上所运行的所有进程,其中包括一个实时进程队列rt_rq和一个CFS运行队列cfs_rq
- 在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行
- 如果没有,才会去CFS运行队列中找是否有进程需要运行
struct rq {
/* runqueue lock: */
raw_spinlock_t lock;
unsigned int nr_running;
unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
struct load_weight load;
unsigned long nr_load_updates;
u64 nr_switches;
struct cfs_rq cfs;
struct rt_rq rt;
struct dl_rq dl;
......
struct task_struct *curr, *idle, *stop;
......
};
对于普通进程公平队列 cfs_rq,定义如下:
/* CFS-related fields in a runqueue */
struct cfs_rq {
struct load_weight load;
unsigned int nr_running, h_nr_running;
u64 exec_clock;
u64 min_vruntime;
#ifndef CONFIG_64BIT
u64 min_vruntime_copy;
#endif
struct rb_root tasks_timeline;
struct rb_node *rb_leftmost;
struct sched_entity *curr, *next, *last, *skip;
......
};
- 这里面 rb_root 指向的就是红黑树的根节点,这个红黑树在 CPU 看起来就是一个队列,不断的取下一个应该运行的进程。
- rb_leftmost 指向的是最左面的节点。
到这里终于凑够数据结构了,上面这些数据结构的关系如下图:
调度类是如何工作的
凑够了数据结构,接下来我们来看调度类是如何工作的。
调度类的定义如下:
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);
bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);
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 (*task_fork) (struct task_struct *p);
void (*task_dead) (struct task_struct *p);
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)
这个结构定义了很多种方法,用于在队列上操作任务。这里注意第一个成员变量,是一个指针,指向下一个调度类。
而调度类又分为下面几种:
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;
它们其实是放在一个链表上的。这里以取下一个任务为例,来解析一下。可以看到,这里面有一个for_each_class循环,沿着上面的顺序,依次调用每个调用类的方法:
/*
* 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;
......
for_each_class(class) {
p = class->pick_next_task(rq, prev, rf);
if (p) {
if (unlikely(p == RETRY_TASK))
goto again;
return p;
}
}
}
这就说明,调度的时候是从优先级最高的调度类到优先级最低的调度类,依次执行。而对于每种调度类,有自己的实现,比如,CFS就有fair_sched_class:
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,
.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,
};
对于同样的 pick_next_task 选取下一个要运行的任务这个动作,不同的调度类有自己的实现。fair_sched_class 的实现是 pick_next_task_fair,rt_sched_class 的实现是 pick_next_task_rt。
我们会发现这两个函数是操作不同的队列,pick_next_task_rt 操作的是 rt_rq,pick_next_task_fair 操作的是 cfs_rq。
static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct task_struct *p;
struct rt_rq *rt_rq = &rq->rt;
......
}
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;
......
}
这样整个运行的场景就串起来了,在每个CPU上都有一个队列rq,这个队列里面包含多个子队列,比如rt_rq和cfs_rq,不同的队列有不同的实现方式,cfs_rq就是用红黑树实现的。
当某个CPU需要找下一个任务执行的时候,会按照优先级依次调用类,不同的调度类操作不同的队列。当然,rt_sched_class会仙贝调度,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
下面我们仔细看一下 sched_class 定义的与调度有关的函数。
- enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
- dequeue_task 将一个进程从就就绪队列中删除;
- pick_next_task 选择接下来要运行的进程;
- put_prev_task 用另一个进程代替当前运行的进程;
- set_curr_task 用于修改调度策略;
- task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。
在这里面,我们重点看 fair_sched_class 对于 pick_next_task 的实现 pick_next_task_fair,获取下一个进程。调用路径如下:pick_next_task_fair->pick_next_entity->__pick_first_entity。
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。
总结
上面主要讲了调度相关的数据结构。
一个CPU上有一个队列,CFS的队列是一颗红黑树,树的每一个节点都是一个sched_entity,每个sched_entity都属于一个task_struct,task_struct里面有指针指向这个进程属于哪个调度类
在调度的时候,依次调用调度类的函数,从 CPU 的队列中取出下一个进程。上面图中的调度器、上下文切换这一节我们没有讲,下一节我们讲讲基于这些数据结构,如何实现调度。
问题
那线程的调度是什么样的呢?Linux调度的基本单位是进程还是线程呢?
进程和线程都是task,一起调度
如果是新建的进程如何处理, 它 vruntime 总是最小的, 总被调度直到与其他进程相当.
每次新进程创建完毕后,都会试图先让新的抢占一次
如果优先队列一直有任务,普通队列的task一直得不到处理,操作系统会怎么做呢?
饿死,cpu一直转,低优先级的没响应
一个task 分配给一个cpu执行后,就不会再被其他cpu 执行了吧?
是的,同一个时刻同一个cpu只能给一个进程用
感觉这个sched_class结构体类似面向对象中的基类啊,通过函数指针类型的成员指向不同的函数,实现了多态。
Linux的最小调度单位是线程,每个进程又至少都有一个线程,当我们在谈Linux调度的时候为啥不说是线程调度?所谓的进程调度是说的也是调度的里面的线程吗?那是不是就没有所谓的进程调度了呢?
是的,叫任务调度吧