23.7 RCU总结

你们应该已经看到了RCU并不是广泛通用的,你不能把所有使用spinlock并且性能很差的场景转化成使用 RCU,并获得更好的性能。主要的原因是RCU完全帮不到写操作,甚至会让写操作更慢,只有当读操作远远多于写操作时才有可能应用RCU。因为RCU有这样的限制:代码不能在sleep的时候持有指针指向被RCU保护的数据,所以这会使得一些代码非常奇怪。当一定要sleep的时候,在sleep结束之后需要重新进入RCU critical区域再次查找之前已经看过的数据,前提是这些数据还存在。所以RCU使得代码稍微复杂了一些。

另一方面可以直接应用RCU的数据结构在更新时,需要能支持单个操作的committing write。你不能在原地更新数据,而是必须创建一个新的链表元素对象来替代之前的元素对象。所以单链表,树是可以应用RCU的数据结构,但是一些复杂的数据结构不能直接使用RCU。论文里面提到了一些更复杂的方法,例如sequence lock,可以在允许原地更新数据的同时,又不用数据读取者使用锁。但是这些方法要复杂一些,并且能够提升性能的场景也是受限的。

另一个小问题是,RCU并没有一种机制能保证数据读取者一定看到的是新的数据。因为如果某些数据读取者在数据写入者替换链表元素之前,获取了一个指针指向被RCU保护的旧数据,数据读取者可能会在较长的时间内持有这个旧数据。大部分时候这都无所谓,但是论文提到了在一些场景中,人们可能会因为读到旧数据而感到意外。

作为一个独立的话题,你们或许会想知道对于一个写操作频繁的数据该如何提升性能。RCU只关心读操作频繁的数据,但是这类数据只代表了一种场景。在一些特殊场景中,写操作频繁的数据也可以获取好的性能,但是我还不知道存在类似RCU这样通用的方法能优化写操作频繁的数据。不过仍然有一些思路可以值得借鉴。

  • 最有效的方法就是重新构造你的数据结构,这样它就不是共享的。有的时候共享数据完全是没必要的,一旦你发现数据共享是个问题,你可以尝试让数据不共享。

  • 但是某些时候你又的确需要共享的数据,而这些共享数据并没有必要被不同的CPU写入。实际上你们已经在lab中见过这样的数据,在locking lab的kalloc部分,你们重构了free list使得每个CPU核都有了一个专属的free list,这实际上就是将一个频繁写入的数据转换成了每个CPU核的半私有数据。大部分时候CPU核不会与其他CPU核的数据有冲突,因为它们都有属于自己的free list。唯一的需要查看其他CPU核的free list的场景是自己的free list用光了。有很多类似的例子用来处理内核中需要频繁写入的数据,例如Linux中的内存分配,线程调度列表。对于每个CPU核都有一套独立的线程对象以供线程调度器查看(注,详见11.8,线程对象存储在struct cpu中)。CPU核只有在自己所有工作都完成的时候才会查看其他CPU核的线程调度列表。另一个例子是统计计数,如果你在对某个行为计数,但是计数变化的很频繁,同时又很少被读出,你可以重构你的计数器,使得每个CPU核都有一个独立的计数器,这样每个CPU核只需要更新属于自己的计数器。当你需要读取计数值时,你只需要通过加锁读出每个CPU核的计数器,然后再加在一起。这些都是可以让写操作变得更快的方法,因为数据写入者只需要更新当前CPU核的计数器,但是数据读取者现在变得更慢了。如果你的计数器需要频繁写入,实际上通常的计数器都需要频繁写入,通过将更多的工作转换到数据读取操作上,这将会是一个巨大的收益。

这里想说的是,即使我们并没有太讨论,但是的确存在一些技术在某些场合可以帮助提升需要频繁写入数据的性能。

最后总结一下,论文中介绍的RCU对于Linux来说是一个巨大的成功。它在Linux中各种数据都有使用,实际中需要频繁读取的数据还挺常见的,例如block cache基本上就是被读取,所以一种只提升读性能的技术能够应用的非常广泛。尽管已经有了许多有趣的并发技术,同步(synchronization)技术,RCU还是很神奇,因为它对数据读取者完全去除了锁和数据写入(注,这里说的数据写入是指类似读写锁时的计数值,但是RCU在读数据的时候还是需要写标志位关闭context switch,只是这里的写操作代价并不高),所以相比读写锁,RCU是一个很大的突破。RCU能工作的核心思想是为资源释放(Garbage Collection)增加了grace period,在grace period中会确保所有的数据读取者都使用完了数据。所以尽管RCU是一种同步技术,也可以将其看做是一种特殊的GC技术。

学生提问:为什么数据读取者可以读到旧数据呢?在RCU critical区域里,你看到的应该就是实际存在的数据啊?

Robert教授:通常来说这不是个问题。通常来说,你写代码,将1赋值给x,之后print ”done“。

在print之后,如果有人读取x,可能会看到你在将1赋值给x之前x的数值,这里或许有些出乎意料。而RCU允许这种情况发生,如果我们在使用RCU时,并将数据赋值改成list_replace,将包含1的元素的内容改成2。

在函数结束后,我们print ”done“。如果一些其他的数据读取者在查看链表,它们或许刚刚看到了持有1的链表元素,之后它们过了一会才实际的读取链表元素内容,并看到旧的数值1(注,因为RCU是用替换的方式实现更新,数据读取者可能读到了旧元素的指针,里面一直包含的是旧的数值)。所以这就有点奇怪了,就算添加memory barrier也不能避免这种情况。不过实际上大部分场景下这也没关系,因为这里数据的读写者是并发的,通常来说如果两件事情是并发执行的,你是不会认为它们的执行顺序是确定的。

但是论文中的确举了个例子说读到旧数据是有关系的,并且会触发一个实际的问题,尽管我并不太理解为什么会有问题。

学生提问:RCU之所以被称为RCU,是因为它的基本实现对吧?

Robert教授:Read-Copy-Update,是的我认为是因为它的基本实现,它不是在原地修改数据,你是先创建了一个拷贝再来更新链表。

学生提问:在介绍读写锁时,我们讨论了为了实现缓存一致需要O(n^2)时间。对于spinlock这是不是也是个问题,为什么我们在之前在介绍spinlock的时候没有讨论这个问题,是因为spinlock有什么特殊的操作解决了这个问题吗?

Robert教授:并没有,锁的代价都很高。如果没有竞争的话,例如XV6中的标准spinlock会非常快。但是如果有大量的CPU核在相同的时候要获取相同的锁就会特别的慢。存在一些其他的锁,在更高负载的时候性能更好,但是在更低负载的时候性能反而更差。这里很难有完美的方案。

学生提问:或许并不相关,可能存在不同操作系统之间的锁吗?

Robert教授:在分布式系统中,有一种锁可以存在于多个计算机之间。一个场景是分布式数据库,你将数据分发给多个计算机,但是如果你想要执行一个transaction,并使用分布在多个计算机上的数据,你将需要从多个计算机上收集锁。另一个场景是,有一些系统会尝试在独立的计算机之间模拟共享内存,比如说一个计算机使用了另一个计算机的内存,背后需要有一些工具能够使得计算机之间能交互并请求内存。这样就可以在一个集群的计算机上运行一些现有的并行程序,而不是在一个大的多核计算机上,这样成本会更低。这时需要对spinlock或者任何你使用的锁做一些额外的处理,人们发明了各种技术来使得锁能很好的工作,这些技术与我们介绍的技术就不太一样了,尽管避免性能损失的压力会更大。

Last updated