操作系统真相还原 第九章 线程
第九章 线程
实现内核线程
执行流
一个处理器任意时刻只能执行一个任务,真正的并行指多个处理器同时工作,并行能力取决于其物理处理器的数量。
任务:完成一个任务,可能需要用户进程部分和内核部分共同完成。
调度器:在内核中维护一个任务表(进程表或调度表),按照一定的算法,从任务表中取一个任务,把该任务放在处理器上运行,当任务运行的时间片到期后,再从任务表中取另一个一个任务,周而复始。
执行流:对应于代码,如进程,线程或函数。执行流是独立的,每个执行流都有自己的栈,一套自己的寄存器映像和内存资源,这就是执行流的上下文环境。
进程与线程的关系和区别
程序:静态的存储在文件系统上,尚未运行的代码,它是实际运行时程序的映象。
进程:正在运行的程序,程序必须在获得运行所需要的各类资源后才能成为进程,资源包括进程所使用的的栈,寄存器等。进程是执行流(线程)的集合。
线程:进程内的线程共享进程资源,线程是处理器的执行单位,调度单位。线程是进程之后才提出的概念,在没有线程前,进程是执行流。有了线程的概念后,线程成为最小的执行单元。原来的进程实际相当于单一线程的进程,即单线程进程。现在进程中可以有多个线程,就是多线程进程。
有了线程后的的优点:进程采用多个执行流(线程)和其他进程抢占处理器资源,更快完成任务。
线程是执行单元,进程是资源整合体,把所有线程运行时用到的资源收集到一起,供线程使用。线程是执行流,调度器调度送上处理器的是线程。
进程的身份证-PCB
操作系统为每个进程提供了一个PCB,Process Control Block,进程的身份证,记录进程相关的信息,如进程状态、PID、优先级等。
每个进程都有自己的PCB,所有PCB放在一张表中维护,这张表就是进程表(任务表或调度表),供调度器使用。PCB是进程表中的“项”,所以PCB又叫进程表项。
PCB中的栈式进程所使用的的0特权级栈。
实现线程的两种方式-内核或用户线程
用户空间实现线程:
在用户进程中实现线程,进程的线程自己调度,进程内的线程共同使用进程的时间片。
优点:自己实现,能够定制。不用陷入到内核态。
缺点:操作系统感知不到用户进程中的线程,会把用户进程当成执行流,不能把线程作为执行流去竞争时间片,进程整体无法获取到更多时间片。
内核中实现线程:
内核中提供线程机制,用户进程中不再单独实现。
缺点:陷入内核
优点:线程可以直接竞争时间片,整个进程能得到更多的时间片。一个线程阻塞不会堵塞进程中的其他线程。
在内核空间实现线程
简单的PCB及线程栈的实现
线程状态
/* 进程或线程的状态 */
enum task_status {
TASK_RUNNING,
TASK_READY,
TASK_BLOCKED,
TASK_WAITING,
TASK_HANGING,
TASK_DIED
};
中断栈,用于发生中断时,保护进程或线程上下文
/*********** 中断栈intr_stack ***********
* 此结构用于中断发生时保护程序(线程或进程)的上下文环境:
* 进程或线程被外部中断或软中断打断时,会按照此结构压入上下文
* 寄存器, intr_exit中的出栈操作是此结构的逆操作
* 此栈在线程自己的内核栈中位置固定,所在页的最顶端
********************************************/
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;
};
线程栈,有两个作用,主要体现在第五个成员eip
- 线程执行其实就是函数执行,eip保存该函数的地址
- 任务切换时,eip保存任务切换后新任务的返回地址
栈中前几个成员用户保存函数参数。
/*********** 线程栈thread_stack ***********
* 线程自己的栈,用于存储线程中待执行的函数
* 此结构在线程自己的内核栈中位置不固定,
* 用在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时使用 ****/
/* 参数unused_ret只为占位置充数为返回地址 */
void (*unused_retaddr);
thread_func* function; // 由Kernel_thread所调用的函数名
void* func_arg; // 由Kernel_thread所调用的函数所需的参数
};
ABI(Application Binary Interface ,即应用程序二进制接口)中对函数参数的规则:
位于 Intel386 硬件体系上的所有寄存器都具有全局性,因此在函数调用时,这些寄存器对主调函数和被调函数都可见。这5个寄存器 ebp ebx, edi esi 、和 esp 归主调函数所用,其余的寄存器归被调函数所用。换句话说,不管被调函数中是否使用了这5个寄存器,在被调函数执行完后,这几个寄存器的值不该被改变 因此被调函数必须为主调函数保护好这5个寄存器的值,在被调函数运行完之后,这5个寄存器的值必须和运行前一样,它必须在自己的栈中存储这些寄存器的值。
c语言层面,函数的执行都是由调用者发起调用的,这通过call指令完成,此指令会在栈中留下返回地址。因此被调用的函数在执行时,会认为调用者己经把返回地址留在栈中,而且是在栈顶的位置。
call 指令属于“有去有回”的指令,它在“去”之前先在栈中(进入被调函数时的栈顶处)留下返回地址,它的“回”则需要在 ret 指令的配合下才能完成, ret 将栈顶的值当作 call 留下的返回地址,在保证栈顶值正确的情况下, ret 能把处理器重新带回到主调函数中(把返回地址放入cs,ip)。
pcb,其实就相当于thread对象
/* 进程或线程的pcb,程序控制块 */
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
uint8_t priority; // 线程优先级
char name[16];
uint32_t stack_magic; // 用这串数字做栈的边界标记,用于检测栈的溢出
};
线程的实现
其实就是初始化pcb结构task_struct
多线程调度
简单优先级调度的基础
pcb(thread)结构添加字段
ticks:剩余时间片,每次时钟中断时,当前任务的时间片减1,当减到0时被换下处理器。
elapsed_ticks:此任务自上cpu运行后至今占用了多少cpu嘀嗒数,也就是此任务执行了多久
general_tag: 类型是struct list_elem ,是 双向链表中的结点。它是线程的标签, 当线程被加入到就绪队列thread-ready_ list 或其他等待队列中时,就把该线程 PCB general_tag 的地址加入队列。
all_list_tag :类型也是由struct list_elem ,它专用于线程被加入全部线程队列时使用。为管理所有线程,还存在一个全部线程队列thread_all_list。
pgdir:是任务自己的占用的内存页页表。线程与进程的最大区别就是进程独享自己的地址空间,即进程有自己的页表,而线程共享所在进程的地址空间,即线程无页表。
/* 进程或线程的pcb,程序控制块 */
struct task_struct {
uint32_t* self_kstack; // 各内核线程都用自己的内核栈
enum task_status status;
char name[16];
uint8_t priority;
uint8_t ticks; // 每次在处理器上执行的时间嘀嗒数
/* 此任务自上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; // 用这串数字做栈的边界标记,用于检测栈的溢出
};
声明
struct task_struct* main_thread; // 主线程PCB
struct list thread_ready_list; // 就绪队列
struct list thread_all_list; // 所有任务队列
static struct list_elem* thread_tag;// 用于保存队列中的线程结点
初始化线程
list_init创建空队列
/* 初始化线程环境 */
void thread_init(void) {
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
/* 将当前main函数创建为线程 */
make_main_thread();
put_str("thread_init done\n");
}
make_main_thread,为主线程添加pcb,放入thread_all_list队列。
其实我们从开机到创建第1个线程前,程序都有个执行流,这个执行流带我们从BIOS到mbr到loader到kernel,其实它就是我们所说的主线程。但此时主线程还没有 PCB ,所以我们提前调用make_main_thread函数为主线程赋予了 PCB。
/* 将kernel中的main函数完善为主线程 */
static void make_main_thread(void) {
/* 因为main线程早已运行,咱们在loader.S中进入内核时的mov esp,0xc009f000,
就是为其预留了tcb,地址为0xc009e000,因此不需要通过get_kernel_page另分配一页*/
main_thread = running_thread();
init_thread(main_thread, "main", 31);
/* main函数是当前线程,当前线程不在thread_ready_list中,
* 所以只将其加在thread_all_list中. */
ASSERT(!elem_find(&thread_all_list, &main_thread->all_list_tag));
list_append(&thread_all_list, &main_thread->all_list_tag);
}
init_thread,初始化线程,设置线程名
/* 初始化线程基本信息 */
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 = 0x19870916; // 自定义的魔数
}
kernel_thread,用于执行线程的函数。进入中断时关闭了中断,在线程的函数执行前要打开中断。
/* 由kernel_thread去执行function(func_arg) */
static void kernel_thread(thread_func* function, void* func_arg) {
/* 执行function前要开中断,避免后面的时钟中断被屏蔽,而无法调度其它线程 */
intr_enable();
function(func_arg);
}
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);
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;
}
任务调度器和任务切换
调度机制RR:
Round-Robin Scheduling,即轮询调度。从就绪队列头部取就绪线程,并从队列中删除。
调度过程:
- 时钟中断处理函数。
- 调度器schedule。
- 任务切换函数switch_to。
时钟中断处理函数
intr_timer_handler,获取当前线程,判断当前线程的时间片是否为0,不为0时,时间片减一,完成中断函数,退出中断,继续执行当前线程;为0时执行调度器schedule()。
时钟中断处理函数需要在中断向量表中注册,和对应的时钟中断向量做映射。
/* 时钟的中断处理函数 */
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
- 当前线程如果是TASK_RUNNING状态,若此线程只是cpu时间片到了,重置时间片为优先级priority的值,将其加入到就绪队列尾部,其他情况暂不处理。
- 从就绪队列头部取出就绪线程,状态改为TASK_RUNNING,调用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;
} 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);
}
任务切换函数switch_to
switch_to是汇编函数,调用形式为“switch_to(cur, next)”,意为将线程 cur 的上下文保护好,再将线程 next 的上下文装载到处理器,从而完成了任务切换(cs,ip设为下一个任务的地址。)。
- 上下文保护的第一部分负责保存任务进入中断前的全部寄存器,目的是能让任务恢复到中断前
- 上下文保护的第二部分负责保存这 个寄存器 esi edi ebx ebp ,目的是让任务恢复执行在任务切换发生时剩下尚未执行的内核代码,保证顺利走到退出中断的出口,利用第一部分保护的寄存器环境彻底恢复任务。
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
完整的程序分为两部分,一部分是做重要工作的内核级代码,另一部分就是做普通工作的用户级代码。所以,“完整的程序=用户代码+内核代码”。而这个完整的程序就是我们所说的任务,也就是线程或进程,也就是说,任务在执行过程中会执行用户代码和内核代码,当处理器处于低特权级下执行用户代码时我们称之为用户态,当处理器进入高特权级执行到内核代码时,我们称之为内核态,当处理器从用户代码所在的低特权级过渡到内核代码所在的高特权级时,这称为陷入内核。无论是执行用户代码,还是执行内核代码,这些代码都属于这个完整的程序,即属于当前任务,并不是说当前任务由用户态进入内核态后当前任务就切换成内核了,这样理解是不对的。任务与任务的区别在于执行流一整套的上下文资源,这包括寄存器映像、地址空间、 IO 位图等,这套上下文资源恰恰就是 TSS 结构中的内容,拥有这些资源才称得上是任务。因此,处理器只有被新的上下文资源重新装载后,当前任务才被替换为新的任务,这才叫任务切换。当任务进入内核态时,其上下文资源并未完全替换,只是执行了“更厉害”的代码。这有点像咱们在游戏中打怪,用不同的武器打不同的怪,游戏的人物角色始终没有变。
启用线程调度
thread_start启动线程,intr_enable()打开中断,使得中断处理函数能够触发
int main(void) {
put_str("I am kernel\n");
init_all();
thread_start("k_thread_a", 31, k_thread_a, "argA ");
thread_start("k_thread_b", 8, k_thread_b, "argB ");
intr_enable(); // 打开中断,使时钟中断起作用
while(1) {
put_str("Main ");
};
return 0;
}
init_all(),初始化了线程,thread_init将当前main函数添加pcb,创建为线程
/*负责初始化所有模块 */
void init_all() {
put_str("init_all\n");
idt_init(); // 初始化中断
mem_init(); // 初始化内存管理系统
thread_init(); // 初始化线程相关结构
timer_init(); // 初始化PIT
}
/* 初始化线程环境 */
void thread_init(void) {
put_str("thread_init start\n");
list_init(&thread_ready_list);
list_init(&thread_all_list);
/* 将当前main函数创建为线程 */
make_main_thread();
put_str("thread_init done\n");
}