schedule函数浅析

本文主要讲了:
1、schedule函数的大体框架
2、调度的时候对prev进程都做了什么操作
3、怎么选出的next进程(O(1)算法和CFS算法)
4、CPU负载均衡在做什么

现代用户对操作系统的要求越来越“苛刻“,进程相应时间尽可能的快,系统的吞吐量要尽可能的多等等。这些要求在表面上看起来是互相矛盾的,所以现代操作系统提出了进程的概念,随之而来的就是进程调度。进程调度就是调度程序根据一定的准则,在就绪队列里边选择一个进程来执行。所以调度算法是调度程序讨论的核心点,不一样的内核版本调度算法以及一些机制可能是不同的,但是基本上一些大的方面是没有改变的,我看的内核版本是3.14.54。
调度函数在内核中的实现函数是schedule(),函数的申明如下:

asmlinkage void __sched schedule(void);

asmlinkage宏定义:表示这个函数的参数并不是从栈中传递的,而是在寄存器中。__sched表示函数是内核中的调度程序。返回值是void,参数也是void类型。

struct task_struct *tsk = current;
sched_submit_work(tsk);
__schedule();

schedule函数的定义只有三行:通过current宏定义获取当前进程的task_struct;如果当前进程申请了I/O资源或者要进入睡眠状态,提交这个信息给系统,避免死锁;调用函数__schedule(),schedule函数的功能全部被封装在了这个函数中,函数定义如下:

preempt_disable();
cpu = smp_processor_id();
rq = cpu_rq(cpu);
rcu_note_context_switch(cpu);
schedule_debug(prev);
prev = rq->curr;
schedule_debug(prev);
...
smp_mb__before_spinlock();
raw_spin_lock_irq(&rq->lock);

不允许抢占,得到当前的需要调度的cpu(多核cpu),得到该cpu的就绪队列。rcu切换,时间调试检查和统计,记录当前进程为上一个进程(此时的当前进程还没有被替换),调试当前进程是不是通过do_exit来触发调度的(这种情况下的调度需要是一个原子操作)。使用自旋锁之前做一些优化,调用自旋锁锁住运行队列,同时关中断。

switch_count = &prev->nivcsw;
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;      

把当prev进程切换计数的地址赋给switch_count。
在2.6以后内核允许cpu抢占,但是不能在任何时候都可以抢占比如终端处理的时候或者调度的时候,所以在thread_info有一个字段preempt_count,表示是否允许内核抢占,为0允许,非0不允许;
PREEMPT_ACTIVE表示此时是否正在进行内核抢占;
TASK_RUNNING的定义是0;
如果prev进程的状态是TAAK_RUNNING,则直接跳到下一个代码片。这里要解释一下为什么要判断这个状态,如果prev进程不是自愿让出自己的cpu(被内核抢占,抢占的时候prev的状态会被修改为别的),调度程序为了公平一点会再给prev进程一次运行机会(修改prev状态为就绪状态)但是prev进程已经是就绪状态了,那就不用判断了;如果prev进程的状态不是就绪状态,并且是内核抢占导致的进程调度,调用函数signal_pending_state判断prev的状态是不是可中断、被杀死的或者未决信号集为空,则修改prev进程的状态为就绪状态;否则删除prev进程,prev进程的就绪队列存在标志清零;如果prev进程在工作对列中(申请了I/O资源被插入工作对列,那么意味着prev进程要进入睡眠期等待资源来唤醒),prev进程开始沉睡,然后在工作队列唤醒一个新进程并插入就绪队列(如果没有进程需要被唤醒则返回NULL)。

if (unlikely(!rq->nr_running))     
        idle_balance(cpu, rq);
put_prev_task(rq, prev);
next = pick_next_task(rq);
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
if (likely(prev != next)) {    
        rq->nr_switches++;    
        rq->curr = next;      
        ++*switch_count;   

context_switch(rq, prev, next);
}
else
    raw_spin_unlock_irq(&rq->lock);

如果rq里边没有进程(nr_running=0),启动cpu负载均衡。把prev进程放入就绪队列(具体解释见后文),从就绪队列中选出一个next进程,清除prev需要重新调度的标志,打开允许抢占。如果next进程还是prev进程,解开就绪队列的自旋锁;如果next进程不是prev进程(如果就绪队列里只有一个prev进程,那重新选出来的进程还是prev进程),就绪队列中的进程切换次数加1,就绪队列的current进程换成了next进程,调度程序的进程切换次数加1。进行prev进程和next进程的上下文切换(在上下文切换中就绪队列完成解锁操作)。

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

查看是否需要调度,如果需要再次执行该程序。到这里调度程序就执行完了,下面我们具体来分析以下几个问题:
1、对于prev进程进行了什么操作。
2、怎么选出来的next进程。
3、上下文切换具体做了什么操作。
先来看第一个问题,对于prev进程,调度程序都干了什么。
前面的分析中可以看出调度程序对prev进程作的一些收尾工作有判断prev进程是在什么情况下调度的(自愿让出cpu还、被抢占或者申请了I/O资源)根据不同的调度情境,对prev进程进行了不同的操作,最后调用函数put_prev_task插入就绪队列中(不理解为什么最后还要插入就绪队列),函数put_prev_task代码如下:

if (prev->on_rq || rq->skip_clock_update < 0)
        update_rq_clock(rq);
    prev->sched_class->put_prev_task(rq, prev);

如果prev进程还在cpu上或者就绪队列的clock未初始化,则初始化就绪队列的clock。调用函数task_struct 的成员结构体sche_class 的成员函数put_prev_task()后函数运行结束。函数put_prev_task是内核定义的一个钩子函数,并没有实现其功能。到这里我也不知道内核对prev进程都干了什么。
第二个问题,如何选择next进程(调度的核心)。
内核中的所有进程按类别区分,不同类别的进程需要的调度算法是不一样的。所以kernel在内核实现的时候抽象出来一个调度类别sche_class类,这个类里边定义了调度需要的所有函数,但是全部是钩子函数,然后在根据不同进程类别分别实现了。kernel中实现的具体类别如下(这些类和sche_class类是抽象和实现的关系):

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;

而调度类中的函数通过对进程实体(sche_entity)进行操作来完成其功能的。
然后来假想以下,调度程序是被要求来完成选择下一个要运行的进程的工作的。首先,必须保证公平,每个进程都要有可能被执行到,不能让一个进程无休止的等待;其次,尽量选中最需要被执行的进程。这两点不难想到。
实现选择next进程的函数是pick_next_task(感谢kernel的函数命名,虽然我还没有看这个函数,但是我知道这个函数实现了什么功能),代码如下:

 if (likely(rq->nr_running == rq->cfs.h_nr_running)) {
        p = fair_sched_class.pick_next_task(rq);
        if (likely(p))
            return p;         
    }  

    for_each_class(class) {   
        p = class->pick_next_task(rq); 
        if (p)
            return p;
    }  

如果就绪队列里边的进程的个数等于需要公平调度的进程个数(全部是普通进程),那么就采用公平调度算法。如果不是说明里边有很多种类型的进程。当就绪队列中不全是普通进程的时候,内核就会先去看看stoptask就绪队列是不是空,不空就选出next进程,返回;如果空,那么就去看dl_sched这种进程的就绪队列,队列不空,选出下一个进程……以此类推,按顺序访问,最优先处理的是stoptask,最后处理的是空闲进程。内核把进程分为五种类型,分别是如上一个代码片所示的stoptask、dl_sched、实时进程、普通进程和空闲进程。每一个类型的进程有自己的就绪队列,以及指向下一个进程类型的指针(next)。

每一个类型的进程都实现了自己的调度类,所以每一种进程的调度算法都不相同。本文只分析最经典的的实时进程O(1)算法和普通进程的CFS算法。
先来看看普通进程的公平算法,部分代码如下(在这里认为你已经了解了红黑树的原理):

do {
        se = pick_next_entity(cfs_rq); 
        set_next_entity(cfs_rq, se);
        cfs_rq = group_cfs_rq(se);                                                                                             
    } while (cfs_rq);

static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq)
{
    struct sched_entity *se = __pick_first_entity(cfs_rq);
    struct sched_entity *left = se;

    if (cfs_rq->skip == se) { 
        struct sched_entity *second = __pick_next_entity(se);
        if (second && wakeup_preempt_entity(second, left) < 1)
            se = second;
    }
    if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
        se = cfs_rq->last;

    if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
        se = cfs_rq->next;

    clear_buddies(cfs_rq, se);

    return se;
}

先来选择树最左边的结点保存在se和left单元,如果se是skip(???),选择下一个进程second(根节点的右孩子最左边的节点),比较se和second进程的优先级(根据虚拟运行时间和粒度来判断),如果second进程的优先级高一点,se单元保存second进程;再次比较se进程和last进程,优先级高者保存在se单元;最后从树中删去选中的进程并获取该进程的task_struct 返回。
这里有一个细节要来说说,优先级是怎么计算的。根据虚拟运行时间和粒度来确定的,粒度又是根据权重计算的。这部分具体代码分析略,有兴趣的话可以自己去看看wakeup_preempt_entity函数的实现。
下面看看O(1)算法。
在介绍O(1)算法的实现之前,有一个比较重要的结构体要来说一说,实时进程的就绪队列结构体,定义如下:

struct rt_prio_array {                                               
    DECLARE_BITMAP(bitmap, MAX_RT_PRIO+1); /* include 1 bit for delimiter */
    struct list_head queue[MAX_RT_PRIO];
};
#define DECLARE_BITMAP(name,bits) \
    unsigned long name[BITS_TO_LONGS(bits)]  

第一个成员是位图,在内核中位图的实现并不是我们期待的那样是一个数,因为实时进程的优先级有100个,并没有一个数据类型可以占100位,所以我们定义成了一个无符号的长整型类型的数组,有四个元素(BITS_TO_LONGS(bits)函数具体明细不在一一贴出,有兴趣的人自己可以看看)。
你可能想问,定义成了一个数组,势必要遍历,怎么做到的O(1),注意,我们常常说的遍历一般时复是O(n)是对于不知道遍历次数的情况下来定义的。在内核这种情况下,最多需要循环四回,我们依旧认为这个时间复杂度是O(1)。
第二个成员是就绪队列——双向链表类型的数组,就绪队列根据进程的优先级来决定进程挂在哪一个链表上。(0号优先级的进程挂在下边为0的元素下,以此类推)。
下面看来看看实时进程选择下一个进程的函数是pick_next_task,而这个函数的基本功能都被封装在了_pick_next_task_rt中,我们只来分析这个函数,代码如下:

static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
    rt_rq = &rq->rt;
    if (!rt_rq->rt_nr_running)
            return NULL;

    if (rt_rq_throttled(rt_rq))    
            return NULL;
    do {  
        rt_se = pick_next_rt_entity(rq, rt_rq);
        BUG_ON(!rt_se);
        rt_rq = group_rt_rq(rt_se);
       } while (rt_rq);
       p = rt_task_of(rt_se);
        p->se.exec_start = rq_clock_task(rq);
        return p;
}

根据参数就绪队列取出其中的实时进程就绪队列(就绪队列实际上并不是一个对列,而是好几个,每种类型的进程都有与之对应的就绪队列),进行一些错误判断,调用函数pick_next_rt_entity选择下一个进程并返回这个进程的调度实体,如果选择失败进入错误处理。根据next进程的进程实体得到该进程的task_struct(因为调度类里边的函数是对sche_entity进行操作的)保存在p里。上述分析中我们可以看到调度算法的实现被封装在了函数pick_next_rt_entity,函数分析如下:

static struct sched_rt_entity *pick_next_rt_entity(struct rq *rq,struct rt_rq *rt_rq)
{
    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;
}

在位图中找到第一个不为0的位,那这一位代表的优先级号保存在idex中;如果idex比最大的优先级还大进入错误处理;找到该优先级对应的就绪队列(通过计算地址),取出队列中的第一个进程并返回。
以上就是全部的实时进程调度的O(1)算法内核实现。
最后一点内容是CPU负载均衡。
调度域:调度域的组织结构是分层结构,负载均衡就是对一个一个的调度域进行操作。
现代PC都是多核的CPU,当系统中还有进程在等待的时候,OS的设计人员并不希望有一个CPU处于空闲状态。这就有了CPU负载均衡,这个机制期望确保每个CPU就绪队列上的进程数量相当。当有一个CPU处于空闲状态时候启动负载均衡机制,从别的CPU就绪队列中“拿“一些进程来执行。代码如下:

raw_spin_unlock(&this_rq->lock);
rcu_read_lock();
for_each_domain(this_cpu, sd) {
    if (sd->flags & SD_BALANCE_NEWIDLE) {
    t0 = sched_clock_cpu(this_cpu);
    pulled_task = load_balance(this_cpu, this_rq,
                sd, CPU_NEWLY_IDLE,
                &continue_balancing);
    domain_cost = sched_clock_cpu(this_cpu) - t0;
    if (domain_cost > sd->max_newidle_lb_cost)

打开就绪队列的自旋锁(并没有打开终端和允许抢占),调用rcu锁,从子到父遍历当前CPU的每个调度域。如果调度域并不需要均衡负载则检查下一个;如果调度域需要,调用load_balance函数(分析见后文)检查是否需要负载均衡如果需要则移动进程。
主要来分析load_balance函数,代码如下:

struct cpumask *cpus = __get_cpu_var(load_balance_mask);
struct lb_env env = {
    .sd     = sd,
    ...
};
if (idle == CPU_NEWLY_IDLE)
env.dst_grpmask = NULL;
if (!should_we_balance(&env)) { 
    *continue_balancing = 0;
    goto out_balanced;
group = find_busiest_group(&env);
if (!group) {
     schedstat_inc(sd, lb_nobusyg[idle]);
     goto out_balanced;
}

获取cpu掩码,复制环境变量。如果cpu是最近空闲的,设置环境变量中的组掩码为NULL,拷贝当权cpu的掩码至cpus单元中。判断如果不需要执行均衡操作,跳出函数的执行;如果需要均衡,找出最繁忙的组(调度域中进程是分组管理的,这个函数分析见后文),如果找出来的组为空(这个组是平衡的),退出函数执行;如果最繁忙的组中的就绪进程个数超过1移动任务。

看完内核习惯性说几点:
1、内核的代码是比较难懂的风格,比代码更难懂的是机制。
2、难以掩饰内心的愉悦,终于看懂了著名的O(1)算法(虽然后来想想很简单)。为了这一刻的欣喜,两天的舟车劳顿都是值得的。
3、休息整顿,继续征服公平算法。
4、祝周末愉快,1024节快乐。
5、看到这里调度这块第一遍算是看完了,还有很多东西不明白。先看看内核同步,再回头来看。
6、看调度的时候想到一个问题,就绪队列是怎么创建起来的(不同的进程应该插入就绪进程的实现不一样),想想觉得还是很有看头的。理论上来说应该是在do_fork函数做的,虽然我看完了do_fork函数但还是不知道(窘…)。所以要回头去多看看。

你可能感兴趣的:(linux内核的一些事,kernel)