uCore操作系统编程实验手记(六)

uCore操作系统编程实验手记(六)

  • 实验uCore进程调度的有关准备
    • 调度时机和系统调度框架
    • Round Robin时间片轮转算法
    • 进程切换的过程
  • Lab6
    • 使用 Round Robin 调度算法
    • 实现 Stride Scheduling 调度算法

实验uCore进程调度的有关准备

实验四、实验五是uCore操作系统与内核线程、用户进程创建有关的内容。其实在前两个实验中,已经或多或少地接触到了进程调度的内容,只不过遇到时没怎么深究。实验六的内容就是探索uCore操作系统的调度器,理解调度器的原理、实现,彻底弄懂进程切换的过程。首先还是先进行有关的准备,看有什么数据结构和接口可用,然后看RR调度算法的实现机制,最后理清switch_to()函数的实现过程。

调度时机和系统调度框架

欲清除调度器的实现,首先要弄懂调度的时机。即什么时候要/可以执行进程调度?系统内核可以被调度、抢占吗?在uCore中,系统内核是不能被抢占的。但是这与响应中断、甚至嵌套中断并不冲突。这个系统内核不能被抢占的意思是,执行内核代码时,不能调度去执行其他进程。但有的系统却可以被抢占,这与要同步机制的支持。
话说回来,什么时候要/可以执行进程调度?这里列举几个:
1.中断返回时。这是比较典型的,是我记住的第一个进程调度时机。
2.请求的服务不能立刻得到而阻塞时。
3.进程主动放弃CPU使用权。
4.和调度算法有关,如被高优先级抢占、时间片用完时…
注意,在uCore中只有用户进程是可以在任意点被抢占的

wakup_proc()函数schedule()函数run_timer_list()函数是uCore系统完成进程调度的3个核心函数,最重要的是schedule()函数。实验指导书中指出,如果我们能够让这三个调度相关函数的实现与具体调度算法无关,那么就可以认为ucore实现了一个与调度算法无关的调度框架。实际上,uCore系统定义调度器接口时,也是这么考虑的。
wakeup_proc()函数其实完成了把一个就绪进程放入到就绪进程队列中的工作,为此还调用了一个调度类接口函数sched_class_enqueue(),这使得wakeup_proc()的实现与具体调度算法无关。
run_timer_list()函数在每次timer中断处理过程中被调用,从而可用来调用调度算法所需的timer时间事件感知操作,调整相关进程的进程调度相关的属性值。通过调用调度类接口函数sched_class_proc_tick(),使得此操作与具体调度算法无关。
schedule()函数完成了与调度框架和调度算法相关三件事情:把当前继续占用CPU执行的运行进程放放入到就绪进程队列中,从就绪进程队列中选择一个“合适”就绪进程,把这个“合适”的就绪进程从就绪进程队列中摘除。通过调用三个调度类接口函数sched_class_enqueue()sched_class_pick_next()sched_class_enqueue() 来使得完成这三件事情与具体的调度算法无关。
可见,实现调度算法,最核心的就是实现以下的几个函数(sched.c):

    sched_class_enqueue()
    sched_class_dequeue()
    sched_class_pick_next()
    sched_class_proc_tick()

所以uCore系统定义了如下调度接口类,一目了然,上述“关键函数”最终都对应调用下述类中的函数(sched.h):

 struct sched_class {
     // 调度器的名字
     const char *name;
     // 初始化运行队列
     void (*init) (struct run_queue *rq);
     // 将进程 p 插入队列 rq
     void (*enqueue) (struct run_queue *rq, struct proc_struct *p);
     // 将进程 p 从队列 rq 中删除
     void (*dequeue) (struct run_queue *rq, struct proc_struct *p);
     // 返回 运行队列 中下一个可执行的进程
     struct proc_struct* (*pick_next) (struct run_queue *rq);
     // timetick 处理函数
     void (*proc_tick)(struct  run_queue* rq, struct proc_struct* p);
};

为与相应的接口配套,需要有合适的数据结构。在引入调度功能后,需要对进程描述符做适当的改进(proc.h):

struct proc_struct {
    // . . .
    // 该进程是否需要调度,只对当前进程有效
    volatile bool need_resched;
    // 该进程的调度链表结构,该结构内部的连接组成了 运行队列 列表,见后说明
    list_entry_t run_link;
    // 该进程剩余的时间片,只对当前进程有效
    int time_slice;
    // 指向进程所在的运行队列
    struct run_queue *rq;
    // round-robin 调度器并不会用到以下成员,实现stride算法时候用到
    // 该进程在优先队列中的节点,仅在 LAB6 使用,斜堆
    skew_heap_entry_t  lab6_run_pool;
    // 该进程的调度优先级,仅在 LAB6 使用
    uint32_t lab6_priority;
    // 该进程的调度步进值,仅在 LAB6 使用
    uint32_t lab6_stride;
};

通过数据结构 struct run_queue 来描述完整的 run_queue运行队列(sched.h):

struct run_queue {
    //其运行队列的哨兵结构,可以看作是队列头和尾
    list_entry_t run_list;
    //优先队列形式的进程容器,只在 LAB6 练习2中使用
    skew_heap_entry_t  *lab6_run_pool;
    //表示其内部的进程总数
    unsigned int proc_num;
    //每个进程一轮占用的最多时间片
    int max_time_slice;
};

在 ucore 框架中,运行队列存储的是当前可以调度的进程,所以,只有状态为runnable的进程才能够进入运行队列。当前正在运行的进程并不会在运行队列中。

Round Robin时间片轮转算法

关于RR算法的理论性的知识、优劣等,这里不赘述。
这部分重点是看在系统代码层面,如何实现了RR算法。上一小节,熟悉了系统定义的调度器接口。要实现RR调度算法,首先要实例化接口类:

struct sched_class default_sched_class = {
    .name = "RR_scheduler",
    .init = RR_init,
    .enqueue = RR_enqueue,
    .dequeue = RR_dequeue,
    .pick_next = RR_pick_next,
    .proc_tick = RR_proc_tick,
};

在sched.c的void sched_init(void)函数中,有:

static struct sched_class *sched_class;
sched_class = &default_sched_class;

当执行过初始化函数之后,系统调度类指针指向了RR算法的调度结构,RR算法开始生效。下面把上面提出的4个核心函数列出来看看RR是怎么实现的。(default_sched.c)

//被sched_class_enqueue()所调用,执行“入队”的功能
static void RR_enqueue(struct run_queue *rq, struct proc_struct *proc) {
    assert(list_empty(&(proc->run_link)));
    //rq->run_list是RR运行队列的双向链头结点,proc->run_link是插入进程描述符的链接结构
    list_add_before(&(rq->run_list), &(proc->run_link));
    //time_slice是当前进程剩余时间片,如果它为0或者超过了系统定义的最大值
    if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
        proc->time_slice = rq->max_time_slice;//当前时间片设置为时间片最大值
    }
    proc->rq = rq;//指向该进程所在的运行队列
    rq->proc_num ++;//运行队列计数器的值+1
}

//被sched_class_dequeue()所调用,执行“出队”的功能
static void RR_dequeue(struct run_queue *rq, struct proc_struct *proc) {
    assert(!list_empty(&(proc->run_link)) && proc->rq == rq);
    list_del_init(&(proc->run_link));//把进程在所在队列中删除
    rq->proc_num --;//运行队列计数器的值-1
}

//被sched_class_pick_next()所调用,执行“选择”的功能
static struct proc_struct * RR_pick_next(struct run_queue *rq) {
    list_entry_t *le = list_next(&(rq->run_list));//选择头结点的下一个结点
    if (le != &(rq->run_list)) {//如果这个下个结点不是头结点,也就是运行队列非空
        return le2proc(le, run_link);//返回该节点进程控制块的指针
    }
    return NULL;//如果进程运行队列为空,则返回NULL
}

//被sched_class_proc_tick()所调用,执行“时间响应”的功能
static void RR_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
    if (proc->time_slice > 0) {
        proc->time_slice --;//如果时间片还大于0,则剩余时间片-1
    }
    if (proc->time_slice == 0) {
        proc->need_resched = 1;//如果时间片等于0,则需要调度标志位置1
    }
}

以上即是RR时间片轮转算法的基本实现。

进程切换的过程

这里面不是讨论调度算法,而是弄懂把一个进程从处理机取下,换上另一个进程的实现细节,换句话说,就是弄懂switch_to()函数的细节。
其位于kern/process/switch.S:

.globl switch_to
switch_to:                      # switch_to(from, to)
	//switch_to函数的原型是switch_to(from, to)
	//from是原进程的PCB中的context指针,to是新进程的PCB中的上下文结构的指针
    # save from's registers
    //把原进程的context指针保存在EAX中
    movl 4(%esp), %eax          # eax points to from
    //调用switch_to()函数时,会把下一条指令地址EIP压入栈中,现在把它弹出存在上下文中
    popl 0(%eax)                # save eip !popl
    movl %esp, 4(%eax)//ESP入栈
    movl %ebx, 8(%eax)//EBX入栈
    movl %ecx, 12(%eax)//ECX入栈
    movl %edx, 16(%eax)//EDX入栈
    movl %esi, 20(%eax)//ESI入栈
    movl %edi, 24(%eax)//EDI入栈
    movl %ebp, 28(%eax)//EBP入栈

    # restore to's registers
    //把新进程的context指针保存在EAX中,思考调用switch_to(from, to)时参数入栈顺序
    movl 4(%esp), %eax          # not 8(%esp): popped return address already
                                # eax now points to to
    movl 28(%eax), %ebp//按照自高址向低址顺序逐个恢复上下文中的寄存器
    movl 24(%eax), %edi
    movl 20(%eax), %esi
    movl 16(%eax), %edx
    movl 12(%eax), %ecx
    movl 8(%eax), %ebx
    movl 4(%eax), %esp
	//上下文context结构中的EIP存放着新进程即将要执行的下一指令的地址,先压栈
    pushl 0(%eax)               # push eip
	//伴随着一条ret指令,栈顶保存的EIP值被传入EIP寄存器,开始新进程执行
    ret

以上就是switch_to(from, to)函数的具体工作过程。
那么问题来了:
1.原进程在执行时那么多寄存器怎么切换时就保存了这么几个寄存器?
答案是,原进程在被中断后,进入调度程序。这个中断的中断帧保存了当前现场几乎的所有信息(保存于原进程的内核栈)。当下一次再被调度到时,它实行层层函数调用返回,当返回到中断时,再把所有寄存器的内容恢复出来。
2.考虑新老进程的内核栈是怎样切换的?
切换内核栈,就是正确切换新老进程的ESP。由上面的过程可以看出,ESP是被正确切换的,切换到新进程的ESP应该能够正确执行新进程的中断返回,从而正确执行新进程的代码。那问题又来了,进程切换了,进程页目录切换了没有?新的ESP值能否正确寻址新进程的内核栈?答案是,当然切换了!在执行switch_to(from, to)函数之前刚好切换过。多说无益,看关键代码(proc.c):

void proc_run(struct proc_struct *proc) {
    if (proc != current) {//如果下一个要调度上的进程不是当前进程,说明合法
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;//设置prev/next指针
        local_intr_save(intr_flag);
        {
            current = proc;//设置current指针为新进程
            //在任务状态段中装载新进程内核栈指针ESP0,注意不是ESP
            load_esp0(next->kstack + KSTACKSIZE);
            lcr3(next->cr3);//加载新进程的页目录CR3!!!此时,切换了页表
            switch_to(&(prev->context), &(next->context));//执行切换函数
        }
        local_intr_restore(intr_flag);
    }
}

3.为什么加载新进程的页目录不会影响到后面switch_to(from, to)函数的运行?不会导致原进程prev -> context寻址失败吗?显然不会,不同进程不同的地方在于用户进程的地址空间。而进程控制块PCB位于系统内核空间,使用虚地址可以在任何用户进程页目录中正确对他们寻址。

Lab6

终于进入进程管理的最后一个实验了,回想起来还是弄清楚了很多东西,先高兴3秒…
下面开始实验六。从这个实验开始,我不能再手动合并实验代码了,否则能累死。看来,无论干什么,都得效率放在最前头!为提升效率而学习新东西都不是无用的。
我选择用meld。这个软件还没有完全使用顺手。。。待继续熟悉。
还有一个老师在视频中用到的,查找关键字在文件夹的文件中的位置(shell命令):

find . -name "*.[chS]" -exec grep -Hn LABx {} \;

使用 Round Robin 调度算法

由于RR时间片轮转的算法实现在前面已经结合源码做了分析,这里就不重复。
下面回答问题:简要说明如何设计实现”多级反馈队列调度算法“。
答:从原理上出发,该算法是有多个队列,每个队列都有不同的优先级。高优先级的队列,时间片比较短,低优先级的队列时间片比较长,不同的队列时间片成2^n关系。当一个进程处于所在的优先级,并且被调度后:如果在所分配的时间片内执行完,那么进程正常结束;如果在时间片内没有执行完,那么就在该队列取下进程,放在更低一优先级的队尾;最后一个队列采用FCFS方法。任务到来时,首先进入高优先级队列等待调度。同时,新任务可以抢占当前执行的低优先级任务。
可见,不同优先级之间采取优先级调度,同一优先级采取先来先服务、时间片轮转的调度策略。其好处是,兼顾了优先级和时间片轮转的优点,既能使高优先级的进程得到响应又可使短进程任务迅速完成。当只有一个队列时,算法退化为FCFS算法。
从实现上看,运行队列是一个含有多个不同优先级的队列的集合。可以用向量实现给定数目的优先级,向量中的元素是指定优先级队列的头结点(指针)。然后实现出队、入队、选择和时间响应等4个“核心函数”。这里PCB中的priority优先级随着被调度动态修改。

实现 Stride Scheduling 调度算法

这是本次实验的重点内容。Stride Scheduling调度算法原理略。
首先,需要对进程PCB做什么改进?其实系统已经为我们明确了。

struct proc_struct {
    // . . .
    // round-robin 调度器并不会用到以下成员,实现stride算法时候用到
    // 该进程在优先队列中的节点,仅在 LAB6 使用,斜堆
    skew_heap_entry_t  lab6_run_pool;
    // 该进程的调度优先级,仅在 LAB6 使用
    uint32_t lab6_priority;
    // 该进程的调度步进值,仅在 LAB6 使用
    uint32_t lab6_stride;

其次,运行队列run_queue怎么改进?

struct run_queue {
    //其运行队列的哨兵结构,可以看作是队列头和尾,本算法中不用
    list_entry_t run_list;
    //优先队列形式的进程容器,只在 LAB6 练习2中使用,本算法中使用
    skew_heap_entry_t  *lab6_run_pool;
    //表示其内部的进程总数
    unsigned int proc_num;
    //每个进程一轮占用的最多时间片
    int max_time_slice;
};

最后,就是实现Stride Scheduling调度算法的“核心函数”了。先实例化一个调度类:

//kern/process/default_sched.c
struct sched_class default_sched_class = {
     .name = "stride_scheduler",
     .init = stride_init,
     .enqueue = stride_enqueue,
     .dequeue = stride_dequeue,
     .pick_next = stride_pick_next,
     .proc_tick = stride_proc_tick,
};

然后针对新算法初始化运行队列run_queue,重写stride_init()函数:

static void stride_init(struct run_queue *rq) {
     /* LAB6: YOUR CODE */
     list_init(&(rq->run_list));//针对链表组织方式的初始化
     rq->lab6_run_pool = NULL;//针对斜堆组织方式的初始化
     //如果优先队列为空,则其指向空指针(NULL)
     rq->proc_num = 0;//进程计数初始化为0
}

最后是,4个“核心函数”,如下:

//被sched_class_enqueue()所调用,执行“入队”的功能
static void stride_enqueue(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
     //下面是参考答案的代码,我先把它贴出来了,因为这个代码充分考虑了两种情况
     //即使用链表实现运行队列和斜堆实现运行队列,具体使用哪一个,在编译时
     //使用条件编译可以自由选择,这种编译时多选的情况很关键,应该掌握并运用
#if USE_SKEW_HEAP//如果定义了USE_SKEW_HEAP宏,就使用斜堆的代码
     rq->lab6_run_pool =
          skew_heap_insert(rq->lab6_run_pool, &(proc->lab6_run_pool), proc_stride_comp_f);//调用预定义的斜堆插入函数,向斜堆运行队列中插入一个节点
#else//否则,就使用链表实现运行队列的代码
     assert(list_empty(&(proc->run_link)));
     list_add_before(&(rq->run_list), &(proc->run_link));//向链表最后插入一个节点
#endif//条件编译结束
	 //如果当前进程剩余时间片为0,或者大于系统最大时间片的值
     if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
          proc->time_slice = rq->max_time_slice;//就把当前进程的时间片重设为最大时间片
     }
     proc->rq = rq;//设定当前进程所在的优先队列指针指向rq
     rq->proc_num ++;//运行队列中进程计数器+1
}

//被sched_class_dequeue()所调用,执行“出队”的功能
static void stride_dequeue(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP//如果定义了USE_SKEW_HEAP宏,就使用斜堆的代码
     rq->lab6_run_pool =
          skew_heap_remove(rq->lab6_run_pool, &(proc->lab6_run_pool), proc_stride_comp_f);//调用预定义的斜堆删除函数,从斜堆运行队列中删除指定节点
#else//否则,就使用链表实现运行队列的代码
     assert(!list_empty(&(proc->run_link)) && proc->rq == rq);
     list_del_init(&(proc->run_link));//从链表运行队列中删除该节点(即取消指针的链接)
#endif//条件编译结束
     rq->proc_num --;//运行队列中进程计数器-1
}

//被sched_class_pick_next()所调用,执行“选择”的功能
static struct proc_struct * stride_pick_next(struct run_queue *rq) {
     /* LAB6: YOUR CODE */
#if USE_SKEW_HEAP//如果定义了USE_SKEW_HEAP宏,就使用斜堆的代码,从这里可以看出斜堆优势
     if (rq->lab6_run_pool == NULL) return NULL;//如果斜堆为空,则返回NULL
     //否则,斜堆顶端的元素就是要取的元素,反查其PCB指针,并用指针p返回
     struct proc_struct *p = le2proc(rq->lab6_run_pool, lab6_run_pool);
#else//否则,就使用链表实现运行队列的代码
     list_entry_t *le = list_next(&(rq->run_list));//首先获取链表的第一个节点
     if (le == &rq->run_list)//如果,该节点指向了rq->run_list,说明链表是空的,返回NULL
          return NULL;     
     //以下到endif实际上是一个通过遍历实现的简单比较算法
     struct proc_struct *p = le2proc(le, run_link);//让p指向链表的第一个节点的PCB
     le = list_next(le);//le指针后移一个节点
     while (le != &rq->run_list)//只要没有到哨兵节点rq->run_list,就循环
     {
          struct proc_struct *q = le2proc(le, run_link);//让q指向链表的第2个节点的PCB
          if ((int32_t)(p->lab6_stride - q->lab6_stride) > 0)//若p所指进程步长大于q的
               p = q;//p就指向q的进程
          le = list_next(le);//然后,q后移一位,重新指向一个PCB
          //可以看出,q是一个游标,向后遍历所有PCB,而p始终指向当前的最小步长进程
     }
#endif
     if (p->lab6_priority == 0)//优先级如果为0,则进程步长+BIG_STRIDE
          p->lab6_stride += BIG_STRIDE;
     //否则进程步长+BIG_STRIDE / p->lab6_priority;
     else p->lab6_stride += BIG_STRIDE / p->lab6_priority;
     return p;//返回选定的p(进程PCB指针)
}

//被sched_class_proc_tick()所调用,执行“时间响应”的功能
static void stride_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
     /* LAB6: YOUR CODE */
     if (proc->time_slice > 0) {//如果进程剩余时间片还没用完,则-1即可
          proc->time_slice --;
     }
     if (proc->time_slice == 0) {//如果进程剩余时间片用完,则需要调度标志位置1
          proc->need_resched = 1;
     }
}

以上就是4个所谓的“关键核心函数”,我几乎照着答案搬过来了。
另外,uCore中BIG_STRIDE是这样定义的:

#define BIG_STRIDE    0x7FFFFFFF

为什么这样定义呢?保证stride属性的不会溢出,或者说让计算的结果仍然反应正确的值。在uCore中,stride用32位无符号整数表示。计算比较时,计算结果用带符号整数表示仍得到正确的值。要达到这个目的,需要正确设定BIG_STRIDE的范围。我们要保证任意两个 Stride 之差都会在机器整数表示的范围之内:因为stride单次加的值不超过BIG_STRIDE(规定优先权大于1),所以比较时计算两个stride的差也不会超过BIG_STRIDE的范围。在32位整数中,把最高一位做符号位,其余31位表示BIG_STRIDE,当他们全1时是BIG_STRIDE可以的最大值。假设按照无符号加法,某一数值较大的stride发生了回卷,变成了0x000000FE,而原来stride较小的还没有达到上限0xFFFFFFFF,现在是0xFFFFFCDE。想在把他们看作是带符号减法比较,一个正数减去一个负数,仍然为整数,这个符号结果仍是正确的。但是,如果单次加减的stride值超过了0x7FFFFFFF,就会导致所谓的数值较大的stride在有符号数表示下为负数,从而得出相反的结果!所以,32位下,这个BIG_STRIDE不能超过0x7FFFFFFF。

本次实验六结束。
2019-07-29 09:05

你可能感兴趣的:(调度器,RR算法,BIG_STRIDE,Stride,Scheduling,switch_to,操作系统)