【深入Linux内核架构笔记】第二章 进程调度(3)--完全公平调度CFS

2,170 阅读9分钟

2.6 完全公平调度CFS

  • 核心思想:度量每个进程的虚拟运行时间,调度运行时间最少的进程运行
  • 虚拟运行时间:由物理时钟、进程优先级确定,优先级越高,虚拟时钟越慢,也就是进程越容易被调度到

2.6.1 数据结构

  • CFS的就绪队列
    • vruntime:位于sched_entity,记录进程在虚拟时钟上流逝的数量
    • min_vruntime:是单调递增的,跟踪队列上所有进程的最小虚拟运行时间,比最左边的树结点的vruntime大些
/* CFS-related fields in a runqueue */
struct cfs_rq {
    struct load_weight load;         //就绪队列上进程的累积负荷值
    unsigned long nr_running;        //队列上可运行进程的数目
    u64 min_vruntime;                //所有进程的最小虚拟运行时间

    struct rb_root tasks_timeline;   //管理红黑树所有进程
    struct rb_node *rb_leftmost;     //指向红黑树最左边的节点(需要被调度的进程)
    struct sched_entity *curr;       //当前执行进程的可调度实体
......
};

2.6.2 CFS虚拟时间

1. 虚拟时钟计算

  • 虚拟时钟:完全公平调度算法依赖虚拟时钟,度量进程能得到的CPU时间

    • 依据:实际时钟、进程相关的负荷权重(进程优先级
    • 工作:更新当前进程执行的实际时钟、计算对应的虚拟时钟、更新队列的min_vruntime
// 核心函数:更新进程的物理时钟和虚拟时钟
static void update_curr(struct cfs_rq *cfs_rq) {
    //curr表示就绪队列当前执行的进程
    struct sched_entity *curr = cfs_rq->curr;
    //now表示就绪队列的真实时钟
    u64 now = rq_of(cfs_rq)->clock;
    unsigned long delta_exec;
......
    //计算 当前就绪队列时钟 和 上一次就绪队列时钟 的时间差delta_exec
    delta_exec = (unsigned long)(now - curr->exec_start);

    //委托给__update_curr,更新当前进程在CPU花费的物理时间和虚拟时间
    __update_curr(cfs_rq, curr, delta_exec);
    curr->exec_start = now;
......
}

static inline void
__update_curr(struct cfs_rq *cfs_rq, struct sched_entity *curr,
          unsigned long delta_exec)
{
    unsigned long delta_exec_weighted;
    u64 vruntime;
......
    //更新当前进程的执行总时间(物理时间)
    curr->sum_exec_runtime += delta_exec;
......
    //根据负荷权重计算当前进程增加的虚拟时钟(虚拟时间)
    delta_exec_weighted = delta_exec;
    if (unlikely(curr->load.weight != NICE_0_LOAD)) {
        delta_exec_weighted = calc_delta_fair(delta_exec_weighted,
                            &curr->load);
    }
    curr->vruntime += delta_exec_weighted;

    //对CFS队列更新min_vruntime(内核确保值单调增加)
    if (first_fair(cfs_rq)) {
        //如果红黑树有最左边结点(有进程在等待调度),vruntime=最左边结点进程的vruntime
        vruntime = min_vruntime(curr->vruntime,
                __pick_next_entity(cfs_rq)->vruntime);
    } else {
        //如果红黑树是空的,vruntime=当前进程的虚拟运行时间
        vruntime = curr->vruntime;
    }
    cfs_rq->min_vruntime =
        max_vruntime(cfs_rq->min_vruntime, vruntime);
}

其中,虚拟时钟的增长量delta_exec_weighted根据公式计算,取四舍五入:

delta_exec_weighted=delta_exec×NICE_0_LOADload_weightdelta\_exec\_weighted = delta\_exec \times \frac{NICE\_0\_LOAD}{load\_weight}
  • 根据表prio_to_weightnice=0的负荷权重load_weight=1024
    • nice=0(prio=120):delta_exec_weighted = delta_exec1024/1024 = delta_exec*
    • nice=-10(prio=110):delta_exec_weighted = delta_exec1024/9548 = 0.107delta_exec

image.png

【总结】

  • 对于nice=0的进程,虚拟时间=物理时间;对于其他优先级,根据负荷权重load_weight重新衡定时间。
  • 进程优先级越高,nice值越小,进程负荷权重load_weight越大,虚拟时钟跑得越慢
    • 进程运行时,vruntime稳定增加,越重要的进程虚拟时钟跑得越慢,在红黑树中位置更靠左,越容易被调度到
    • 进程进入睡眠,vruntime保持不变。每个队列min_vruntime增加,睡眠进程醒来则在红黑树中位置更靠左,更容易被调度

2. 进程调度时间计算

  • CFS没有时间片的概念,某个调度实体分配到的时间由以下公式决定:
slice=队列调度周期×调度实体的权重就绪队列的权重slice = 队列调度周期 \times \frac {调度实体的权重} {就绪队列的权重}
  • 其中就绪队列的权重就是所有调度实体权重之和,参见update_load_add()
/* 计算某个调度实体分配到的时间 */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) {
    u64 slice = __sched_period(cfs_rq->nr_running);

    slice *= se->load.weight;
    do_div(slice, cfs_rq->load.weight);

    return slice;
}
/* 计算就绪队列调度的总时间 */
static u64 __sched_period(unsigned long nr_running) {
    //默认延迟周期为20ms
    u64 period = sysctl_sched_latency;
    //就绪队列上活动进程数目,默认5
    unsigned long nr_latency = sched_nr_latency;
    //如果队列上数目超过默认值,总时间要拉长(sysctl_sched_latency*nr_running/nr_latency)
    if (unlikely(nr_running > nr_latency)) {
        period *= nr_running;
        do_div(period, nr_latency);
    }
    return period;
}
  • 等价的虚拟时间在__sched_vslice函数给出,公式:
vslice=time×NICE_0_LOADweightvslice = time \times \frac {NICE\_0\_LOAD} {weight}
  • 进程调度时间计算涉及到三个内核参数:用于跟踪CFS的调度延迟(物理时间)
    • sysctl_sched_latency:CPU 密集型任务的目标抢占延迟,默认值20ms。每个调度实体一定在20ms内会被调度到(注意:和时间片不一样,时间片是固定的
    • sysctl_sched_min_granularity:CPU 密集型任务的最小抢占粒度,默认4ms。设的越小切换越频繁(个人理解为这4ms都是你的,不能被抢走)
    • sched_nr_latency:控制一个延迟周期中处理的最大活动进程数目。如果活动进程数目超过该上限,延迟周期成比例地线性扩展(20ms最多处理5个任务,每个任务4ms)
/*
 * Targeted preemption latency for CPU-bound tasks:
 * (default: 20ms * (1 + ilog(ncpus)), units: nanoseconds)
 *
 * NOTE: this latency value is not the same as the concept of
 * 'timeslice length' - timeslices in CFS are of variable length
 * and have no persistent notion like in traditional, time-slice
 * based scheduling concepts.
 *
 * (to see the precise effective timeslice length of your workload,
 *  run vmstat and monitor the context-switches (cs) field)
 */
unsigned int sysctl_sched_latency = 20000000ULL;

/*
 * Minimal preemption granularity for CPU-bound tasks:
 * (default: 4 msec * (1 + ilog(ncpus)), units: nanoseconds)
 */
unsigned int sysctl_sched_min_granularity = 4000000ULL;

/*
 * is kept at sysctl_sched_latency / sysctl_sched_min_granularity
 */
static unsigned int sched_nr_latency = 5;

2.6.3 队列操作

1. 进程加入就绪队列

  • enqueue_task_fair完成
    • 进程最近在运行,更新vruntime后,直接加入就绪队列红黑树中
    • 进程此前在睡眠,可能其vruntime与队列的vruntime差的很远,所以要先调整后再加到红黑树里,确保进程不会一直执行并更优先被调度到
    • 如果进程是新fork的,分两种情况
      • 父进程先执行,加入到就绪队列最后面,保证进程最后才被调度到
      • 子进程先执行(Linux 2.6.24默认):保证子进程虚拟时钟小于父进程,即子进程先执行(2.6.7节),并加入到就绪队列最后面,进程最后被调度到
/* 进程加入队列,wakeup表示入队的进程是否此前在睡眠,当前被唤醒并转换为运行态 */
static void enqueue_task_fair(struct rq *rq, struct task_struct *p, int wakeup) {
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &p->se;

    for_each_sched_entity(se) {
        //已经在就绪队列中,直接返回
        if (se->on_rq)
            break;
        cfs_rq = cfs_rq_of(se);
        enqueue_entity(cfs_rq, se, wakeup);
        wakeup = 1;
    }
}

/* 核心函数,将调度实体加入队列 */
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int wakeup)
{
    //更新当前CFS队列时间
    update_curr(cfs_rq);

   if (wakeup) {
        //如果进程此前在睡眠,调整进程的虚拟时间
        place_entity(cfs_rq, se, 0);
        enqueue_sleeper(cfs_rq, se);
    }
......
    if (se != cfs_rq->curr)
        //内核函数,将调度实体置入红黑树中
        __enqueue_entity(cfs_rq, se);
    //就绪队列活动进程数cfs_rq->nr_running++;
    account_entity_enqueue(cfs_rq, se);
}

/*更新新进程的vruntime值,以便把他插入红黑树 */
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
    u64 vruntime;
    //以当前队列的min_vruntime作为基准时间
    vruntime = cfs_rq->min_vruntime;
......
    //是新进程被加到系统中,那么新进程的vruntime需要加上整个调度周期的虚拟实践,保证最后才被调度到
    if (initial)
        vruntime += sched_vslice_add(cfs_rq, se);
    //进程此前在睡眠,扣掉sysctl_sched_latency,保证进程唤醒后能够更早被调度到
    if (!initial) {
......
        vruntime -= sysctl_sched_latency;
        //取se->vruntime和vruntime的最大值
        vruntime = max_vruntime(se->vruntime, vruntime);
    }
    //更新进程的vruntime
    se->vruntime = vruntime;
}
  • 其中__enqueue_entity将进程置入红黑树中。加入的位置由entity_key确定。
    • 当前进程的vruntime越小,则加入的位置越靠左,越容易被选到(2.6.4节)
static inline s64 entity_key(struct cfs_rq *cfs_rq, struct sched_entity *se) {
    return se->vruntime - cfs_rq->min_vruntime;
}

2.6.4 选择下一个进程

  • 选择红黑树最靠左的进程作为下一个进程
static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq) {
    struct sched_entity *se = NULL;

    if (first_fair(cfs_rq)) {
        //选择最靠左的进程作为下一个进程
        se = __pick_next_entity(cfs_rq);
        //将进程取出标记为运行进程
        set_next_entity(cfs_rq, se);
    }
    return se;
}

/* 选择进程后需要做一些工作,标记为运行进程 */
static void set_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
......
    //从就绪队列中移除最左边的进程
    if (se->on_rq) {
        __dequeue_entity(cfs_rq, se);
    }
    //就绪队列标记当前进程,并更新进程运行的时间
    update_stats_curr_start(cfs_rq, se);
    cfs_rq->curr = se;
......
    se->prev_sum_exec_runtime = se->sum_exec_runtime;
}

2.6.5 处理周期性调度器

  • 内核按频率HZ执行周期性调度。由task_tick_fair负责,实际工作由entity_tick完成
    • 更新就绪队列实际时钟、虚拟时钟统计量
    • 如果当前进程运行时间过长,则重新调度
static void entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr) {
    //更新统计量
    update_curr(cfs_rq);

    if (cfs_rq->nr_running > 1 || !sched_feat(WAKEUP_PREEMPT))
        //看看进程运行的时间是否过长,需要重新调度
        check_preempt_tick(cfs_rq, curr);
}

static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr) {
    unsigned long ideal_runtime, delta_exec;
    //计算当前进程的调度时间
    ideal_runtime = sched_slice(cfs_rq, curr);
    delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
    //如果当前进程的运行时间过长,超过了调度时间额度,需要重新调度
    if (delta_exec > ideal_runtime)
        resched_task(rq_of(cfs_rq)->curr);
}
  • 重新调度resched_task()设置标志TIF_NEED_RESCHED。进程在适当时机(比如系统调用返回、中断返回)发起schedule()
asmlinkage void __sched schedule(void)
{
need_resched:
......
    prev = rq->curr;
......
    prev->sched_class->put_prev_task(rq, prev);  //将当前进程重新放回就绪队列中
    next = pick_next_task(rq, prev);             //选择下一个进程调度执行
......
    if (unlikely(test_thread_flag(TIF_NEED_RESCHED)))
        goto need_resched;
}

2.6.6 唤醒抢占

  • try_to_wake_upwake_up_new_task中唤醒进程时,内核使用check_preempt_curr看看是否新进程可以抢占当前进程
  • 完全公平调度器使用check_preempt_wakeup实现check_preempt_curr
    • 如果是实时进程,会立即抢占CFS进程
    • 如果是批处理进程,不抢占其他进程
    • CFS进程抢占,要保证进程运行sysctl_sched_wakeup_granularity(默认4ms),避免频繁切换
/*
 * Preempt the current task with a newly woken task if needed:
 */
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p)
{
    struct task_struct *curr = rq->curr;
    struct cfs_rq *cfs_rq = task_cfs_rq(curr);
    struct sched_entity *se = &curr->se, *pse = &p->se;
    unsigned long gran;
    //如果是实时进程,会立即抢占CFS进程
    if (unlikely(rt_prio(p->prio))) {
        update_rq_clock(rq);
        update_curr(cfs_rq);
        resched_task(curr);
        return;
    }
    //如果是批处理进程,不抢占其他进程
    if (unlikely(p->policy == SCHED_BATCH))
        return;
......
    //CFS进程被新CFS进程抢占,需要至少运行4ms。注意这里4ms要转成对应的虚拟时间
    gran = sysctl_sched_wakeup_granularity;
    if (unlikely(se->load.weight != NICE_0_LOAD))
        gran = calc_delta_fair(gran, &se->load);
    //超过4ms则重新调度
    if (pse->vruntime + gran < se->vruntime)
        resched_task(curr);
}

2.6.7 处理新进程

  • sysctl_sched_child_runs_first控制fork进程后是父进程先执行还是子进程先执行
    • Linux 2.6.24默认值为1,表示子进程先执行。后续内核有修订
    • 过程:更新虚拟时钟、确保子进程先执行、子进程加入就绪队列、重新调度(注意:这里子进程加入就绪队列后不一定会立即执行)
long do_fork(unsigned long clone_flags,    //标志集合,SIGCHLD表示fork后发送SIGCHLD信号给父进程
         unsigned long stack_start,    //用户状态下栈的起始地址
         struct pt_regs *regs,         //指向寄存器集合的指针
         unsigned long stack_size,     //用户状态下栈的大小,通常为0
         int __user *parent_tidptr,    //指向父进程PID
         int __user *child_tidptr)     //指向子进程PID
{
......
        // 将子进程加入调度器队列,由调度器选择它运行
        if (!(clone_flags & CLONE_STOPPED))
                wake_up_new_task(p, clone_flags);
......
}

//唤醒子进程
void fastcall wake_up_new_task(struct task_struct *p, unsigned long clone_flags)
{
    //初始化子进程优先级
    p->prio = effective_prio(p);
......
    //子进程调度时间初始化
    p->sched_class->task_new(rq, p);
    inc_nr_running(p, rq);
......
}

//完全公平调度走task_new_fair
static void task_new_fair(struct rq *rq, struct task_struct *p) {
    struct cfs_rq *cfs_rq = task_cfs_rq(p);
    struct sched_entity *se = &p->se, *curr = cfs_rq->curr;
    int this_cpu = smp_processor_id();

    sched_info_queued(p);
    //更新虚拟时钟,更新se->vruntime。注意第三个参数initial=1
    update_curr(cfs_rq);
    place_entity(cfs_rq, se, 1);

    //这里保证子进程先执行
    if (sysctl_sched_child_runs_first && this_cpu == task_cpu(p) &&
            curr && curr->vruntime < se->vruntime) {
        /*
         * Upon rescheduling, sched_class::put_prev_task() will place
         * 'current' within the tree based on its new key value.
         */
        swap(curr->vruntime, se->vruntime);
    }
    //加入队列,重新请求调度
    enqueue_task_fair(rq, p, 0);
    resched_task(rq->curr);
}
  • 代码中swap()的作用:如果父进程虚拟运行时间curr->vruntime小于子进程运行时间se->vruntime,表示父进程先于子进程执行。所以通过交换虚拟时钟的方式,保证子进程先执行。