Q&A
1、什么是线程?线程的产生背景?
线程是任务调度器进行调度的基本单位。
一开始OS内核不支持创建多线程,后来为了满足程序高并发的需求,OS内核开始支持多线程,使用户进程可以通过系统调用使用。
2、进程和线程的区别?
如果程序没有使用多线程,那么运行时整个进程将成为调度的基本单位;如果使用了多线程,则整个进程会分为多个线程供处理器调度。
线程共享进程的地址空间,进程有自己的页表,线程没有页表。
3、在用户空间实现线程和在内核实现线程的优劣比较?
在用户空间实现线程的优点在于(相对内核提供支持):
(1)线程调度算法可修改,可以为某些线程加权调度;
(2)避免因陷入到内核态而切换栈,避免了陷入内核的代价。
劣势在于(相对内核提供支持):
(1)内核调度器感受不到线程的阻塞态,因此无法将处理器控制权交给进程内的其他线程,因此提速不明显。
3、多线程的优势与危险?
优势:程序提速
1、建立了多个执行流,能更多占有处理器的运行时间
2、避免因为用户输入等原因阻塞整个进程
危险:线程共享进程的地址空间,可以互相修改数据
4、进程控制块PCB结构:
进程控制块由OS提供,记录进程的状态信息,结构如下:
寄存器映像:保存寄存器现场;
栈:进程0特权级下的内核栈;
进程(线程)状态:阻塞态、就绪态、运行态;
优先级:体现任务在处理器上执行的时间片长度,优先级越高,时间片越长;
时间片:当时间片为0时,表示应该下CPU
结构体定义:
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
char name[16];
uint8_t priority;
uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数,也就是时间片
//总时钟数
uint32_t elapsed_ticks;
//线程在队列中的结点
struct list_elem general_tag;
//all_list_tag的作用是用于线程队列thread_all_list中的结点
struct list_elem all_list_tag;
uint32_t* pgdir; // 进程自己页表的虚拟地址
uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出
};
5、线程栈结构定义:
struct thread_stack {
uint32_t ebp;
uint32_t ebx;
uint32_t edi;
uint32_t esi;
/*
线程第一次执行时,eip指向待调用的函数kernel_thread
其它时候,eip是指向switch_to的返回地址
*/
void (*eip) (thread_func* func, void* func_arg);
/***** 以下仅供第一次被调度上cpu时使用 ****/
/* 参数unused_ret只为占位置充数为返回地址 */
void (*unused_retaddr);
thread_func* function; // 由Kernel_thread所调用的函数名
void* func_arg; // 由Kernel_thread所调用的函数所需的参数
};
6、负责管理线程的数据结构:
双向链表定义的队列:
// 链表结构,用来实现队列
struct list {
// head是队首,是固定不变的,不是第1个元素,第1个元素为head.next
struct list_elem head;
// tail是队尾,同样是固定不变的
struct list_elem tail;
};
链表结点定义,结点是线程PCB中定义的tag:
struct list_elem {
struct list_elem* prev; // 前躯结点
struct list_elem* next; // 后继结点
};
7、任务调度机制的实现原理:
每发生一次时钟中断,当前运行线程PCB中定义的ticks就减一,当ticks为0时,时钟中断的中断处理程序调用调度器schedule,让调度器选择另一个线程上处理器。
8、线程调度算法:
实现中用的是轮询调度(Round Robin Scheduling),简单来说就是让队列中的线程挨个执行,也需要根据当前线程的几种情况分别判断
(1)如果当前进程的状态是TASK_RUNNING,说明是时间片到期,将ticks重新赋值为优先级prio,将状态改为TASK_READY,并加入到就绪队列的末尾。将就绪队列的头部线程状态设为TASK_RUNNING, 之后通过函数switch_to将新线程的寄存器环境恢复.
(2)如果状态为其他,不需要任何操作。
线程的创建
创建内核线程的顺序逻辑为:
main.c -> thread_start -> init_thread(初始化PCB) -> thread_create(初始化线程栈)
线程栈和PCB的关系:
当前的内核main函数:
int main(void) {
init_all();
thread_start("k_thread_a", 31, k_thread_a, "argA ");
while(1);
return 0;
}
// 用void*来通用表示参数,被调用的函数知道自己需要什么类型的参数,自己转换再用
void k_thread_a(void* arg) {
char* para = arg;
while(1) {
put_str(para);
}
}
thread_start:
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);
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;
}
init_thread,初始化PCB:
void init_thread(struct task_struct* pthread, char* name, int prio) {
memset(pthread, 0, sizeof(*pthread));
strcpy(pthread->name, name);
pthread->status = TASK_RUNNING;
pthread->priority = prio;
/* self_kstack是线程自己在内核态下使用的栈顶地址 */
pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE);
pthread->stack_magic = 0x19870916; // 自定义的魔数
}
thread_create,初始化线程栈:
//void* 是通用指针类型,可以接受任何类型的赋值
void thread_create(struct task_struct* pthread, thread_func function, void* func_arg) {
//预留线程中断栈的空间
pthread->self_kstack -= sizeof(struct intr_stack);
//再留出线程栈空间,可见thread.h中定义
pthread->self_kstack -= sizeof(struct thread_stack);
struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack;
kthread_stack->eip = kernel_thread;
/*
kernel_thread定义:
static void kernel_thread(thread_func* function, void* func_arg) {
function(func_arg);
}
*/
kthread_stack->function = function;
kthread_stack->func_arg = func_arg;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}
线程调度的实现
1、注册时钟中断处理函数
时钟中断发生时,检查当前运行线程的时间片是否为0,如果为0则调用schedule切换线程,如果不为0,则线程的时间片减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--;
}
}
缺页异常的处理:当虚拟地址对应的物理地址不存在,虚拟地址会存放在控制寄存器CR2中,处理函数将CR2中的地址存放在变量page_fault_vaddr中,并在屏幕上打印显示
/* 通用的中断处理函数,一般用在异常出现时的处理 */
static void general_intr_handler(uint8_t vec_nr) {
/*
略
*/
if (vec_nr == 14) { // 若为Pagefault,将缺失的地址打印出来并悬停
int page_fault_vaddr = 0;
asm ("movl %%cr2, %0" : "=r" (page_fault_vaddr)); // cr2是存放造成page_fault的地址
put_str("\npage fault addr is ");put_int(page_fault_vaddr);
}
put_str("\n!!!!!!! excetion message end !!!!!!!!\n");
while(1);
}
2、调度器schedule的实现
schedule的功能:将当前线程换下处理器,将就绪队列中的第一个线程换上(轮询实现)
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;
} else {
/* 若此线程需要某事件发生后才能继续上cpu运行,
不需要将其加入队列,因为当前线程不在就绪队列中。*/
}
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);
next->status = TASK_RUNNING;
switch_to(cur, next);
}
因为从就绪队列中弹出的只是线程的tag,所以需要将其转换为PCB:
//将地址elem_ptr转换为struct_type类型的指针
//转换方法:tag地址减去在PCB中的偏移量,再通过强制类型转换得到struct_type类型的指针
#define elem2entry(struct_type, struct_member_name, elem_ptr) \
(struct_type*)((int)elem_ptr - offset(struct_type, struct_member_name))
3、switch_to的实现
一般中断处理执行过程:
中断处理过程中的上下文保护:
1、保护用户程序上下文,就是保存当前的全部寄存器映像
intr%1entry: ; 每个中断处理程序都要压入中断向量号
%2 ; 中断若有错误码会压在eip后面
; 以下是保存上下文环境
push ds
push es
push fs
push gs
pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
恢复用户程序上下文:
intr_exit:
add esp, 4 ; 跳过中断号
popad
pop gs
pop fs
pop es
pop ds
add esp, 4 ; 跳过error_code
iretd ;中断返回
2、第二部分是保护内核程序上下文,ABI(Application Binary Interface)标准规定只保存esi,edi,ebx和ebp这四个寄存器即可,由switch_to函数负责实现,接收两个参数,第一个参数是当前线程,第二个参数是待切换的线程
任务切换时的栈:
switch_to:
[bits 32]
section .text
global switch_to
switch_to:
;保护ABI标准规定的四个寄存器
push esi
push edi
push ebx
push ebp
mov eax, [esp + 20] ; 得到栈中的参数cur, 当前线程的PCB地址
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;--------------------------- 恢复下一个线程的环境 -----------------------------
mov eax, [esp + 24] ; 得到栈中的参数next,切换目标线程的地址
mov esp, [eax] ; 使esp指向next线程的栈指针
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
;接下来pop的是在新线程的栈中
pop ebp
pop ebx
pop edi
pop esi
ret ;执行next线程的代码
启用线程切换
1、创建就绪队列和全部队列,添加当前main线程:
void thread_init(void) {
//创建就绪队列
list_init(&thread_ready_list);
//创建全部队列
list_init(&thread_all_list);
// 将当前main函数创建为线程
make_main_thread();
}
2、创建多个线程
int main(void) {
init_all();
//线程k_thread_a打印argA
thread_start("k_thread_a", 31, k_thread_a, "argA ");
//线程k_thread_b打印argB
thread_start("k_thread_b", 8, k_thread_b, "argB ");
intr_enable(); // 打开中断,使时钟中断起作用
//主线程打印Main
while(1) {
put_str("Main ");
};
return 0;
}
3、线程切换效果