《操作系统真象还原》第九章 多线程调度

1,前言

上一节我们分析了线程是如何被调用执行的。本节将分析多个线程调度的实现方式。在分析之前,我们可以根据已有的知识去宏观的理解可能用的知识点。
1,既然要实现多线程调度,肯定要对多线程管理。这里便涉及到了队列,分别是就绪队列和全部线程队列
2,线程调度应该是由时钟中断驱动的,
总结:完整的调度过程需要三部分的配合
1,时钟中断处理函数
2,调度器schedule
3,任务切换函数switch_to

2,重点流程分析

1,时钟中断部分

/* 时钟的中断处理函数 */
static void intr_timer_handler(void)
{
    struct task_struct *cur_thread = running_thread();
    ASSERT(cur_thread->stack_magic == 0x19870916); // 检查栈是否溢出
    cur_thread->elapsed_ticks++; // 记录此线程占用的cpu时间嘀
    ticks++;                     //从内核第一次处理时间中断后开始至今的滴哒数,内核态和用户态总共的嘀哒数
    if (cur_thread->ticks == 0)
    { // 若进程时间片用完就开始调度新的进程上cpu
        schedule();
    }
    else
    { // 将当前进程的时间片-1
        cur_thread->ticks--;
    }
}

在时钟中断函数中做时间片的处理和判定,当当前线程时间片用完了,就执行调度器schedule函数
2,任务调度函数schedule

//实现任务调度
void schedule()
{
   ASSERT(intr_get_status()== INTR_OFF);
   struct task_struct* cur = running_thread();
   if(cur->status == TASK_RUNNING){
      //此线程只是时间片用完了,直接放到就绪队列的尾
      ASSERT(!elem_find(&thread_ready_list,&cur->general_tag));
      list_append(&thread_ready_list,&cur->general_tag);
      cur->ticks = cur->priority;
      cur->status = TASK_READY;
   }else {
     //其他调度可能
   }
   ASSERT(!list_empty(&thread_ready_list));
   thread_tag = NULL;// 
   //将就绪队列的第一个线程弹出
   thread_tag = list_pop(&thread_ready_list);
   struct task_struct* next = elem2entry(struct task_struct,general_tag,thread_tag);//1,得到
   next->status = TASK_RUNNING;
   switch_to(cur,next);//将当前cur线程上下文保护好,将next线程上下文装入处理器
}

说明:schedule函数的调用者并不是只有时钟中断函数。中断函数能调用schedule函数的唯一情况就是当前线程的时间片用完了。所以在schedule函数中需要判断调度的原因。这种不同的调度方式与线程的status有关。

从操作系统的理论知识上我们可以知道运行态的线程时间片用完后是直接回到就绪态。所以判断(那个时间片已经用完的)线程状态是否为运行态,是,那就直接加入就绪队列,置为就绪态。等待下次调度运行。其他状态暂时不处理。

变量next表示即将被换上处理器的线程。是从就绪队列中取出的tag转化成线程的pcb首地址。

难点知识:在使用链表对PCB管理时,我们并不是对PCB进行直接管理。而是使用每个PCB中的一个tag变量的地址作为寻找对应PCB的依据。好处是减小了内存的占用。

为什么tag的地址可以找到对应的PCB首地址呢?

**因为结构体结构在定义时就已经固定了各个成员的相对于结构体首地址的偏移量。**当我们取到tag的地址信息时,减去这个偏移量,便是PCB的首地址。所以elem2entry这个宏并不难理解,就是利用tag的地址得到PCB的地址。依据就是偏移量是固定且已知的。

#define offset(struct_type,member) (int)(&((struct_type*)0)->member)// 计算结构体成员对结构体首地址的偏移量
#define elem2entry(struct_type, struct_member_name, elem_ptr) \
	 (struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name)) //拿成员地址 减去它的偏移值 得到结构体的起始地址

3,任务切换函数switch_to

主要作用将当前cur线程上下文保护好,将next线程上下文装入处理器。由schedule函数在最后调用,并传递curnext作为参数给它。

section .text
global switch_to
switch_to:
    ;栈中此处是返回地址
    push esi 
    push edi 
    push ebx 
    push ebp
    mov eax,[esp +20];得到栈中的参数cur ,即当前线程的PCB地址,
    mov [eax],esp;      将当前线程的栈指针保存在当前线程PCB的self——kstack中
    ;    以上是备份当前线程的环境,下面是恢复下一个线程的环境

    mov eax,[esp+24];获取栈中的next,也就是下一个要上处理器的线程的PCB地址 
    mov esp ,[eax];并且pcb的首地址上是self,保存的就是线程的栈指针,这里的【eax】保存的是next的栈指针
    ;0级栈中保存了进程或线程所有信息,包括3级栈指针。这一步替换了新线程的栈了,从而可以获取到新线程的上下文环境
    pop ebp 
    pop ebx 
    pop edi 
    pop esi 
    ret ; 返回的是next的线程的地址,从而使新的线程运行起来

说明:切换的本质是任务切换,即任务的上下文资源的全部切换。
在调用switch_to(cur,next)函数,执行了push操作后栈数据分布情况如下图:
《操作系统真象还原》第九章 多线程调度_第1张图片

重点语句是:

    mov eax,[esp +20];得到栈中的参数cur ,即当前线程的PCB地址,
    mov [eax],esp;     将当前线程的栈指针保存在当前线程PCB的self——kstack中

从而实现了将(被换下)线程的栈指针保存在自己线程PCB的self_kstack中。这也意味着以后要从self_kstack中找到线程上一次栈指针数据

所以在取到next线程的pcb后,将pbc的self_kstack赋值给了esp,即实现了栈指针寄存器指向next的上一次保存的栈顶位置。实现代码如下:

mov eax,[esp+24];获取栈中的next,也就是下一个要上处理器的线程的PCB地址 
mov esp ,[eax];并且pcb的首地址上是self,保存的就是线程的栈指针,这里的【eax】保存的是next的栈指针

这时候已经切换到next线程的栈数据,所以后面的pop语句操作的都是next线程的栈。而next线程的上一次esp的位置也是停留在存放ebp处。此过程与cur线程中的push语句操作是逆向关系不同的是操作的栈,是属于不同的线程。

当执行到ret指令时,会将当前栈顶数据赋值给eip寄存器,从而开始新的执行流。这时候我们就要分析当前返回地址是谁的地址了?

1,先说好理解的。从上面的分析流程看,switch_to函数是被schedule函数调用的。所以返回地址的一种情况就是返回到schedule函数中。这种情况被认为next线程不是首次执行的状态。
2,那么首次执行的状态是什么样的?
在main函数中,我们用如下的方法实现了多线程的初始化处理。

thread_start(“k_thread_a”,31,k_thread_a,"argA ");
thread_start(“k_thread_b”,20,k_thread_b,"argB ");

//其中参数3,k_thread_a就是线程函数名,即该线程函数的入口地址了。
thread_start函数的具体实现如下:

/* 创建一优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg) */
struct task_struct *thread_start(char *name, int prio, thread_func function, void *func_arg)
{
   /* pcb都位于内核空间,包括用户进程的pcb也是在内核空间 */
   struct task_struct *thread = get_kernel_pages(1); //获取内核页作为PCB空间 此时还是指向地址最低端——申请空间
   init_thread(thread, name, prio);                  //让PCB自己的栈指针 指向位置正确 完成状态 优先级等的初始化       ——初始化
   thread_create(thread, function, func_arg);

   ASSERT(!elem_find(&thread_ready_list, &thread->general_tag)); //队列中的结点就是tag
   list_append(&thread_ready_list, &thread->general_tag);        //这里就是将PCB的tag作为结点加入队列 结点中只有2个地址元素,大小为8字节
   ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag));
   list_append(&thread_all_list, &thread->all_list_tag);
   return thread;
}

关键的信息是thread_create函数的作用。对thread_create函数的分析具体见上一节。我们这里直接给出结论:该函数将kernel_thread的地址初始化到线程栈的eip位置上。代码实现如下图:

/* 初始化线程栈thread_stack,将待执行的函数和参数放到thread_stack中相应的位置 */
void thread_create(struct task_struct *pthread, thread_func function, void *func_arg)
{
   /* 先预留 中断栈 的空间,*/
   pthread->self_kstack -= sizeof(struct intr_stack); //初始化时已经将栈指针指向PCB的最顶端了
   /* 再留出线程栈空间 */
   pthread->self_kstack -= sizeof(struct thread_stack);
   struct thread_stack *kthread_stack = (struct thread_stack *)pthread->self_kstack; //定义了线程栈指针指向线程栈最低处
   kthread_stack->eip = kernel_thread;//关键点在这里!!!!!
   kthread_stack->function = function;//
   kthread_stack->func_arg = func_arg;
   kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}

好了,绕了一大圈。我们知道线程初始化在线程栈中安排的返回地址是kernel_thread函数。即线程第一次运行时,switch_to中的next将返回到kernel_thread函数中,去调用线程函数。

4,启动线程调度

这里我们对线程的首次启动到循环调度的过程进行分析。首先main函数本质上也是一个线程,并且还是主线程。之前我没有分析过它,因为难度不大。main函数中又创建了两个线程。然后开启了中断功能。

1,中断处理函数在1ms的中断下不断的被触发,检查当前线程的时间片是否已经到了。当前线程就是mian所在的主线程。

2,当主线程时间片用完后,调用schedule函数准备换其他就绪线程。thread_a和thread_b在初始化时被加载到就绪队列中。在schedule函数中cur变量指向的是main线程的pcb,next变量指向的thread_a的pcb。(因为我们先把thread_a加入就绪队列的。先入先出嘛),然后开始调用switch_to函数。

3,在switch_to函数完成线程栈的切换,切换到thread_a的线程栈。我们知道此时的thread_a线程是第一次运行。所以switch_to中的ret指令将触发kernel_thread函数,从而真正意义上运行起thread_a线程函数。(注意:此时才是thread_a真正在CPU上运行的时刻)。

4,当thread_a在CPU上运行时,中断处理函数还会定时判断它的时间片是否已经用完了。如果用完了,又将会调用schedule函数,从就绪队列中取出下个线程,即thread_b。此时的cur执行thread_a的pcb,next指向thread_b的pcb。然后调用switch_to函数。

5,thread_b的第一次运行也是由kernel_thread调用实现的。原理同第三步中对thread_a的描述。

6,当thread_b的时间片用完了,中断处理函数又开始调用schedule函数,schedule函数从就绪队列取出main线程,此时的cur执行thread_b的pcb,next指向main的pcb。然后调用switch_to函数。

7,main线程得到运行。至此一个多线程的循环得以实现。

你可能感兴趣的:(操作系统,ubuntu,linux,系统架构)