23.1 使用锁带来的问题

今天的话题是如何在多核CPU计算机上获得好的性能,这是一个非常有趣,深入且令人着迷的话题。今天我们只会涉及这个话题的很小的一个部分,也就是在面对内核中需要频繁读但是不需要频繁写的共享数据时,如何获得更好的性能。在不同的场景下有不同的方法可以在多核CPU的机器上获得更好的性能,我们今天要看的是Linux的RCU,它对于需要频繁读的内核数据来说是一种非常成功的方法。

如果你有一个现代的计算机,或许包含了4、8、16、64个并行运行的CPU核,这些CPU核共享了内存数据,操作系统内核将会是一个并行运行的程序。如果你想要获得好的性能,你需要确保内核能尽可能的在多个CPU核上并行的完成它的工作。如果你能将内核并行的运行在8个CPU核上,并且它们都能完成有效的工作,那么相比运行在单个CPU核上,你就能获得8倍的性能。从理论上来说,这明显是可能的。

如果你在内核中有大量的进程,那就不太用担心,在不做任何额外工作的前提下,这些进程极有可能是并行运行的。另一方面,如果你有很多应用程序都在执行系统调用,很多时候,不同的应用程序执行的不同系统调用也应该是相互独立的,并且在很多场景下应该在相互不影响的前提下运行。例如,通过fork产生的两个进程,或者读取不同pipe的两个进程,或者读写不同文件的两个进程。表面上看,这些进程之间没有理由会相互影响,也没有理由不能并行运行并获得n倍的吞吐量。

但问题是内核中包含了大量的共享数据。出于一些其他的原因,内核共享了大量的资源,例如内存,CPU,磁盘缓存,inode缓存,这些东西都在后台被不同的进程所共享。这意味着,即使两个完全不相关的进程在执行两个系统调用,如果这两个系统调用需要分配内存或使用磁盘缓存或者涉及到线程调度决策,它们可能最终会使用内核中相同的数据结构,因此我们需要有办法能让它们在使用相同数据的同时,又互不影响。

在过去的许多年里,人们付出了巨大的努力来让内核中的这些场景能更快的运行。我们之前看过其中一种可以保证正确性的方法,也就是spinlock。spinlock很直观,它的工作就是当两个进程可能会相互影响时,阻止并行运行。所以spinlock的直接效果就是降低性能。它使得正确性有了保障,但是又绝对的阻止了并行执行,这并不总是能令人满意。

今天我们会关注需要频繁读的数据,也就是说你的数据主要是在被读取,相对来说很少被写入。我将使用单链表来作为主要的例子。对于单链表,会存在一个指向头指针(head)的全局变量,之后是一些链表元素,每个链表元素都包含了一个数据,假设是字符串。第一个链表元素包含了“hello”。每个链表元素还包含了一个next指针,指向了下一个链表元素。最后一个链表元素的next指针指向空指针。

接下来我们假设对于这个链表的大部分操作是读,比如说内核线程大部分时候只会扫描链表来找到某些数据,而不会修改链表。假设一个写请求都没有的话,我们就根本不必担心这个链表,因为它是完全静态的,它从来都不会更新,我们可以自由的读它。但是接下来我们假设每隔一会,一些其他的线程会来修改链表元素中的数据;删除一个链表元素;又或者是在某个位置插入链表元素。所以尽管我们关注的主要是读操作,我们也需要关心写操作,我们需要保证读操作在面对写操作时是安全的。

在XV6中,我们是通过锁来保护这个链表。在XV6中,不只是修改数据的线程,读取数据的线程也需要获取锁,因为我们需要排除当我们在读的时候某人正在修改链表的可能,否则的话会导致读取数据的线程可能读到更新一半的数据或者是读到一个无效的指针等等,所以XV6使用了锁。

但是使用锁有个缺点,如果通常情况下没有修改数据的线程,那么意味着每次有一个读取数据的线程,都需要获取一个排他的锁。XV6中的spinlock是排他的,即使只是两个读取数据的线程也只能一次执行一个线程。所以一种改进这里场景的方法是使用一种新的锁,它可以允许多个读取线程和一个写入线程。接下来我们来看看这种锁,不仅因为它是有趣的,也因为它的不足促成了对于RCU的需求。

Last updated