六、线程的代码实现:pcb栈、线程栈、PCB初始化、中断处理函数、调度函数->多线程调度

2019-07-14 06:16发布

进程与线程的关系和区别

进程是正在运行的程序,按照进程中的线程数量划分,进程分为单线程进程和多线程进程。线程就是执行流,平时我们所写的程序,如果其中是“显示”创建线程,它就属于单线程进程。所以存粹的进程实际上就是单线程进程。线程是后来才提出的概念,本来都是单线程进程只有一个执行流。后来一个进程中多了执行流,就把这些执行流叫做线程。线程实质上是一段引导处理器执行的、具有能动性的代码。 线程必然属于某一个进程,线程要运行必须要有相应的资源,而进程就是这个资源的提供者,所以线程存在于进程中。所以进程=线程+资源。 进程拥有整个地址空间,其中包括各种资源,而进程中的所有线程共享同一个地址空间。只有线程才具有能动性,它才是处理器的执行单元,因此它是调度器眼中的调度单元。进程只是一个资源整合体,它将进程中所有线程运行时用到的资源收集在一起,真正上处理器运行的其实是线程,进程中的线程才是一个个的执行实体、执行流。 总结:线程是具有能动性、执行力、独立的代码块。进程=线程+资源。执行流、调度单元、运行实体都是针对线程而言的,线程才是解决问题的思路、步骤,只有它才能上处理器运行。进程和线程的区别是线程没有自己独享的资源,因此没有自己的地址空间,它要依附在进程的地址空间中从而借助进程的资源运行。说白了就是线程没有自己的页表,而进程有。

进程、线程的状态

操作系统把执行过程所经历的不同阶段按状态分为:阻塞态、就绪态、运行态。状态描述的是执行流,也就说状态是描述线程的。我们一般说的进程状态都是指的是单线程的进程。

PCB

操作系统为每一个进程都提供了一个PCB:程序控制块,它记录着与此进程相关的信息:进程状态、优先级、PID等等。 每个进程都有自己的PCB,所有的PCB都放在一张表格中维护,这就是进程表。调度器根据这个表来选择处理器上运行的进程。我们设计的系统中PCB只占一页:4K。

实现线程的两种方式--内核或用户进程

线程要么在0特权级的内核空间中实现,要么在3特权级的用户空间实现。 我们的系统在内核中实现。

中断栈

功能:因为栈是在PCB中的,当发生中断时,通过要设置中断栈,将寄存器都备份到中断栈中。调度单元调度每个线程是根据每个线程的时间片来调度的,每一次时钟中断线程的时间片减1,减到0时就调用调度函数将 当前线程换下处理器,将下一个线程换上处理器,重新开始中断然后减1。
struct intr_stack { uint32_t vec_no; // kernel.S 宏VECTOR中push %1压入的中断号 uint32_t edi; uint32_t esi; uint32_t ebp; uint32_t esp_dummy; // 虽然pushad把esp也压入,但esp是不断变化的,所以会被popad忽略 uint32_t ebx; uint32_t edx; uint32_t ecx; uint32_t eax; uint32_t gs; uint32_t fs; uint32_t es; uint32_t ds; /* 以下由cpu从低特权级进入高特权级时压入 */ uint32_t err_code; // err_code会被压入在eip之后 void (*eip) (void); uint32_t cs; uint32_t eflags; void* esp; uint32_t ss; }; 注意顺序:结构体先声明的元素地址位比较低。我们要根据实际的压栈情况来设置元素。

线程栈

一个线程的运行的目的就是执行这个线程的代码,执行代码就要有代码的地址,所以线程栈的功能:记录这个线程执行的代码地址。当首次调度这个线程时候,线程栈记录的是该线程中代码中的第一个要执行的函数地址,当不是第一次执行时候,里面记录的是上次被换下线程时候执行到的地址,即 jmp intr_exit 这行代码的地址,永远都是,因为被换下时是在shedule() 中执行到 switch to 函数被换下的,这个函数的下一条代码句子要在 switch to 后面找,switch to 下一条是shedule()函数终结,然后往时钟中断函数找,找到了 jmp intr_exit,等待下次被换上时候直接开始接着执行。 struct thread_stack { uint32_t ebp; uint32_t ebx; uint32_t edi; uint32_t esi; /* 线程第一次执行时,eip指向待调用的函数kernel_thread 其它时候,eip是指向上次被换下时执行到的地址*/ void (*eip) (thread_func* func, void* func_arg); /***** 以下仅供第一次被调度上cpu时使用 ****/ /* 参数unused_ret只为占位置充数为返回地址 */ void (*unused_retaddr); thread_func* function; // 由Kernel_thread所调用的函数名 void* func_arg; // 由Kernel_thread所调用的函数所需的参数 };我们通过  kernel_thread () 函数去调用线程的函数。 /* 由kernel_thread去执行function(func_arg) */ static void kernel_thread(thread_func* function, void* func_arg) { /* 执行function前要开中断,避免后面的时钟中断被屏蔽,而无法调度其它线程 */ intr_enable(); function(func_arg); }

PCB栈

/* 进程或线程的pcb,程序控制块 */ struct task_struct { uint32_t* self_kstack; // 各内核线程都用自己的内核栈 enum task_status status; char name[16]; uint8_t priority; uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数(时间片) uint32_t elapsed_ticks; //该线程总共执行的时间 /* general_tag的作用是用于 就绪态线程队列 中的结点 */ struct list_elem general_tag; /* all_list_tag的作用是用于 所有线程队列thread_all_list 中的结点 */ struct list_elem all_list_tag; uint32_t* pgdir; // 进程自己页表的虚拟地址 uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出 }; 最后PCB这个4K的结构为:

多线程实现

第一步:初始化pcb栈

程序都是从 main 函数开始执行,所以都有一个主线程PCB,PCB都为4K,包括了中断栈、线程栈、普通栈、PCB栈。我们首先要初始化主线程PCB。因为main线程首先运行,咱们在loader.S中进入内核时的mov esp,0xc009f000,就是为其预留了tcb,地址为 0xc009e000,因此不需要通过 get_kernel_page 另分配一页。我们初始化PCB栈即可,不用初始化线程栈,因为我们一开始就运行了 main主线程 。所以我们不需要初始化线程栈。中断栈、线程栈、pcb栈这些只是数据结构,本来里面的元素在PCB 4K 空间中都存在的,只是定义了这些结构后,我们方便赋值了而已,所以main 主线程虽然没有初始化 线程栈,但是循环起来执行时候,还是和其他的普通线程一样,存在线程栈的,只是main 线程没有初始化而已。所以程序开始执行时候先初始化主线程栈,把主线程的状态改为 运行态,并将链表元素加入 所有线程队列中。
对于其他普通线程来说,初始化 pcb栈 时,将 self_kstack 初始化为PCB的最高地址,这样是为了操作下一步执行。
/* 初始化 PCB栈 信息 */ void init_thread(struct task_struct* pthread, char* name, int prio) { memset(pthread, 0, sizeof(*pthread)); strcpy(pthread->name, name); if (pthread == main_thread) { /* 由于把main函数也封装成一个线程,并且它一直是运行的,故将其直接设为TASK_RUNNING */ pthread->status = TASK_RUNNING; } else { pthread->status = TASK_READY; } /* self_kstack是线程自己在内核态下使用的栈顶地址 */ pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE); pthread->priority = prio; pthread->ticks = prio; pthread->elapsed_ticks = 0; pthread->pgdir = NULL; pthread->stack_magic = 0x19930504; // 自定义的魔数 }

第二步:初始化线程栈

线程栈中记录的是这个线程的函数和上次执行到的代码地址。 /* 初始化线程栈thread_stack,将待执行的函数和参数放到thread_stack中相应的位置 */ void thread_create(struct task_struct* pthread, thread_func function, void* func_arg) { /* 先预留中断使用栈的空间,此时值为 intr_stack 底部的地址值*/ pthread->self_kstack -= sizeof(struct intr_stack); /* 再留出线程栈空间 ,此时值为thread_stack 底部的地址值 */ pthread->self_kstack -= sizeof(struct thread_stack); struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack; kthread_stack->eip = kernel_thread; //eip的值为 调用函数kernel_stack 的地址 kthread_stack->function = function; kthread_stack->func_arg = func_arg; kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0; }

eip的值为 kernel_thread()函数的地址值,我们通过 void kernel_thread(thread_func* function, void* func_arg);从而去执行  function(func_arg);这个函数。所以这就是这个栈中占位符的作用,当esp 指向 eip 时候,我们是通过 ret 弹出去。使用 ret 制造了调用的假象,于是开始执行 kernel_thread 函数。函数本质就是一段代码段,从汇编角度来说: static void kernel_thread(thread_func* function, void* func_arg) { intr_enable(); function(func_arg); } 这是一个被调函数,它的参数都是被主调函数压入栈中的,所以 被调函数的代码都是写好固定的,不管哪个主调函数调用它,都是这样的代码:需要使用参数时,直接去当前栈指针的上面找。 当主调函数调用被调函数时:先压栈,然后  call  kernel_thread  开始执行: kernel_thread: call intr_enable push [esp+8] call [esp+4] 因为当前 esp 经过 ret 后跳过了 eip,指向 unused addr,所以执行这个函数体时候找参数时候就会跳过这个占位符,因此代码就可以顺利执行,所以我们的占位符只是占了位,并不给它赋值。

 第三步:分配页表(PCB),创建一个线程

要在物理内存池中分配一个页,创建完整的线程。即使是用户进程,也要在物理内存池中分配页。分配页表后,我们就可以初始化里面的各种栈,从而创建出一个完整的线程。/* 创建一优先级为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); init_thread(thread, name, prio); //创建pcb栈,初始化线程的信息 thread_create(thread, function, func_arg); //创建线程栈,初始化它的函数 /* 确保之前不在队列中 */ ASSERT(!elem_find(&thread_ready_list, &thread->general_tag)); /* 加入 就绪线程队列 */ list_append(&thread_ready_list, &thread->general_tag); /* 确保之前不在队列中 */ ASSERT(!elem_find(&thread_all_list, &thread->all_list_tag)); /* 加入 全部线程队列 */ list_append(&thread_all_list, &thread->all_list_tag); return thread; }

第四步:创建时钟中断处理程序

我们利用时钟中断来计算各个线程的时间,从而实现线程的调度。每发生一次时间中断,我们会在时间中断函数中处理当前线程的时间片,如果当前线程的时间片到了时间,则会调用 调度函数 ,从而更换线程。因此我们需要设置时钟中断函数。之前我们设置的中断函数都是通用的中断函数,我们的中断函数地址数组中的地址都是通用中断处理函数的地址。当我们写好时钟中断处理函数,只要给把函数地址(函数名)储存到对应的中断函数地址数组项中即可。 /* 时钟的中断处理函数 */ 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()函数功能:将当前进程换下,选择出下一个进程 switch_to(,)函数功能:备份当前指针的寄存器,将上一个指针换上处理器。 void schedule() { ASSERT(intr_get_status() == INTR_OFF); //因为是中断程序,所以是关中断状态 struct task_struct* cur = running_thread(); //当前进程 if (cur->status == TASK_RUNNING) { // 若此线程只是cpu时间片到了,将其加入到就绪队列尾 ASSERT(!elem_find(&thread_ready_list, &cur->general_tag)); list_append(&thread_ready_list, &cur->general_tag); cur->ticks = cur->priority; // 重新将当前线程的ticks再重置为其priority; cur->status = TASK_READY; } ASSERT(!list_empty(&thread_ready_list)); thread_tag = NULL; // thread_tag清空 /* 将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上cpu. */ thread_tag = list_pop(&thread_ready_list); struct task_struct* next = elem2entry(struct task_struct, general_tag, thread_tag);//将链表元素指针转换为pcb栈指针,原理就是利用链表u元素在pcb栈中的偏移量 next->status = TASK_RUNNING; switch_to(cur, next); }section .text global switch_to switch_to: ;栈中此处是返回地址 push esi push edi push ebx push ebp 注意压栈将这些都压入了 cur 的栈中 mov eax, [esp + 20] ;此时的地址是压入参数:cur的值,也代表的是cur线程的pcb栈中的第一个元素self_kstack mov [eax], esp ;把cur线程的指针存入self_kstack字段中,下次该线程换上处理器时候用 ;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ---------------- mov eax, [esp + 24] ; 得到栈中的参数next,也代表的是next线程的pcb栈中的第一个元素self_kstack mov esp, [eax] ; 将next线程的pcb的self_kstack储存的是上次被换下时候的esp值 pop ebp ;此时esp 指向了next 的PCB的栈中,所以此时弹出的是 next 的栈 pop ebx pop edi pop esi ret 特别注意:此时 eip 已经指向了next 的PCB中,所以 ret 返回的将是 next 的 PCB 中的值:ret 将 此时 esp 指向的值 加到 eip 中,并且 加4,然后eip 开始执行起来。

可以看到两个链表队列将各个PCB链接起来:我们从就绪态链表队列中弹出线程。

函数调用规则ABI

在我们的压栈时候,可能会看到压入了寄存器。ABI规则:5个寄存器ebp、ebx、edi、esi、esp这5个寄存器是归主调函数所有,其余寄存器是归被调函数所有,也就是说:在调用函数完后,主调函数所拥有的这个5个寄存器的值不能变。所以被调函数要负责保护好这5个寄存器的值。