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