Lecture 11 - Thread Switching

在这节课中,我们将深入探讨线程的概念,以及XV6操作系统如何实现线程切换。线程是现代操作系统中的一个关键组件,它允许计算机在多个任务之间切换,以实现多任务处理和更高的效率。

为什么计算机需要运行多线程?

计算机需要运行多线程有几个主要原因:

  1. 多任务处理
    • 用户通常希望计算机能同时运行多个任务。例如,在MIT的Athena系统中,多个用户可以同时登录并运行各自的进程。即使在单用户系统(如你的iPhone)上,也有多个进程同时运行,这使得多线程成为必要。
  2. 简化编程
    • 多线程可以简化程序的结构。在某些情况下,线程可以帮助程序员将代码组织得更加简单和优雅,减少程序的复杂度。例如,在第一个lab中的素数计算部分,通过多个进程,可以更简单地组织代码。
  3. 并行运算
    • 在多核CPU上,多线程允许程序利用多个CPU核同时运行不同的任务,从而提高处理速度。通过将程序拆分成多个部分并在不同的CPU核上并行运行,可以大幅提升性能。例如,在拥有4个CPU核的系统上,通过4个线程运行程序的不同部分,理论上可以获得4倍的运行速度提升。

线程的定义和状态

线程可以看作是程序执行的最小单位。具体来说,线程是一个单一的顺序执行代码的单元。一个线程只占用一个CPU,并且顺序执行指令。在我们的讨论中,线程就是一个串行的执行流。

线程的状态

线程在执行过程中,操作系统需要管理其状态,以便在需要时暂停或恢复线程。线程的状态包括以下三个关键部分:

  1. 程序计数器(Program Counter, PC)
    • PC指向当前线程正在执行的指令。它保存了线程的执行位置,以便在恢复线程时知道从哪里继续执行。
  2. 寄存器(Registers)
    • 寄存器保存了当前线程的工作变量和临时数据。在多线程环境下,操作系统需要保存和恢复这些寄存器的内容,以确保线程切换时不丢失数据。
  3. 栈(Stack)
    • 每个线程都有自己的栈,用于保存函数调用的记录和局部变量。栈反映了当前线程的执行路径,并确保函数调用能够正确返回。

操作系统中的线程管理

操作系统的线程管理系统负责调度和管理多个线程的运行。随着操作系统启动大量的线程,线程管理系统需要决定如何分配CPU时间给不同的线程,确保所有线程都有机会运行。特别是在多核系统中,操作系统还需要有效地利用多个CPU核来并行运行线程,以最大化系统性能。

总结

  • 线程的角色:线程是执行程序代码的基本单元,通过线程,我们可以在计算机上实现多任务处理、简化代码结构,并利用多核CPU实现并行运算。
  • 线程的状态管理:线程的状态包括程序计数器、寄存器和栈,操作系统通过保存和恢复这些状态来实现线程的暂停和恢复。
  • 操作系统的线程管理:操作系统负责管理大量线程的调度和运行,确保多线程程序能够在系统中高效运行。

在接下来的课程中,我们将进一步探讨XV6如何在多个线程之间进行切换,深入了解线程调度的具体实现细节。

多线程并行运行的策略

接下来讨论多线程的并行运行策略,特别是如何在单个CPU上实现多个线程的切换。多线程技术对于现代操作系统至关重要,因为它不仅能够在多核处理器上有效利用硬件资源,还能在单核处理器上通过线程切换来模拟并发执行,从而提升系统的响应能力和处理效率。

1. 多核处理器上的并行运行

  • 多核处理器:在多核处理器上,多个CPU核可以并行运行多个线程。例如,如果你有4个CPU核,你可以同时运行4个线程,每个线程占用一个CPU核。
  • 局限性:尽管多个CPU核可以并行运行多个线程,但在实际系统中,线程的数量往往远远超过可用的CPU核数量。例如,可能有数千个线程在一个系统中运行,而只有4个CPU核。因此,单靠多核处理器并不能完全解决多线程的并行运行问题。

2. 单个CPU上的线程切换

  • 线程切换:当线程的数量超过CPU核的数量时,操作系统需要在单个CPU上实现线程的快速切换。通过保存和恢复线程的状态(如程序计数器、寄存器和栈),操作系统能够在多个线程之间快速切换,使得每个线程都能在一段时间内得到执行。
  • 轮换执行:例如,在一个只有1个CPU的系统中,如果有1000个线程,XV6操作系统会通过线程切换的机制让每个线程依次得到执行。这种机制确保了即使只有一个CPU,系统也能够表现出多线程并行运行的效果。

组合策略

现代操作系统通常会结合这两种策略:

  • 多核并行:操作系统会在所有可用的CPU核上并行运行线程。
  • 线程切换:每个CPU核还会在多个线程之间快速切换,从而实现更细粒度的多任务处理。

线程的内存共享

线程之间的内存共享是多线程系统中的一个关键问题,不同的操作系统和线程模型对内存共享的处理方式不同。

  1. 共享内存的线程
    • 线程可以共享同一个地址空间,这意味着一个线程对内存的修改可以被其他线程立即看到。例如,在共享内存模型下,如果一个线程修改了一个变量,其他线程也会立即看到这个变量的变化。
    • 这种情况下,需要使用锁来保护共享资源,以防止竞态条件的发生。
  2. XV6的内存模型
    • 内核线程:XV6的内核线程共享内核的内存空间,每个用户进程都有一个对应的内核线程,用来处理系统调用。这些内核线程共享内存,因此需要使用锁来保证内存的一致性。
    • 用户线程:在XV6中,每个用户进程都有一个独立的地址空间,并且只有一个线程。因此,XV6中的用户线程之间没有内存共享的问题。
  3. 其他系统中的多线程
    • Linux:与XV6不同,Linux允许一个用户进程中包含多个线程,这些线程共享进程的地址空间。这种模型更为复杂,需要更复杂的线程管理和同步机制。
    • 在Linux中,多个线程可以在多个CPU核上并行执行,且它们共享同一个地址空间,这样的设计使得应用程序可以更好地利用多核处理器的能力。

线程与其他多任务技术的对比

尽管线程是实现多任务处理的一个重要工具,但它并不是唯一的选择。还有其他技术也可以支持在一台计算机上运行多个任务:

  1. 事件驱动编程(Event-Driven Programming)
    • 在事件驱动编程中,程序通过事件循环来处理不同的任务,而不是通过多个线程。事件驱动编程通常用于高效处理I/O密集型任务,特别是在单线程环境中。
  2. 状态机(State Machine)
    • 状态机通过状态转移来处理不同的任务,而不是依赖多个线程。这种方法在处理复杂的逻辑流程时特别有效,常用于嵌入式系统和实时系统。

虽然这些技术在某些场合可能比线程更为高效,但线程通常是对程序员最友好的多任务实现方法,支持范围广,易于理解和实现。

内核中实现线程系统

在内核中实现线程系统面临着几个关键挑战:线程切换的实现、状态的保存与恢复,以及处理运算密集型线程的调度。这些挑战直接影响到操作系统的性能、响应速度以及对多线程应用的支持。接下来,我们详细探讨这些挑战及其解决方案。

挑战一:线程间的切换(Scheduling)

线程调度(Scheduling)

  • 线程调度是指操作系统决定哪个线程在某个时刻运行的过程。在多核处理器环境中,操作系统会为每个CPU核创建一个线程调度器。调度器负责管理和调度线程,确保每个线程都有机会得到CPU时间片,从而实现公平和高效的资源分配。

XV6的调度机制

  • XV6操作系统为每个CPU核分配了一个调度器。调度器根据预定的策略(如时间片轮转调度)来决定哪个线程应该运行,并在线程之间进行切换。

挑战二:保存与恢复线程状态

状态保存与恢复

  • 当操作系统从一个线程切换到另一个线程时,它必须保存当前线程的状态,并在切换回该线程时恢复这些状态。线程的状态主要包括程序计数器(PC)、CPU寄存器和栈指针等。

状态保存的关键

  • 操作系统必须决定哪些线程信息需要保存,以及如何保存这些信息。通常,状态信息会被保存在内核的专用数据结构中,以便在需要时能够快速恢复线程的执行。

上下文切换

  • 上下文切换是指从一个线程切换到另一个线程的过程。这个过程包括保存当前线程的状态、恢复目标线程的状态,并更新相关的CPU寄存器。上下文切换虽然开销较大,但对于支持多线程和多任务处理是必要的。

挑战三:处理运算密集型线程(Compute-bound Threads)

运算密集型线程的问题

  • 运算密集型线程是指那些需要大量CPU时间进行计算的线程,例如计算π的前一百万位。这类线程往往会长时间占用CPU,如果不加以控制,它们可能会导致其他线程得不到执行的机会。

自愿式调度的局限

  • 自愿式调度(voluntary scheduling)依赖于线程自己放弃CPU时间片,让出CPU给其他线程。然而,对于运算密集型线程,通常不会自愿让出CPU。这会导致系统中其他线程无法得到执行的机会,严重影响系统的响应速度和用户体验。

抢占式调度(Pre-emptive Scheduling)

  • 为了解决运算密集型线程的问题,操作系统采用了抢占式调度。抢占式调度通过定时器中断来强制收回CPU的控制权,即使线程没有主动放弃CPU。
  • 定时器中断:每个CPU核上都有一个定时器,它会定时产生中断信号。当定时器中断发生时,CPU会立即切换到内核模式,触发内核的中断处理程序。内核可以利用这个中断来强制中断当前运行的线程,将CPU时间片交给调度器,让调度器决定下一个要运行的线程。
  • 实现流程
    1. 定时器中断发生,CPU切换到内核模式。
    2. 内核的中断处理程序保存当前线程的状态。
    3. 中断处理程序自愿让出(yield)CPU,并将控制权交给调度器。
    4. 调度器选择下一个线程并恢复其状态,线程切换完成。

抢占式调度 vs 自愿式调度

  • 抢占式调度:系统强制回收CPU时间片,不依赖线程自愿放弃CPU。这种方式更加公平,能够确保系统中的所有线程都有机会得到执行,避免某些线程长时间占用CPU资源。
  • 自愿式调度:依赖线程自己放弃CPU时间片,通常适用于合作性多任务处理的系统。在这种系统中,线程会在合适的时机主动放弃CPU以避免长时间占用资源。

这些机制确保了XV6能够在多任务环境下高效地管理线程,支持并发操作,并保证系统的公平性和响应性。在后续的课程中,我们将深入探讨XV6中具体的调度和线程切换实现细节。

线程调度的实现方式

在XV6和其他操作系统中,线程调度是一项复杂而关键的任务。操作系统必须在多个线程之间分配CPU时间,以确保系统的高效运行。在这个过程中,定时器中断和线程状态管理起着至关重要的作用。

1. 抢占式调度(Pre-emptive Scheduling)

  • 定时器中断:在XV6中,定时器中断是强制执行线程调度的关键机制。定时器中断会周期性地打断正在运行的用户进程,将控制权从用户进程转移到内核。这是抢占式调度的核心。
  • 转换为可运行状态:当定时器中断发生时,正在运行的线程会被迫停止执行,并且内核会将其状态从RUNNING转换为RUNNABLE。这样,该线程不再占用CPU,但它已经准备好在下一个时间片中继续运行。

2. 自愿式调度(Voluntary Scheduling)

  • 内核代表用户进程:当内核获得CPU控制权后,它会根据调度算法决定下一个要运行的线程。这时,内核中的线程调度器会自愿地将CPU时间片分配给其他等待运行的线程。这一过程体现了自愿式调度的理念,即线程在合适的时机自愿放弃CPU,允许其他线程获得执行机会。

线程状态管理

在执行线程调度时,操作系统需要管理多个线程的状态。每个线程的状态决定了它是否可以运行,以及在何时能够运行。

主要的线程状态

  1. RUNNING(正在运行)
    • 处于RUNNING状态的线程当前正在某个CPU上执行。此时,线程的程序计数器(PC)和寄存器内容保存在CPU的硬件中。
  2. RUNNABLE(可运行)
    • 处于RUNNABLE状态的线程尚未分配到CPU,但一旦有空闲的CPU,就可以立即运行。此时,线程的程序计数器和寄存器内容已经从CPU中保存到内存中,等待下一次调度时重新加载。
  3. SLEEPING(等待中)
    • 处于SLEEPING状态的线程正在等待某个事件(例如I/O操作的完成)。它不会被调度器选择运行,直到等待的事件发生。这部分将在后续课程中详细介绍。

线程状态的转换

  • RUNNINGRUNNABLE的转换
    • 当定时器中断发生时,操作系统会将当前RUNNING的线程暂停,将其状态转换为RUNNABLE。此时,操作系统必须保存该线程的当前状态(包括程序计数器和寄存器内容)到内存中的某个位置。这些信息最初位于CPU的寄存器中,现在被保存到线程控制块(TCB)或类似的数据结构中。
  • RUNNABLERUNNING的转换
    • 当调度器决定重新运行一个RUNNABLE线程时,它需要将之前保存的线程状态重新加载到CPU中。具体而言,操作系统会将保存的程序计数器和寄存器内容从内存复制回CPU的寄存器中,然后恢复线程的执行。这一过程称为“上下文切换”。

上下文切换的细节

上下文切换是线程调度中的核心操作。它涉及以下几个关键步骤:

  1. 保存当前线程状态
    • 当线程从RUNNING状态转换为RUNNABLE状态时,操作系统需要保存当前线程的状态。具体包括:
      • 程序计数器(PC):指示当前正在执行的指令地址。
      • 寄存器内容:包括通用寄存器、栈指针(SP)、基址指针(BP)等,用于保存线程的运行时数据。
  2. 切换到新线程
    • 调度器选择一个RUNNABLE线程运行后,需要将该线程的状态从内存恢复到CPU中。具体操作包括:
      • 恢复程序计数器:将线程的PC值加载到CPU的PC寄存器中。
      • 恢复寄存器内容:将寄存器内容从内存恢复到CPU的寄存器中。
  3. 恢复执行
    • 恢复所有必要的状态后,CPU可以继续执行新的线程,从保存的PC值处继续运行该线程的代码。

线程切换的详细流程

用户进程与内核线程的关系

  1. 用户进程与用户线程
    • 用户进程(例如C Compiler、LS、Shell等)在运行时,每个进程实际上对应一个用户线程。这个线程在RISC-V处理器中有自己的程序计数器(PC)和寄存器集。
    • 每个用户进程都有自己的用户栈(User Stack),用于保存函数调用、局部变量等信息。
  2. 进入内核空间
    • 当用户线程执行系统调用或者发生中断时,线程会从用户空间切换到内核空间。此时,用户线程的状态(包括程序计数器、寄存器等)会被保存到一个叫做trapframe的结构中。
    • 接下来,处理器会切换到内核栈上执行代码,这些代码通常包括系统调用处理程序、或中断处理程序。用户线程对应的内核线程开始运行。

线程切换过程

1. 触发切换

  • 触发条件:线程切换的触发条件通常包括系统调用的完成、中断(如定时器中断)、进程阻塞、进程终止或优先级调度等。
    • 系统调用完成:当用户进程发起系统调用并完成时,操作系统可能需要检查是否有更高优先级的任务需要执行,从而决定是否切换到另一个进程。
    • 定时器中断:定时器中断是实现预抢占式调度的关键机制。当中断发生时,操作系统检查当前进程是否已超出其时间片,如果是,则准备调度其他进程。

2. 保存当前线程的内核状态

  • 保存内容:当XV6决定切换到另一个进程时,当前内核线程的状态必须被保存。这些状态信息包括:
    • 寄存器状态:所有的通用寄存器(如eaxebx等)需要被保存,这些寄存器保存了当前线程的计算状态。
    • 内核栈指针:内核栈保存了函数调用的上下文、局部变量等。保存当前线程的栈指针(Kernel Stack Pointer,通常在esprsp寄存器中)以保证当线程恢复时可以正确使用原来的内核栈。
    • context数据结构:所有这些信息被保存在一个名为context的数据结构中。context通常是线程控制块(TCB,Thread Control Block)的一部分,用于保存内核线程的执行状态。

3. 加载下一个线程的内核状态

  • 加载内容:被调度运行的下一个进程的内核线程状态会从之前保存的context结构中恢复出来。这个过程涉及:
    • 恢复寄存器状态:从新的线程的context结构中恢复所有寄存器的值,使得CPU能够继续执行新线程的代码。
    • 恢复内核栈指针:恢复新的线程的内核栈指针,使其指向正确的内核栈位置,确保函数调用和栈帧能够正确进行。

4. 恢复用户空间状态

  • trapframe的作用:当内核线程完成了系统调用或中断处理后,它需要将控制权返回给用户进程。此时,内核将使用trapframe来恢复用户态的执行状态。trapframe保存了:
    • 程序计数器(PC):指向用户进程上次执行的位置。
    • 通用寄存器:恢复用户态的寄存器状态,包括之前保存的所有寄存器值。
    • 用户栈指针:恢复用户态的栈指针,以确保用户进程能够正确使用其栈。

具体的例子:从CC切换到LS

+------------------------+                  +------------------------+
|    User Process (CC)   |                  |    User Process (LS)   |
| +--------------------+ |                  | +--------------------+ |
| |   User Thread      | |                  | |   User Thread      | |
| | +----------------+ | |                  | | +----------------+ | |
| | | Program Counter| | |                  | | | Program Counter| | |
| | | (PC)           | | |                  | | | (PC)           | | |
| | +----------------+ | |                  | | +----------------+ | |
| | +----------------+ | |                  | | +----------------+ | |
| | |   Registers    | | |                  | | |   Registers    | | |
| | +----------------+ | |                  | | +----------------+ | |
| +--------------------+ |                  | +--------------------+ |
| +--------------------+ |                  | +--------------------+ |
| |  User Stack        | |                  | |  User Stack        | |
| +--------------------+ |                  | +--------------------+ |
+------------------------+                  +------------------------+
       |                                        ^   Return to User Process
       |   Syscall/Interrupt                    |   (Restore User State with Trapframe)
       v                                        |   Continue Execution
+---------------------------------------------------------------------+
|                           Kernel Space                              |
| +---------------------+                  +------------------------+ |
| |   Kernel Thread (CC)|                  |   Kernel Thread (LS)   | |
| | +-----------------+ |                  | +--------------------+ | |
| | | Trapframe       | |                  | | Trapframe          | | |
| | | (Save CC's      | |                  | | (Saved LS's        | | |
| | | User State)     | |                  | |  User State)       | | |
| | +-----------------+ |                  | +--------------------+ | |
| | +-----------------+ |  Switch Thread   | +--------------------+ | |
| | | Context         | |  --- and --->    | | Context            | | |
| | | (Save Kernel    | | Continue running | | (Load Kernel       | | |
| | |  State)         | |                  | |  State)            | | |
| | +-----------------+ |                  | +--------------------+ | |
| | +-----------------+ |                  | +--------------------+ | |
| | | Kernel Stack    | |                  | | Kernel Stack       | | |
| | +-----------------+ |                  | +--------------------+ | |
| +---------------------+                  +------------------------+ |
+---------------------------------------------------------------------+

假设我们有两个用户进程:C Compiler(CC)和LS。当XV6从CC切换到LS时,会发生以下详细步骤,包括对trapframe的操作。

1. 保存CC的内核线程状态

  • 触发切换:假设CC进程正在执行某项任务,此时发生了定时器中断,XV6的调度器决定将CPU资源分配给另一个进程LS。

  • 保存trapframe:中断发生后,系统立即切换到内核模式,生成并保存当前CC进程的trapframetrapframe是一个保存用户态状态的数据结构,包括:

    • 程序计数器(PC):指向用户进程正在执行的下一条指令。
    • 用户态的所有寄存器(如eaxebx等)。
    • 用户栈指针(ESP):指向当前用户栈的位置。
    • 其他处理器状态(如标志寄存器)。

    通过保存trapframe,系统确保当CC进程将来被重新调度时,它可以从中断发生的地方继续执行。

  • 保存context:接着,内核会保存CC进程的内核态上下文信息,这包括:

    • 内核态寄存器:保存当前内核线程使用的寄存器值。
    • 内核栈指针:指向当前内核栈的顶端。
    • 这些信息被保存在CC的context结构中。

    这样,CC进程的完整执行状态,包括内核态和用户态,均被妥善保存。

2. 加载LS的内核线程状态

  • 加载context:XV6调度器决定切换到LS进程。系统首先从LS的context结构中恢复该进程的内核态信息。这包括:
    • 恢复内核态寄存器:将LS的寄存器值加载到CPU中。
    • 恢复内核栈指针:设置CPU的栈指针指向LS的内核栈。
  • 设置环境:内核栈指针被恢复后,LS的内核线程能够正确访问其内核栈,并从先前暂停的地方继续执行。这意味着LS的内核线程可以继续处理之前未完成的任务,例如处理中断或系统调用。

3. 继续执行LS的内核线程

  • 内核态执行:LS的内核线程现在获得CPU的控制权,继续执行从上次暂停的地方开始的任务。
    • 如果LS之前被暂停时正在处理中断,那么内核线程会继续执行中断处理程序的剩余部分。
    • 如果LS是在执行系统调用过程中被暂停的,则会继续完成系统调用。
  • 准备恢复用户态:在内核线程处理完所有内核态的任务后,它将准备恢复用户态,继续执行LS的用户代码。

4. 恢复LS的用户进程状态

  • 恢复trapframe:内核线程完成任务后,系统将从LS的trapframe中恢复用户态信息。这包括:
    • 恢复用户态的程序计数器(PC):指向LS进程中断前的下一条指令。
    • 恢复用户态寄存器:将trapframe中保存的寄存器值恢复到CPU寄存器中。
    • 恢复用户栈指针(ESP):恢复到LS用户栈的正确位置。
  • 切换到用户模式:在恢复所有用户态信息后,CPU将切换回用户模式,LS进程将继续执行用户代码,从中断发生的地方开始。

通过保存和恢复trapframecontext,XV6能够在不同进程之间无缝切换。在从CC切换到LS的过程中,系统确保了CC的用户态和内核态都被妥善保存,随后将LS的内核态和用户态恢复到中断前的状态,使其能够继续执行。这一过程确保了进程的正确性和系统的平稳运行。

线程调度器就是在这个过程中起作用的。线程调度器决定哪个线程可以运行,并进行状态切换。我们将在接下来的课程中详细介绍调度器的工作原理和实现。

实际XV6中的线程切换流程

在XV6操作系统中,线程切换的流程较为复杂,特别是在多核环境下,涉及多个CPU核、内核线程和调度器线程。以下是一个详细的流程描述,假设我们有两个进程P1和P2,其中P1正在运行,而P2处于RUNNABLE状态但尚未运行。

1. 定时器中断触发:从用户空间切换到内核空间

  • 中断发生:假设P1正在CPU0上运行,此时定时器中断触发,强制CPU0从用户空间切换到内核空间。这是预抢占式调度的一部分,确保进程不会长时间独占CPU。

  • 保存用户态寄存器:中断触发时,硬件首先切换到XV6的trampoline代码,负责保存P1的用户态寄存器到P1进程的trapframe结构中。trapframe保存了所有用户态寄存器的状态,包括程序计数器(PC)、栈指针(SP)等。

2. 执行中断处理:进入内核线程

  • 进入内核模式:完成用户态寄存器的保存后,CPU0进入内核模式,并执行usertrap函数来处理中断。

  • 内核栈:此时,CPU0正在P1进程的内核栈上运行,执行普通的内核代码。usertrap函数处理定时器中断等任务,并为后续的调度工作做准备。

3. P1进程的内核线程出让CPU

  • 决定出让CPU:在内核中,P1的内核线程可能决定出让CPU,比如当前任务已完成或时间片用尽。此时,P1的内核线程会准备进行线程切换。

  • 调用swtch函数:为了进行线程切换,P1的内核线程调用swtch函数。这是XV6中线程切换的核心函数之一。swtch函数负责保存P1的内核态寄存器(如内核栈指针、通用寄存器等)到P1的context结构中。此时,P1的状态被完整保存:用户态寄存器在trapframe中,内核态寄存器在context中。

4. 切换到调度器线程

  • 调度器线程的切换:在XV6中,swtch函数并不会直接从一个内核线程切换到另一个内核线程,而是先切换到CPU0对应的调度器线程。

  • 恢复调度器线程swtch函数会恢复之前为CPU0的调度器线程保存的寄存器和栈指针,使得调度器线程重新获得执行权。此时,CPU0的调度器线程在内核栈上运行,开始执行调度任务。

5. 调度器线程调度下一个进程

  • 清理工作:调度器线程开始运行后,首先会进行必要的清理工作,例如将P1的状态标记为RUNNABLE,表示P1可以被重新调度。

  • 选择下一个进程:调度器线程通过遍历进程表,选择下一个处于RUNNABLE状态的进程。假设选中的是P2,调度器线程准备将CPU资源分配给P2。

6. 从调度器线程切换到P2进程

  • 保存调度器状态:在将CPU切换到P2之前,调度器线程会再次调用swtch函数,保存调度器线程的寄存器状态到调度器的context结构中。

  • 恢复P2的内核态swtch函数从P2的context结构中恢复其内核态寄存器,包括内核栈指针等。恢复完成后,P2的内核线程开始在内核栈上执行。

  • 返回P2的内核线程:由于P2在之前被调度为RUNNABLE时曾调用过swtch,此时的swtch调用将返回到P2之前的内核执行点,可能是一个系统调用或中断处理程序中。

7. 恢复P2的用户态并继续执行

  • 恢复trapframe:P2的内核线程在完成当前的内核任务后,会通过trapframe恢复用户态寄存器。这包括恢复程序计数器(PC)、用户态栈指针(SP)等。

  • 切换回用户模式:所有寄存器恢复完毕后,系统从内核模式切换回用户模式。P2进程在CPU0上恢复执行,从上次被中断的地方继续运行。

关于context对象及线程切换的问答总结

1. context对象保存在哪?

每一个内核线程都有一个context对象,它用于保存线程切换时的内核态寄存器状态。在XV6中,内核线程分为两类:

  • 用户进程对应的内核线程:每个用户进程都有一个对应的内核线程,它的context对象保存在用户进程的proc结构体中。proc结构体是XV6中用于描述进程的核心数据结构,包含了进程的所有关键信息。
  • 调度器线程:调度器线程也有自己的context对象,但它没有与之对应的proc结构体。调度器线程的context对象保存在cpu结构体中。每个cpu结构体对应一个CPU核,包含了该CPU核上运行的调度器线程的context

2. 为什么不将context对象保存在trapframe中?

虽然context对象理论上可以保存在trapframe中,但XV6选择将它们分开保存以简化代码并使逻辑更加清晰。具体来说:

  • trapframetrapframe用于保存用户态寄存器的状态,主要是当用户进程进入内核时(如发生中断或系统调用时)保存用户态的寄存器状态。trapframe包含了程序计数器(PC)、用户栈指针(SP)等信息。
  • contextcontext则保存内核态寄存器的状态,特别是在内核线程之间(如用户进程内核线程与调度器线程之间)进行切换时需要保存和恢复的寄存器状态。context中包含了内核栈指针等关键信息。

将这两者分开管理,使得代码结构更清晰,职责分离明确:trapframe负责用户态到内核态的转换,而context则管理内核态的线程切换。

3. 出让CPU是由用户发起的还是由内核发起的?

在XV6中,CPU的出让(线程切换)通常由内核发起,而不是由用户进程直接发起。虽然某些系统调用(如等待I/O操作)可能会导致线程出让CPU,但这些都是内核决定的。内核在以下两种情况下会决定让当前线程出让CPU:

  • 定时器中断:当定时器中断触发时,内核会强制当前进程出让CPU,以便其他进程也有机会运行。这是预抢占式调度的一部分,确保系统中所有RUNNABLE状态的进程都能公平地获得CPU时间。
  • 等待I/O:当进程调用系统调用并需要等待I/O操作(如读取文件或等待用户输入)时,进程会进入等待状态(阻塞),这时内核会切换到其他可运行的进程,直到I/O操作完成。

4. 用户进程调用sleep函数是否会触发进程切换?

当用户进程调用sleep函数时,通常是在系统调用(如read)过程中。例如,如果进程在执行read系统调用时需要等待数据从磁盘读取,那么进程会进入内核态,调用sleep函数。这时,sleep函数最终会调用swtch函数,将当前内核线程的寄存器状态保存在context中,并切换到调度器线程。调度器线程随后会调度其他进程运行。因此,尽管这个流程与定时器中断不同,但结果相同:当前线程出让CPU,其他线程获得执行机会。

5. 每个CPU的调度器线程有自己的栈吗?

是的,每个CPU的调度器线程都有自己的独立栈。调度器线程的栈和context都是在系统启动时由XV6内核设置好的,与用户进程的栈不同,调度器线程在系统的整个生命周期内都存在,并负责在各个进程之间切换。系统在启动时,通过start.s(注:应为entry.Sstart.c)文件为每个CPU核设置调度器线程及其栈和context

术语解释与概念澄清:Context Switching 与 线程管理

在操作系统中,context switching(上下文切换)是一个重要的概念,它涉及到在不同线程或进程之间切换执行权。以下是对这个术语的深入解释,并针对XV6的实现提供一些具体说明。

1. Context Switching 的含义

  • 线程间切换:通常情况下,当人们谈论context switching时,指的是在两个线程之间的切换。这涉及到:
    • 保存当前线程的寄存器状态:在切换过程中,操作系统会将当前线程的所有寄存器(包括程序计数器、栈指针等)保存到该线程的context对象中。
    • 恢复目标线程的寄存器状态:然后,操作系统会从目标线程的context对象中恢复之前保存的寄存器状态,使得目标线程能够继续从上次暂停的位置开始执行。
  • 用户进程间的切换:在某些上下文中,context switching也可以指从一个用户进程切换到另一个用户进程的整个过程。这种情况下,切换涉及两个方面:
    • 用户态和内核态的切换:用户进程进入内核态(如系统调用或中断)时,需要保存用户态的寄存器状态到trapframe,并可能通过context对象切换内核线程。
    • 进程调度:操作系统选择下一个要运行的进程,并恢复其状态,使其开始运行。
  • 用户空间和内核空间之间的切换:偶尔,context switching也指的是在用户空间内核空间之间的切换。例如,用户进程发起系统调用时,会从用户空间进入内核空间,完成系统调用后再返回用户空间。

2. XV6中的Context Switching

在XV6操作系统的背景下,context switching主要指的是内核线程调度器线程之间的切换。这一切换过程的关键在于context对象的管理。

  • 单一时间点上的单一线程:在XV6中,每个CPU核在任意时间点只能运行一个线程。这个线程可能是:

    • 用户进程的内核线程:正在处理系统调用或中断的内核代码。
    • 调度器线程:负责在不同进程之间进行调度。
    • 用户进程的用户态线程:在用户态执行应用程序代码。

    因此,在一个时间点,CPU核只能做一件事情,而线程切换则通过快速的上下文切换,创造了多个线程同时运行的假象。

  • 线程的绑定与调度:每个线程要么在一个CPU核上运行,要么它的状态被保存到context对象中,等待下次调度。线程不会在多个CPU核上同时运行。这意味着,当一个线程正在某个CPU核上运行时,其他CPU核不会运行这个线程的代码。

3. Context 对象与 swtch 函数

  • swtch 函数的作用:在XV6中,context对象的创建和管理主要通过swtch函数完成。每当系统需要在内核线程之间进行切换时,swtch函数会被调用:

    • 保存当前内核线程的状态swtch函数将当前内核线程的寄存器状态保存到它的context对象中。
    • 恢复目标内核线程的状态:然后,从目标线程的context对象中恢复其寄存器状态,使得目标线程从之前的swtch函数调用点继续执行。

    在调度器线程切换到用户进程的内核线程时,或者从一个用户进程切换到另一个用户进程的内核线程时,swtch函数都会被调用。线程切换完成后,恢复的线程会从之前的swtch函数调用点继续执行。

4. 线程的定义与命名

  • 进程与线程的区别:在XV6的实现中,一个进程通常只有一个线程,这可能导致术语上的混淆。在典型的多线程操作系统中,一个进程可以拥有多个线程,每个线程可以独立执行不同的任务。

  • 在XV6中的术语使用:在XV6中,我们可以将一个进程视为拥有两个“线程”:

    • 用户态线程:在用户空间执行应用程序代码。
    • 内核态线程:在内核空间执行系统调用或处理中断。

    虽然这两个“线程”从不同时运行,但它们代表了进程在不同执行状态下的表现。因此,尽管XV6中没有多线程的实现,我们仍然可以使用“线程”来描述进程在不同执行模式下的行为。

XV6中的proc结构体与代码演示解析

我们将通过对proc结构体的分析,结合一个简单的进程切换演示程序,来理解操作系统是如何管理和调度进程的。

在xv6中,proc结构体是进程控制块(PCB)的具体实现,它用于管理和维护进程的状态和相关信息。每个进程在系统中都对应一个proc结构体,该结构体记录了进程的各种信息,如内存地址、寄存器状态、进程状态等。

1. proc结构体分析

// Per-process state
struct proc {
  struct spinlock lock;         // 保护proc结构体中的敏感数据,防止竞争条件

  // p->lock must be held when using these:
  enum procstate state;         // 进程状态 (如RUNNING, RUNNABLE, SLEEPING)
  void *chan;                   // 如果非零,表示进程正在等待某个事件(如等待I/O)
  int killed;                   // 如果非零,表示进程已被标记为需要终止
  int xstate;                   // 进程退出状态码,父进程通过wait系统调用获取
  int pid;                      // 进程ID

  // wait_lock must be held when using this:
  struct proc *parent;          // 父进程的指针

  // these are private to the process, so p->lock need not be held.
  uint64 kstack;                // 内核栈的虚拟地址,保存内核中函数调用的栈帧
  uint64 sz;                    // 进程的内存大小(以字节为单位)
  pagetable_t pagetable;        // 用户页表的指针,管理进程的虚拟内存
  struct trapframe *trapframe;  // 保存用户空间线程的寄存器状态,供内核与用户空间切换时使用
  struct context context;       // 保存内核线程的寄存器状态,供内核线程切换时使用
  struct file *ofile[NOFILE];   // 打开的文件列表
  struct inode *cwd;            // 当前目录的指针
  char name[16];                // 进程名,用于调试
};
  • trapframe字段:保存了用户空间线程的寄存器状态,供从用户空间切换到内核空间时使用。每当一个进程发生系统调用或中断,trapframe将保存当前用户态的寄存器信息,以便在返回用户态时恢复这些信息。
  • context字段:保存了内核线程的寄存器状态,主要用于内核线程之间的切换。每当XV6需要在不同的内核线程之间切换时,context字段中的信息会被保存或恢复。
  • kstack字段:指向进程的内核栈。内核栈用于保存内核模式下的函数调用链和局部变量。当进程在内核态执行时,内核栈记录着所有函数调用的返回地址和中间数据。
  • state字段:表示进程当前的状态,如RUNNING(正在运行)、RUNNABLE(可运行)或SLEEPING(睡眠中)。调度器根据进程的state字段来决定哪个进程可以获得CPU。
  • lock字段:用于保护proc结构体中的关键数据,防止在多核系统中发生竞争条件。比如,当两个CPU核的调度器线程同时尝试操作同一个进程时,这个锁可以确保安全的并发访问。
注意这里每个`proc`结构体都有`lock`、`state`、`chan`、`pagetable` 、`trapframe`、`context`等

因此,proc结构体中的字段意味着每个进程在系统中都有以下对应的资源和状态信息:

1. lock

  • 描述: 每个进程都有一个独立的自旋锁(lock,用于保护进程的关键字段,确保这些字段在多核环境下的修改是安全的。由于锁是针对具体进程的,因此每个进程在访问和修改其控制块中的敏感数据时,需要先获取它自己的锁。

  • 意义: 这意味着每个进程都有自己专属的锁,确保在对进程状态、资源等信息进行操作时不会与其他进程/线程发生冲突。

2. state

  • 描述: 每个进程都有一个状态字段(state),它表示当前进程的运行状态,比如是否正在运行、是否在等待资源、是否已完成等。

  • 意义: 这意味着每个进程在操作系统中都有独立的生命周期管理。通过状态字段,操作系统可以调度和管理进程的执行。

3. chan

  • 描述: 当进程进入睡眠状态时(即等待某个事件或资源),chan字段记录了进程正在等待的具体资源或条件。

  • 意义: 这意味着每个进程在等待某些资源或事件时都有独立的等待目标。chan的存在使得操作系统可以更精确地唤醒那些因特定事件而被挂起的进程。

4. pagetable

  • 描述: 每个进程都有独立的页表(pagetable),它管理了该进程的虚拟内存和物理内存之间的映射。

  • 意义: 这意味着每个进程都有独立的内存空间,操作系统通过页表为进程提供内存隔离,保证进程之间的内存访问不会相互干扰。

5. trapframe

  • 描述: trapframe用于保存进程在发生中断时的寄存器状态,特别是在用户态下执行时。这是操作系统在从内核态返回用户态时恢复进程状态的依据。

  • 意义: 这意味着每个进程都有独立的寄存器状态保存机制。当发生中断时,操作系统可以准确地保存和恢复该进程的执行状态。

6. context

  • 描述: context保存了进程在上下文切换时的CPU寄存器状态。context字段包含了所有需要保存的寄存器,以便在进程被重新调度时恢复其执行状态。

  • 意义: 这意味着每个进程都有自己独立的执行上下文。当进程被切换出去或切换回来时,操作系统通过context来保存和恢复进程的运行状态,确保其执行的连续性。

综上所述,xv6中的每个proc结构体代表了一个独立的进程,并且通过字段如lockstatechanpagetabletrapframecontext等,实现了对每个进程的独立管理。具体来说:

  • 独立的锁机制: 每个进程都有一个自旋锁,用于保护进程的关键字段,确保进程状态和资源的安全访问。
  • 独立的生命周期管理: 每个进程都有自己的状态信息,操作系统根据状态进行调度和管理。
  • 独立的资源等待: 通过chan字段,进程可以在需要等待的资源或事件上休眠,等待资源就绪后再被唤醒。
  • 独立的内存管理: 通过页表(pagetable),每个进程都有独立的虚拟内存空间,保证内存访问的隔离性。
  • 独立的中断处理和上下文切换: trapframecontext确保了每个进程在中断和上下文切换时能够准确地保存和恢复其运行状态。

在xv6中,所有的proc结构体都保存在一个全局的进程表(proc[NPROC])中,这个表格存储在内核的全局内存区域中。proc结构体数组的大小由NPROC常量定义,表示系统最多同时可以管理的进程数。

内存中的proc结构体:

  • 全局数组: struct proc proc[NPROC]; 定义了一个固定大小的数组,用于保存系统中所有进程的proc结构体。这些proc结构体是连续地存储在内存中的,并由内核进行管理。
  • 指针访问: 调度器和其他内核函数通过指针访问这些proc结构体,并根据需要修改进程的状态、上下文、内存映射等信息。
  • 固定位置: 在系统启动时,proc数组就会被分配到内存中的一个固定位置,内核通过这个位置来管理和调度进程。

调度器线程有自己的context对象,但它没有与之对应的proc结构体。调度器线程的context对象保存在cpu结构体中。每个cpu结构体对应一个CPU核,包含了该CPU核上运行的调度器线程的context

在多任务操作系统中,context对象保存了当前线程或进程的CPU寄存器状态。这个状态包括程序计数器(PC)、栈指针(SP)、通用寄存器等,用于在上下文切换时保存当前执行状态,并在切换回来时恢复这些状态。

对于调度器线程,它负责管理和调度CPU上的用户进程或内核线程。尽管它不属于具体的用户进程,但它仍需要在执行上下文切换时保存自己的状态,以便在切换回调度器时恢复这些信息。

cpu结构体中的context

在xv6中,每个CPU核心对应一个cpu结构体,cpu结构体中包含了该CPU上运行的调度器线程的context对象。这意味着每个CPU核心都有一个专用的context,用于保存和恢复调度器线程的状态。

struct cpu {
  struct proc *proc;           // 当前运行的进程,或者为0
  struct context context;      // 调度器的上下文
  int noff;                    // 禁用中断嵌套的计数
  int intena;                  // 中断是否在进入pushcli前启用
};
  • context字段: cpu结构体中的context字段保存了调度器线程的CPU上下文。每次在sched函数中执行上下文切换时,如果切换到某个进程,调度器的上下文会被保存在cpu->context中。当控制权切换回调度器时,调度器的上下文就会从这个context字段中恢复。

调度器线程和proc结构体的区别

  • proc结构体: 用于表示用户进程或内核线程,包含了与该进程相关的所有状态信息,包括进程状态、页表、文件描述符等。每个进程有自己的context,用于保存和恢复该进程的CPU状态。

  • 调度器线程的context: 尽管调度器线程没有proc结构体,但它需要保存其上下文(CPU状态),以便在处理完某个进程后,能够正确地恢复执行。这部分上下文信息保存在cpu结构体中的context字段里。

调度器线程的执行

在xv6中,调度器线程在每个CPU核心上以一个无限循环的方式运行,负责在可运行的进程之间进行上下文切换。每次调度器线程决定切换到某个用户进程时,它会调用swtch函数,将自己的context保存到cpu->context中,并加载目标进程的context。当目标进程放弃CPU(例如通过yield)后,控制权会再次返回调度器线程,此时调度器会从cpu->context中恢复它的状态,并继续执行。

2. 进程切换演示:spin.c 程序

#include "kernel/types.h"
#include "user/user.h"

int main(int argc, char * argv[]){
    int pid;
    char c;

    pid = fork();  // 创建一个子进程
    if(pid == 0){
        c = '/';   // 子进程输出"/"
    } else{
        printf("parent pid is %d, child is %d\n", getpid(), pid);
        c = '\\';  // 父进程输出"\"
    }

    for(int i = 0;;i++){
        if((i % 1000000) == 0)
            write(2, &c, 1);  // 每1000000次循环输出一次字符
    }

    exit(0);
}
  • 程序说明:这个程序通过fork系统调用创建了一个子进程。父进程和子进程进入各自的无限循环,每隔一定时间在控制台输出一个字符。父进程输出“\”,子进程输出“/”。

  • 切换的必要性:由于spin.c程序中的两个进程都在执行计算密集型的任务(即不断循环且没有sleep调用),它们不会主动出让CPU。这导致需要操作系统的调度器主动进行进程切换,以便这两个进程能够在单核CPU上交替执行。

  • 定时器中断的作用:在单核环境下,两个进程无法同时运行,因此操作系统依赖定时器中断来强制进行上下文切换。每当定时器中断触发时,XV6的调度器会将当前运行的进程从RUNNING状态切换到RUNNABLE状态,并选择下一个RUNNABLE进程进行调度。

3. 演示结果

在运行spin.c程序时,控制台会显示“/”和“\”字符交替输出。这表明XV6正在两个进程之间切换,即使只有一个CPU核。切换背后的机制是定时器中断驱动的调度器,它通过context switching在两个进程之间切换,保证了两者都能得到CPU时间片。

image-20240827143118521

定时器中断与proc结构体在进程切换中的应用

接下来我们通过在devintr函数中的定时器中断代码设置断点,结合gdb调试工具,详细分析了XV6操作系统中处理定时器中断的过程以及如何进行进程切换。

1. devintr函数中的定时器中断

devintr函数负责处理外部中断和软件中断。代码中包括对多个设备中断的处理,但在这个实验中,我们关注的是定时器中断。

int
devintr()
{
  uint64 scause = r_scause();  // 读取中断原因

  if(scause == 0x8000000000000009L){
    // 处理外部设备中断...

  } else if(scause == 0x8000000000000005L){  // 定时器中断
    // timer interrupt.
    clockintr();  // 调用clockintr处理定时器中断
    return 2;  // 表示这是一个定时器中断
  } else {
    return 0;  // 未识别的中断
  }
}
  • 定时器中断:当scause值为0x8000000000000005L时,表示发生了定时器中断。此时,devintr函数调用clockintr来处理定时器中断,并返回2,标志这是一个定时器中断。

2. 调试定时器中断

在实验中,通过gdb// 定时器中断处设置断点并继续运行代码,我们可以捕捉到定时器中断发生的时刻。我们看到程序在中断发生时停在了devintr函数的定时器中断处理代码处。

  • usertrap函数:定时器中断触发时,usertrap函数通过调用devintr来识别中断类型。如果是定时器中断,devintr返回2usertrap随即调用yield函数让出CPU,以便操作系统可以调度其他进程运行。
// usertrap()

...
  } else if((which_dev = devintr()) != 0){
    // 中断处理完毕
  } else {
    printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid);
    setkilled(p);  // 处理未知中断
  }

  if(killed(p))
    exit(-1);

  // 如果是定时器中断,则让出CPU
  if(which_dev == 2)
    yield();

  usertrapret();  // 返回用户态

3. proc结构体和当前进程状态

在实验中,通过gdb打印当前进程的proc结构体(通过print p),我们可以获取进程的详细信息,包括:

  • 进程名称p->name,显示当前进程名称。在实验中,名称为spin,对应我们运行的程序。
  • 进程ID:通过p->pid可以获取当前进程ID。在实验中,pid3,表示当前运行的进程是spin程序的一个实例。

此外,proc结构体中的trapframe字段保存了用户态寄存器的状态,包括程序计数器(PC),栈指针(SP),以及通用寄存器的值。通过这些信息,我们可以分析中断发生时用户程序正在执行的指令。

4. 检查trapframe中的程序计数器

我们可以通过查看trapframe中的程序计数器(PC),来确定定时器中断发生时用户进程正在执行的指令。

(gdb) print p->trapframe->epc  // 打印程序计数器的值
  • 指令检查:在实验中查看 spin.asm 文件中对应地址指令,可以看到程序计数器指向了spin程序中的加1操作,这与程序的行为(死循环)一致,表明定时器中断发生时,进程正在执行循环加1操作。

image-20240827143934310

5. 区分不同进程的内核线程

  • 内核栈:每个进程都有自己的内核栈,proc结构体中的kstack字段指向该进程的内核栈。通过这个字段,可以区分不同进程的内核线程。
  • myproc函数:内核线程可以通过调用myproc函数来获取当前正在运行的进程。myproc通过使用tp寄存器获取当前CPU核的ID,然后通过这个ID在一个数组中找到对应的proc结构体,从而识别当前的进程。

关于定时器中断与线程切换的进一步讨论

1. 定时器中断的关键作用

  • 定时器中断的必要性:定时器中断是实现预抢占调度(preemptive scheduling)的核心机制。在XV6中,用户进程的执行是可以被中断的,这种中断通常由定时器触发。定时器中断会强制操作系统检查当前的进程是否占用了过多的CPU时间,如果是,则切换到其他进程。

  • 用户态与内核态的中断控制:在用户态下,定时器中断始终是启用的,这确保了操作系统能够定期中断正在运行的用户进程,实现公平的进程调度。返回到用户空间时,XV6总是确保中断是开启的,这意味着无论用户进程在做什么,定时器中断都能够触发。

  • 内核态的复杂性:在内核态中,情况稍微复杂一些。例如,在执行一些关键操作(如获取锁)时,内核可能会暂时关闭中断。关闭中断的原因是为了防止在关键代码段中发生上下文切换,从而保证代码的原子性和安全性。然而,XV6的设计确保了中断在完成这些关键操作后会被重新开启,以防止系统陷入死锁或进程无法被切换的状态。

2. 定时器硬件故障的应对

  • 如果硬件定时器出现故障,整个系统将会面临什么情况?
    • 如果定时器硬件出现故障,计算机可能无法正常工作。这类问题超出了操作系统的管理范围,因为操作系统的正常运行依赖于硬件的可靠性。虽然在某些情况下,软件可以通过机制如校验和(checksum)来检测和处理硬件错误,但大多数情况下,硬件故障需要通过更换硬件来解决。

3. 线程结束与CPU的占用

  • exit系统调用的作用:当一个线程在用户空间通过exit系统调用结束时,这不仅意味着用户空间的代码停止执行,也意味着相应的内核线程将被终止。在线程结束和下一次定时器中断之间的时间段内,CPU是否仍会被已经结束的线程占用。
    • exit系统调用本身会导致当前线程出让CPU。这是因为exit会执行一系列清理操作,包括释放资源、通知父进程以及调用yield函数来出让CPU。因此,线程的结束不依赖于定时器中断。换句话说,当一个线程调用exit时,操作系统会立即调度其他可运行的进程,而不是等待下一个定时器中断。

4. 定时器中断与其他线程切换机制

  • 定时器中断的局限性:虽然定时器中断是操作系统实现线程切换的重要手段,但它并不是唯一的。XV6中许多线程切换发生在系统调用期间,而这些切换通常不是由定时器中断引发的。例如,I/O操作、等待事件、或其他系统调用(如exit)都会触发线程出让CPU,从而导致上下文切换。

  • 其他触发条件:系统中很多系统调用在等待某些条件(如I/O完成)时,都会通过yield函数出让CPU,这些情况同样会导致进程调度和线程切换,而不依赖于定时器中断。

yield函数与sched函数

1. yield函数的功能

yield函数是进程出让CPU的第一步。当一个进程在定时器中断或其他情况下决定出让CPU时,它会调用yield函数。这个函数执行了以下步骤:

// Give up the CPU for one scheduling round.
void
yield(void)
{
  struct proc *p = myproc();  // 获取当前进程的proc结构体
  acquire(&p->lock);          // 获取进程的锁,防止并发访问
  p->state = RUNNABLE;        // 将进程状态设置为RUNNABLE,表示可调度
  sched();                    // 调用sched函数进行调度
  release(&p->lock);          // 释放锁,在调度回继续运行之后,由调度器获取的
}
  • 获取进程锁yield函数首先调用acquire(&p->lock)获取当前进程的锁。这一步非常重要,因为在锁被释放之前,进程的状态可能会不一致。例如,进程的状态可能已经被标记为RUNNABLE,但实际上进程仍在运行。锁的目的是确保在这个状态变化期间,不会有其他CPU核的调度器线程尝试调度这个进程。

  • 将状态设置为RUNNABLE:接下来,yield函数将进程的状态设置为RUNNABLE。这表示该进程准备好再次运行,但当前它即将出让CPU。此时,进程仍在运行其内核线程,但是它的状态已经被标记为可以被调度。

  • 调用sched函数:然后,yield函数调用proc.c中的sched函数来执行实际的线程切换。sched函数将切换到调度器线程,并为其他进程的调度做好准备。

  • 释放进程锁:最后,sched函数返回后yield函数释放锁(由调度器获取的锁)。此时,进程的状态已经被正确设置,调度器可以安全地调度其他进程。

2. sched函数的功能

sched函数是执行线程切换的核心函数,它负责从当前正在运行的进程切换到调度器线程。sched函数的代码如下:

// Switch to scheduler. Must hold only p->lock
// and have changed proc->state. Saves and restores
// intena because intena is a property of this
// kernel thread, not this CPU. It should
// be proc->intena and proc->noff, but that would
// break in the few places where a lock is held but
// there's no process.
void
sched(void)
{
  int intena;
  struct proc *p = myproc();  // 获取当前进程的proc结构体

  if(!holding(&p->lock))
    panic("sched p->lock");   // 确保持有进程的锁
  if(mycpu()->noff != 1)
    panic("sched locks");     // 确保没有其他锁持有,防止死锁
  if(p->state == RUNNING)
    panic("sched running");   // 确保进程状态不是RUNNING
  if(intr_get())
    panic("sched interruptible"); // 确保中断被禁用

  intena = mycpu()->intena;   // 保存当前内核线程的中断状态
  swtch(&p->context, &mycpu()->context);  // 执行线程切换
  mycpu()->intena = intena;   // 恢复中断状态
}
  • 合理性检查sched函数开始时进行了多项检查,以确保系统的正确性。如果某些条件不满足,函数会触发panic。这些检查包括:
    • 检查是否持有进程的锁(p->lock)。
    • 确保当前CPU核没有持有其他锁(防止死锁)。
    • 确保进程的状态不是RUNNING(因为在sched调用时,进程应已出让CPU)。
    • 确保中断已经被禁用(防止在关键时刻被中断)。
  • 保存中断状态:在执行线程切换之前,sched函数会保存当前内核线程的中断状态(intena)。这是因为中断状态是线程的属性,而不是CPU的属性。当我们切换回该线程时,需要恢复其原来的中断状态。

  • 执行线程切换:核心操作是调用swtch函数。swtch函数负责将当前线程的上下文保存到当前进程的context中,并恢复调度器线程的上下文。

  • 恢复中断状态:在swtch函数返回后,sched函数会恢复之前保存的中断状态,使得内核线程可以继续按照原先的中断设置运行。

    // kernel/spinlock.c
    ...
    // Check whether this cpu is holding the lock.
    // Interrupts must be off.
    int
    holding(struct spinlock *lk)
    {
      int r;
      r = (lk->locked && lk->cpu == mycpu());
      return r;
    }
    

3. swtch函数的作用

yieldsched函数的最终目的是调用swtch函数来执行实际的上下文切换。swtch函数将当前线程的寄存器状态保存到context中,并恢复调度器线程的寄存器状态。这是线程切换的核心操作,负责在不同的内核线程之间切换执行权限。

通过对yieldsched函数的深入分析,我们了解到操作系统如何通过锁的机制来确保线程状态的一致性,并且如何通过合理性检查和上下文切换来安全地执行线程切换。sched函数中的多项检查和对中断状态的保存与恢复,确保了系统在执行复杂的线程切换操作时的稳定性和正确性。接下来,我们可以进一步研究swtch函数,它是实现实际上下文切换的关键部分。

swtch函数及其在线程切换中的作用

在XV6操作系统中,swtch函数是执行上下文切换的核心函数。它负责保存当前内核线程的寄存器状态,并恢复调度器线程的寄存器状态,从而实现线程切换。以下是对swtch函数及其相关机制的详细分析。

1. swtch函数的基本机制

swtch函数的作用是执行上下文切换,其工作原理如下:

# Context switch
#
#   void swtch(struct context *old, struct context *new);
# 
# Save current registers in old. Load from new.    

.globl swtch
swtch:
        # 保存当前线程的寄存器状态到old(当前线程的context)
        sd ra, 0(a0)       # 保存返回地址
        sd sp, 8(a0)       # 保存栈指针
        sd s0, 16(a0)      # 保存寄存器s0
        sd s1, 24(a0)      # 保存寄存器s1
        sd s2, 32(a0)      # 保存寄存器s2
        sd s3, 40(a0)      # 保存寄存器s3
        sd s4, 48(a0)      # 保存寄存器s4
        sd s5, 56(a0)      # 保存寄存器s5
        sd s6, 64(a0)      # 保存寄存器s6
        sd s7, 72(a0)      # 保存寄存器s7
        sd s8, 80(a0)      # 保存寄存器s8
        sd s9, 88(a0)      # 保存寄存器s9
        sd s10, 96(a0)     # 保存寄存器s10
        sd s11, 104(a0)    # 保存寄存器s11

        # 从new(调度器线程的context)恢复寄存器状态
        ld ra, 0(a1)       # 恢复返回地址
        ld sp, 8(a1)       # 恢复栈指针
        ld s0, 16(a1)      # 恢复寄存器s0
        ld s1, 24(a1)      # 恢复寄存器s1
        ld s2, 32(a1)      # 恢复寄存器s2
        ld s3, 40(a1)      # 恢复寄存器s3
        ld s4, 48(a1)      # 恢复寄存器s4
        ld s5, 56(a1)      # 恢复寄存器s5
        ld s6, 64(a1)      # 恢复寄存器s6
        ld s7, 72(a1)      # 恢复寄存器s7
        ld s8, 80(a1)      # 恢复寄存器s8
        ld s9, 88(a1)      # 恢复寄存器s9
        ld s10, 96(a1)     # 恢复寄存器s10
        ld s11, 104(a1)    # 恢复寄存器s11
        
        ret                # 返回到恢复的ra指向的位置
  • 保存当前线程的寄存器状态swtch函数的第一个参数a0指向当前线程的context结构体。函数首先将当前线程的重要寄存器状态(如rasp和一系列的s寄存器)保存到context中。

  • 恢复调度器线程的寄存器状态swtch函数的第二个参数a1指向调度器线程的context结构体。函数接着从context中恢复调度器线程的寄存器状态,特别是rasp等寄存器。

  • 返回执行:最后,函数通过ret指令返回,这会将程序控制权交给恢复的ra寄存器指向的地址。在调度器线程的情况下,这通常会指向scheduler函数。

2. 调试中的关键点:检查调度器线程的context

在GDB中,通过检查当前CPU核的调度器线程的context,我们可以了解到接下来线程切换会发生什么。例如:

print cpus[0].context

这会显示当前CPU核的调度器线程的寄存器状态,包括ra寄存器。ra寄存器保存的是函数的返回地址,它指向了调度器线程将要继续执行的位置。通过kernel.asm查看ra寄存器指向的地址,可以确认调度器线程将返回到scheduler函数。

image-20240828120136783

3. swtch函数的核心:Callee Saved Register

RISC-V架构中有32个寄存器,但swtch函数只保存和恢复了14个寄存器。原因如下:

  • Callee Saved Register:在函数调用过程中,有两类寄存器:
    • Caller Saved Register:由调用者保存,因为调用的函数可能会修改这些寄存器的值。
    • Callee Saved Register:由被调用的函数保存,在swtch函数中保存了这些寄存器。

swtch函数中,只需要保存Callee Saved Register,因为这些寄存器是被调用函数(即swtch)必须保护的。Caller Saved Register已经由调用者(如sched函数)处理。

4. 程序计数器(PC)的管理

swtch函数并没有显式保存程序计数器(PC)。这是因为在RISC-V架构中,程序计数器由ra(返回地址寄存器)隐式管理。ra寄存器保存了swtch函数的调用点,当swtch函数返回时,ra寄存器将确保代码从正确的位置继续执行,因此不需要单独保存PC。

5. sp寄存器的作用

sp(Stack Pointer)寄存器指向当前栈顶,用于管理函数调用时的栈帧。在执行线程切换时,sp寄存器的值会发生变化,从而将CPU的控制从一个线程的栈切换到另一个线程的栈。

在GDB调试过程中,通过查看sp寄存器的值,可以观察到线程切换前后sp的变化。

print $sp
  • 初始sp:在调度器线程被恢复之前,sp指向当前进程的内核栈地址。这个地址通常由虚拟内存系统映射到一个高地址空间,专用于当前进程的内核栈。

  • 切换后sp:在调度器线程的上下文被恢复后,sp的值会改变,指向调度器线程的栈。这是一个在系统启动时由start.s设置的栈,称为bootstack,用于调度器线程的栈操作。

6. 上下文切换中的spra寄存器

swtch函数中,通过将当前线程的sp(栈指针)和ra(返回地址)寄存器保存到其context结构中,然后恢复调度器线程的相应寄存器值,完成了线程上下文的切换。

ld sp, 8(a1)       # 恢复调度器线程的栈指针
ld ra, 0(a1)       # 恢复调度器线程的返回地址
  • sp寄存器:指向调度器线程的栈,通常在内存的bootstack区域。这个区域是在系统启动时设置的,用于调度器线程的栈操作。

  • ra寄存器:恢复后指向scheduler函数,这意味着执行swtch后的下一步将返回到调度器线程的scheduler函数。

7. 调度器线程的恢复

swtch函数完成后,控制权交还给调度器线程。此时,spra寄存器已经被设置为调度器线程的上下文,因此当ret指令执行时,CPU会跳转到调度器线程中的scheduler函数。

# 在swtch函数末尾时,打印$sp和$ra
print $sp  # 显示新的栈指针,指向调度器线程的栈
print $ra  # 显示新的返回地址,指向scheduler函数

image-20240828121131696

此时,调度器线程的栈顶位于bootstack区域,ra寄存器指向scheduler函数的位置。

8. 汇编实现switch函数的必要性

为什么swtch函数需要用汇编语言实现,而不能用C语言实现?

  • 直接操作寄存器swtch函数的核心任务是直接操作CPU寄存器,如spra。这些寄存器管理栈指针和返回地址,在C语言中无法直接访问或修改这些寄存器。C语言的抽象层级高于汇编语言,因此无法实现像swtch这样的低级操作。

  • C语言的限制:C语言无法直接控制spra等寄存器。这是因为C语言的设计目标是实现可移植性和易用性,而寄存器操作通常依赖于具体的处理器架构。

  • 汇编语言的必要性:为了实现线程上下文切换,必须使用汇编语言直接操作寄存器,以确保能够精确控制CPU的执行流和栈操作。这就是为什么swtch函数以汇编语言编写的原因。

9. 浮点状态的管理

在不同的处理器架构中,线程上下文切换的实现细节可能有所不同。例如:

  • Intel x86架构:在x86架构中,线程切换可能涉及到更多的状态保存,例如浮点寄存器、SIMD寄存器等。这些寄存器的状态可能需要在切换时保存和恢复。

  • RISC-V架构:在RISC-V中,浮点状态管理可能有所不同。在XV6内核中,未使用浮点运算,因此无需处理浮点状态。但在更复杂的操作系统中,可能需要考虑这一点。

通过分析swtch函数及其在上下文切换中的作用,我们理解了如何通过汇编语言直接操作寄存器,实现高效的线程切换。

scheduler函数的深入解析

scheduler函数是XV6操作系统中调度器线程的核心部分。它负责在多个进程之间进行调度,确保每个进程都能公平地获得CPU时间。以下是对scheduler函数的详细解析。

1. scheduler函数的基本结构

scheduler函数是一个无限循环,它在每个CPU核上运行,用于选择并运行可运行(RUNNABLE)状态的进程。以下是scheduler函数的代码:

void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();  // 获取当前CPU的结构体指针

  c->proc = 0;  // 初始化当前CPU核运行的进程为0
  for(;;){
    // 开启中断,防止死锁
    intr_on();

    int found = 0;
    for(p = proc; p < &proc[NPROC]; p++) {  // 遍历所有进程
      acquire(&p->lock);  // 获取进程锁(新的,想要调度运行的)
      if(p->state == RUNNABLE) {  // 如果进程是可运行状态
        // 切换到选择的进程
        p->state = RUNNING;  // 设置进程状态为RUNNING
        c->proc = p;  // 将当前CPU核的进程设置为p
        swtch(&c->context, &p->context);  // 切换到进程p的上下文

        // 进程暂时停止运行,处理完后的状态已经不是RUNNING
        c->proc = 0;  // 清除当前CPU核的进程记录
        found = 1;  // 表示找到了一个可运行的进程
      }
      release(&p->lock);  // 释放进程锁(旧的,调度前的)
    }
    if(found == 0) {  // 如果没有找到可运行的进程
      // 没有可运行的进程,停止运行直到发生中断
      intr_on();
      asm volatile("wfi");  // 使CPU进入等待中断状态
    }
  }
}

2. 调度器线程的核心逻辑

  • 初始化scheduler函数开始时,首先获取当前CPU核的cpu结构体指针,并将c->proc设置为0。c->proc表示当前CPU核正在运行的进程,但此时还没有选择任何进程,因此初始化为0。

  • 启用中断:进入循环之前,intr_on()被调用。这是为了防止死锁,因为如果所有进程都在等待某些事件(如I/O),而中断被关闭,那么调度器线程将无法调度其他进程,导致系统挂起。

  • 遍历进程列表:调度器线程循环遍历系统中的所有进程(通过proc数组)。对于每个进程,调度器检查其状态是否为RUNNABLE

  • 切换到选定的进程
    • 状态更新:如果找到一个RUNNABLE状态的进程,调度器将其状态更新为RUNNING,表示该进程即将获得CPU执行权。
    • 上下文切换:调度器通过调用swtch(&c->context, &p->context)函数,保存当前调度器线程的上下文,并切换到选定进程的上下文。此时,CPU将停止执行调度器线程,而开始执行选定的进程。
  • 清理与锁释放:当选定的进程通过swtch返回调度器线程时,调度器首先将c->proc设置为0,表示当前CPU核不再运行该进程(旧的,调度前的,由调用sched函数前获取的锁,如yieldsleep)。随后,调度器释放进程锁,允许其他CPU核调度并运行该进程。

  • 等待中断:如果没有找到任何可运行的进程,调度器线程将调用wfi(Wait For Interrupt)指令,使CPU进入低功耗状态,直到下一个中断发生。

3. 调度器线程的wfi指令

wfi(Wait For Interrupt)指令是RISC-V架构中的一条特殊指令,它使CPU进入等待中断状态,从而降低功耗。这通常在调度器线程无法找到任何可运行的进程时使用。当有新的中断(如定时器中断或I/O中断)发生时,CPU会退出等待状态,继续执行调度器线程的循环。

4. 线程切换的清理与准备

当一个进程被中断或决定让出CPU(如通过调用yield函数)时,scheduler函数会重新获得控制权。此时,调度器需要完成一些清理工作:

  • 清除当前进程的记录:调度器将当前CPU核的c->proc设置为0,以确保在调度器运行期间,CPU核不再记录正在运行的进程。
  • 释放锁:在swtch返回后,调度器释放进程的锁。这允许其他CPU核访问并可能调度该进程。

确保你理解了以下内容,或者在学完本节和L13后再次尝试理解:

系统第一次进入调度器时,调度器会初始化当前CPU的proc指针为0(即c->proc = 0;),表示此时没有进程正在运行。

调度器运行后,进入一个无限循环,调度器遍历进程表(proc[]),寻找状态为RUNNABLE的进程。

每次循环都会清空c->proc(即c->proc = 0;),表明当前CPU不再运行任何进程,并开始寻找下一个可运行的进程。

一旦调度器找到一个RUNNABLE的进程,它就会将这个进程的状态设置为RUNNING,并将c->proc设置为这个进程的指针,表示当前CPU现在正在运行这个进程。

当该进程的时间片用完,或者它再次调用schedsched函数又会将上下文切换回调度器

除了第一次,每次运行调度器再次运行,都是从清空c->proc开始释放旧的进程的锁,然后进入下个循环寻找下一个可运行的进程,重复这个过程。


sched 确保当前进程持有自己的锁,这对于状态一致性是必须的。即,在 sched调用前获取锁,如yieldsleep中。

sched 调用 swtch,把当前进程的上下文保存到 p->context 中,并切换到调度器的上下文。这时,锁仍然持有,存在于p->lock

调度器通过清除当前 c->proc释放锁,并进入下一个循环寻找下一个 RUNNABLE 的进程。

这是一对儿


调度器决定调度回原来的进程时,重新获取锁,并切换回原进程的上下文。

进程通过 swtch 恢复上下文,返回到原函数的 sched 调用后,锁仍然被持有,接下来原函数会释放锁

这是一另对儿


image-20240830120307630

5. 线程切换的上下文管理

当调度器线程通过swtch函数切换回原来的上下文时,它实际上返回到先前调用swtch的代码位置。在scheduler函数中,这意味着CPU核将继续从swtch返回的地方执行调度器线程的代码。

这确保了调度器线程能够管理多个进程的执行,并在适当的时机将控制权交还给其他进程。

p->lock 在调度中的作用及其必要性

在操作系统的进程调度过程中,锁的机制对于确保进程状态的一致性和操作的原子性至关重要。下面我们深入分析p->lock锁在调度中的两个关键作用,并进一步探索进程切换的细节。

1. 锁的第一个作用:确保进程状态转换的原子性

在XV6操作系统中,当一个进程出让CPU时,需要执行多个步骤来确保进程的状态被正确保存,并使得其他CPU核不会看到该进程的中间状态。以下是出让CPU时涉及的关键步骤:

  • 将进程状态从RUNNING改为RUNNABLE:这个步骤表示进程将不再占用CPU,但可以被重新调度。
  • 保存进程的寄存器状态到context:保存进程的当前执行状态,以便将来能从相同状态恢复。
  • 停止使用当前进程的栈:将CPU切换到调度器线程的栈上运行,确保当前进程的栈不被继续使用。

这些步骤必须作为一个原子操作来执行,任何中断或调度器线程在中间插入都会导致状态不一致,可能导致数据损坏或系统崩溃。因此,p->lock的第一个作用是确保这些步骤的原子性:要么所有步骤都完成,要么一个都不完成。这意味着在这些步骤完成之前,其他CPU核的调度器线程无法看到并调度该进程。

2. 锁的第二个作用:确保启动进程的原子性

当调度器线程选择一个新的进程运行时,也涉及到多个关键步骤:

  • 将进程状态设置为RUNNING:这一步标志着进程已经获得CPU控制权。
  • context中的寄存器状态恢复到CPU寄存器:这是上下文切换的核心部分,将新的进程的寄存器状态加载到CPU中。

在这些步骤完成之前,如果发生中断(如定时器中断),可能会导致进程状态不一致。具体来说,如果进程的状态已经设置为RUNNING,但寄存器状态还未完全恢复,此时的中断可能导致不完整的寄存器状态被保存到context中,进而影响进程的后续执行。因此,在切换到新进程时,必须关闭中断并加锁以确保这些步骤的原子性。这就是为什么在调度器线程获取锁之后才会进行进程状态更新,并且在执行swtch之前关闭中断的原因。

3. scheduler `获取进程锁与进程切换

scheduler函数的循环中,调度器会遍历所有进程,寻找一个RUNNABLE状态的进程,并将其切换到RUNNING状态。以下是调度器线程选择并切换到新进程的步骤:

  • 获取锁并检查状态:调度器首先调用acquire(&p->lock)获取进程锁,以确保在检查和更新进程状态时,其他CPU核不会干扰。

  • 设置状态为RUNNING:在锁保护下,将选中的进程状态设置为RUNNING。这标志着该进程即将获得CPU控制权。

  • 切换到新进程:通过调用swtch(&c->context, &p->context)函数,调度器线程将当前的上下文保存到调度器的context中,并将CPU的控制权切换到新进程。

  • 释放锁:在上下文切换后,调度器线程释放进程锁,使其他CPU核可以访问并调度该进程。

image-20240828130000479

通过在调度器中设置断点,我们可以观察到调度器线程如何将新的进程记录为当前正在运行的进程,并通过swtch函数切换到新的进程。

(gdb) print p->name

通过GDB查看新进程的名称,我们确认了当前正在调度的进程是否与预期一致。

4. swtch函数的返回与恢复

swtch函数中,寄存器状态的保存和恢复过程中,最重要的是ra寄存器,因为它决定了函数返回的位置。通过查看swtch函数返回的地址(ra寄存器的内容),我们可以确认切换后的进程将从哪里继续执行。

image-20240828130127973

打印ra寄存器的内容,我们可以看到swtch函数将返回到sched函数中的某个位置,通常是因为之前的进程是通过sched函数挂起的。

swtch函数中的线程切换与状态管理

线程切换是一个复杂但关键的过程。在XV6中,swtch函数是执行这一切换的核心部分。让我们详细分析backtrace输出以及线程切换过程中涉及的其他状态管理。

1. backtrace 输出与线程切换

gdb中使用where命令查看backtrace,我们可以清楚地看到线程切换的历史调用链:

(gdb) where

image-20240828131326921

  • swtch返回:从swtch函数内部返回时,backtrace显示的调用链是我们当前线程的执行历史。值得注意的是,虽然我们现在返回到了一个sched函数调用的位置,但这个sched函数的调用实际上属于目标进程,而不是调度器线程。也就是说,当我们从swtch函数返回时,我们实际上进入了目标进程的上下文。

  • usertrap调用backtrace中还显示了之前的usertrap调用,这意味着之前发生了定时器中断,并触发了从用户态到内核态的切换。随后在内核中,usertrap调用了yieldsched函数来进行调度。

  • 切换的上下文:当前切换的上下文是从调度器线程到目标进程的内核线程。目标进程之前因为定时器中断而被挂起,并保存了上下文信息。现在,swtch返回后,恢复了目标进程的上下文,并从该上下文中继续执行。

2. 定时器中断之外的切换场景

如果线程切换不是因为定时器中断触发的,那么swtch函数的返回位置会如何变化?

  • 系统调用中的切换:除了定时器中断外,系统调用(如sleep)也可能触发线程切换。如果进程因系统调用而阻塞,它也可能调用sched函数来让出CPU。此时,swtch函数的返回位置会在系统调用代码中,而不是调度器线程。

  • 不同的backtrace:在非定时器中断引发的线程切换中,backtrace可能会显示与系统调用相关的调用链,而不是usertrap。例如,如果进程在sleep中被挂起,然后通过swtch返回,那么返回的位置可能是在sleep函数内部,而不是usertrap

3. 线程切换的核心与状态管理

swtch函数是线程切换的核心,它执行了以下关键步骤:

  • 寄存器状态保存swtch首先保存当前线程的寄存器状态到其context中。这包括返回地址寄存器(ra)、栈指针寄存器(sp)以及其他Callee Saved寄存器。

  • 寄存器状态恢复:随后,swtch从目标线程的context中恢复这些寄存器状态,使得CPU能够继续执行目标线程的代码。

重要的是,除了寄存器状态之外,线程的其他状态(如堆、全局变量等)都保存在内存中,并不会在切换时改变。因此,swtch只需要处理寄存器的保存与恢复,这也是为什么它能如此高效地执行线程切换的原因。

4. 线程的其他状态管理

尽管寄存器是唯一需要在线程切换中动态保存和恢复的状态,但线程还包含以下静态保持的状态:

  • :线程的栈用于保存函数调用帧、局部变量等。尽管栈指针寄存器sp会在切换时被保存和恢复,但栈中的数据本身保持不变。

  • :堆是进程动态分配内存的区域。线程切换不会改变堆中的数据,因此无需在swtch中处理。

  • 全局变量和静态数据:这些数据在内存中是稳定的,线程切换也不会改变它们的状态。

5. 关键点总结

  • 寄存器状态的唯一性:线程切换过程中,唯一需要保存和恢复的动态状态是CPU的寄存器。这些寄存器决定了线程的执行流。

  • 线程的内存状态保持不变:线程的栈、堆以及全局变量在切换过程中保持稳定,这确保了线程切换的高效性。

  • 多种线程切换触发条件:除了定时器中断,系统调用也可以触发线程切换。不同的触发条件会导致swtch返回到不同的代码位置,但最终都会通过swtch进行寄存器状态的恢复和切换。

通过这些分析,我们进一步理解了XV6中swthc函数的角色和线程切换的机制。这些机制确保了操作系统能够在多任务环境中高效而稳定地调度和管理线程。

Linux中的多线程实现和调度机制

1. Linux中的线程与进程的关系

在Linux中,线程与进程的关系相对特殊。通常我们认为线程是一个轻量级的进程,它们之间共享同一个地址空间。实际上,在Linux内核中,线程被实现为与进程几乎等价的实体,这意味着每个线程在系统内核中看起来像是一个独立的进程。

  • 共享内存:多个线程共享相同的虚拟地址空间,这包括代码段、全局数据段、堆和共享库。它们可以同时访问和修改同一块内存,这是多线程的核心特性之一。
  • 独立调度:虽然多个线程共享相同的地址空间,但在内核的调度器眼中,每个线程依然是独立的调度实体。内核将这些线程视为独立的任务(task),并且可以将它们调度到不同的CPU核上执行。

2. 线程调度与CPU绑定

Linux调度器在多核环境下的工作方式类似于在单核环境下的进程调度。当多个线程存在时,调度器会尝试将它们分配到不同的CPU核上,以充分利用系统的并行处理能力。调度器的主要任务是平衡CPU负载,并确保各个线程在系统资源使用上的公平性。

  • CPU绑定(CPU Affinity):用户可以通过设置线程的CPU亲和性(affinity)来将线程绑定到特定的CPU核上,这样线程只能在指定的核上运行。尽管如此,通常情况下,这种做法并不常见,除非在一些特定的实时系统或性能优化场景下。

3. 进程的Page Table与线程的关系

关于页表(page table),它是操作系统用来实现虚拟内存的一个关键数据结构,它映射了虚拟地址到物理地址。在多线程环境下,线程共享进程的虚拟地址空间,因此它们通常共享相同的页表。

  • 共享Page Table:在大多数情况下,一个进程的多个线程会共享同一个页表,因为它们共享同一个地址空间。这意味着它们对内存的访问权限和方式都是一致的。
  • 独立Page Table的可能性:虽然多个线程共享相同的虚拟地址空间,但为了支持特定的功能或优化,一些操作系统实现可能会为不同线程分配独立的页表,但这些页表的内容会保持一致。这种设计通常用于处理特定的安全或性能问题。

4. 多核环境下的线程管理

在一个多核处理器的环境中,Linux会尝试将任务分配到多个CPU核上以最大化并行处理能力。对于多线程应用,内核会将线程尽量分配到不同的核上,这样可以确保在多核系统上线程可以真正并行执行,而不仅仅是在一个CPU核上轮流切换。

  • 多线程的优点:通过将多个线程分配到不同的核上,系统可以实现更高的吞吐量和更低的延迟。
  • 线程切换的开销:线程切换的开销包括上下文切换、缓存失效(cache invalidation)等。但因为线程共享相同的地址空间,所以相比于进程切换,线程切换的开销相对较小。

线程第一次调用swtch函数

1. 线程第一次调用swtch的背景

在操作系统中,swtch函数用于在两个线程之间切换上下文。线程上下文包括寄存器状态、程序计数器、栈指针等。每个线程需要保存自己的上下文以便在切换回来时能够继续执行。

问题:当一个线程第一次调用swtch时,它还没有之前的上下文记录,因为它之前没有运行过。所以需要预先伪造一个“另一个线程”的上下文,以便在第一次调用swtch时有一个切换目标。

2. allocproc函数的作用

在XV6中,allocproc函数用于创建和初始化新进程。这个函数不仅分配了进程的结构体proc,还设置了进程的上下文,这样可以保证在第一次调用swtch时有一个有效的切换目标。

// kernel/proc.c allocproc()

// 在进程表中寻找一个状态为 UNUSED(未使用)的进程。
// 如果找到,则初始化其在内核中运行所需的状态,
// 并在返回时保持 p->lock 锁住。
// 如果没有可用的进程,或者内存分配失败,则返回 0。
static struct proc*
allocproc(void)
{
  struct proc *p;

  // 遍历进程表,寻找状态为 UNUSED 的进程条目
  for(p = proc; p < &proc[NPROC]; p++) {
    acquire(&p->lock);  // 获取进程的锁
    if(p->state == UNUSED) {  // 如果进程状态为 UNUSED
      goto found;  // 跳转到 found 标签,准备初始化进程
    } else {
      release(&p->lock);  // 否则释放锁
    }
  }
  return 0;  // 如果未找到 UNUSED 的进程,返回 0

found:
  p->pid = allocpid();  // 为进程分配一个唯一的 PID
  p->state = USED;  // 将进程状态设置为 USED,表示已被使用

  // 分配一个 trapframe 页。
  if((p->trapframe = (struct trapframe *)kalloc()) == 0){
    freeproc(p);  // 如果分配失败,释放进程并返回 0
    release(&p->lock);
    return 0;
  }

  // 创建一个空的用户页表。
  p->pagetable = proc_pagetable(p);
  if(p->pagetable == 0){
    freeproc(p);  // 如果页表分配失败,释放进程并返回 0
    release(&p->lock);
    return 0;
  }

  // 设置新的上下文,以便在 forkret 函数开始执行时返回用户空间。
  memset(&p->context, 0, sizeof(p->context));  // 清空上下文结构体
  p->context.ra = (uint64)forkret;  // 设置返回地址为 forkret 函数
  p->context.sp = p->kstack + PGSIZE;  // 设置栈指针指向内核栈顶

  return p;  // 返回已初始化的进程结构体指针
}

  • p->context.ra 被设置为forkret函数的地址:这意味着当swtch函数切换到这个新线程时,程序计数器会跳到forkret函数开始执行,就像是forkret刚刚调用了swtch并且返回了一样。
  • p->context.sp 被设置为线程的内核栈的顶端:这是为了确保新线程有一个独立的栈空间进行执行。

3. forkret函数的工作

forkret函数是在新线程第一次调度时执行的,它完成了一些必要的初始化工作,并最终切换到用户空间去执行用户程序。

// kernel/proc.c

// 当一个通过 fork 创建的子进程第一次被调度器调度时,
// 将通过 swtch 切换到 forkret 函数开始执行。
void forkret(void) {
  static int first = 1;  // 用于标记是否是第一次调用 forkret

  // 此时调度器仍持有 p->lock 锁。
  release(&myproc()->lock);  // 释放当前进程的锁

  // 如果是第一次调用 forkret,执行文件系统初始化。
  if (first) {
    fsinit(ROOTDEV);  // 初始化文件系统
    first = 0;  // 将 first 置为 0,确保文件系统只初始化一次
    __sync_synchronize();  // 保证对 first 的修改对所有 CPU 核心可见
  }

  // 切换到用户空间并开始执行用户程序。
  usertrapret();  // 从陷阱返回,恢复用户态上下文并跳转到用户程序
}
  • 释放调度器锁forkret函数首先释放了调度器在分配进程时获得的锁,以便其他进程可以被调度。
  • 文件系统初始化fsinit(ROOTDEV)在第一次调用forkret时被执行,它初始化了文件系统。这一步需要在进程上下文中完成,因为文件系统操作可能涉及I/O等待,而这些操作只能在进程上下文中进行。
  • 用户空间切换usertrapret()函数将控制权交还给用户程序,开始执行用户代码。

4. trapframe的初始化

在XV6中,trapframe保存了从用户空间到内核空间的切换过程中需要保存的上下文信息。在初始化第一个用户进程时,userinit函数设置了trapframe的初始状态。

// Set up first user process.
void
userinit(void)
{
  struct proc *p;

  p = allocproc();
  initproc = p;
  
  // allocate one user page and copy initcode's instructions
  // and data into it.
  uvmfirst(p->pagetable, initcode, sizeof(initcode));
  p->sz = PGSIZE;

  // prepare for the very first "return" from kernel to user.
  p->trapframe->epc = 0;      // user program counter
  p->trapframe->sp = PGSIZE;  // user stack pointer

  safestrcpy(p->name, "initcode", sizeof(p->name));
  p->cwd = namei("/");

  p->state = RUNNABLE;

  release(&p->lock);
}
  • 程序计数器(epc)p->trapframe->epc = 0; 程序计数器初始化为0,表示用户程序从头开始执行。
  • 栈指针(sp) p->trapframe->sp = PGSIZE; 栈指针指向用户栈的顶端,这样用户程序可以正常使用栈空间。

5. forkret中的first变量

forkret中,if (first)的条件判断是为了确保文件系统初始化只在第一次调用forkret时执行。初始化文件系统是一个需要等待I/O操作的过程,所以必须在进程上下文中进行。这也是为什么文件系统初始化被放在forkret中进行,而不是在系统启动时立即进行。


以下是更多的补充和总结

回顾:用户态和内核态之间的切换

在XV6中,从用户态进入内核态的过程可以由系统调用(如ecall)或中断(如定时器中断)触发。无论是哪种情况,进入内核态和返回用户态的流程都涉及相似的步骤。

1. 触发条件

  • 系统调用(ecall
    • 用户进程在用户态执行ecall指令,请求内核提供某种服务(如sleep、文件操作、进程管理等)。
    • ecall指令导致处理器进入内核态,并触发软中断。
  • 中断(如定时器中断)
    • 用户进程在用户态正常执行时,硬件定时器达到设定时间,触发定时器中断。
    • 定时器中断是一种硬件中断,它强制处理器进入内核态。

2. 进入内核态:从用户态到usertrap

  • 进入uservec
    • 无论是系统调用还是硬件中断,处理器在进入内核态后,都会首先跳转到trampoline.S中的uservec代码。stvec寄存器被设置为指向uservec
    • uservec的主要任务是将当前用户进程的寄存器状态保存到该进程的trapframe中。具体地,它会保存ra(返回地址)、sp(栈指针)、s0-s11(保存寄存器)、t0-t6(临时寄存器)等。
  • 跳转到usertrap
    • 寄存器状态保存到trapframe后,uservec将执行权交给usertrap函数。这个跳转是通过jr t0完成的,其中t0寄存器存储了usertrap函数的地址。
    • usertrap是核心的中断和系统调用处理函数。在usertrap中,根据scause寄存器的值,判断进入内核态的原因:
      • 系统调用:如果scause的值为8,则是系统调用。usertrap会调用syscall函数来处理这个系统调用。
      • 其他中断:例如定时器中断,则调用devintr函数来处理中断。

3. 在内核态处理

  • 系统调用的处理
    • syscall函数根据系统调用编号,从系统调用表中选择相应的处理函数(如sys_sleepsys_read等)。
    • 处理完系统调用后,syscall函数会将结果存入trapframea0寄存器中(用于存储返回值)。
  • 中断的处理
    • 如果是定时器中断,devintr函数会调用clockintr来处理定时器中断,可能会增加系统时钟计数ticks,并且触发进程调度。
    • 如果进程需要出让CPU,usertrap会调用yield函数进入调度流程,最终调用sched进行上下文切换。

4. 返回用户态:从usertrapret到用户代码

  • 调用usertrapret
    • 无论是系统调用还是中断处理完成后,usertrap都会调用usertrapret函数,准备返回用户态。
    • usertrapret设置好返回用户态时需要的上下文,包括将stvec重新设置为uservec,以及恢复用户态的sepc(程序计数器)和sstatus(状态寄存器)。
  • 跳转到userret
    • usertrapret的最后,它调用了trampoline.S中的userret代码。userret的任务是从trapframe中恢复所有用户态的寄存器,并通过sret指令切换到用户态。
    • sret指令会恢复处理器状态并切换到用户模式,使用户进程继续从中断或系统调用前的地方执行。

5. 关键调用链

  • 进入内核态的过程
    1. 用户态代码(ecall 或 中断触发)
    2. 处理器进入内核态,跳转到 uservec
    3. uservec 保存寄存器状态到 trapframe
    4. uservec 跳转到 usertrap
    5. usertrap 处理系统调用或中断
      • 系统调用:usertrap -> syscall -> 对应系统调用函数
      • 中断:usertrap -> devintr -> 对应中断处理函数
  • 返回用户态的过程
    1. usertrapret 恢复用户态上下文
    2. usertrapret 调用 userret
    3. userret 恢复寄存器状态,并通过 sret 切换到用户态
    4. 用户态代码继续执行

不论是系统调用还是中断,用户态到内核态的切换都是通过trampoline.S中的uservec来完成的,返回用户态的过程则通过userretsret指令来完成。这一流程确保了用户态进程在内核态处理完系统调用或中断后,能够正确恢复并继续执行。


定时器中断引发的线程切换流程

定时器中断是操作系统实现预抢占调度的关键机制。以下是定时器中断引发线程切换的详细全流程,包括从定时器中断发生到内核调度器选择下一个进程的全过程。

  1. 定时器中断触发
    • 当前进程正在用户态运行时,硬件定时器触发中断。
    • 定时器中断强制CPU从用户态切换到内核态,保存当前进程的用户态上下文并执行中断处理。
  2. 进入usertrap之前的处理:uservec
    • CPU切换到内核态后,首先跳转到trampoline.S中的uservec代码段,这段代码是由stvec寄存器指定的。
    • uservec负责将当前用户态进程的所有寄存器状态保存到该进程的trapframe结构中,以便稍后能够恢复这些寄存器状态。
    • 保存完成后,uservec跳转到内核中的usertrap函数。
  3. 进入usertrap函数,处理中断或异常
    • usertrap函数负责处理进入内核态后的中断。它会检查当前中断的原因,通过读取scause寄存器的值来判断具体的中断类型。
    • 如果是定时器中断或者其他外部中断,usertrap会调用devintr来处理中断。
  4. 处理定时器中断,调用yield出让CPU
    • 如果中断类型为定时器中断(devintr返回2),则意味着可能需要进行进程调度。此时,usertrap会调用yield函数,让出CPU,从而允许调度器选择另一个进程运行。
  5. yield函数的执行
    • yield函数首先获取当前进程的锁p->lock,以确保接下来的操作是原子性的。
    • 将当前进程的状态从RUNNING设置为RUNNABLE,表示它已准备好再次被调度。
    • 然后调用sched函数来进行上下文切换,将CPU控制权让给内核调度器。
  6. sched函数的执行
    • sched函数检查进程的锁是否被持有,确保状态的一致性。
    • 调用swtch函数,将当前进程的上下文(寄存器状态等)保存到进程的context结构中。
    • swtch函数将CPU控制权切换到调度器线程(即调度器的上下文),完成当前进程的挂起。
  7. swtch函数:上下文切换
    • swtch函数保存当前进程的寄存器状态到其context中,然后恢复调度器线程的寄存器状态。
    • 调度器线程恢复执行,通过scheduler函数选择下一个可运行的进程。
  8. 调度器线程的工作
    • 调度器线程在scheduler函数中遍历所有进程,找到一个RUNNABLE状态的进程。
    • 调度器将选定的进程状态设置为RUNNING,并通过swtch切换到该进程的上下文。
  9. 恢复挂起的进程
    • swtch函数在恢复进程的上下文后,从上次挂起的位置继续执行进程的代码。
    • 进程是由于定时器中断而被挂起的,恢复后的进程从schedyeild返回从usertrap函数的上下文中继续执行,最终通过usertrapret返回到用户态。
    • usertrapret通过调用trampoline.S中的userret,使用sret指令切换回用户态,使进程继续执行用户代码。

函数调用链

  • 进入中断处理
    1. 用户态:user code(定时器中断触发)
    2. 内核态:usertrap -> devintr -> clockintr -> usertrap -> yield -> sched -> swtch
  • 挂起和上下文切换
    1. 调度器:swtch -> scheduler (choose another process) -> swtch
  • 恢复挂起进程
    1. 调度器:swtch (restore context)
    2. 继续执行:sched -> yield -> usertrap -> usertrapret -> userret -> user code

下面部分可以学完L13再回来看

sys_sleep函数

uint64
sys_sleep(void)
{
  int n;
  uint ticks0;

  argint(0, &n);  // 从系统调用参数中获取休眠时间
  if(n < 0)
    n = 0;
  acquire(&tickslock);  // 获取系统时钟的锁
  ticks0 = ticks;       // 记录当前的tick数
  while(ticks - ticks0 < n){
    if(killed(myproc())){
      release(&tickslock);  // 如果进程被杀死,释放锁并返回-1
      return -1;
    }
    sleep(&ticks, &tickslock);  // 调用sleep函数进行休眠
  }
  release(&tickslock);  // 休眠结束后释放锁
  return 0;
}

功能sys_sleep是一个系统调用处理函数,用于将用户进程休眠指定的时间(以tick为单位)。

实现

  • sys_sleep首先从系统调用参数中获取要休眠的时间n
  • 然后它获取tickslock(保护系统时钟ticks的锁),记录当前的系统时间ticks0
  • 进入一个循环,直到系统时间增加了指定的n tick数。
  • 在循环中,它调用sleep(&ticks, &tickslock),将当前进程放到ticks通道上等待。
  • 当系统时间达到指定的tick数,进程醒来并释放tickslock

系统调用挂起(如 sys_sleep通过sleep)引发的线程切换全流程

我们提到了定时器中断之外的切换场景,系统调用(如sys_sleep)也可能触发线程切换。在XV6操作系统中,系统调用(如sys_sleep)引发的线程切换涉及多个步骤。以下是详细的全流程描述,包括从用户进程发起系统调用,到内核进行调度的全过程。

流程详解

  1. 用户进程发起系统调用

    • 用户进程在用户态执行代码时,遇到需要执行系统调用的情况(如sys_sleep)。
    • 用户进程通过ecall指令发起系统调用。ecall是RISC-V架构中的指令,用于从用户态进入内核态。
  2. 进入内核态:保存用户态寄存器并跳转到syscall函数

    • ecall指令触发软中断,处理器切换到内核态。
    • 处理器首先跳转到trampoline.S中的uservec代码段,uservec负责将当前用户态的寄存器状态保存到进程的trapframe中。
    • 然后,uservec跳转到usertrap函数,usertrap函数识别出这是一个系统调用,并调用syscall函数。
  3. 执行具体的系统调用处理(如sys_sleep
    • syscall函数根据trapframe中存储的系统调用编号,调用相应的内核函数(如sys_sleep)。
    • 在这个例子中,sys_sleep函数根据传入的参数(如休眠时间)执行休眠操作,并判断进程是否需要等待。
  4. 进程进入等待状态
    • 调用sleep函数判断当前进程需要等待指定时间后再继续执行,因此将当前进程的状态设置为SLEEPING
    • 为了让其他进程获得CPU,sleep函数会调用sched函数来来进行调度。
  5. sched函数的执行
    • sched函数要求进程锁已经被持有,它检查锁的持有状态以防止不一致。
    • sched函数调用swtch函数,将当前进程的上下文(寄存器状态等)保存到该进程的context结构中。
    • swtch函数将CPU控制权切换到调度器线程,进程的执行被挂起。
  6. swtch函数:上下文切换
    • swtch函数保存当前进程的寄存器状态,并恢复调度器线程的寄存器状态。
    • 调度器线程恢复执行,选择其他可运行的进程。
  7. 调度器线程选择下一个进程

    • 调度器线程通过scheduler函数选择下一个可运行的进程,并使用swtch切换到该进程。
    • 如果找到的是另一个可运行的进程,调度器线程会将其状态从RUNNABLE设置为RUNNING,并将控制权交给它。
  8. 恢复挂起的进程并继续执行

    • 当调度器线程再次选择之前被sleep挂起的进程时,它通过swtch恢复该进程的上下文。
    • 恢复后的进程从被挂起的位置继续执行。
    • 系统调用处理完毕后,syscall函数将返回值写入trapframe中的a0寄存器中。
  9. 返回用户态

    • syscall函数完成后,usertrap函数调用usertrapret,准备返回到用户态。

    • usertrapret会调用trampoline.S中的userret,恢复用户态寄存器并使用sret指令切换回用户态,继续执行用户进程的代码。

函数调用链

  • 进入系统调用
    1. 用户态:user code (ecall)
    2. 内核态:uservec -> usertrap -> syscall -> sys_sleep -> sleep -> sched -> swtch
  • 挂起和上下文切换
    1. 调度器线程:swtch -> scheduler (choose another process) -> swtch
  • 恢复挂起进程
    1. 调度器线程:swtch (restore context)
    2. 继续执行:sched -> sleep -> sys_sleep -> syscall -> usertrap -> usertrapret -> userret -> user code