目录

线程切换(1):调度机制、上下文切换

线程状态

  1. 在上一篇笔记中,笔者提到了线程的一些状态,现在正式列举出线程有哪些状态:
线程状态定义
运行线程拥有CPU时间碎片,正在执行代码
就绪线程未运行,等待被调度进入执行状态
等待线程主动调用Slepp、WaitForSingleObject等函数,主动交出CPU
换出由于线程长时间不活跃,系统将线程对象、相关堆栈换出到磁盘,节省内存资源
换入当不活跃的线程被激活时,系统会将资源换入到内存中

33个调度链表

  1. 在早期的Win内核中,系统设置了33个链表来管理线程,同一条线程处在不同状态下时,会保存在不同的链表中,这些链表信息如下所示:
调用链表定义
1个等待链表(WaitList)等待状态的线程,系统会将其移动到其中
32个调度链表(SwapList)这32个调度链表,其实也是区分出了32个线程执行的优先级
  1. 双机调试情况下,等待链表其实是KiWaitListHead一个全局变量,如下所示:

/threadswitchscheduling/Image/1-%E7%AD%89%E5%BE%85%E9%93%BE%E8%A1%A8.png

  1. 双机调试情况下,查看32个就绪链表如下所示:

/threadswitchscheduling/Image/2-%E5%B0%B1%E7%BB%AA%E9%93%BE%E8%A1%A8.png

线程优先级

  1. 在发生线程切换时,系统会调用KiFindReadyThread来查找下一个就绪的线程对象

  2. 前文我们已经知道了存在32个就绪链表,那么0-31这些链表中,31号链表的优先级最高,所以KiFindReadyThread会从31号链表开始查找,直到取出了有效的线程对象

  3. 但是这里存在一个效率问题,32个链表,其中大部分链表可能都是空的,所以内核中设置了一个位图,这是一个4字节32位的数据,按位来指示某个线程中是否有线程对象,这样在查找时先读取一下这个Bitmap就可以高效判断了

  4. 如下图所示,30、28号链表中就有就绪的线程对象

/threadswitchscheduling/Image/3-%E7%BA%BF%E7%A8%8B%E5%B0%B1%E7%BB%AA%E4%BD%8D%E5%9B%BE.png

查找就绪的线程对象

  1. 相关反汇编如下图所示,先尝试KiFindReadyThread找到一个就绪的线程,如果返回线程对象不为NULL,那么就将其传递给ecx寄存器,再调用KiSwapContext切换线程:

/threadswitchscheduling/Image/4-%E6%9F%A5%E6%89%BE%E5%B0%B1%E7%BB%AA%E7%BA%BF%E7%A8%8B.png

KiSwapContext

  1. 首先保存非易失寄存器环境
  2. 将原线程对象保存到edi寄存器
  3. 将参数1,也就是ecx中的新线程对象保存到esi中
  4. 设置KPCR.CurThread修改为新线程对象
  5. 此时esi = 下一个线程对象、edi = 当前线程对象,调用内部函数SwapContext

/threadswitchscheduling/Image/5-SwapContext.png

SwapContext

  1. 关于该函数内部做了什么,初见时我们只要知道这个线程切换的工作流程如下所示:
    1. 切换线程堆栈
    2. 判断新、老线程是否同属一个进程,如果不是就切Cr3
    3. 保存原线程的寄存器环境,这下下次切回去运行时,才能"无感知"地接续执行代码

/threadswitchscheduling/Image/6-SwapContext.png

主动线程切换

  1. 当一个线程主动调用Sleep、WaitForSingleObject时会交出触发线程切换、主动交出CPU拥有权
  2. 我们查看一下KeWaitForSingleObject,会发现有着大量的引用,如图所示:

/threadswitchscheduling/Image/7-%E4%B8%BB%E5%8A%A8%E5%88%87%E6%8D%A2.png

被动线程切换(时钟中断)

  1. 当发生时钟中断(Int 0x30)时,在中断例程内,会减少当前线程的时间碎片,当减到0时,当前线程时间碎片耗尽,失去本次CPU执行权
  2. 使用ARK工具查看0x30号中断信息,如图所示:

/threadswitchscheduling/Image/8-%E6%97%B6%E9%92%9F%E4%B8%AD%E6%96%AD.png

  1. 使用IDA分析该函数流程,会发现内部最终调用的也是SwapContext实现切换线程:

/threadswitchscheduling/Image/9-%E6%97%B6%E9%92%9F%E4%B8%AD%E6%96%AD.png

  1. 每次时钟中断触发时,都会调用KeUpdateRunTime函数(刷新线程时间片信息),该函数每次执行,就会将当前线程的时间片减3,如果减到0会将KPCR.PrcbData.QuantumEnd设置一个非0值表示时间片到期

/threadswitchscheduling/Image/10-%E5%88%B7%E6%96%B0%E6%97%B6%E9%97%B4%E7%A2%8E%E7%89%87.png

  1. 系统时钟中断完成后,会去调用KiDispatchInterrupt函数,该函数主要判断判断KPCR.PrcbData.QuantumEnd是否非0,该值非0则会将该值重新置0,并调用_KiQuantumEnd函数(在该函数里面重新将时间片数值设置为6),之后调用KiFindReadyThread查找下一个要切换的线程

/threadswitchscheduling/Image/11-%E6%97%B6%E9%97%B4%E7%89%87%E8%80%97%E5%B0%BD.png

线程切换小结

  1. 到了这里我们可以发现,不管是主动调用Slepp函数交出执行权、还是时钟中断,最终都会走到SwapContext实现线程切换
  2. 所以我们可以得到一个结论:线程切换是系统主动发生的
  3. 所以我们可以设想一个极端场景:某线程屏蔽了时钟中断(cli)、并且不调用任何API、不发生异常,那么该线程将会永久占用当前CPU

空闲线程

  1. 我们设想一个极限的情况,32个就绪链表中都没有线程,那么线程查找就会失败
  2. 但是CPU只要上电了就要一直跑代码的,所以系统设置了一个空闲线程(应该是原地空转),让没事干的CPU去执行这个线程
  3. 这个空闲线程对象保存在KPCR.PrcbData.IdleThread中

线程附加

  1. 有了前置的基础知识,我们知道了线程是代码执行的实体,而进程只是提供了内存换进程(即Cr3)
  2. 一个线性对象中有2处地方保存了进程对象:
    1. ETHREAD.ThreadProcess:该线程是由哪个进程对象创建的,有人喜欢管他叫亲生父亲
    2. ETHREAD.ApcState.Process:当前线程运行时实际使用的进程上下文,有人喜欢叫他养父
  3. 在内核中,不管是代码执行、线程切换时,真正使用的对象是ETHREAD.ApcState.Process
进程与线程 Windows内核