开门见山说一句: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;
uint64 s2;
uint64 s3;
uint64 s4;
uint64 s5;
uint64 s6;
uint64 s7;
uint64 s8;
uint64 s9;
uint64 s10;
uint64 s11;
};
看到没有,这里没有保存x0到x17这些临时寄存器,只保存了ra、sp和s0到s11。这正是RISC-V calling convention的约定:s0到s11是callee-saved,也就是“被调函数如果在使用这些寄存器,必须负责保存和恢复原值”。而ra保存的是函数返回地址,之所以需要保存它,是因为我们要做的不是一个普通的函数调用返回,而是跨线程跳转。每次switch调用后,ra的值决定了这个线程恢复时执行的下一条指令是什么。
你可以把context理解成一个游戏的存档点:存档的时候保存角色位置、血量、背包,读档的时候恢复这些数据,游戏就从存档点继续。线程切换做的正是这件事,只不过“存档”和“读档”的数据是CPU寄存器的值。
2.2 switch函数的汇编逻辑
xv6中真正执行寄存器保存和恢复的函数是switch,它不是用C写的,而是一段汇编。为什么用汇编?因为C语言没有直接操作寄存器的通用抽象,你没法在C里“把当前所有寄存器存到一个变量里”,只能用内联汇编或独立汇编文件。xv6的实现很简洁,switch.S的核心逻辑就是:保存当前context到old结构体,从new结构体恢复寄存器,然后ret。
assembly复制# xv6 kernel/swtch.S 的核心逻辑(简化)
.globl swtch
swtch:
sd ra, 0(a0) # 保存 ra
sd sp, 8(a0) # 保存 sp
sd s0, 16(a0)
sd s1, 24(a0)
# ... 保存 s2 到 s11 ...
ld ra, 0(a1) # 恢复新线程的 ra
ld sp, 8(a1) # 恢复新线程的 sp
ld s0, 16(a1)
ld s1, 24(a1)
# ... 恢复 s2 到 s11 ...
ret # 跳转到新线程的 ra
这段代码没什么魔法,就是两条流水线操作:先把当前寄存器的值存到old context,再把new context里的值加载到寄存器。最后一条ret指令会跳到新线程的ra地址继续执行。整个切换过程是在内核态完成的,因为xv6的线程切换都是在内核上下文之间进行的。
这里有一个非常容易忽略的点:切换线程时,CPU的内核栈也换了。每个内核线程有自己的内核栈(xv6里是进程结构的一部分),sp寄存器保存了新线程的内核栈顶。这样新线程在调用其他函数时,栈空间是独立的,不会跟旧线程的栈混在一起。如果你在写多线程程序时遇到了栈溢出或者栈被破坏的问题,多半就是线程栈没有隔离开。
2.3 调度器是怎么跑起来的
有了switch函数这个底层原语,调度器就可以在“没有线程在运行”的空档期选择下一个要运行的线程。xv6的调度器是一个死循环,运行在CPU0(多核环境下每个CPU都有独立的调度器),它的逻辑大致是:
- 遍历进程表,找到一个状态为RUNNABLE的进程。
- 持有该进程的锁,把它当前的状态改成RUNNING。
- 调用switch函数,从调度器自己的context切换到目标进程的context。
- 当前执行的进程运行一段时间后被定时器中断打断,通过yield主动让出CPU,又通过switch切回调度器的context。
- 调度器继续循环,找下一个RUNNABLE进程。
这个过程中,switch承担了调度器和被调度进程之间的桥梁作用。理解的关键点在于:调度器也有自己的context,它不是一个特殊的抽象实体,而是和普通线程一样,有自己的栈和上下文。切换发生时,调度器把当前进程的context保存下来,然后加载下一个进程的context,接着执行ret,CPU就“跳”进了新进程的执行流。反过来,当进程让出CPU时,调度器又通过switch从进程的context切回到调度器的context,调度器接着在for循环里继续找下一个进程。
这个过程我反复看了很多遍才真正理解。你一旦把“进程切换 == 两个context之间的switch调用”这个模型刻进脑子里,再去看Linux的schedule函数,会发现骨架是类似的。只是Linux要处理的事情更多,比如多核负载均衡、条件变量、sleep队列,但核心的context switch思想跟xv6完全一致。
3. 锁的哲学:为什么xv6只给你一个自旋锁
3.1 自旋锁的实现:一条指令背后的原子性
多线程共享数据时,如果两个线程同时对同一个变量做“读-改-写”,就可能出现竞态条件。比如两个线程同时对一个计数器执行 count++,这个操作在机器层面其实是三步:读count到寄存器、寄存器加一、把新值写回count。如果线程A读到了1,线程B也读到了1,然后A写回2,B也写回2,最终结果是2而不是3。这个bug是经典中的经典。
解决竞态的办法就是锁。xv6实现的是自旋锁(spinlock),这是最简单的一种锁:如果锁被占用,线程就在原地循环等待,而不是睡眠。xv6选择自旋锁而不是信号量或互斥锁,有教学上的考虑——自旋锁的代码量最小,并且能让学习者直观理解“原子操作 + 内存屏障”这两个并发编程的基石概念。
自旋锁的核心是一个通过原子指令实现的标志位。RISC-V提供了一条原子指令叫amoswap,它可以原子地完成“读一个内存地址的值并写入一个新值”。xv6的acquire函数就是靠这条指令实现的:
c复制// 摘自 xv6 kernel/spinlock.c(语义说明)
void acquire(struct spinlock *lk)
{
// 关闭中断,避免当前CPU死锁
push_off();
// 原子地交换:把1写入lk->locked,同时取出原来的值
while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
// 内存屏障:保证后续的读写操作不会上浮到锁获取之前
__sync_synchronize();
}
__sync_lock_test_and_set 其实是gcc内建函数,底层就是amoswap那条指令。如果locked的原值是0,说明锁空闲,交换后变成1,当前线程成功拿到锁,循环退出;如果原值是1,说明锁被占用,交换结果还是1,条件不满足,线程继续循环尝试。这就是自旋的含义:锁被占时,线程不会让出CPU,而是不断重试。
release则是相反的操作:先加内存屏障,再把locked写回0。这个顺序很重要——必须先确保临界区内的所有写入都完成并对外可见,再释放锁,否则另一个线程拿到锁后可能看到未更新的数据。
3.2 acquire/release代码拆解与为什么先关中断
上面的acquire函数还有一个关键步骤被我省略了,就是第一行的push_off()。为什么获取锁之前要关中断?考虑一个场景:当前CPU运行进程A,A尝试获取一个锁,但锁被B占用,于是A自旋等待。如果此时时钟中断来了,A被迫让出CPU,调度器可能会选一个也需要这个锁的进程C来运行。C也自旋等待,而且C持有该锁的可能性为零(锁还在B手里),于是C也卡住了。更糟的情况是调度器永远挑不出一个能释放锁的进程,形成死锁。
xv6的做法是在acquire里关中断,在release里恢复原来的中断状态。这样在自旋等待期间,当前CPU不会被时钟中断打断,也就不会出现“持有锁的进程还没执行完就被换下去”的情况。这个细节值得反复体会:锁的设计不只是要考虑数据一致性,还要考虑它和中断、调度的交互。你以后写Linux内核模块或者看网络驱动代码时,会反复遇到这种“该不该关本地中断”的权衡。
还有一点:多核场景下,每个CPU不能再关其他CPU的中断,所以自旋锁还得保证在别的核上看到的内存状态是一致的。这就是为什么acquire的成功路径最后要加__sync_synchronize()内存屏障——它告诉CPU和编译器,屏障前面的内存读写不能被移到屏障后面执行,反之亦然。没有这个屏障,编译器可能优化掉一些看似冗余的read,CPU也可能乱序执行指令,导致临界区里读到的数据是过期的。初学者容易忽略内存屏障这一层,总以为“加锁 = 原子地把locked变成1”就够了,实际上锁还隐含着内存序的语义。
3.3 锁粒度的权衡:从big kernel lock到per-bucket锁
xv6最初版本(以及早期Linux内核)都在用一种叫“big kernel lock”的策略:整个内核只设一把全局锁,任何内核代码路径进入临界区前都得先拿到它。这种策略在单核时代没问题,因为一次只有一个CPU在跑,锁争用的概率很小。但到了多核时代,全局锁就成了性能瓶颈:哪怕不同CPU在操作完全无关的数据结构,它们也得串行排队拿同一把锁,这对并行度是巨大的损害。
6.S081的locks实验就是让你体验这个优化过程。实验内容大致是:xv6里有一个buffer cache,它维护了很多磁盘块缓存,所有CPU访问缓存时都要先拿到一个全局的大锁。这个锁争用严重,导致多核并发读不同磁盘块时性能上不去。课程要求你重新设计锁的粒度,比如把缓存分成多个哈希桶,每个桶一把独立的锁,这样不同桶上的操作就可以并行执行,只有操作同一个桶时才需要竞争。
锁粒度优化的本质是“把一把大锁拆成多把小锁”,但拆分不是简单地把lock数组从1改成N。你还要考虑并发一致性的问题:原本全局锁保护的是缓存项之间的链表关系(比如LRU淘汰时要把某个块移到链表头部),拆成多把锁之后,跨桶的LRU操作要怎么做?xv6提供了一个提示:你可以用一个单独的timestamp字段来记录每个缓存项的最后使用时间,淘汰时扫描所有缓存项找最旧的,而不是依赖全局链表顺序。这个思路本身也是一种极好的工程实践——有时候数据结构的调整能比锁优化更有效地降低争用。
我在做这个实验时踩过一个坑:刚开始我把哈希桶的锁粒度加到了每个桶一把锁,但访问一个缓存项时,由于需要执行“找到桶 -> 在桶内链表查找 -> 更新refcnt -> 更新timestamp”这一整串操作,我在中途释放了桶锁,结果别的CPU趁虚而入把缓存项淘汰掉了,导致refcnt校验失败。后来发现必须保证“从桶里找到一项并把它标记为正在使用”这个动作是原子的,至少reference count的增加要在桶锁保护下完成。这种经验其实很难从文档里学到,只有真正跑崩了才能体会。
4. 实验记录:在xv6里实现用户线程与锁优化
4.1 用户线程实验:从0到1造一个协程调度器
6.S081的uthread实验要求你实现一个用户态线程切换库。xv6本身没有用户态多线程的系统调用,所以你得在用户态模拟。实验给了一个骨架,包含thread结构体、三个用户线程的栈、一个schedule函数,你需要补全thread_create、thread_schedule和thread_switch。
thread结构体大致是:
c复制struct thread {
char stack[STACK_SIZE]; // 每个线程独立的栈
int state; // RUNNABLE / RUNNING / EXITED
uint64 context; // 切换时需要保存的上下文
};
这个设计跟内核对线程的描述如出一辙:每个线程有自己的栈,有一个状态,还有保存现场用的上下文。你需要在thread_switch函数里用内联汇编或独立汇编把寄存器保存到旧线程的context,再从新线程的context恢复。
这个实验最有价值的地方在于它极其直观。你在用户态配合printf调试,可以看到执行流是如何从线程0跳到线程1再跳到线程2的。我第一次跑通的时候,看着printf输出的顺序从0/1/2交错出现,再对照着swtch汇编一行行看寄存器是怎么存的,才真正理解了“切换”这个词的含义——本质上就是一次跨栈的函数跳转,只不过这个跳转不是靠call实现的,而是靠手动保存ra、sp再ret来实现的。
写这个实验时有个细节:thread_switch你可能会尝试在C函数里用函数指针模拟跳转,比如把新线程的栈指针和入口地址传给一个函数,但这个函数本身返回地址是乱的,因为新线程的栈上还没有合法的返回地址。正确的做法是像内核swtch那样用一个纯汇编函数,把保存和恢复寄存器的工作做完后再ret。如果强行用C写,一般都会栈溢出或者跳飞。
4.2 锁优化实验:多把锁不一定比一把锁更快
再回到locks实验。fork的时候需要给每个进程分配一个pid,xv6里的pid计数器也是被一把全局锁保护的。实验要求你把这个计数器改成每CPU核一个计数器,每个核从自己的计数器分配pid段,定期汇总。这样做的思路是降低锁的争用频率——原本每次fork都要竞争同一把全局锁,改成per-CPU计数器后,不同核上的fork互不干扰。
但要注意一个陷阱:在多核环境下,per-CPU计数器汇总时必须考虑并发。如果你在每个CPU的计数器上再套一把锁,其实没解决根本问题。更好的做法是利用每个CPU核“同一时间只有一个线程在跑”这个事实,把每个核的计数器做成核私有数据,不需要锁保护,只要在需要分配全局唯一pid时才对所有核的计数器求和。正确性验证也很关键:你fork了大量进程之后,必须确认所有进程的pid都是唯一的,一个重复的pid就能炸掉整个文件系统实验环境。
做locks实验时我用了一个简单的并发测试脚本:起多个CPU核同时跑fork炸弹,观察是否出现断言失败或者死锁。这个实验让我明白一个道理:锁的粒度越细,代码路径上需要加锁的次数往往越多,如果每次锁的持有时间很短,额外开销可能超过并行带来的收益。所以“多把锁一定更快”是个伪命题,你必须先用perf或代码逻辑分析清楚瓶颈到底在哪里。
这个体会在真实的工程里同样成立。我后来看Linux内核里一些性能相关的补丁讨论,经常看到“本来是全局锁,改成percpu lock之后反而慢了,因为某种访问模式下锁的开销大于争用时间”的案例。所以说,这个实验虽然在xv6上跑,但它的方法论是通用的。
4.3 调试验证:怎么确认自己真的改对了
做完这两个实验后,你可能会有一个疑问:我怎么知道我的实现真的正确?除了跑通课程提供的测试用例,我建议再做这三件事:
第一,给切换代码加上计数器。在thread_switch每次被调用时递增一个全局变量,然后在线程退出前打印这个计数,确认切换次数符合预期。如果切换次数明显偏多,比如线程一直在乒乓切换但没实际推进,多半是调度逻辑有bug,比如没有把RUNNING状态的线程排除。
第二,用xv6的gdb调试工具逐步单步跟踪switch汇编。我强烈建议你把gdb断点打在thread_switch上,一步步看寄存器是怎么被保存和恢复的。你可能会有意外发现,比如某些寄存器被编译器隐含使用,导致保存不完整。
第三,构造一个压力测试。对于锁优化实验,让两个核同时对同一个buffer cache进行大量读写,然后对比优化前后的耗时。我当时的测试结果是per-bucket锁比全局锁快了大约40%,而且并发度越高提升越明显。这个数字本身不是重点,重点是你要理解为什么会有这个提升,以及什么情况下提升会消失。
5. 调试与避坑:那些排查到凌晨的bug
5.1 竞态条件复现的经典技巧
竞态条件是并发编程里最难复现的bug之一,因为它依赖精确的时序。我在做xv6的锁实验时遭遇过一个问题:两个CPU同时写一个共享日志缓冲区,偶发出现日志记录丢失。第一次跑测试时没复现,跑了20多次才出现一次。
复现竞态条件的技巧是制造时间窗口。最简单的方法是在临界区的临界代码前后加一个sleep或延迟循环,人为放大竞态窗口。窗口越大,冲突概率越高。比如你可以这样验证锁是否真的保护了临界区:临时把acquire和release注释掉,如果bug立刻高频复现,说明锁确实在起作用;如果注释掉也无法复现,那问题可能不在锁保护范围内。
更进阶一点,可以在关键位置打印CPU编号和当前进程的pid,用来确认确实是多核并发导致的,而不是单核时序问题。xv6的printf本身就带锁,打印本身不会引入新的竞态,可以放心用。
5.2 死锁的几条侦察路径
死锁也是多线程编程的标配噩梦。xv6里最简单的一种死锁是:进程A持有锁1,想拿锁2;进程B持有锁2,想拿锁1,两个卡死。
调试死锁的思路是,先看卡住的进程栈。在xv6里你可以在gdb里执行info threads查看所有CPU的当前上下文,或者直接在崩溃现场打印所有进程的状态。如果发现多个CPU都停在acquire的自旋循环里,死锁可能性极大。
然后你要看锁的获取顺序。xv6的源代码里有一处著名的“锁顺序约定”:任何代码路径如果要获取多个锁,必须按照相同的顺序获取。这个约定在真实项目里靠code review和维护文档来保证,新手最容易违反它。我在做文件系统相关实验时,就因为在两个函数里用不同的顺序获取同一个inode锁和buffer cache锁,导致偶发死锁。当时排查了很久,最后把两个函数的锁顺序统一才解决。
5.3 常见问题速查表
这里整理一份我在刷这章内容时记录的常见问题,多少有些主观,但踩过的坑应该对后来者有用:
| 问题现象 | 可能原因 | 排查方式 |
|---|---|---|
| 线程切换后栈内容被破坏 | 切换函数保存/恢复的寄存器不完整 | gdb单步跟踪switch,对比切换前后寄存器的值 |
| 自旋锁获取后卡死 | 中断未关闭,调度器切走了持有锁的线程 | 检查acquire里是否先push_off关闭中断 |
| 多核下数据不一致 | 缺少内存屏障,编译器或CPU重排指令 | 检查acquire/release是否调用__sync_synchronize |
| fork后pid重复 | per-CPU计数器汇总逻辑有误 | fork大量进程后打印所有pid,排序查重 |
| buffer cache偶发读错数据 | 缓存项的refcnt增加与淘汰操作不在同一锁保护下 | 审查淘汰路径和获取路径是否持同一把桶锁 |
| 多线程程序性能不升反降 | 锁粒度太细,频繁加锁开销大于争用开销 | 对比一把大锁和拆成多把锁的耗时 |
这张表不是标准答案,但它反映的是我实际调试中比较常遇到的类型。大部分坑只要你理解了“锁的存在是为了防止交错执行破坏不变量”这句话,就能自动避开一半。
5.4 独家心得:用测试验证一致性,而不是用直觉
我自己在写完锁优化实验后的一个感悟是:工程上判断锁是否搞对了,不能靠读代码“感觉没问题”,而是要靠并发压力测试加断言。xv6的测试脚本虽然能查基本功能,但不会主动暴露竞态条件。你可以在关键数据结构上临时加上若干不变量断言,比如持有锁期间链表长度必须一致、reference count必须大于0。这些断言在单核串行下永远成立,只有并发跑到临界点时才会触发,一旦触发,你就是真的找到了一处锁漏洞。
这种“拿着断言去撞竞态”的思路,在我后来写用户态服务器代码时也一直沿用。多线程程序里百分之九十的诡异问题,都是不变量被并发破坏导致的。你早点掌握这个思维,后面看多少并发库源码都不怕。
6. 学习路径建议:多线程这块该如何吃透
MIT6.S081的多线程内容学完之后,我觉得可以再往下走三步来巩固:
第一,动手把xv6里的自旋锁换成Linux风格的mutex,体验两者语义的差异。你可以简单把acquire的自旋等待改成让出CPU,比如调用yield,然后观察会不会引入新的性能问题。这一步能帮你理解“自旋 vs 睡眠”的取舍。
第二,把用户态协程的知识迁移到真实编程语言里。比如用C语言实现一个简单的协程库,或者用Java的CompletableFuture处理并发任务,你会发现很多底层机制跟你在xv6里写的thread_schedule极其相似:任务状态机、任务队列、上下文切换。不同语言只是换了壳,核心思想没变。
第三,如果你想深入内核级多线程,建议去读《xv6 book》的Scheduling章节以及xv6最新的RISC-V代码,反复琢磨scheduler函数和sched函数之间的对称性。之后可以再看Linux的kernel/sched/core.c里__schedule的实现,此时你对上下文切换的认知已经足够看懂大部分代码了。
要提醒的是,MIT6.S081不是一门轻松课,尤其多线程部分概念密度很高。如果你也是初学者,碰到不懂的地方可以先跳过去,先把实验跑起来再回头看书。我当时就是先花了两个小时把uthread实验的调用链理清楚,才回头重新读xv6 book,效果比反复读死书好得多。
