13.1 线程切换过程中锁的限制

今天这节课中,首先我们会花几分钟来重温一下上节课,也就是线程切换的内容,因为有些内容对于这节课来说还挺重要的。之后我们大部分时间都会讨论coordination,XV6通过Sleep&Wakeup实现了coordination。最后我们会讨论lost wake-up的问题。

首先是上节课的回顾。在XV6中,任何时候调用switch函数都会从一个线程切换到另一个线程,通常是在用户进程的内核线程和调度器线程之间切换。在调用switch函数之前,总是会先获取线程对应的用户进程的锁。所以过程是这样,一个进程先获取自己的锁,然后调用switch函数切换到调度器线程,调度器线程再释放进程锁。

实际上的代码顺序更像这样:

  1. 一个进程出于某种原因想要进入休眠状态,比如说出让CPU或者等待数据,它会先获取自己的锁;

  2. 之后进程将自己的状态从RUNNING设置为RUNNABLE;

  3. 之后进程调用switch函数,其实是调用sched函数在sched函数中再调用的switch函数;

  4. switch函数将当前的线程切换到调度器线程;

  5. 调度器线程之前也调用了switch函数,现在恢复执行会从自己的switch函数返回;

  6. 返回之后,调度器线程会释放刚刚出让了CPU的进程的锁

在第1步中获取进程的锁的原因是,这样可以阻止其他CPU核的调度器线程在当前进程完成切换前,发现进程是RUNNABLE的状态并尝试运行它。为什么要阻止呢?因为其他每一个CPU核都有一个调度器线程在遍历进程表单,如果没有在进程切换的最开始就获取进程的锁的话,其他CPU核就有可能在当前进程还在运行时,认为该进程是RUNNABLE并运行它。而两个CPU核使用同一个栈运行同一个线程会使得系统立即崩溃。

所以,在进程切换的最开始,进程先获取自己的锁,并且直到调用switch函数时也不释放锁。而另一个线程,也就是调度器线程会在进程的线程完全停止使用自己的栈之后,再释放进程的锁。释放锁之后,就可以由其他的CPU核再来运行进程的线程,因为这些线程现在已经不在运行了。

以上是线程切换中非常重要的知识点。我们之后会用到它,因为这是Sleep&Wakeup设计中众多限制条件之一。对于在线程切换的过程中需要一直持有p->lock,大家有什么问题吗?

学生提问:当我们有多个CPU核时,它们能看到同样的锁对象的唯一原因只可能是它们有一个共享的物理内存系统,对吧?

Robert教授:是的。如果你有两个电脑,那么它们不会共享内存,并且我们就不会有这些问题。现在的处理器上,总是有多个CPU核,它们共享了相同的内存系统。

在线程切换的过程中,还有一点我之前没有提过。XV6中,不允许进程在执行switch函数的过程中,持有任何其他的锁。所以,进程在调用switch函数的过程中,必须要持有p->lock(注,也就是进程对应的proc结构体中的锁),但是同时又不能持有任何其他的锁。这也是包含了Sleep在内的很多设计的限制条件之一。如果你是一个XV6的程序员,你需要遵循这条规则。接下来让我解释一下背后的原因,首先构建一个不满足这个限制条件的场景:

我们有进程P1,P1的内核线程持有了p->lock以外的其他锁,这些锁可能是在使用磁盘,UART,console过程中持有的。之后内核线程在持有锁的时候,通过调用switch/yield/sched函数出让CPU,这会导致进程P1持有了锁,但是进程P1又不在运行。

假设我们在一个只有一个CPU核的机器上,进程P1调用了switch函数将CPU控制转给了调度器线程,调度器线程发现还有一个进程P2的内核线程正在等待被运行,所以调度器线程会切换到运行进程P2。假设P2也想使用磁盘,UART或者console,它会对P1持有的锁调用acquire,这是对于同一个锁的第二个acquire调用。当然这个锁现在已经被P1持有了,所以这里的acquire并不能获取锁。假设这里是spinlock,那么进程P2会在一个循环里不停的“旋转”并等待锁被释放。但是很明显进程P2的acquire不会返回,所以即使进程P2稍后愿意出让CPU,P2也没机会这么做。之所以没机会是因为P2对于锁的acquire调用在直到锁释放之前都不会返回,而唯一锁能被释放的方式就是进程P1恢复执行并在稍后release锁,但是这一步又还没有发生,因为进程P1通过调用switch函数切换到了P2,而P2又在不停的“旋转”并等待锁被释放。这是一种死锁,它会导致系统停止运行。

虽然我刚刚的描述是基于机器上只有一个CPU核,但是你可以通过多个锁在多个CPU核的机器上构建类似的死锁场景。所以,我们在XV6中禁止在调用switch时持有除进程自身锁(注,也就是p->lock)以外的其他锁。

学生提问:难道定时器中断不会将CPU控制切换回进程P1从而解决死锁的问题吗?

Robert教授:首先,所有的进程切换过程都发生在内核中,所有的acquire,switch,release都发生在内核代码而不是用户代码。实际上XV6允许在执行内核代码时触发中断,如果你查看trap.c中的代码你可以发现,如果XV6正在执行内核代码时发生了定时器中断,中断处理程序会调用yield函数并出让CPU。

但是在之前的课程中我们讲过acquire函数在等待锁之前会关闭中断,否则的话可能会引起死锁(注,详见10.8),所以我们不能在等待锁的时候处理中断。所以如果你查看XV6中的acquire函数,你可以发现函数中第一件事情就是关闭中断,之后再“自旋”等待锁释放。你或许会想,为什么不能先“自旋”等待锁释放,再关闭中断?因为这样会有一个短暂的时间段锁被持有了但是中断没有关闭,在这个时间段内的设备的中断处理程序可能会引起死锁。

所以不幸的是,当我们在自旋等待锁释放时会关闭中断,进而阻止了定时器中断并且阻止了进程P2将CPU出让回给进程P1。嗯,这是个好问题。

学生提问:能重复一下死锁是如何避免的吗?

Robert教授:哦,在XV6中,死锁是通过禁止在线程切换的时候加锁来避免的。

XV6禁止在调用switch函数时,获取除了p->lock以外的其他锁。如果你查看sched函数的代码(注,详见11.6),里面包含了一些检查代码来确保除了p->lock以外线程不持有其他锁。所以上面会产生死锁的代码在XV6中是不合法的并被禁止的。

将这里描述的对于锁的两个限制条件记住,因为我们后面讨论Sleep&Wakeup如何工作时会再次使用它们。

Last updated