13.3 Lost wakeup

在解释sleep函数为什么需要一个锁使用作为参数传入之前,我们先来看看假设我们有了一个更简单的不带锁作为参数的sleep函数,会有什么样的结果。这里的结果就是lost wakeup。

假设sleep只是接收任意的sleep channel作为唯一的参数。它其实不能正常工作,我们称这个sleep实现为broken_sleep。你可以想象一个sleep函数内会将进程的状态设置为SLEEPING,表明当前进程不想再运行,而是正在等待一个特定的事件。如果你们看过了XV6的实现,你们可以发现sleep函数中还会做很多其他操作。我们需要记录特定的sleep channel值,这样之后的wakeup函数才能发现是当前进程正在等待wakeup对应的事件。最后再调用switch函数出让CPU。

如果sleep函数只做了这些操作,那么很明显sleep函数会出问题,我们至少还应该在这里获取进程的锁。

之后是wakeup函数。我们希望唤醒所有正在等待特定sleep channel的线程。所以wakeup函数中会查询进程表单中的所有进程,如果进程的状态是SLEEPING并且进程对应的channel是当前wakeup的参数,那么将进程的状态设置为RUNNABLE。

在一些平行宇宙中,sleep&wakeup或许就是这么简单。在我回到XV6代码之前,让我演示一下如何在UART驱动中使用刚刚介绍的sleep和wakeup函数。这基本上是重复前一节的内容,不过这次我们使用刚刚介绍的稍微简单的接口。

首先是定义done标志位。之后是定义uartwrite函数。在函数中,对于buffer内的每一个字符,检查done标志位,如果标志位为0,就调用sleep函数并传入tx_channel。之后将字符传递给UART并将done设置为0。

之后是中断处理函数uartintr。函数中首先将done标志位设置为1,并调用wakeup。

以上就是使用broken_sleep的方式。这里缺失的是锁。这里uartwrite和uartintr两个函数需要使用锁来协调工作。

  • 第一个原因是done标志位,任何时候我们有了共享的数据,我们需要为这个数据加上锁。

  • 另一个原因是两个函数都需要访问UART硬件,通常来说让两个线程并发的访问memory mapped register是错误的行为。

所以我们需要在两个函数中加锁来避免对于done标志位和硬件的竞争访问。

现在的问题是,我们该在哪个位置加锁?在中断处理程序中较为简单,我们在最开始加锁,在最后解锁。

难的是如何在uartwrite函数中加锁。一种可能是,每次发送一个字符的过程中持有锁,所以在每一次遍历buffer的起始和结束位置加锁和解锁。

为什么这样肯定不能工作?一个原因是,我们能从while not done的循环退出的唯一可能是中断处理程序将done设置为1。但是如果我们为整个代码段都加锁的话,中断处理程序就不能获取锁了,中断程序会不停“自旋”并等待锁释放。而锁被uartwrite持有,在done设置为1之前不会释放。而done只有在中断处理程序获取锁之后才可能设置为1。所以我们不能在发送每个字符的整个处理流程都加锁。

上面加锁方式的问题是,uartwrite在期望中断处理程序执行的同时又持有了锁。而我们唯一期望中断处理程序执行的位置就是sleep函数执行期间,其他的时候uartwrite持有锁是没有问题的。所以另一种实现可能是,在传输字符的最开始获取锁,因为我们需要保护共享变量done,但是在调用sleep函数之前释放锁。这样中断处理程序就有可能运行并且设置done标志位为1。之后在sleep函数返回时,再次获取锁。

让我来修改代码,并看看相应的运行结果是什么。现有的代码中,uartwrite在最开始获取了锁,并在最后释放了锁。

中断处理程序也在最开始获取锁,之后释放锁。

接下来,我们会探索为什么只接收一个参数的broken_sleep在这不能工作。为了让锁能正常工作,我们需要在调用broken_sleep函数之前释放uart_tx_lock,并在broken_sleep返回时重新获取锁。broken_sleep内的代码与之前在白板上演示的是一样的。也就是首先将进程状态设置为SLEEPING,并且保存tx_chan到进程结构体中,最后调用switch函数。

接下来编译代码并看一下会发生什么。

在XV6启动的时候会打印“init starting”,这里看来输出了一些字符之后就hang住了。如果我输入任意字符,剩下的字符就能输出。这里发生了什么?

这里的问题必然与之前修改的代码相关。在前面的代码中,sleep之前释放了锁,但是在释放锁和broken_sleep之间可能会发生中断。

一旦释放了锁,当前CPU的中断会被重新打开。因为这是一个多核机器,所以中断可能发生在任意一个CPU核。在上面代码标记的位置,其他CPU核上正在执行UART的中断处理程序,并且正在acquire函数中等待当前锁释放。所以一旦锁被释放了,另一个CPU核就会获取锁,并发现UART硬件完成了发送上一个字符,之后会设置tx_done为1,最后再调用wakeup函数,并传入tx_chan。目前为止一切都还好,除了一点:现在写线程还在执行并位于release和broken_sleep之间,也就是写线程还没有进入SLEEPING状态,所以中断处理程序中的wakeup并没有唤醒任何进程,因为还没有任何进程在tx_chan上睡眠。之后写线程会继续运行,调用broken_sleep,将进程状态设置为SLEEPING,保存sleep channel。但是中断已经发生了,wakeup也已经被调用了。所以这次的broken_sleep,没有人会唤醒它,因为wakeup已经发生过了。这就是lost wakeup问题。

学生提问:是不是总是这样,一旦一个wakeup被丢失了,下一次wakeup时,之前缓存的数据会继续输出?

Robert教授:这完全取决于实现细节。在我们的例子中,实际上出于偶然才会出现当我输入某些内容会导致之前的输出继续的现象。这里背后的原因是,我们的代码中,UART只有一个中断处理程序。不论是有输入,还是完成了一次输出,都会调用到同一个中断处理程序中。所以当我输入某些内容时,会触发输入中断,之后会调用uartintr函数。然后在中断处理程序中又会判断LSR_TX_IDLE标志位,并再次调用wakeup,所以刚刚的现象完全是偶然。如果出现了lost wakeup问题,并且你足够幸运的话,某些时候它们能自动修复。如果UART有不同的接收和发送中断处理程序的话,那么就没办法从lost wakeup恢复。

学生提问:tx_done标志位的作用是什么?

Robert教授:这是一种简单的在uartintr和uartwrite函数之间通信的方法。tx_done标志位为1表示已经完成了对于前一个字符的传输,并且uartwrite可以传输下一个字符,所以这是用来在中断处理程序和uartwrite之间通信的标志位。

同一个学生提问:当从sleep函数中唤醒时,不是已经知道是来自UART的中断处理程序调用wakeup的结果吗?这样的话tx_done有些多余。

Robert教授:我想你的问题也可以描述为:为什么需要通过一个循环while(tx_done == 0)来调用sleep函数?这个问题的答案适用于一个更通用的场景:实际中不太可能将sleep和wakeup精确匹配。并不是说sleep函数返回了,你等待的事件就一定会发生。举个例子,假设我们有两个进程同时想写UART,它们都在uartwrite函数中。可能发生这种场景,当一个进程写完一个字符之后,会进入SLEEPING状态并释放锁,而另一个进程可以在这时进入到循环并等待UART空闲下来。之后两个进程都进入到SLEEPING状态,当发生中断时UART可以再次接收一个字符,两个进程都会被唤醒,但是只有一个进程应该写入字符,所以我们才需要在sleep外面包一层while循环。实际上,你可以在XV6中的每一个sleep函数调用都被一个while循环包着。因为事实是,你或许被唤醒了,但是其他人将你等待的事件拿走了,所以你还得继续sleep。这种现象还挺普遍的。

学生提问:我们只看到了一个lost wakeup,当我们随便输入一个字符,整个剩下的字符都能输出,为什么没有在输出剩下字符的时候再次发生lost wakeup?

Robert教授:这会发生的。我来敲一下cat README,这会输出数千个字符。可以看到每过几个字符就会hang一次,需要我再次输入某个字符。这个过程我们可以看到很多lost wakeup。之前之所以没有出现,是因为lost wakeup需要中断已经在等待获取锁,并且uartwrite位于release和broken_sleep之间,这需要一定的巧合并不总是会发生。

Last updated