执行流
这里先说一说执行流,有助于线程的理解。
程序计数器中的下一条指令地址组成的轨迹称为程序的执行流。执行流是逻辑上独立的指令区域,是人为给处理器安排的处理单元。指令指导处理器的执行方向,从处理器的角度看,执行的指令形成一条路径,称为执行流。执行流可大可小,可以是整个程序文件,也可以是一个函数。线程本质上是一个函数。
有一点汇编基础的就会知道,一个代码段想要突然去执行另外一个代码段的指令,比如call指令或者因中断去执行中断处理程序,只要先将调用前或中断前的上下文环境保存好就可以在iret或中断返回后继续执行原代码段的指令。所以说当我们为任何一段指令提供它所需要的上下文环境,那么这段指令就可以独立的上CPU运行,也就是说这段指令成为了一个单独的执行流。这里说的上下文环境指的是指令所使用的寄存器映像、栈、内存等资源。这样更能理解执行流是独立的,可以独立的上CPU运行,哪怕被中断也可以返回继续执行,因为它所需要的资源得到了维护。
在任务调度器的眼里,执行流是调度单元,即处理器上运行的每个任务都是调度器分配的执行流。话句话说,实现任务调度,就是换不同的执行流上CPU。我们要说的线程就是一个执行流。进程和线程有很多相似的地方,当一个进程中只有一个线程时,我们称之为单线程进程,那么它比线程就只多了处理的资源。我们可以认为线程和进程都是由执行流实现的。
线程
回想创建线程的过程,我们先声明并定义一个函数作为线程的处理函数,该函数的返回值为void*参数也是void*,然后调用pthread_create()函数创建线程。可以理解为线程就是去执行一个函数,但线程和普通的函数的区别在于线程拥有独立的上下文环境成为了独立的执行流,也就成为了独立的调度单元,可以独立上的CPU运行。在一般的函数调用中,函数随着程序的执行流被顺便执行。给每个执行流分配的时间是有限的,一个普通函数要等到该它运行的时候才可以上CPU,前面有再多的函数它都要等着,还没有到它运行的时候可能该执行流就被换下CPU了。而线程则因为成了单独的执行流,可以独自享用分配的CPU时间,这才是线程真正优势的地方。
理解的线程的优势,在使用线程的时候才能恰到好处,现在再回想自己写的多线程程序是否真的需要使用多线程。将要处理的单独一类事件放在一个执行流等待就好了,没有必要写成多线程,在调度器调度的时候反而会花费额外的时间。
线程是一套机制,给一段代码块构建它依赖的上下文环境,从而让代码块称为单独的执行流,也就成为了调度器的调度单元可以直接上CPU运行。
线程中调用的函数让所运行的函数以调度单元的身份独立上CPU运行,当函数运行时,可以让程序中多个函数(执行流)以伪并行的方式运行,为程序提速。
进程与线程
进程是运行中的程序。对于处理器来说,进程是执行流的集合,至少包含一个执行流,执行流之间是相互独立的,但它们共享进程的所有资源。执行流是处理器的执行单元,或者称为调度单元,它们就是线程。
在线程出现之前,Linux中是没有线程的,可以翻看Linux早期版本(Linux 0.11),其中并没有操作系统书籍说到的有关线程的代码,比如thread_info结构体和创建线程的函数。那时CPU调度的单元是进程,进程就是各个执行流(调度单元),这里想说明的是进程和线程都是概念上的。在线程出现之前依然能够实现并发处理,线程在进程的基础上实现了二次并发,目的是提高效率。进程与线程的区别,一个是上面所说的进程中可以有多个线程;第二个就是线程没有自己的资源,没有自己的地址空间,必须要依附于进程的地址空间中才可以运行。
现在应该理解为什么说进程中至少有一个线程,线程又是轻量级进程。
进程线程的状态
上面说了进程和线程是概念上的,真正实现时都是认为创造的代码块,因此执行流的状态也是人为划分的。比如因为有的线程在读写磁盘时需要等待,那么就需要该线程为阻塞状态,当线程可以上CPU运行时该线程就叫就绪态,在CPU运行时就称为运行态。在有其他需求的时候可能还会由别的状态出现,只要合理就可以,这些涉及操作系统设计的方面没有太了解就不多说了。只是想说状态都是因为某种需求而出现的,然后当状态满足后就说明线程符合了某些条件,比如线程由阻塞态变为就绪态说明现在线程可能正在等待的资源已经等到了可以上CPU运行了。
程序控制块PCB
PCB(Process Control Block)是进程的身份证,记录了与进程相关的所有信息,比如进程状态、PID、优先级等。每个进程都有自己的一个PCB所有PCB放到一张表格中维护,就是进程表,调度器根据这张表选择上处理器运行的进程。PCB的内容取决于操作系统功能的复杂程度。PCB可以确定处理器要执行的任务、记录程序运行所需要的资源信息、记录给任务分配的时间大小、上下文信息的存储地址、进程状态、进程地址空间等信息。
实现线程的两种方式
在用户空间中实现线程
这里线程实现用户空间创建的,内核中还是早起的以进程为调度单位。在用户空间中实现线程的好处是程序的可移植性强,因为是在用户空间实现的,在不支持多线程的操作系统中也可以运行。
在用户空间实现的方法就是在用户空间实现调度器,用户进程自己为自己构建线程结构,并自己分配线程每一次的执行时间。
但在用户空间实现线程也有缺点。进程中某个线程阻塞了的话,操作系统无法运行其他的线程,整个进程就会被挂起。对于操作系统来说无法把握线程的运行时间,也就不知道何时将线程换下,无法确定所有的线程都能主动地让出CPU。还有就是在进程的运行时间是由操作系统决定的,在有限的时间内多线程进行还要自己进程线程调度,将执行的有效时间缩短,这样得不偿失。
在内核实现线程
在内核实现线程的优点:
- 内核提供线程相当于让进程多占用处理器资源。比如进程A创建3个线程(进程A中就有4个线程),进程B为单线程进程,此时的调度队列中有5个调度单元。假设创建的进程的优先级都是相同的,那么进程A的运行机会为80%,得到更多的处理器资源也就提高的进程运行速度,提高了效率
- 当一个线程阻塞后不会影响其他线程的运行。因为线程的调度是由操作系统完成的,操作系统知道线程何时该下处理器了,知道线程当前的状态。
缺点就是从用户态陷入内核是对现场的保护需要消耗时间,但和提高的速度比没有什么。
线程实现
非常简单的构建线程,PCB的结构很简单,线程栈也很小,不能和Linux这种大型操作系统比较。只是为了更深的了解线程的原理。这里还没有提到进程结构体,但用到了task_struct的命名方式,是因为后续实现用户进程也是通过线程实现的,到时候只是在现有的结构上增加结构体成员变量。进程和线程的区别只是有无资源。
/*将在很多线程函数中作为参数类型*/
typedef void thread_func(void*);
/*进程或线程的状态*/
enum task_status
{
TASK_RUNNING,
TASK_READY,
TASK_BLOCKED,
TASK_WAITING,
TASK_HANGING,
TASK_DIED
};
上面是的一些声明,thread_func用来指定线程中运行的函数,用它来接收函数的地址,用来跳转到线程要执行的函数。task_status定义了线程的状态。
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是不断变化的,所以会被popadhulve
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;
void (*eip) (void);
uint32_t cs;
uint32_t eflags;
void* esp;
uint32_t ss;
};
下面定义了中断栈,因为系统是由中断驱动的。在要任务调度的时候发生中断,中断就需要保护上下文,以保证中断返回后能够继续执行中断前的指令。初始化时该结构在PCB的最顶端。这里的中断处理程序与Linux也有不同,这里更简单,也只是为了简单实现理解原理。
但我觉得实现的方法比Linux0.11好,其他版本不知道。
/******* 线程栈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时使用
void (*unused_retaddr);//参数unused_ret只为占位置充数为返回地址
thread_func* function; //由kernel_thread所调用的函数名
void* func_arg; //由kernel_thread所调用的函数所需的参数
};
线程栈是构建线程非常重要的结构体。eip是一个函数指针指向线程要运行的函数,在第一次之后后。该位置还记录发生任务切换时指令地址,保证在任务再次运行的时候能够继续运行。ebp,ebx,edi,esi是一些ABI要求的需要保存的寄存器(ABI内容这里不多说)。仅第一次上CPU使用的内容是线程要运行函数kernel_thead的参数,function是函数的名成,func_arg是函数运行时的参数。组合起来就是funciton(func_arg);该函数就是我们真正想要运行的函数。
这里细说一下下面的三个成员。汇编中调用另一个程序段,通过call指令,通过ret指令返回。因为使用C语言实现的内核,函数的参数由主调函数确定。这里要说一下call指令必须要通过ret指令返回,但是ret指令并不需要绑定call使用。ret指令的作用就是读取栈中的返回地址加载到eip,这样程序计数器的方向就发生了改变。在后面kernel_thread(function, func_arg)中kernel_thread是直接通过switch_to函数返回的(直接执行ret指令,不是通过call指令调用),kernel_thread函数主要是要执行funciton(func_arg)函数,结构体中eip这个函数指针让switch_to函数知道要去执行kernel_thread函数,这个函数里要去调用另外一个函数,这里就要提前为该函数——function(func_arg)构造好它需要的参数。因为要符合C语言函数调用的标准这里需要一个unused_ret作占位用。
可以理解为在平时是程序员为函数确定函数调用的参数,现在需要内核为函数确定函数调用的参数。 unused_ret是为了符合C语言函数调用的规则,function和func_arg是函数调用的参数。
现在再把视角换到kernel_thread执行function(func_arg),此时栈中的情况是这样的。
这样就只是普普通通的函数调用。看下面的kernel_thread函数,并参考上面的栈的情况。kernel_thread先call intr_enable,然后push [esp+8], call [esp+4]。这样过后function(func_arg)得以运行。
static void kernel_thread(thread_func* function, void* func_arg)
{
//执行function(func_arg)前要开中断,避免后面的时钟中断被屏蔽,而无法调度其他线程
intr_enable(); //call intr_enable
function(func_arg); //push [esp + 8]; call [esp + 4]
}
/*进程或线程的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; //此任务自上cpu运行后至今占用了多少cpu滴答数,从运行开始到运行结束所经历的总时钟数
struct list_elem general_tag; //的作用是用于线程在一般的队列中的节点,线程的标签
struct list_elem all_list_tag; //用于线程队列thread_all_list中的节点,用于线程被加入到全部线程队列时使用
uint32_t* pgdir; //进程自己页表的虚拟地址
struct virtual_addr userprog_vaddr; //用户进程的虚拟地址
uint32_t stack_magic; //栈的边界标记,用于检测栈的溢出
};
该结构体中声明了一些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) {
pthread->status = TASK_RUNNING; //由于把main函数也封装成一个线程,并且它一直是运行的,故将其直接设为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; //自定义魔数
}
函数说明:初始化task_struct结构体中的成员。此时PCB中的情况如下图。
初始化线程栈
void thread_create(struct task_struct* pthread, thread_func function, void* func_arg)
{
//先预留中断使用栈的空间,可见thread.h中定义的结构
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->eip = kernel_thread;
kthread_stack->function = function;
kthread_stack->func_arg = func_arg;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}
函数说明:初始化线程栈。任务调度是由中断驱动的,现在PCB的顶端一定是中断处理程序要保存的上下文,所以先给中断栈留出位置。然后留出线程栈的空间,因为栈内存中是高地址到低地址扩展的,结构体定义是从低地址到高地址定义。然后进行初始化,让self_kstack指向现在的栈顶;eip指向要kernel_thread,原因已经在上面说明过了;function和func_arg分别指点kernel_thread要调用的函数,然后初始化结构体中定义的寄存器的值,线程第一次运行时这些寄存器的值应该为0。此时的PCB如下图所示。intr_stack是中断栈,这里就不展开了,里面保存了所有通用寄存器。
创建线程
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;
}
PCB需要在内存总存储,这样才能根据PCB进行任务调度,所以先申请一页的内存用于保存线程的PCB,这里的PCB比较小申请一页就行了。然后调用上面介绍的函数,进行行初始化,经过初始化后,线程已经构建完成了。现在将PCB保存在就绪队列中和全部任务队列中,根据任务队列找到要调度的任务。就绪队列保存可以上CPU运行的线程,全部任务队列保存内核中所有的线程。只有就绪队列中的线程才可以直接上CPU运行。
时钟中断处理程序
这里任务调度是通过时钟中断实现的,所以要注册相应的时钟中断处理程序。
/*时钟的中断处理函数*/
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 {
cur_thread->ticks--;
}
}
时钟中断处理程序先获取当前正在运行的现场,确定线程的PCB是完整的没有被破坏。每次时钟中断是一个滴答,当前线程的elapsed_ticks用来记录总共占用CPU的时间。线程ticks记录一次上CPU运行的时间,这个时间不为0说明当前线程还可以继续使用CPU,只需要-1即可,当这个时间为0时,需要被换下处理器了,此时就调用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);
}
该调度函数主要是将当前正在运行线程的状态变为就绪态,然后加入到就绪队列中,然后从就绪队列中出队一个线程,进行线程调度。当前线程为cur,马上要上CPU运行的线程状态变为运行态,记录在next中,然后调用switch_to(cur, next)完成调度。如果当前线程因为某些原因不是运行态(比如线程阻塞),则不加入就绪队列参与调度。
线程替换switch_to
switch_to是一个汇编程序,该程序本身没有很复杂。switch_to是通过schedule()调用的,参数为cur,next。cur为当前要被换下CPU的线程,next为要换上CPU运行的线程。此时栈中的情况如下图。只关心switch_to有关的内容。
;=================================================
[bits 32]
;=================================================
section .text
global switch_to
switch_to:
;栈中此处是返回地址,返回地址为schedule()
push esi
push edi
push ebx
push ebp
mov eax,[esp + 20] ;得到栈中的参数cur,cur = [esp + 20]
mov [eax],esp ;保存栈顶指针esp tesk_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
task_struct的第一个成员为self_stask,指向的是线程的线程栈。经过刚开始的push esi,edi,ebx,ebp,此时栈中的情况如下图。
esp+20的位置存储的是线程cur,esp+24的位置存储的是线程next。现在将cur取出存入eax临时保存,eax现在的值为当前线程PCB的起始地址,PCB的起始地址为self_stack,将当前esp的值存入当前线程的self_stack保存起来。
然后从栈esp+24中取出线程next存入eax,现在eax的值为线程PCB的起始地址,PCB的起始地址保存着线程next的栈顶self_stack,将该栈顶存入esp,此时线程栈交换完成。
然后进行返回,如果线程正在运行的线程是第一次运行,将上面初始化的线程栈再次放到这以方便看。
这是交换栈后新线程(next)的栈,经过pop ebp,ebx,edi,esi后,esp指向了eip,然后switch_to执行最后一条指令ret,读取eip的值,此时eip的值为函数kernel_thread的地址,之前说过ret指令并不需要绑定call指令使用,ret指令将esp指向的内存保存进eip使程序计数器cs:eip的方向发生改变。现在eip的值为kernel_thread的地址,就是说现在要去执行kernel_thread。将上面的kernel_thread角度的栈放到下面方便看。
开始执行kernel_thread函数。这样next线程中我们要执行的function(func_arg)就得以执行了,实现多个线程运行。
/*由kernel_thread区执行function(func_arg)*/
static void kernel_thread(thread_func* function, void* func_arg)
{
//执行function(func_arg)前要开中断,避免后面的时钟中断被屏蔽,而无法调度其他线程
intr_enable(); //call intr_enable
function(func_arg); //push [esp + 8]; call [esp + 4]
}
如果当前线程不是第一次执行,那么上面ret之前栈中的eip就是调用switch_to的返回地址,这里是schedule()调用的switch_to,所以eip指向的返回地址在schedule中。
每当发生时钟中断时,就会进行上述的情况,往复进行实现了多线程调度。
参考书籍:《操作系统真相还原》-- 郑刚