Lec 11: Thread switching
- Ref: https://github.com/huihongxiao/MIT6.S081/tree/master/lec11-thread-switching-robert
- Preparation: xv6 book Chapter 7 through Section 7.4
概述
- 计算机需要多任务(多线程)的原因:
- 需要计算机同时完成多项任务, 支持分时复用
- 多任务可以简化程序结构
- 使得多核计算机获得并行加速. 使用多线程可以让一个程序运行在多个 CPU 上
- 线程: 单一串行执行 one serial execution
- 线程需要保存的状态:
- 程序计数器(PC): 当前线程执行指令的位置
- 保存变量的寄存器(REGS)
- 线程的栈: 记录函数调用的记录, 反应当前线程的执行点
- 多线程交错运行的策略(Interleave):
- 多核(CPU) Multi-core: 每个 CPU 运行一个线程
- 一个 CPU 进行多个线程的切换 Switch
- XV6 会在所有可用核心上运行线程, 并且每个核心会在多个线程之间进行切换
- 不同线程系统的一个主要区别是线程之间是否共享内存.
- XV6 内核共享内存, 并且支持内核线程的概念. 每个用户进程都有一个内核线程来执行系统调用. XV6 每个用户进程有独立的内存空间, 且包含一个控制用户进程代码执行的线程.
Linux 支持一个用户进程中包含多个线程, 多个线程间共享进程空间.
线程调度
线程的挑战
- 线程的切换 – 线程调度, XV6 为每个 CPU 核都创建了一个线程调度器(Scheduler)
- 需要保存的线程信息
- 如何处理运算密集型线程(compute bound thread).
处理运算密集型线程
利用定时器中断(Timer Interrupt). 内核的定时器中断程序会让 CPU 出让(yield)给线程调度器, 切换到其他线程. 该调度方式为抢占调度(preemptive scheduling).
与抢占调度相反的是非抢占调度(voluntary scheduling).
线程状态
- RUNNING: 正在某个 CPU 上运行的线程
- RUNNABLE: 没有在 CPU 上运行但一旦有空闲 CPU 就可以运行的线程
- SLEEPING: 在等待 I/O 事件的线程, 在事件完成后才能运行
线程切换时需要将 RUNNING 线程的程序计数器和寄存器保存到内存中, 将要运行的 RUNNABLE 线程的程序计数器和寄存器从内存中拷贝到对应的 CPU 中.
XV6 线程切换
- 注: 此处是 XV6 线程切换, 由于 XV6 不支持单用户进程中的多用户线程(但一个用户进程包含 1 个用户线程和 1 个内核线程), 因此此处的线程切换更近似于用户进程的切换.
XV6 从一个进程 A 切换到另一个进程 B 时, 不会看到用户到用户的上下文切换, 而是从一个进程的内核线程切换到另一个进程的内核线程, 然后再到另一个进程的上下文. 即保存用户进程状态, 然后进行内核线程的切换, 然后恢复新进程的用户进程状态, 最后返回到新用户进程继续执行.
简要过程
- XV6 将当前 RUNNING 线程 A 的 PC 和内核寄存器(和内核线程有关的寄存器)进行保存, 被成为上下文(Context).
- 待切换运行的线程 B 此时的状态为 RUNNABLE, 其用户空间状态已经保存到了 trapframe 中, 同时其内核线程的内核寄存器也已经保存到了该线程的上下文中.
- 内核线程从 A 切换到 B, 并恢复 B 的上下文. B 继续在内核线程上运行, 完成(定时器)中断处理程序
- 恢复 B 程序 trapframe 中的用户进程状态, 回到 B 程序的用户空间
- 恢复 B 程序的执行.
具体过程
- 定时器中断会强迫 CPU 从进程 A 的用户空间切换到内核, trampoline 代码将用户寄存器保存在进程 A 对应的 trapframe 中.
- 在内核运行
usertrap()
执行相应的中断处理程序. 此时 CPU 正在进程 A 的内核线程和内核展示执行代码. - 进程 A 的内核线程决定出让 CPU, 会进行一系列工作, 最后调用
swtch()
函数. swtch()
函数会将进程 A 对应的内核线程的寄存器保持到上下文 context 中. (用户寄存器保存在 trapframe 中, 内核线程的寄存器保存在 context 中)swtch()
函数并非将内核线程从进程 A 切换到进程 B, 而是将当前运行的进程 A 的内核线程切换到该 CPU 对应的调度器线程.swtch()
恢复之前调度器线程锁保存的寄存器和栈指针(SP), 在调度器线程的 context 下执行scheduler()
函数.- 在
scheduler()
函数中会做一些清理工作, 如将进程 A 的状态设置为 RUNNABLE, 之后通过进程表单找到下一个 RUNNABLE 线程 B. 然后会再次调用swtch()
函数. swtch()
函数会保存调度器线程的寄存器到其 context 中. 然后找到进程 B 的 context, 恢复其中的寄存器.- 因为进程 B 在进入 RUNNABLE 状态前会像进程 A 一样调用
swtch()
函数, 因此此时之前调用的swtch()
函数会被恢复, 并返回到进程 B 的系统调用或中断处理程序中(因为进程 B 之前调用swtch()
函数必然在系统调用或中断处理程序中). - 进程 B 会在其进入到内核空间时保存用户寄存器到 trapframe 中, 因此在内核程序执行完成后, 会从其 trapframe 中恢复用户寄存器.
- 最后用户进程 B 恢复运行.
相关说明
- 用户进程的内核线程, 它的上下文 context 保存在用户进程
struct proc
结构体中, 即p->context
字段. - 每个 CPU 都有完整独立的调度器线程. 调度器线程是一种内核线程, 有自己的 context 以及
scheduler()
函数. 但调度器线程没有对应的进程, 其上下文 context 保存在struct cpu
结构体中.
每个 CPU 核心都对应一个struct cpu
结构体, 其中包括该 CPU 调度器线程的 context 以及内核栈. - trapframe 用于保存进入和离开内核需要的数据, 如用户寄存器, context 用于保存内核线程和调度器线程切换时需要保存和回复的数据, 如内核寄存器. 将二者进行区分而非统一将数据保存到一起主要处于简化和清晰代码的目的.
- 每个 CPU 核在一个时刻只会运行一个线程, 一个线程在一个时刻只能运行在一个 CPU 上. (exit 系统调用也会调用
yield()
函数来出让 CPU) - 对于刚恢复的线程, 首先要做的就是从之前的
swtch()
函数中返回.
出让 CPU 的场景
对于 XV6 出让 CPU 有内核决定, 包括两个场景:
- 定时器中断触发
- 进程调用了系统调用并等待 I/O, 等待 I/O 的机制会触发出让 CPU.
锁的作用
在线程切换时锁 p->lock
的作用:
- 进程状态的改变(如 RUNNING 变为 RUNNABLE), 进程的寄存器保持在 context 中, 以及停止使用当前进程栈, 以上三个步骤需要满足原子性, 以阻止中途其他核的调度器线程发现该进程.
- 同时进程的启动过程(进程由 RUNNABLE 变为 RUNNING)需要满足原子性, 同时需要关闭中断, 避免定时器中断看到处于切换过程中的进程.