开门见山说一句:MIT6.S081这门课,刷到多线程这一讲的时候,才算真正把操作系统的“魂”给接上了。前面的进程、中断、文件系统,都是在说“一台机器怎么把一个程序跑起来”,到了多线程这里,画风突变,变成“一台机器怎么让一堆程序同时跑起来还不打架”。这中间的思维方式差异非常大,从单线程到多线程,不是多开几个进程那么简单,而是要把“并发”当作一种基本约束来考虑问题。
这篇文章是MIT6.S081学习记录的第七篇,主题锁定在多线程。我会把课程里关于xv6线程模型、上下文切换、自旋锁实现、锁粒度优化,以及6.S081配套实验(uthread和locks)的实操内容铺开讲。适合正在刷MIT6.S081的读者,也适合学过操作系统但没动手写过线程切换代码的人——看完能对“线程到底切换了什么”“锁到底在锁什么”有画面感,而不是停留在背八股文的层面。
1. 多线程章节到底在讲什么:一门操作系统的核心命题
1.1 为什么学到这里才谈线程
在MIT6.S081的课程顺序里,多线程被放在了进程、系统调用、页表和中断之后,这个顺序是有讲究的。前面的内容解决的是“单个程序的执行”问题:一个程序从加载进内存,到获得CPU,再到调系统调用、陷入内核、返回用户态,整条路径是单线程的。你不需要考虑“另一个进程也在同时跑”这件事对代码的影响,因为操作系统已经通过进程隔离把世界切成了一个个独立的空间,每个进程觉得CPU是自己独占的。
但现实中的CPU核心数量是有限的,服务器上跑的进程数量却远远超过核心数。操作系统靠时间片轮转来制造“同时运行”的假象,这就是并发的基础。而一旦你开始把一个大任务拆成多个线程去跑,或者多个进程同时去操作共享资源,问题就来了:指令的执行是交错进行的,你对某个变量做的“读-改-写”操作,可能会被另一个线程的同类操作打断,结果导致数据出错。
多线程这一章的核心命题就是这个:怎么在并发交错执行的条件下,保证数据一致性和程序正确性。这不仅仅是xv6的问题,也是你在Java、Python、C++里写多线程代码时遇到的根本问题。
1.2 xv6的线程模型:一个进程一个线程的简单版本
先要明确xv6的线程模型。我们知道xv6是一个教学操作系统,它的设计原则是“用最小的代码量把核心机制讲清楚”。在xv6里,每个进程默认只有一个线程,这个线程在用户态跑用户代码,进内核跑系统调用时就切换成内核线程。所以xv6的线程模型可以理解为:一个进程 = 一段地址空间 + 一个用户线程 + 一个与之对应的内核线程。
这个模型比Linux简单很多。Linux里一个进程可以有多个线程,这些线程共享地址空间但有各自的寄存器和栈,内核提供clone系统调用来支持线程创建。xv6没有提供类似clone的多线程系统调用,所以在xv6里写用户态多线程程序,你得自己实现线程库。这就是6.S081实验里uthread的由来:课程要求你在用户态用协程的方式实现一个简单的线程调度器,让多个用户线程在一个进程内切换运行。
为什么要这样做?理解了“一个进程只有一个线程”的简单版本,你才能明白Linux里多线程的实现难点在哪里。难点不在于创建线程时需要分配栈、保存寄存器状态,而在于切换线程时,你要保证保存现场和恢复现场的时机是安全的。xv6把用户线程切换和内核线程切换分离开来,让你先学最简单的用户态线程切换,再去看内核态进程调度,难度曲线就顺了。
这里还要澄清一个概念:硬件线程、内核线程、用户线程的区别。我们常说的“8核16线程”,指的是CPU层面的硬件线程,它关乎的是并行能力。内核线程是操作系统调度器管理的执行单元,它有自己的内核栈和上下文,是CPU上真正运行的实体。用户线程是用户态库调度的执行单元,对内核来说它只是某个进程的一部分,内核感知不到用户线程的存在。xv6的进程切换实际上是在切换内核线程,而uthread实验里切换的则是用户线程,两者寄存器保存的位置不同,但原理是相通的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程切换的底层真相:从一条switch指令说起
2.1 context结构:线程切换的“存档点”
线程切换的本质,是暂停一个执行流,保存它的全部现场,再恢复另一个执行流的现场,让CPU接着跑。这里的“现场”就是寄存器的值,包括通用寄存器、程序计数器(PC)、栈指针(SP)以及一些状态寄存器。保存和恢复这两步动作必须原子完成,否则切换过程中一旦被打断,现场就乱了。
xv6里保存寄存器的结构体叫context,定义在proc.h里。它保存的不是全部寄存器,而是一组精简的callee-saved寄存器。为什么只需要保存callee-saved寄存器?因为caller-saved寄存器的值由编译器在函数调用边界保存和恢复,切换线程这个动作本身也是一个函数调用,编译器已经帮我们处理好一部分寄存器的保存了。我们只需要额外保存那些“调用者不管、被调函数自己负责保存”的寄存器即可。
xv6的context结构体大概是这样:
c复制// xv6 proc.h 中的上下文结构
struct context {
uint64 ra; // 返回地址,即切换后要继续执行的地址
uint64 sp; // 栈指针
uint64 s0; // callee-saved 寄存器
uint64 s1;
