无锁编程—RCU
当我们对链表等数据结构进行并发读写时,通常会通过读写锁进行保护。但是,每一次对读写锁的操作都必须直接在内存中进行,不能够使用cache,这也就导致了读写锁的效率其实是比较低的。即使是在没有写者的情况下,每一次上读锁仍然需要访问内存。更严重的是如果多个CPU同时执行到CAS指令,每一次CAS指令的执行都会导致其他CPU的cache失效,需要重新读取内存,也就意味着最坏情况下执行CAS指令的代价是O(n^2)
。
而一种实现无锁编程的方法就是RCU(Read-Copy Update)。RCU最常见的地方就是链表,在Linux内核中甚至有一个单独的RCU链表include/linux/rculist.h
。
对于写者来说有以下三种情况:
- 修改链表元素的值,将字符串修改为另一个字符串;此时读者可能读取到正在被修改的字符串
- 插入一个链表元素;读者读取到一个未插入完成的元素,
next
指针未能指向下一个元素 - 删除一个元素;读者读取到一个已经被删除的元素
RCU主要就是通过一种无锁的方法修复上述问题,该方法会使写者的速度变慢,但是读者能不用锁、不需要写入内存,速度会明显变快。
对于链表修改的情况,RCU是禁止发生的,因此需要把链表修改替换为链表结点替换:
H1 -> E1 -> E2 -> E3 -> E4
H1 -> E1 E2 -> E3 -> E4
|---> E2' --^
例如修改E2结点,就先建立新结点E2',使其先指向E3,之后修改E1的指针指向E2'。对于读者来说,如果在E2位置,那么可以顺利读取到E2的旧值以及E3,如果在E1位置,那么可以顺利读取到新的E2'。不会出现错误的核心就是写入E1->next = E2'
的操作是原子的,也就是E1->next
要么指向E2要么指向E2'。这种特性是RCU是最基本也是最重要的性质,例如如果是双向链表,就难以实现RCU,因为不能原子性地进行修改;而对于树来说,就是一种能够实现RCU的结构。
这里存在一个问题就是处理器和编译器会进行指令重排,导致E1指向E2'发生在E2'指向E3之前,这时候我们就需要使用内存屏障来避免发生指令重排了。
另一个问题就是什么时候对旧的E2进行删除,保证没有读者在读取E2。使用引用计数是一种方法,但是引用计数每次读取就要读写内存增加计数,这就和我们使用RCU的目的相违背了。另一个方法就是使用垃圾回收,垃圾回收器能准确地对旧E2进行回收。但是如果是在内核等没有垃圾回收器的环境中,又要怎么处理呢?
我们可以使用一种规则来在合适的时候释放旧元素:
- 读者不能在上下文切换时持有被RCU保护的元素,即读者不能在RCU临界区内释放CPU
- 当每个CPU核都执行了一次上下文切换时,写者就可以删除旧元素
即写者的操作变为了如下所示:
E1->next = E2'
synchronize_rcu()
free(E2)
synchronize_rcu
的执行可能会需要1ms左右,看起来代价很大,但是被RCU保护的数据是读多写少的,这个代价还是可以接受的。