初始时,CPU的执行流为进程;当产生了线程概念后,CPU执行流变为了线程,大大增大了一个周期以内进程的执行速度。
线程产生的作用就是为了提速,利用线程提速,原理就是实现多个执行流的伪并行,让处理器多执行自己进程中的代码。若进程只占用处理器的一个时间片,那将进程细分为线程后,一个进程中的多个线程可同时占用处理器。类比拼车与不可拼车两种模式,CPU相当于一辆车,拼车表示开启线程,允许拼车后,用户的出行成本就大大降低了。
Ⅰ.程序、进程、线程关系
程序是指静态的、存储在文件系统上、尚未运行的指令代码,它是实际运行时程序的映像。
进程是指正在运行的程序,即进行中的程序,程序必须在获得运行所需要的各类资源后才能成为进程,资源包括进程所使用的栈,使用的寄存器等。
进程=线程+资源,线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
1.进程线程区别
- 进程是资源分配的基本单位,线程是处理器调度的基本单位。
- 进程拥有自己独立的地址空间,每启动一个进程,系统为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段;线程没有自己的地址空间,需要借助进程的资源“生存”。
- CPU切换线程的开销比进程小。
- 创建线程的资源开销比进程小。
- 线程之间通信更方便,同一个进程下,线程共享全局变量,静态变量等数据,进程之间的通信需要以通信的方式(IPC)进行。
- 多进程程序更安全,生命力更强,一个进程死掉不会对另一个进程造成影响(源于有独立的地址空间);多线程程序更不易维护,一个线程死掉,可能整个进程就死掉了(因为共享地址空间)。
- 进程对资源保护要求高,开销大,效率相对较低,线程资源保护要求不高,但开销小,效率高,可频繁切换。
2.线程进程状态
初始态、就绪态、运行态、阻塞态、终止态
3.进程的身份证-PCB
针对多任务处理系统中,任务切换执行存在的如下疑问:
(1)要加载一个任务上处理器运行,任务由哪来?也就是说,调度器从哪里才能找到该任务?进程表
(2)即使找到了任务,任务要在系统中运行,其所需要的资源从哪里获得?PCB中的寄存器和栈等资源
(3)即使任务已经变成进程运行了,此进程应该运行多久呢?总不能让其独占处理器吧。时间片
(4)即使知道何时将其换下处理器,那当前进程所使用的这一套资源(寄存器内容)应该存在哪里?PCB最顶层的寄存器映像
(5)进程被换下的原因是什么?下次调度器还能把它换上处理器运行吗?取决于PCB的状态
(6)前面都说过了,进程独享地址空间,它的地址空间在哪里? PCB-页表
……
为解决以上问题,操作系统为每个进程提供了一个 PCB,Process Control Block,即程序控制块,它就是进
程的身份证,用它来记录与此进程相关的信息,比如进程状态、PID、优先级等。
每个进程都有自己的 PCB,所有 PCB 放到一张表格中维护,这就是进程表,调度器可以根据这张表选择上处理器运行的进程 。
PCB的栈为进程所使用的 0 特权级下内核栈,寄存器映像存储的也是内核态的寄存器,栈指针对应的是内核态下的寄存器映像地址,即内核态栈地址。
3.实现线程的两种方式一一内核或用户进程
线程仅仅是个执行流,在用户空间,还是在内核空间实现它,最大的区别就是线程表在哪里,由谁来调度它上处理器 。 如果线程在用户空间中实现,线程表就在用户进程中,用户进程就要专门写个线程用作线程调度器,由它来调度进程内部的其他线程。如果线程在内核空间中实现,线程表就在内核中,该线程就会由操作系统的调度器统一调度,无论该线程属于内核,还是用户进程。
(1)内核中实现线程
线程机制由内核完成。
好处:
- 相比在用户空间中实现线程,内核提供的线程相当于让进程多占了处理器资源
- 当进程中的某一线程阻塞后 , 由于线程是由内核空间实现的,操作系统认识线程,所以就只会阻塞这一个线程,此线程所在进程内的其他线程将不受影响
缺点:
- 用户进程需要通过系统调用陷入内核,这多少增加了 一些现场保护的栈操作,这还是会消耗一些处理器时间
(2)用户程序中实现线程
**线程机制由用户程序通过标准库完成。**处理器依旧按照进程作为执行流执行程序。
好处:
- 可移植性强,在不支持线程的操作系统上也可以写出完美支持线程的用户程序。
- 线程的调度算法是由用户程序自己实现的,可以根据实现应用情况为某些线程加权调度。
- 将线程的寄存器映像装载到 CPU 时,可以在用户空间完成,即不用陷入到内核态,这样就免去了进入内核时的入栈和出栈操作。
缺点:
- 进程中的某个线程若出现了阻塞(通常是由于系统调用造成的),操作系统不知道进程中存在线程,它以为此进程是传统型进程(单线程进程),因此会将整个进程挂起,即进程中的全部线程都无法运行。
- 如果在用户空间中实现线程,但凡进程中的某个线程开始在处理器上执行后,只要该线程不主动让出处理器,此进程中的其他线程都没机会运行。 只能凭借开发人员“人为”地在线程中调用类似 pthread_yield 或 pthread_exit 之类的方法使线程让出处理器使用权,此类方法通过回调方式触发进程内的线程调度器,让调度器有机会选择进程内的其他线程上处理器运行。
- 进程在一个时间片内既要完成资源分配,又要处理线程调度的工作,导致提速效果较差。
如果在用户空间中实现线程,用户线程就要肩负起调度器的责任,因此除了要实现进程内的线程调度器外,还要自己在进程内维护线程表 ,导致开销很大。
Ⅱ.在内核空间实现线程
包括主线程和新建立的线程两种方式。
call 指令属于“有去有回”的指令,它在“去”之前先在栈中(进入被调函数时的栈顶处〉留下返回地址,它的“回”则需要在 ret 指令的配合下才能完成, ret 将楼顶的值当作 call 留下的返回地址,在保证栈顶值正确的情况下, ret 能把处理器重新带回到主调函数中。
1.线程执行过程
1.构建就绪线程队列和所有线程队列
struct task_struct* main_thread; // 主线程 PCB
struct list* thread_ready_list; // 就绪队列
struct list* thread_all_list; // 所有线程
static struct list_elem* thread_tag; // 用于保存队列中的线程结点
队列中存储的是PCB标志位,general_tag和all_list_tag,分别表示就绪线程队列标志和所有线程队列标志。
/* 根据结构体成员找到结构体地址 */
// 计算偏移量,建立一个起始地址为0的虚拟结构体,对成员取地址,就是偏移量
#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))
2.申请一页大小的PCB(内核空间)
struct task_struct* thread= get_kernel_pages(l);
3.初始化线程PCB
(1)定义PCB结构
/* 进程控制块 */
struct task_struct{
uint32_t *self_kstack; // 线程的内核栈
enum THREAD_STATUS status; // 线程状态
uint32_t priority; // 线程优先级
char name[16];
// 此任务自上 cpu 运行后至今占用了多少 cpu 嘀嗒数,也就是此任务执行了多久
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; // 线程栈的边界标记,用于标记栈是否溢出
};
-
内核栈。存储线程执行的函数、传参、寄存器、内存地址等信息
-
任务的时间片。每次时钟中断都会将当前任务的 ticks 减1 ,当减到 0时就被换下处理器。
-
优先级。priority 表示任务的优先级,咱们这里优先级体现在任务执行的时间片上,即优先级越高,每次任务被调度上处理器后执行的时间片就越长。
-
general_tag。线程的标签, 当线程被加入到就绪队列也thread_ready_list 或其他等待队列中时,就把该线程 PCB 中 general_tag 的地址加入队列。
-
all_list_tag。在所设计的系统中, 为管理所有线程,还存在一个全部线程队列thread_all_list,因此线程还需要另外一个标签,即 all_list_tag。专用于线程被加入全部线程队列时使用。
这两个标签仅仅是加入队列时用的,将来从队列中把它们取出来时,还需要再通过 offset 宏与 elem2entry宏的“反操作“实现从&general_tag 到&thread 的地址转换,将它们还原成线程的 PCB 地址后才能使用。
-
pgdir。任务自己的页表 。如果该任务为线程, pgdir 则为 NULL,否则 pgdir会被赋予页表的虚拟地址,注意此处是虚拟地址,页表加载时还是要被转换成物理地址的 。
如何实现就绪队列的标签到PCB转换的过程呢?通过上面的offset函数和elem2entry函数
(2)初始化PCB
/* 初始线程PCB */
void init_thread(struct task_struct* pthread, char* name, uint32_t proi){
memset(pthread,0, sizeof(*pthread));
strcpy(pthread->name, name);
if (pthread == main_thread){
pthread->status = TASK_RUNNING;
}
else{
pthread->status = TASK_READY;
}
// 初始化在线程PCB的最顶端,栈向下生长
// pthread在分配了一页内存后指向PCB的最底端,加上PG_SIZE即为PCB最顶端地址
pthread->self_kstack =(uint32_t*) ((uint32_t)pthread + PG_SIZE);
pthread->priority = proi;
pthread->ticks = proi;
pthread->elapsed_ticks = 0;
pthread->pgdir = NULL;
pthread->stack_magic = 0x19870916; // 自定义魔数
}
分配栈空间
初始化PCB中优先级、时间片、状态
3.初始化线程栈
(1)定义线程栈
线程栈结构,由于线程中断后再次执行需要恢复执行的函数、参数,以及对应的寄存器信息,因此建立栈维护线程现场
/* 建立线程自己的栈
* 线程自己的栈,用于存储线程中待执行的函数
* 此结构在线程自己的内核梭中位置不固定,
* 仅用在 switch_to 时保存线程环境。
* 实际位置取决于实际运行情况。
*/
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 时使用 ****/
void (*unused_retaddr); // 参数 unused_retaddr 只为占位置充数为返回地址
thread_func* funciton; // 自 kernel_thread 所调用的函数名
void* func_arg; // 由 kernel_thread 所调用的函数所需的参数
};
(2)初始化线程栈
/* 初始化线程栈 */
void thread_creat(struct task_struct* pthread, thread_func* function, void* func_arg){
// 先预留出中断栈空间
pthread->self_kstack -= sizeof(struct intr_stack);
// 预留出线程栈空间
pthread->self_kstack -= sizeof(struct thread_stack);
// 设置线程栈起始地址
struct thread_stack* kthread_stack = (struct thread_stack*) pthread->self_kstack;
kthread_stack->funciton = function;
kthread_stack->func_arg = func_arg;
kthread_stack->eip = kernel_thread;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->edi = kthread_stack->esi = 0;
}
4.将创建的线程添加到就绪线程队列和所有线程队列中
/* 确保之前不在就绪队列中 */
ASSERT(!(elem_find(&thread_ready_list, &thread->general_tag)));
list_append(&thread_ready_list, &thread- >general_tag);
/* 确保之前不在所有队列中 */
ASSERT(!(elem_find(&thread_ready_list, &thread->all_list_tag)));
list_append(&thread_ready_list, &thread- >all_list_tag);
5.设置kernel中main函数为主线程
main函数在启动之后会自动建立线程栈,我们在kernel.S中为其分配了PCB空间,并未初始化PCB,因此需要对其进行初始化 。
(1)获取当前esp指针赋值给main_thread的PCB
/* 获取当前线程 pcb 指针 */
struct task_struct* running_thread(){
uint32_t esp;
asm("mov %%esp, %0":"=g"(esp));
/* 取 esp 整数部分,即 pcb 起始地址*/
return (struct task_struct*) (esp & Oxfffff000);
}
(2)完善main_threadPCB信息
static void make_main_thread(void){
// 定义main_thread的PCB地址
main_thread = running_thread();
// 初始化名字和优先级
init_thread(main_thread, "main", 31);
// 由于main程序已经在运行了,因此只需要放入all_list即可
ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag));
list_append(&thread_all_list, &main_thread->all_list_tag);
}
Ⅲ.任务调度器和任务切换
1.任务调度器工作过程
线程调度器主要任务就是读写就绪队列,增删里面的结点,结点是线程 PCB 中的 general_tag,“相当于”线程的 PCB,从队列中将其取出时一定要还原成 PCB 才行 。
- 当前任务执行的状态,什么时候结束?ticks===0
- 结束了之后,如何完成保护现场操作?
- 结束了如何找到下一个线程?thread_ready_list
- 如何恢复下一个线程的现场?switch_to
(1)PCB的ticks决定了任务执行的时间,系统设定:当ticks减为0时,当前任务被换下处理器,根据线程的运行状态决定线程是否加入就绪队列:
- TASK-RUNNING。将当前队列加入thread_ready_list队尾,并将ticks设置为prio。
- others。不对当前线程进行任何操作。
故需要设定时钟中断系统。
(2)调度器按照队列先进先出的顺序,把就绪队列中的第 1 个结点作为下一个要运行的新线程,将该线程的状态置为 TASK_RUNNING,之后通过函数 switch_to 将新线程的寄存器环境恢复,新线程便开始执行 。
(1)完整调度过程的3个步骤
- 时钟中断处理函数
- 调度器schedule
- 任务切换函数switch_to
(2)PART1-注册时钟中断处理函数
Intel处理器支持256个中断,在前面的kernel.S中,通过中断向量号调用中断处理程序数组 idt_table 中的 C 版本的处理程序,就是文件 kemel.S 中代码 call [idt_table + %1 *4]的作用。由于idt_table存储的就是中断处理程序,因此,为设备注册中断处理程序的工作变得很简单,我们不用去修改中断描述符,直接把中断向量作为数组下标,去修改 idt_table[中断向量]数组元素即可。
for (i = 0; i < IDT_DESC_CNT; i++) {
/* idt_table数组中的函数是在进入中断后根据中断向量号调用的,
* 见kernel/kernel.S的call [idt_table + %1*4] */
idt_table[i] = general_intr_handler; // 默认为general_intr_handler。
// 以后会由register_handler来注册具体处理函数。
intr_name[i] = "unknown"; // 先统一赋值为unknown
}
之前的时钟中断处理函数还是用通用的函数来处理的, 即 general_intr_handler,此函数作为默认的中断处理函数,即某个中断源没有中断处理程序时才用它来代替。
(2.1)改进的通用中断处理函数general_intr_handler()
-
由于需要打印输出中断信息,为防止由于光标错误值引发异常,加入了
set_cursor()
光标位置设置函数 -
为保证中断调用出现的缺页异常能及时被发现和处理,设置缺页中断号14判定函数,标定缺页异常
加了 Pagefault 的处理。 Pagefault 就是通常所说的缺页异常,它表示虚拟地址对应的物理地址不存在,也就是虚拟地址尚未在页表中分配物理页,这样会导致 Pagefault 异常。导致 Pagefault 的虚拟地址会被存放到控制寄存器 CR2 中,我们加入的内联汇编代码就是让 Pagefault 发生时,将寄存器 cr2 中的值转储到整型变量 page_fault_vaddr 中,并通过 put_str函数打印出来。因此,如果程序运行过程中出现异常 Pagefault 时,将会打印出导致 Pagefault 出现的虚拟地址。
/* 通用的中断处理函数,一般用在异常出现时的处理 */
static void general_intr_handler(uint8_t vec_nr) {
if (vec_nr == 0x27 || vec_nr == 0x2f) { // 0x2f是从片8259A上的最后一个irq引脚,保留
return; //IRQ7和IRQ15会产生伪中断(spurious interrupt),无须处理。
}
/* 将光标置为0,从屏幕左上角清出一片打印异常信息的区域,方便阅读 */
set_cursor(0);
int cursor_pos = 0;
while(cursor_pos < 320) {
put_char(' ');
cursor_pos++;
}
set_cursor(0); // 重置光标为屏幕左上角
put_str("!!!!!!! excetion message begin !!!!!!!!\n");
set_cursor(88); // 从第2行第8个字符开始打印
put_str(intr_name[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.2)注册时钟中断处理函数
……
uint32_t ticks;
……
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){
schedule();
}
else{
cur_thread->ticks--;
}
}
void timer_init(){
put_str("timer init");
// 设置 8253 的定时周期,也就是发中断的周期
frequency_set( CNTRERO_PORT, \
COUNTERO_NO, \
READ_WRITE_LATCH, \
COUNTER MODE, \
COUNTERO_VALUE);
// 注册时钟中断函数
register_handler(0x20, intr_timer_handler);
put_str("timer_init done\n") ;
}
(2.3)中断注册函数
/* 中断处理程序数组第 vector_no 个元素中
注册安装中断处理程序 function */
void register_handler(uint32_t vec_no, intr_handler* function){
/* idt_table 数组中的函数是在进入中断后根据中断向量号调用的
* 见 kernel/kernel.S 的 call [idt_table+%1*4] */
idt_table[vec_no] = function;
}
(3)PART2-调度器schedule
/* 实现任务调度 */
void schedule(){
// 系统关中断下进行
ASSERT(intr_get_status == INTR_OFF);
struct task_struct *cur_thread = running_thread();
// 若此线程只是 cpu 时间片到了,将其加入到就绪队列尾
if(cur_thread->status == TASK_RUNNING){
ASSERT(&thread_ready_list, &cur_thread->general_tag);
list_append(&thread_ready_list, &cur_thread->general_tag);
// 重新将当前线程的 ticks 再重置为其 priority
cur_thread->ticks = cur_thread->priority;
cur_thread->status = TASK_READY;
}
else{
/* 若此线程需要某事件发生后才能继续上 cpu 运行,
不需要将其加入队列,因为当前线程不在就绪队列中 */
}
ASSERT(list_empty(&thread_ready_list));
/* 从就绪队列取出下一个就绪线程*/
// 清空线程节点
thread_tag = NULL;
thread_tag = list_pop(&thread_ready_list);
struct task_struct *next_thread = elem2entry(struct task_struct, general_tag, thread_tag);
next_thread->status = TASK_RUNNING;
// 载入寄存器
switch_to(cur_thread, next_thread);
}
- 判断当前线程是否需要继续执行,决定是否重新加入就绪队列
- 从就绪队列pop出下一个线程
- switch_to载入新线程寄存器组
要求在关中断下进行
(4)PART3-任务切换函数switch_to
任务切换的过程包括:保存当前任务上下文;执行中断处理程序,保存当前中断处理程序上下文;执行下一个任务,因此
需要保存任务的上下文,既需要保存中断发生时任务的寄存器、栈状态,同时也要保存内核中任务的还未执行的环境。具体包括两部分:
(1)上下文保护的第一部分负责保存任务进入中断前的全部寄存器,目的是能让任务恢复到中断前 。
通过kernel.S完成中断前的保存工作,以及中断处理程序跳转执行工作
extern idt_table ;idt_table是C中注册的中断处理程序数组
%macro VECTOR 2
section .text
intr%1entry: ; 每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少
%2 ; 中断若有错误码会压在eip后面
; 以下是保存上下文环境
push ds
push es
push fs
push gs
pushad ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
; 如果是从片上进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI
mov al,0x20 ; 中断结束命令EOI
out 0xa0,al ; 向从片发送
out 0x20,al ; 向主片发送
push %1 ; 不管idt_table中的目标程序是否需要参数,都一律压入中断向量号,调试时很方便
call [idt_table + %1*4] ; 调用idt_table中的C版本中断处理函数
jmp intr_exit
section .data
dd intr%1entry ; 存储各个中断入口程序的地址,形成intr_entry_table数组
%endmacro
section .text
global intr_exit
intr_exit:
; 以下是恢复上下文环境
add esp, 4 ; 跳过中断号
popad
pop gs
pop fs
pop es
pop ds
add esp, 4 ; 跳过error_code
iretd
(2)上下文保护的第二部分负责保存这 4 个寄存器 esi 、 edi 、 ebx 和 ebp ,目的是让任务恢复执行在任务切换发生时剩下尚未执行的内核代码,保证顺利走到退出中断的出口,利用第一部分保护的寄存器环境彻底恢复任务。
[bits 32]
section .text
global switch_to
switch_to:
;栈中此处是返回地址
push esi
push edi
push ebx
push ebp
mov eax, [esp + 20] ; 得到栈中的参数cur, cur = [esp+20]
mov [eax], esp ; 保存栈顶指针esp. task_struct的self_kstack字段,
; self_kstack在task_struct中的偏移为0,
; 所以直接往thread开头处存4字节便可。
;------------------ 以上是备份当前线程的环境,下面是恢复下一个线程的环境 ----------------
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp+24]
mov esp, [eax] ; pcb的第一个成员是self_kstack成员,用来记录0级栈顶指针,
; 用来上cpu时恢复0级栈,0级栈中保存了进程或线程所有信息,包括3级栈指针
pop ebp
pop ebx
pop edi
pop esi
ret ; 返回到上面switch_to下面的那句注释的返回地址,
; 未由中断进入,第一次执行时会返回到kernel_thread
(5)PART4-启用线程调度
系统初始化函数中加入thread_init()函数,在main函数中加入thread_start()函数。
开启执行……
总结:
pushad
本指令将EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI 这8个32位通用寄存器依次压入堆栈,其中SP的值是在此条件指令未执行之前的值,压入堆栈之后,ESP-32–>ESP。
popad
本指令依次弹出堆栈中的32位字到 EDI,ESI,EBP,ESP,EBX,EDX,ECX,EAX中,弹出堆栈之后,ESP+32–>ESP。