浅析Linux中完全公平调度——CFS

日期 内核版本 架构 作者 内容
2018-11-11 Linux-2.6.32

X86

Bystander Linux系统CFS

一、前序

        目前Linux支持三种进程调度策略,分别是SCHED_FIFO 、 SCHED_RR和SCHED_NORMAL;而Linux支持两种类型的进程,实时进程和普通进程。实时进程可以采用SCHED_FIFO 和SCHED_RR调度策略;普通进程则采用SCHED_NORMAL调度策略。从Linux2.6.23内核版本开始普通进程(采用调度策略SCHED_NORMAL的进程)采用了绝对公平调度算法,不再跟踪进程的睡眠时间,也不区分是否为交互式进程,它将所有的进程都统一对待,这就是完全公平的含义。

二、CFS基本原理概述

      cfs定义了一种新调度模型,它给cfs_rq(cfs的run queue)中的每一个进程都设置一个虚拟时钟-virtual runtime(vruntime)。如果一个进程得以执行,随着执行时间的不断增长,其vruntime也将不断增大,没有得到执行的进程vruntime将保持不变。
    而调度器将会选择最小的vruntime那个进程来执行。这就是所谓的“完全公平”。不同优先级的进程其vruntime增长速度不同,优先级高的进程vruntime增长得慢,所以它可能得到更多的运行机会。

三、CFS算法设计核心

       CFS根据各个进程的权重分配进程运行时间。
进程的运行时间计算公式为:
       分配给进程的运行时间 = 调度周期 * 当前进程权重 / 所有进程权重总和   (公式3.1)
    备注:调度周期:将所有处于TASK_RUNNING态进程都调度一遍的时间,在O(1)调度算法中就是运行队列中进程运行一遍的时间。所以进程权重与分配给进程的运行时间成正比。
  
vruntime的计算公式为:
       vruntime = 实际运行时间 * NICE_0_LOAD/ 当前进程权重  (公式3.2)

如果分配给进程的运行时间等于实际运行的时间时,将推到出另一vruntime计算公式。把公式3.2中的分配给进程的运行时间 与公式3.1中实际运行时间替换,将得出以下结果:
vruntime = (调度周期 * 当前进程权重 / 所有进程权重总和) *  NICE_0_LOAD/ 当前进程权重 

              = 调度周期 *  NICE_0_LOAD/ 所有进程权重总和
初步结论:当分配给进程的运行时间等于实际运行的时间时,虽然每个进程的权重不同,但是它们的 vruntime增长速度均相同,与权重无关。上文已述用vruntime来选择将要运行的进程,vruntime值较小表明它以前占用cpu的时间较短,受到了“不公平”对待,因此下一个运行进程就是它。如此一来既能公平选择进程,又能保证高优先级进程获得较多的运行时间。

如果分配给进程的运行时间不等于实际运行的时间时:CFS的思想就是让每个调度实体的vruntime增加速度不同,权重越大的增加的越慢,这样高优先级进程就能获得更多的cpu执行时间,而vruntime值较小者也得到执行。

        每一个进程或者调度组都对应一个调度的实体,每一个进程都通过调度实体与CFS运行对列建立联系,每次进行CFS调度的时候都会在CFS运行对列红黑树中选择一个进程(vruntime值较小者)。cfs_rq代表CFS运行对列,它可以找到对应的红黑树。进程task_struct ,可以找到对应的调度实体。调度实体sched_entity对应运行对列红黑树上的一个节点。

四、CFS调度器

4.1调度器概述

         现代的操作系统是多任务的操作系统,硬件的处理器核心和各种资源越来越多,CPU也是一个资源。为了保证进程合理的使用CPU资源,则需要一个管理单元,负责调度进程,由管理单元来决定下一刻应该由谁使用CPU,这里管理单元就是进程调度器。调度器可以临时分配一个任务在上面执行(单位是时间片)。进程调度器的任务就是合理分配CPU时间给运行的进程,创造一种所有进程并行运行的错觉,使得我们同时执行多个程序成为可能,可以具有各种需求的用户共享CPU。因此调度器必须在各个进程之间尽可能公平地共享CPU时间, 而同时又要考虑不同的任务优先级。调度器的一个重要目标是有效地分配 CPU 时间片,同时提供很好的用户体验。调度器的一般原理是, 按所需分配的计算能力, 向系统中每个进程提供最大的公正性, 或者从另外一个角度上说, 试图确保没有进程被亏待。

4.2调度器的结构

        在目前Linux内核中,调度器分成两个层级,在进程中被直接调用的成为通用调度器或者核心调度器,它们作为一个组件和进程其它部分分开,而通用调度器和进程并没有直接关系,其通过第二层的具体的调度器类来直接管理进程。具体架构如下图:

4.2.1调度器类:

Linux内核中实现了一个调度器类的框架,其中定义了调度器应该实现的函数,每一个具体的调度器类都要实现这些函数 。

在Linux版本中(3.11.1),使用了四个调度器类:stop_sched_class、rt_sched_class、fair_sched_class、idle_sched_class,在最新的内核中又添加了一个调度类dl_sched_class。每个进程必然属于一个特定的调度器类,Linux会根据不同的需求实现不同的调度器类。各个调度器类之间具备一定的层次关系,即在通用调度器选择进程的时候,会从最高优先级的调度器类开始选择,如果通用调度器类没有可运行的进程,就选择下一个调度器类的可用进程,这样逐层递减。调度器类的定义 为sched_class的结构体。

struct sched_class {
    const struct sched_class *next;

    //向就绪队列添加一个进程,该操作发生在一个进程变成就绪态(可运行态)的时候。
    void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);

    //执行enqueue_task的逆操作,在一个进程由运行态转为阻塞的时候就会发生该操作。
    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);
    void (*put_prev_task) (struct rq *rq, struct task_struct *p);

#ifdef CONFIG_SMP
    int  (*select_task_rq)(struct task_struct *p, int sd_flag, int flags);
    void (*migrate_task_rq)(struct task_struct *p, int next_cpu);

    void (*pre_schedule) (struct rq *this_rq, struct task_struct *task);
    void (*post_schedule) (struct rq *this_rq);
    void (*task_waking) (struct task_struct *task);
    void (*task_woken) (struct rq *this_rq, struct task_struct *task);

    void (*set_cpus_allowed)(struct task_struct *p,
                 const struct cpumask *newmask);

    void (*rq_online)(struct rq *rq);
    void (*rq_offline)(struct rq *rq);
#endif
    //当进程的调度策略发生变化时,需要执行此函数
    void (*set_curr_task) (struct rq *rq);
    //在每次激活周期调度器时,由周期调度器调用
    void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
    //建立fork系统调用和调度器之间的关联,每次新进程建立后,就调用该函数通知调度器
    void (*task_fork) (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);

#ifdef CONFIG_FAIR_GROUP_SCHED
    void (*task_move_group) (struct task_struct *p, int on_rq);
#endif
};

4.2.2周期调度器:

       周期调度器根据频率自动调用scheduler_tick函数。其主要作用就是根据进程运行时间触发调度;在进程遇到资源等待被阻塞也可以显示的调用调度器函数进行调度;另外在有内核空间返回到用户空间时,会判断当前是否需要调度,在进程对应的thread_info结构中,有一个flag,该flag字段的第二位(从0开始)作为一个重调度标识TIF_NEED_RESCHED,当被设置的时候表明此时有更高优先级的进程,需要执行调度。另外目前的内核支持内核抢占功能,在适当的时机可以抢占内核的运行。周期性调度器并不直接调度,至多设置进程的重调度位TIF_NEED_RESCHED,在返回用户空间的时候仍然由主调度器执行调度。

void scheduler_tick(void)
{
	int cpu = smp_processor_id();
	struct rq *rq = cpu_rq(cpu);
	struct task_struct *curr = rq->curr;

	sched_clock_tick();

	spin_lock(&rq->lock);
	update_rq_clock(rq);
	update_cpu_load(rq);
	curr->sched_class->task_tick(rq, curr, 0);
	spin_unlock(&rq->lock);

	perf_event_task_tick(curr, cpu);

#ifdef CONFIG_SMP
	rq->idle_at_tick = idle_cpu(cpu);
	trigger_load_balance(rq, cpu);
#endif
}

4.2.3主调度器:

主调度器是通过schedule()函数来完成进程的选择和切换。

static void __sched __schedule(void)
{
    struct task_struct *prev, *next;
    unsigned long *switch_count;
    struct rq *rq;
    int cpu;

need_resched:
    //禁止内核抢占
    preempt_disable();
    cpu = smp_processor_id();
    //获取CPU 的调度队列
    rq = cpu_rq(cpu);
    rcu_note_context_switch(cpu);
    //保存当前进程任务
    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);

    switch_count = &prev->nivcsw;
     /*  当内核态没有被抢占, 并内核抢占有效时
        即同时满足以下条件:
        1  该进程处于停止状态
        2  该进程没有在内核态被抢占 */
    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;
    }

    pre_schedule(rq, prev);

    if (unlikely(!rq->nr_running))
        idle_balance(cpu, rq);
    //通知调度器prev进程将被调度出去
    put_prev_task(rq, prev);
    //选择下一个可运行进程
    next = pick_next_task(rq);
    //清除pre的TIF_NEED_RESCHED标志
    clear_tsk_need_resched(prev);
    rq->skip_clock_update = 0;
   //如果next和当前进程不一致时可以调度
    if (likely(prev != next)) {
        rq->nr_switches++;
        //设置当前调度进程为next
        rq->curr = next;
        ++*switch_count;
        //切换进程上下文
        context_switch(rq, prev, next); /* unlocks the rq */
        /*
         * The context switch have flipped the stack from under us
         * and restored the local variables which were saved when
         * this task called schedule() in the past. prev == current
         * is still correct, but it can be moved to another cpu/rq.
         */
        cpu = smp_processor_id();
        rq = cpu_rq(cpu);
    } else
        raw_spin_unlock_irq(&rq->lock);

    post_schedule(rq);
  
    sched_preempt_enable_no_resched();
    if (need_resched())
        goto need_resched;
}

4.2.4上下文切换:

       上下文切换主要由context_switch函数完成,主要做了两件事情:切换地址空间、切换寄存器域和栈空间。整个切换过程需要加锁和关中断,首先切换的是地址空间,mm 和active_mm分别代表调度和被调度的进程的 mm_struct,如果mm为空,则表明next是内核线程,内核线程没有自己独立的地址空间,所以其mm为null,运行的时候使用prev的active_mm即可。如果非空,则是用户进程,那么可以直接切换,这里调用switch_mm函数进行切换;如果prev为内核线程,由于其没有独立地址空间,所以需要设置其active_mm为null。最后进程切换的部分调用switch_to来切换寄存器域和栈。(switch_to是一个宏,由汇编代码实现,有能力者可深入学习)

static inline void
context_switch(struct rq *rq, struct task_struct *prev,
           struct task_struct *next)
{
    struct mm_struct *mm, *oldmm;
    //进程切换准备工作加锁和关中断,最后调用finish_task_switch
    prepare_task_switch(rq, prev, next);
    
    mm = next->mm;
    oldmm = prev->active_mm;
    /*
     * For paravirt, this is coupled with an exit in switch_to to
     * combine the page table reload and the switch backend into
     * one hypercall.
     */
    arch_start_context_switch(prev);
    //如果要执行的是内核线程
    if (!mm) {
        next->active_mm = oldmm;
        atomic_inc(&oldmm->mm_count);
        enter_lazy_tlb(oldmm, next);
    } else
        switch_mm(oldmm, mm, next);
    //如果被调度的是内核线程
    if (!prev->mm) {
        prev->active_mm = NULL;
        rq->prev_mm = oldmm;
    }
    /*
     * Since the runqueue lock will be released by the next
     * task (which is an invalid locking op but in the case
     * of the scheduler it's an obvious special-case), so we
     * do an early lockdep release here:
     */
#ifndef __ARCH_WANT_UNLOCKED_CTXSW
    spin_release(&rq->lock.dep_map, 1, _THIS_IP_);
#endif

    context_tracking_task_switch(prev, next);
    /* Here we just switch the register state and the stack. */
    //切换寄存器域和栈
    switch_to(prev, next, prev);

    barrier();
    /*
     * this_rq must be evaluated again because prev may have moved
     * CPUs since it called schedule(), thus the 'rq' on its stack
     * frame will be invalid.
     */
    finish_task_switch(this_rq(), prev);
}

笔者仓促成文,不当之处,尚祈读者批评指正!笔者及时纠正。

发布了15 篇原创文章 · 获赞 21 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_42092278/article/details/83959440