Nachos Lab01 线程机制
第一部分
调研
调研Linux或Windows中进程控制块(PCB)的基本实现方式,理解与Nachos的异同。调研Linux或Windows中采用的进程/线程调度算法。
Linux版本2.6.11:Linux使用轻量级进程,进程间允许共享资源。实现多线程应用的简单方式就是把轻量级进程与每个线程关联起来,线程间就可以通过共享资源的方式来访问相同的应用数据结构集。在Linux中,使用task_struct结构来存储与一个进程相关的所有信息。其基本结构如下图。其中Linux的进程状态有:可运行状态(TASK_RUNNING),可中断的等待状态(TASK_INTERRUPTIBLE),不可中断的等待状态(TASK_UNINTERRUPTIBLE),暂停状态(TASK_STOPPED),跟踪状态(TASK_TRACED),僵死状态(EXIT_ZOMBIE),僵死撤销状态(EXIT_DEAD)。
在Nachos中也同样使用Thread类来定义线程所需的信息和方法。但是Nachos的实现是基于线程的,要求至少存在一个主线程。而对于主线程则可以使用fork方法来创建子线程来执行任务。
Linux的进程调度,将进程分为不同的类型进行调度。SCHED_FIFO先进先出的实时进程,只要没有可运行的更高优先级实时进程,就可以一直运行。SCHED_RR时间片轮转的实时进程,保证具有相同优先级的SCHED_RR实时进程可以公平地分配CPU时间。SCHED_NORMAL,普通的分时进程,普通进程会同时维护静态优先级(用于评估该进程与其他普通进程之间调度的程度)和动态优先级(用于调度程序选择新进程)。
Linux对于普通进程,调度器会维持两个不相交的可运行进程集合,活动进程和过期进程,用于保证获得较多时间片的高静态优先级进程不会影响静态优先级较低的进程执行。而实时进程则在执行过程中会尽可能多的运行,即禁止优先级低的进程执行。只有在发生诸如高优先级的实时进程抢占、IO阻塞、进程结束、主动放弃CPU、基于时间片轮转且用完了时间片等事件时,才会发生实时进程被另一个进程取代。
第二部分
由于写下此博文时,Nachos已经在做后续的实习了,改动较多,导致曾经的测试函数得不到相同的测试结果了。因此所有的测试结果截图均来自当时的实验报告。
Exercise1 源代码阅读
仔细阅读下列源代码,理解Nachos现有的线程机制。
code/threads/main.cc和code/threads/threadtest.cc
code/threads/thread.h和code/threads/thread.cc
code/threads/main.cc:定义了NachOS的入口,可以通过传入不同的参数,直接调用NachOS上不同部分的功能函数。可以用于调试和测试。
code/threads/threadtest.cc:该文件给出了一个简易的线程测试样例,两个线程0和1,轮流主动让出CPU。后续对Thread的修改,可以在此处设置编写相应的测试函数来进行验证。
code/threads/thread.h:主要定义了Thread所相关的线程管理函数,以及一些与线程上下文环境有关的变量。需要重点提及的是Thread类头两个变量(即栈顶指针和机器状态寄存器)不能修改顺序是因为NachOS在进行线程切换(调用SWITCH函数)时,会按照这个顺序依次找到线程入口,然后设置线程上下文寄存器。
code/threads/thread.cc:这里面比较重要的函数如下:
- Fork:Fork用于创建一个新的线程,而在这个创建过程中比较重要的函数就是StackAllocate。StackAllocate函数仅在Fork中被调用,它会根据宏定义的栈大小创建一个栈,然后在栈顶放入ThreadRoot函数(由ThreadRoot入口可以转而运行线程所需要运行的任务函数),并且设置一些机器状态寄存器。需要特别说明的就是,新线程是由ThreadRoot转而运行任务,而不是直接从任务函数开始。
- Yield和Sleep函数:二者在功能上十分地相近,都是主动让出CPU,调度下一个线程。其中最大的差别就是Sleep在就绪队列为空时,会调用中断中的Idle函数,然后一直等待新的线程进行调度;而Yield函数在就绪队列为空时,会直接返回。
Exercise2 扩展线程的数据结构
增加“用户ID、线程ID”两个数据成员,并在Nachos现有的线程管理机制中增加对这两个数据成员的维护机制。
思路
由于Nachos中并没有实现多用户相关的机制,所以需要人为地维护用户信息。考虑到便捷性,直接在Thread类中增加相关的机制。
实现
在threads/thread.h文件内的Thread类中,新增私有成员用户ID(uid)和线程ID(tid),并分别设置了获取这些成员信息的get函数,以及uid的set函数。
线程内的用户ID(uid)设置set函数是考虑到Nachos没有现成的多用户管理机制,增加一个全局的用户管理机制过于麻烦。为了简便,通过显式地调用setUid的方式来进程用户管理。而线程ID(tid)没有setTid也是因为后续实现了全局线程的管理机制。
1 | /* |
当然,uid和tid和值均需要初始化。uid在Thread的构造函数(位于threads/thread.cc)中初始化为任意值即可。而tid的初始值来源于全局线程管理机制的分配。
Exercise3 增加全局线程管理机制
在Nachos中增加对线程数量的限制,使得Nachos中最多能够同时存在128个线程;
仿照Linux中PS命令,增加一个功能TS(Threads Status),能够显示当前系统中所有线程的信息和状态。
思路
在Nachos中,有两个文件*threads/system.h(cc)*,它们负责管理Nachos中的一些全局变量以及一些初始化的工作,因此可以在此处添加相应全局线程管理机制。因为要求线程数的上限为128,所以可以设置一个长度为128的数组,用于记录tid的分配情况,而数组的大小也限制能够被分配出去的tid的数量。
要实现TS功能,光有tid的管理数组还不够,还需要一个能够根据tid获取相应线程信息的功能。这里可以设置一个与之前的数组等大的平行数组,用于记录每个tid所对应的线程指针。
总之上述的实现方式可以是:
- 设置两个数组,一个用于记录tid的分配情况,另一个用于记录tid所对应的线程指针
- 上述二者可以合并成一个组数,该数组存储线程指针,根据值是否为
NULL
来判断该tid是否已被分配。 - 用结构体来存储tid和线程指针
实现
这里采取上述方法二。
在threads/system.h文件中添加如下的全局变量,分别是全局线程数量的宏NOTHREAD
,指向线程的指针数组(该数组存储各个线程的地址,其地址对应的下标即为线程的tid),以及用于查看所有线程状态的函数ThreadsStatus。
1 | /* |
然后在threads/system.cc文件中将thread_point的数组元素初始化为0(也可以使用NULL替代),这个步骤是为了保证其存储的所有指针为0(0表示未分配),否则系统会误认为该地址已分配给某个进程而导致的误操作。
1 | /* |
相应的在threads/thread.cc文件下的Thread构造函数中添加了tid的分配方式,在线程创建的时候,从全局线程指针数组中获取一个tid,并在其中放入该线程的地址。与之相对应的,在析构函数中将tid所对应的指针设为0。
1 | /* |
最后获取所有线程状态的函数ThreadsStatus实现在system.cc文件中,该函数的主要功能就是遍历线程指针数组,并打印所有线程的uid,tid,name,以及status。因此,也在thread.h文件中,对Thread类新增了一个成员函数getStatus用于获取线程状态信息。
1 | /* |
最后测试函数如下:
1 | /* |
Exercise4 源代码阅读
仔细阅读下列源代码,理解Nachos现有的线程调度算法。
code/threads/scheduler.h和code/threads/scheduler.cc
code/threads/switch.s
code/machine/timer.h和code/machine/timer.cc
code/threads/scheduler.h和scheduler.cc:主要是声明和实现了关于调度器的数据结构及相关方法。ReadyToRun方法,用于将新的线程加入到就绪队列;FindNextToRun则是根据规则从就绪队列中取出一个线程指针;Run方法则是真正负责线程调度,将当前运行的线程换出CPU,将下一个线程换入CPU。
code/threads/switch.s:该文件即是SWITCH函数的真正实现。主要负责保存待换出线程的相关寄存器的值,然后将待换入线程的上下文信息放入寄存器当中。
code/machine/timer.h和timer.cc:主要提供了时间片所需的相关方法。其中最重要的函数就是TimerExpired。Timer类实现了每隔一定时间(时间片的长度从TimeOfNextInterrupt获取)向interrupt发送一个中断,该中断的处理函数中调用了TimerExpired。表明当一个时间片结束的时候,TimerExpired函数被调用从而导致执行TimerInterruptHandler函数(位于threads/system.cc)。而TimerInterruptHandler会间接引发线程的上下文切换,从而实现了时间片的轮转。
Exercise5 线程调度算法扩展
扩展线程调度算法,实现基于优先级的抢占式调度算法
思路
基于优先级的调度,首先要为每个线程设置一个优先级变量,并增设相应的维护函数。然后修改线程调度器Scheduler相应方法的实现,使得优先级最高的线程能够被最先调度。
然后是关于抢占的实现。仔细观察时钟类Timer的实现,可以发现该类会周期地向中断调度器发送时钟中断。初始化时执行一次下述代码,会让中断执行TimerExpired方法,然后TimerExpired方法又会执行下述代码,从而实现每隔一个时间片就会发生一次时钟中断。
1 | /* |
TimerHandler是为了处理类方法无法作为函数指针传入,所以增设的协助函数。该函数主要用途就是调用TimerExpired方法,即让中断调度器能够调用TimerExpired方法。
TimeOfNextInterrupt则是获取两次中断的时间间隔,即时间片。Nachos允许随机时间片。
TimerInt表示中断的类型是一个时钟中断
而时钟中断的处理函数TimerInterruptHandler位于threads/system.cc。该函数会调用interrupt->YieldOnReturn();
方法,该方法会设置中断interrupt的yieldOnReturn属性为TRUE
是为了避免直接调用Yield函数导致中断处理线程被换出CPU。
如果搜索yieldOnReturn被调用的位置可以发现,在machine/interrupt.cc的OneTick函数中会看到如下代码。可以得知在时钟中断发生时,会引发线程的切换。
1 | if (yieldOnReturn) { // if the timer device handler asked |
这里有两个需要关注的点就是:
OneTick在什么时候会被执行
A:查看Interrupt类的SetLevel函数(开中断函数),可以得知,关中断再开中断会执行OneTick函数。也就是说在中断恢复的时候执行。同时检索可以得知,在Machine::Run()中,执行完一次用户指令也会执行OneTick函数。
中断处理程序在什么时候会被执行
A:查看OneTick的注释和代码可以得知有一个CheckIfDue方法,在处理中断调度器。如果出现了一个中断,则调用它的中断处理程序
(*(toOccur->handler))(toOccur->arg);
。所以中断处理程序是在OneTick中被调用执行的。
这样时间中断的流程就很清晰了:由硬件模拟例程调用Interrupt::Schedule发送时钟中断;然后在执行完用户指令或者开中断时调用OneTick函数前进一个时间单位(用户态前进一个用户事件,系统态则前进一个系统时间);再然后由CheckIfDue检查中断是否要发生;如果要发生时钟中断,则引发时钟中断处理函数TimerInterruptHandler的执行,处理结束后引发线程的切换。
注:因为在屏蔽中断期间,不应该允许任何中断的发生或者线程的调度,这样才能模拟原子操作。在系统态下,同时一次OneTick函数的执行也为模拟的系统时间增加了10个单位的时间(该值的定义位于machine/stats.h)。
最后为了Nachos能够产生等长的时间片,修改threads/system.cc,增加else
的部分。因为原先的Nachos仅在随机的时间片上启用时钟。
1 | /* |
实现
修改threads/thread.h文件中的Thread类,为其添加代表优先级的私有成员priority以及相关的管理函数。
1 | /* |
然后修改threads/scheduler.cc中的ReadyToRun函数,ReadyToRun仅负责将线程插入到就绪队列而不考虑调度线程到CPU上。因为是基于优先级的调度,所以原本的将新线程加入就绪队列尾部的做法不能满足需求。将其修改为如下方式,使用有序插入,且排序的依据key是线程的优先级。由于SortedInsert函数是增序排列,key值最小的元素会排在列表的首部,同时在FindNextToRun方法中remove返回的是队列的首部元素,因此导致了priority值越小越会被优先调度,即priority越小优先级越高。
1 | /* |
联想到时间片会引发线程上下文切换。因此修改threads/thread.cc文件中Yield方法的实现。在线程让出CPU的时候,检查就绪队列顶部线程的优先级,如果该线程的优先级低于当前线程的优先级,则不让出CPU继续运行,从而实现新进程可以在时钟中断时抢占。
1 | /* |
为简化测试,让每个线程在执行完一次printf
之后就尝试主动放弃CPU,从而模拟周期性的新线程抢占CPU。测试函数如下:
1 | /* |
Challenge 线程调度算法扩展
这里所实现的是非抢占的多级队列反馈算法。
思路
- 在Thread类中增加属性用于记录时间片的使用情况
- 修改调度器Scheduler类,将原来的单一队列换成三个就绪队列。队列之间根据时间片的不同,安排不同的优先级。优先级越高的队列,所能使用的时间片越短
- 修改线程调度的方式。根据线程已经使用的时间片数量,决定线程即将放入的就绪队列。以及按照队列彼此之间的优先级,决定哪个线程会被优先调度。仅当高优先级的就绪队列为空时,才会调度较低优先级队列中的线程。
- 修改时钟中断处理函数,让线程在耗尽其所在队列允许的时间片之前,不会因为时钟中断而被换出CPU。(由Exercise5的思考部分可知,时钟中断处理函数仅在
interrupt->YieldOnReturn();
方法被执行时才会引发线程上下文切换)
实现
首先在threads/thread.h文件中,给Thread类添加一个私有成员usedTimeSlices,用于记录该线程已使用的时间片,并为其增设两个公共成员函数getUsedTimeSlices和setUsedTimeSlices,用于获取和设置线程已使用的时间片。
1 | /* |
然后修改threads/scheduler.h文件,为Scheduler类增加如下三个不同的队列。这三个队列相互之间的优先级为$QTimeSlice_2 > QTimeSlice_4 > QTimeSlice_8$。
1 | /* |
因此threads/scheduler.cc文件中的构造函数和析构函数中,添加为这三个队列分配和回收空间的代码。同时修改ReadyToRun函数,根据线程已使用的时间片长度,决定线程加入到哪个就绪队列。
1 | /* |
然后修改FindNextToRun函数,根据队列间的优先级选择下一个调度的线程。如果高优先级的队列存在就绪的线程,则会被优先调度。如果较高优先级的队列全部为空,则会调度最低优先级的队列。
1 | /* |
线程调度相关的改动完成,然后是修改计时器相关的代码。在machine/timer.cc文件中修改TimerExpired函数。该函数会在一个时间片(固定时间片TimerTicks
的大小定义在machine/stat.h文件中)结束的时候被调用,因此在此处对线程所使用的时间片+1。
1 | /* |
最后就是修改时钟中断的处理函数TimerInterruptHandler(位于threads/system.cc文件中)。该函数会调用interrupt的YieldOnReturn方法,而该方法会作用在interrupt的OneTick函数中。Onetick函数(位于machine/interrupt.cc)会调用当前线程的Yield函数,因此可以将当前线程赶下CPU,调度下一个线程,从而实现了时间片轮转的效果。因为在调度队列中所定义的时间片长度分别是2、4、8,所以对其他时间片的时刻不进行上下文切换。
1 | /* |
至此非抢占的多级队列反馈算法已实现完成。
测试函数如下:由于使用较大的任务来测试不方便查看效果,所以利用关开中断来强制使时间推进
1 | /* |
为了方便看测试结果将machine/stats.h中的固定时间片大小TimerTicks
值改为20。部分测试结果如下:可以看到每个线程用了2个时间片就会放弃CPU,进入4时间片的队列。
遇到的困难以及解决方法
- 困难1 Thread类最前面两个变量为什么一定要放在最开头且顺序固定
经过与同学的讨论得出结论:对于c++类,其对象内的数据成员在内存上是按照定义顺序来顺序存储的。因此栈顶指针就位于了线程对象地址偏移量为0的位置,machineState的起始地址也就位于了偏移量为4的位置。这样可以从threads/switch.s文件中的汇编代码看到,将线程地址放入eax寄存器中,r然后从eax寄存器的不同偏移来存储和恢复线程的上下文,而这些偏移就正好对应了线程的栈顶指针和machineState数组中的数据。另外,将偏移量为0的位置,即线程栈顶指针,赋值给了栈顶指针寄存器esp,这样就实现了硬件对线程任务的处理。
参考资料
[1] CSDN. nachos 3.4 实现抢占式多级队列反馈算法[EB\OL]. https://blog.csdn.net/eaglex/article/details/6336763?locationNum=3&fps=1
[2] 博韦 (Bovet, Daniel P.(Daniel Pierre)) et al. 深入理解linux内核[M]. 北京: 中国电力出版社, 2007: 261-265
Nachos Lab01 线程机制