目录
一、为什么要调度
二、调度均衡
三、进程调度框架
3.1 调度队列
3.2 进程唤醒
3.3 调度时机
主动调度:
被动调度:
四、调度算法
4.1 先来先服务调度算法
4.2 最短作业优先调度算法
4.3 高响应比优先调度算法
4.4 时间片轮转调度算法
4.5 最高优先级调度算法
4.6 多级反馈队列调度算法
早期的计算机没有调度,程序只能一个一个地运行,一个进程死亡之后才能去运行下一个进程。这里面首先存在的问题就是我们没法同时运行多个进程。其次就算我们不需要同时运行多个进程,程序在运行的过程中如果要等IO,CPU就只能空转,这也十分浪费CPU资源。
后来协作式多任务诞生了,当程序由于要等IO而阻塞时就会去调度执行其它的进程。但是协作式多任务存在着很大的问题,就是每个进程运行的时间片长短是不确定的,而且是很偶然很随机的。如果一个进程它一直在做运算就是不进行IO操作,那么它就会一直霸占CPU。
后来随着计算机的普及,以及计算机的使用者和程序员这两个角色的分离,强制性多任务产生了,也就是抢占式多任务。抢占式多任务使得每个进程都可以相对公平地平分CPU时间,如果一个进程运行了过长的时间就会被强制性地调度出去,不管这个进程是否愿意。有了抢占式多任务,我们在宏观上不仅可以同时运行多个进程,而且它们会一起齐头并进地往前运行,不会出现某个进程被饿死的情况,这样我们使用电脑的体验就非常完美了。抢占式多任务和协作式多任务不是对立的,它们是相互独立的,可以同时存在于系统中。
抢占又分为用户抢占和内核抢占。由于抢占对进程来说是异步的,进程被抢占时不一定运行在什么地方,有可能运行在用户空间,也有可能运行在内核空间(进程通过系统调用进入内核空间)。如果抢占点是在用户空间,那么抢占就是安全的,如果在内核空间就不一定安全,这是为什么呢?因为对于用户空间来说,如果抢占会导致线程同步问题,那么用户空间有责任使用线程同步机制来保护临界区,只要用户空间做好同步就不会出问题。如果内核也做好了同步措施,内核抢占也不会出问题。
所以如果没有调度的话,就不能实现多任务,一次就只能运行一个程序,我们使用电脑的体验就会大大降低。有了调度就有了多任务,我们就能同时执行很多程序。
调度均衡可以分为个体均衡和总体均衡。
个体均衡是从进程的角度出发选择到一个相对清闲的CPU上去运行。总体均衡是从CPU的角度出发如何从别的CPU上拉取一些进程到自己这来执行,使得所有CPU的工作量尽量平均。个体均衡的触发点有三个:一是新进程刚创建时,二是进程要执行新程序时,三是进程被唤醒时,在这三个点进程都可以选择去哪个CPU的运行队列上去等待执行。在个体均衡下,每个进程都尽量选择相对清闲的CPU,所以所有CPU的负载应该还是会比较均衡的。但是时间长了可能还是会出现负载不均衡的情况,此时就要进行总体均衡了。总体均衡的触发点有三个:一是CPU即将idle前会去找到最忙的CPU然后拉取一些任务过来;二是定时器中断的周期性检测,会检查是否所有的CPU都一样忙,如果忙闲差别太大就会进行进程迁移,使得所有CPU忙闲程度接近;三是在idle进程中如果CPU发现自己太忙而有的CPU在idle就会唤醒那个CPU进行负载均衡。
我们先来看一下进程的状态转换图。
处于就绪(Runnable)状态的进程可以被调度到CPU上去执行。但是处于就绪状态的进程可能不止一个,所以我们需要一个运行队列来安放所有就绪的进程,由于CPU也不止一个,所以我们需要NR_CPU个运行队列。
我们看一下调度队列的定义(代码经过了高度删减):linux-src/kernel/sched/sched.h
struct rq {
raw_spinlock_t __lock;
unsigned int nr_running;
struct cfs_rq cfs;
struct rt_rq rt;
struct dl_rq dl;
struct task_struct __rcu *curr;
struct task_struct *idle;
struct task_struct *stop;
int cpu;
int online;
};
linux-src/kernel/sched/core.c
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
内核定义了一个per-CPU变量runqueues,其类型是struct rq。所有的就绪进程都会被放入某个CPU的rq上去,具体放到哪个rq上,这个在调度均衡里面讲。内核一共定义了五个调度类(这个在2.5中细讲),属于不同调度类的进程会被放入不同的子队列,所以rq中包含三个子运行队列cfs_rq、rt_rq、dl_rq。为啥只有3个子运行队列呢?因为有两个调度类idle、stop,每个CPU只能有一个进程,所以没必要弄个队列,直接关联它们的进程就可以了,就是上面代码中的两个struct task_struct * 类型的指针变量idle、stop。
进程是怎么被放入运行队列的呢?都是通过进程唤醒来放入的,包括新创建的进程也是。新建唤醒和阻塞唤醒的代码不太一样,下面我们分别来看一下。
我们先来看一下新建唤醒的代码:linux-src/kernel/sched/core.c
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;
raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING);
p->recent_used_cpu = task_cpu(p);
rseq_migrate(p);
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
activate_task(rq, p, ENQUEUE_NOCLOCK);
check_preempt_curr(rq, p, WF_FORK);
task_rq_unlock(rq, p, &rf);
}
在fork中会调用此函数唤醒新创建的进程,把它放入到运行队列中等待被调度。此函数会先调用select_task_rq来选择自己要去哪个CPU的rq上去,然后调用activate_task把进程加入到相应的运行队列上,最后调用check_preempt_curr看一下是否需要抢占,如果需要就触发抢占。select_task_rq的逻辑我们在调度均衡中讲,check_preempt_curr的逻辑我们在下一节的被动调度中讲。
我们再来看一下阻塞唤醒的代码:linux-src/kernel/sched/core.c
static int
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
unsigned long flags;
int cpu, success = 0;
preempt_disable();
if (p == current) {
goto out;
}
raw_spin_lock_irqsave(&p->pi_lock, flags);
if (!ttwu_state_match(p, state, &success))
goto unlock;
if (READ_ONCE(p->on_rq) && ttwu_runnable(p, wake_flags))
goto unlock;
cpu = select_task_rq(p, p->wake_cpu, wake_flags | WF_TTWU);
if (task_cpu(p) != cpu) {
if (p->in_iowait) {
delayacct_blkio_end(p);
atomic_dec(&task_rq(p)->nr_iowait);
}
wake_flags |= WF_MIGRATED;
set_task_cpu(p, cpu);
}
ttwu_queue(p, cpu, wake_flags);
unlock:
raw_spin_unlock_irqrestore(&p->pi_lock, flags);
out:
preempt_enable();
return success;
}
int wake_up_process(struct task_struct *p)
{
return try_to_wake_up(p, TASK_NORMAL, 0);
}
int wake_up_state(struct task_struct *p, unsigned int state)
{
return try_to_wake_up(p, state, 0);
}
int default_wake_function(wait_queue_entry_t *curr, unsigned mode, int wake_flags,
void *key)
{
WARN_ON_ONCE(IS_ENABLED(CONFIG_SCHED_DEBUG) && wake_flags & ~WF_SYNC);
return try_to_wake_up(curr->private, mode, wake_flags);
}
阻塞唤醒的核心逻辑是try_to_wake_up,但是内核并不是直接用这个函数,而是提供了三个封装函数。wake_up_process是默认的通用的进程唤醒接口,能唤醒TASK_NORMAL状态的进程。TASK_NORMAL就是阻塞状态的进程,包含TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE,前者是前睡眠能被信号唤醒,后者是深睡眠不能被信号唤醒。还有一些特殊状态的进程如被暂停的进程是不能通过wake_up_process来唤醒的,所以内核还提供了接口wake_up_state,可以自己指定唤醒什么状态的进程。还有一个封装函数default_wake_function,它是wait_queue的默认唤醒函数。
try_to_wake_up函数首先进行一些检测,先检测被唤醒的进程是否为当前进程,如果是的话直接go out。再检测进程的状态是否与state相符合,不符合就不唤醒,再看一下进程是否已经处于唤醒状态了,如果是就没有必要唤醒了。然后调用select_task_rq选择要到哪个CPU的运行队列上去运行,最后调用ttwu_queue把进程放到目标运行队列上去。基本逻辑和wake_up_new_task是一样的。
进程放入运行队列之后就等着被调度到CPU上去运行了。但是当前进程正在使用着CPU,它什么时候能把CPU让给其它进程去运行呢?有两种情况:一是当前进程主动让出CPU,这叫主动调度;二是当前进程被动让出CPU,这叫被动调度,也就是进程抢占。
主动调度又可以分为自愿性主动调度和非自愿性主动调度。自愿性主动调度是指进程主动调用sched_yield让出CPU,进程本身还会回到运行队列中去等待下次调度。如果运行队列中没有其它进程的话,此进程还会继续运行。非自愿性主动调度是指进程运行时遇到了无法继续运行的情况,只能进行调度让其它进程运行。进程无法继续运行的情况有加锁失败、要读的文件现在不在内存中、进程死亡等。
下面我们来看一个非自愿性主动调度的例子,信号量获取失败时的操作:linux-src/kernel/locking/semaphore.c
static inline int __sched __down_common(struct semaphore *sem, long state, long timeout)
{
struct semaphore_waiter waiter;
list_add_tail(&waiter.list, &sem->wait_list);
waiter.task = current;
waiter.up = false;
for (;;) {
if (signal_pending_state(state, current))
goto interrupted;
if (unlikely(timeout <= 0))
goto timed_out;
__set_current_state(state);
raw_spin_unlock_irq(&sem->lock);
timeout = schedule_timeout(timeout);
raw_spin_lock_irq(&sem->lock);
if (waiter.up)
return 0;
}
timed_out:
list_del(&waiter.list);
return -ETIME;
interrupted:
list_del(&waiter.list);
return -EINTR;
}
先定义一个等待项,把自己加入到信号量的等待列表中,然后调用schedule_timeout执行调度。schedule_timeout和普通的调度函数schedule的区别是:schedule只能等着被具体的事件唤醒,schedule_timeout可以被事件唤醒,如果事件在规定的时间没有到来就会被定时器超时唤醒。
如果进程自己不调用sched_yield,也不调用任何会阻塞的函数,那么进程岂不是就可以一直霸占着CPU了。所以内核还提供了被动调度,强制进行进程调度,避免一个进程长时间霸占CPU。被动调度是被动的,不能由进程主动去调度,所以被动调度和主动调度的模式差别很大。被动调度的过程分为两步:触发调度和执行调度。触发调度仅仅是做个标记,告诉系统需要调度了。执行调度是系统会在某些特定的点去检查调度标记,如果被设置的话就执行调度。触发调度的点有:定时器中断、唤醒进程时、迁移进程时、改变进程优先级时。执行调度的点有:从系统调用返回用户空间、从中断返回用户空间、从中断返回内核、禁用抢占临界区结束、禁用软中断临界区结束、cond_resched调用点。
定时器中断是保证抢占式多任务能实现的关键。因为其它被动调度的触发点是不确定的,只有定时器中断是确定的周期性的,因此定时器中断也被叫做调度tick。定时器中断的频率是个kconfig选项,可选的值有100、250、300、1000。默认选项是250,也就是说每4ms就会有一个定时器中断,这样就能保证被动调度的及时性,不会有进程过长的占用CPU。
最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Severd, FCFS)算法了。顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
优先权 = (等待时间+要求服务时间)/ 要求服务时间
从上面的公式,可以发现:
最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
另外,时间片的长度就是一个很关键的点:
通常时间片设为 20ms~50ms
通常是一个比较合理的折中值。
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。
进程的优先级可以分为,静态优先级或动态优先级:
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
工作流程:
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。