23.2 读写锁 (Read-Write Lock)

这种锁被称为读写锁(Read-Write Lock),它的接口相比spinlock略显复杂。如果只是想要读取数据,那么可以调用r_lock,将锁作为参数传入,同样的还会有个r_unlock,数据的读取者使用这些接口。数据的写入者调用w_lock和w_unlock接口。

这里的语义是,要么你可以有多个数据的读取者获取了读锁,这样可以获得并行执行读操作的能力;要么你只能有一个数据写入者获取了写锁。但是不能两种情况同时发生,读写锁排除了某人获取了数据的写锁,同时又有别人获取读锁的可能性。你要么只有一个数据写入者,要么有多个数据读取者,不可能有别的可能。

学生提问:当某人持有了读锁时,读写锁是采用什么方案来阻止其他人写入数据的?

Robert教授:并没有什么方案,这就像XV6的锁一样。我们这里讨论的是由值得信赖且负责的开发人员编写的内核代码,所以就像XV6的spinlock一样,如果使用锁的代码是不正确的,那么结果就是不正确的,这是内核代码典型的编写方式,你只能假设开发内核的人员遵循这里的规则。

如果我们有一个大部分都是读取操作的数据结构,我们会希望能有多个用户能同时使用这个数据结构,这样我们就可以通过多核CPU获得真正的性能提升。如果没有其他问题的话,那么读写锁就可以解决今天的问题,我们也没有必要读RCU这篇论文。但实际上如果你深入细节,你会发现当你使用读写锁时,尤其对于大部分都是读取操作的数据结构,会有一些问题。为了了解实际发生了什么,我们必须看一下读写锁的代码实现。

Linux实际上有读写锁的实现,上面是一种简化了的Linux代码。首先有一个结构体是rwlock,这与XV6中的lock结构体类似。rwlock结构体里面有一个计数器n,

  • 如果n等于0那表示锁没有以任何形式被被任何人持有

  • 如果n等于-1那表示当前有一个数据写入者持有写锁

  • 如果n大于0表示有n个数据读取者持有读锁。我们需要记录这里的数字,因为我们只有在n减为0的时候才能让数据写入者持有写锁。

r_lock函数会一直在一个循环里面等待数据写入者释放锁。首先它获取读写锁中计数器n的拷贝,如果n的拷贝小于0的话那意味着存在一个数据写入者,我们只能继续循环以等待数据写入者退出。如果n的拷贝不小于0,我们会增加读写锁的计数器。但是我们只能在读写锁的计数器仍然大于等于0的时候,对其加1。所以我们不能直接对n加1,因为如果一个数据写入者在我们检查n和我们增加n之间潜入了,那么我们有可能在数据写入者将n设置为-1的同时,将n又加了1。所以我们只能在检查完n大于等于0,且n没有改变的前提下,将其加1。

人们通过利用特殊的原子指令来实现这一点,我们之前在看XV6中spinlock的实现时看过类似的指令(注,详见10.7中的test_and_set指令)。其中一个使用起来很方便的指令是compare-and-swap(CAS)。CAS接收三个参数,第一个参数是内存的某个地址,第二个参数是我们认为内存中该地址持有的数值,第三个参数是我们想设置到内存地址的数值。CAS的语义是,硬件首先会设置一个内部的锁,使得一个CAS指令针对一个内存地址原子的执行;之后硬件会检查当前内存地址的数值是否还是x;如果是的话,将其设置为第三个参数,也就是x+1,之后CAS指令会返回1;如果不是的话,并不会改变内存地址的数值,并返回0。这里必须是原子性,因为这里包含了两个操作,首先是检查当前值,其次是设置一个新的数值。

13:50 - 16:45是个不太清晰的提问,没什么价值故略过

学生提问:有没有可能计算x的过程中,发生了一个中断?

Robert教授:你是指我们在执行CAS指令之前计算它的第三个参数的过程中发生中断吗?CAS实际上是一个指令,如果中断发生在我们计算x+1的过程中,那么意味着我们还没有调用CAS,这时包括中断在内的各种事情都可能发生。如果我们在最初读取n的时候读到0,那么不管发不发生中断,我们都会将1作为CAS的第三个参数传入,因为中断并不会更改作为本地变量的x,所以CAS的第二个和第三个参数会是0和1。如果n还是0,我们会将其设置为1,这是我们想看到的;如果n不是0,那么CAS并不会更新n。

如果这里没有使用本地变量x,那么就会有大问题了,因为n可能在任意时间被修改,所以我们需要在最开始在本地变量x中存储n的一个固定的值。

上面介绍了w_lock与r_lock同时调用的场景。多个r_lock同时调用的场景同样也很有趣。假设n从0开始,当两个r_lock同时调用时,我们希望当两个r_lock都返回时,n变成2,因为我们希望两个数据读取者可以并行的使用数据。两个r_lock在最开始都将看到n为0,并且都会通过传入第二个参数0,第三个参数1来调用CAS指令,但是只有一个CAS指令能成功。CAS是一个原子操作,一次只能发生一个CAS指令。不管哪个CAS指令先执行,将会看到n等于0,并将其设置为1。另一个CAS指令将会看到n等于1,返回失败,并回到循环的最开始,这一次x可以读到1,并且接下来执行CAS的时候,第二个参数将会是1,第三个参数是2,这一次CAS指令可以执行成功。最终两次r_lock都能成功获取锁,其中一次r_lock在第一次尝试就能成功,另一次r_lock会回到循环的最开始再次尝试并成功。

学生提问:如果开始有一堆数据读取者在读,之后来了一个数据写入者,但是又有源源不断的数据读取者加入进来,是不是就轮不到数据写入者了?

Robert教授:如果多个数据读取者获取了锁,每一个都会通过CAS指令将n加1,现在n会大于0。如果这时一个数据写入者尝试要获取锁,它的CAS指令会将n与0做对比,只有当n等于0时,才会将其设置为-1。但是因为存在多个数据读取者,n不等于0,所以CAS指令会失败。数据写入者会在w_lock的循环中不断尝试并等待n等于0,如果存在大量的数据读取者,这意味着数据写入者有可能会一直等待。这是这种锁机制的一个缺陷。

学生提问:在刚刚两个数据读取者要获取锁的过程中,第二个数据读取者需要再经历一次循环,这看起来有点浪费,如果有多个数据读取者,那么它们都需要重试。

Robert教授:你说到了人们为什么不喜欢这种锁的点子上了。即使没有任何的数据写入者,仅仅是在多个CPU核上有大量的数据读取者,r_lock也可能会有非常高的代价。在一个多核的系统中,每个CPU核都有一个关联的cache,也就是L1 cache。每当CPU核读写数据时,都会保存在cache中。除此之外,还有一些内部连接的线路使得CPU可以彼此交互,因为如果某个CPU核修改了某个数据,它需要告诉其他CPU核不要去缓存这个数据,这个过程被称为(cache) invalidation。

如果有多个数据读取者在多个CPU上同时调用r_lock,它们都会读取读写锁的计数l->n,并将这个数据加载到CPU的cache中,它们也都会调用CAS指令,但是第一个调用CAS指令的CPU会修改l->n的内容。作为修改的一部分,它需要使得其他CPU上的cache失效。所以执行第一个CAS指令的CPU需要通过线路发送invalidate消息给其他每一个CPU核,之后其他的CPU核在执行CAS指令时,需要重新读取l->n,但是这时CAS指令会失败,因为l->n已经等于1了,但x还是等于0。之后剩下的所有数据读取者都会回到循环的最开始,重复上面的流程,但这一次还是只有一个数据读取者能成功。

假设有n个数据读取者,那么每个r_lock平均需要循环n/2次,每次循环都涉及到O(n)级别的CPU消息,因为至少每次循环中所有CPU对于l->n的cache需要被设置为无效。这意味着,对于n个CPU核来说,同时获取一个锁的成本是O(n^2),当你为一份数据增加CPU核时,成本以平方增加。

这是一个非常糟糕的结果,因为你会期望如果有10个CPU核完成一件事情,你将获得10倍的性能,尤其现在还只是读数据并没有修改数据。你期望它们能真正的并行运行,当有多个CPU核时,每个CPU核读取数据的时间应该与只有一个CPU核时读取数据的时间一致,这样并行运行才有意义,因为这样你才能同时做多件事情。但是现在,越多的CPU核尝试读取数据,每个CPU核获取锁的成本就越高。

对于一个只读数据,如果数据只在CPU的cache中的话,它的访问成本可能只要几十个CPU cycle。但是如果数据很受欢迎,由于O(n^2)的效果,光是获取锁就要消耗数百甚至数千个CPU cycle,因为不同CPU修改数据(注,也就是读写锁的计数器)需要通过CPU之间的连线来完成缓存一致的操作。

所以这里的读写锁,将一个原本成本很低的读操作,因为要修改读写锁的l->n,变成了一个成本极高的操作。如果你要读取的数据本身就很简单,这里的锁可能会完全摧毁任何并行带来的可能的性能提升。

读写锁糟糕的性能是RCU存在的原因,因为如果读写锁足够有效,那么就没有必要做的更好。除了在有n个CPU核时,r_lock的成本是O(n^2)之外,这里的读写锁将一个本来可以缓存在CPU中的,并且可能会很快的只读的操作,变成了需要修改锁的计数器l->n的操作。如果我们写的是可能与其他CPU核共享的数据,写操作通常会比读操作成本高得多。因为读一个未被修改的数据可以在几个CPU cycle内就从CPU cache中读到,但是修改可能被其他CPU核缓存的数据时,需要涉及CPU核之间的通信来使得缓存失效。不论如何修改数据结构,任何涉及到更改共享数据的操作对于性能来说都是灾难。

所以r_lock中最关键的就是它对共享数据做了一次写操作。所以我们期望找到一种方式能够在读数据的同时,又不需要写数据,哪怕是写锁的计数器也不行。这样读数据实际上才是一个真正的只读操作。

Last updated