在 Linux 内核这片充满挑战与机遇的技术海洋中,高效的并发控制始终是开发者们不懈追求的目标。多处理器环境下,数据的一致性与并发访问的安全性,犹如两座巍峨的高山,横亘在每一位内核开发者的前行道路上。传统的锁机制,如自旋锁、互斥锁和读写锁等,虽各有其用武之地,但在面对复杂多变的应用场景时,往往会暴露出性能瓶颈或使用局限性。
它以独特的设计理念和巧妙的工作机制,在众多锁机制中脱颖而出,尤其在处理读多写少的场景时,展现出了无与伦比的性能优势。从文件系统到网络协议栈,从进程管理到内核数据结构维护,RCU 锁的身影无处不在,默默支撑着 Linux 内核高效稳定地运行。今天,让我们深入探讨一种别具一格且高效强大的锁机制 ——RCU 锁。
一、RCU锁的概述
RCU,即 Read - Copy - Update,从字面上看,它的操作似乎仅包含读取、复制和更新三个简单步骤,但实际机制远比这复杂。它专为读多写少的场景而设计,核心思想是允许读操作无锁并发执行,极大地提升读操作的效率。对于写操作,它则采用了一种独特的策略:先复制数据,在副本上进行修改,待所有读操作完成后,再将新副本替换旧数据。
在 RCU 机制中,读取共享数据结构的操作是无锁的,因此读取操作可以并发进行,不会相互干扰。写入共享数据结构的操作则使用了延迟删除的策略,即写入操作并不直接修改共享数据结构,而是将要删除的数据结构标记为“已删除”,并在之后的某个时间点(通常是在不会干扰读取操作的时候)真正删除这些数据结构。
RCU 机制的实现依赖于一些底层机制,比如内存屏障、原子操作等。在 Linux 内核中,RCU 机制被广泛应用于多个子系统,比如进程管理、网络协议栈等,以提高内核的并发性能。
在RCU的实现过程中,我们主要解决以下问题:
- 在读取过程中,另外一个线程删除了一个节点。删除线程可以把这个节点从链表中移除,但它不能直接销毁这个节点,必须等到所有的读取线程读取完成以后,才进行销毁操作。RCU中把这个过程称为宽限期(Grace period)。
- 在读取过程中,另外一个线程插入了一个新节点,而读线程读到了这个节点,那么需要保证读到的这个节点是完整的。这里涉及到了发布-订阅机制(Publish-Subscribe Mechanism)。
- 保证读取链表的完整性。新增或者删除一个节点,不至于导致遍历一个链表从中间断开。但是RCU并不保证一定能读到新增的节点或者不读到要被删除的节点。
二、RCU锁的工作原理
RCU(Read-Copy Update),顾名思义就是读-拷贝修改,它是基于其原理命名的。对于被RCU保护的共享数据结构,读者不需要获得任何锁就可以访问它,但写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。
因此RCU实际上是一种改进的rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令,而且在除alpha的所有架构上也不需要内存栅(Memory Barrier),因此不会导致锁竞争,内存延迟以及流水线停滞。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。
有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。 RCU与rwlock的不同之处是:它既允许多个读者同时访问被保护的数据,又允许多个读者和多个写者同时访问被保护的数据(注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制),读者没有任何同步开销,而写者的同步开销则取决于使用的写者间同步机制。但RCU不能替代rwlock,因为如果写比较多时,对读者的性能提高不能弥补写者导致的损失。
读者在访问被RCU保护的共享数据期间不能被阻塞,这是RCU机制得以实现的一个基本前提,也就说当读者在引用被RCU保护的共享数据期间,读者所在的CPU不能发生上下文切换,spinlock和rwlock都需要这样的前提。写者在访问被RCU保护的共享数据时不需要和读者竞争任何锁,只有在有多于一个写者的情况下需要获得某种锁以与其他写者同步。
写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。等待适当时机的这一时期称为grace period,而CPU发生了上下文切换称为经历一个quiescent state,grace period就是所有CPU都经历一次quiescent state所需要的等待的时间。垃圾收集器就是在grace period之后调用写者注册的回调函数来完成真正的数据修改或数据释放操作的。
要想使用好RCU,就要知道RCU的实现原理。我们拿linux 2.6.21 kernel的实现开始分析,为什么选择这个版本的实现呢?因为这个版本的实现相对较为单纯,也比较简单。当然之后内核做了不少改进,如抢占RCU、可睡眠RCU、分层RCU。但是基本思想都是类似的。所以先从简单入手。
首先,我们提到的写者在访问它时首先拷贝一个副本,然后对副本进行修改,最后使用一个回调(callback)机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据。而这个“适当的时机”就是所有CPU经历了一次进程切换(也就是一个grace period)。
为什么这么设计?
因为RCU读者的实现就是关抢占执行读取,读完了当然就可以进程切换了,也就等于是写者可以操作临界区了。
那么就自然可以想到,内核会设计两个元素,来分别表示写者被挂起的起始点,以及每cpu变量,来表示该cpu是否经过了一次进程切换(quies state)。
就是说,当写者被挂起后要以下步骤:
- 重置每cpu变量,值为0。
- 当某个cpu经历一次进程切换后,就将自己的变量设为1。
- 当所有的cpu变量都为1后,就可以唤醒写者了。
下面我们来分别看linux里是如何完成这三步的:
我们从一个例子入手,这个例子来源于linux kernel文档中的whatisRCU.txt。这个例子使用RCU的核心API来保护一个指向动态分配内存的全局指针。
struct foo {
int a;
char b;
long c;
};
DEFINE_SPINLOCK(foo_mutex);
struct foo *gbl_foo;
void foo_read (void)
{
foo *fp = gbl_foo;
if ( fp != NULL )
dosomething(fp->a, fp->b , fp->c );
}
void foo_update( foo* new_fp )
{
spin_lock(&foo_mutex);
foo *old_fp = gbl_foo;
gbl_foo = new_fp;
spin_unlock(&foo_mutex);
kfee(old_fp);
}
如上代码所示,RCU被用来保护全局指针struct foo *gbl_foo,foo_get_a()用来从RCU保护的结构中取得gbl_foo的值。而foo_update_a()用来更新被RCU保护的gbl_foo的值(更新其a成员)。首先,我们思考一下,为什么要在foo_update_a()中使用自旋锁foo_mutex呢?假设中间没有使用自旋锁.那foo_update_a()的代码如下:
void foo_read(void)
{
rcu_read_lock();
foo *fp = gbl_foo;
if ( fp != NULL )
dosomething(fp->a,fp->b,fp->c);
rcu_read_unlock();
}
void foo_update( foo* new_fp )
{
spin_lock(&foo_mutex);
foo *old_fp = gbl_foo;
gbl_foo = new_fp;
spin_unlock(&foo_mutex);
synchronize_rcu();
kfee(old_fp);
}
假设A进程在上图—-标识处被B进程抢点.B进程也执行了goo_ipdate_a().等B执行完后,再切换回A进程.此时,A进程所持的old_fd实际上已经被B进程给释放掉了.此后A进程对old_fd的操作都是非法的。所以在此我们得到一个重要结论:RCU允许多个读者同时访问被保护的数据,也允许多个读者在有写者时访问被保护的数据(但是注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制)。
说明:本文中说的进程不是用户态的进程,而是内核的调用路径,也可能是内核线程或软中断等。
三、RCU的核心机制
宽限期的确定是 RCU 锁实现的难点与核心。为了准确判断宽限期,RCU 机制有以下限制:
- 禁止内核抢占:在使用 RCU 锁前,必须禁止内核抢占。这意味着 CPU 不能随意调度到其他线程,只能等待当前线程离开临界区(不再引用旧数据)才能进行调度。
- 临界区内限制:在 RCU 锁保护的临界区中,不能使用可能触发调度的函数。因为一旦发生调度,就意味着当前线程已经退出了临界区,不再引用旧数据。
当所有 CPU 都至少发生过一次调度时,就可以确定没有任何线程再引用旧数据,此时宽限期结束,写者便可以安全地释放旧数据。
四、RCU锁的使用场景
(1)文件系统
在 Linux 文件系统中,RCU 锁有着广泛的应用。例如,当多个进程同时读取文件系统的目录结构时,读操作可以并行进行,而当需要对目录结构进行修改(如创建新文件、删除文件等)时,写操作会在确保所有读操作完成后进行,保证了文件系统的一致性,同时提升了整体性能。
(2)网络协议栈
在网络协议栈中,对于一些只读的网络配置信息(如路由表),读操作频繁,而写操作相对较少。使用 RCU 锁可以让多个网络数据包处理线程快速读取路由信息,而当网络管理员需要修改路由配置时,写操作会在合适的时机进行,避免了读操作的阻塞。
(3)内核数据结构管理
Linux 内核在管理进程表、inode 表等数据结构时,也常常借助 RCU 锁。以进程表为例,众多线程可能频繁读取进程信息,而对进程表的修改(如进程创建、销毁)相对较少。通过 RCU 锁,读操作可以高效进行,写操作也能在不影响读性能的前提下有序完成。
五、RCU锁的优势
(1)高性能读操作
由于读操作无需加锁,在高并发读的场景下,RCU 锁能够显著提高系统的性能。相比传统的锁机制,读操作的延迟大大降低,吞吐量显著提升。
(2)减少锁争用
在多处理器环境下,锁争用是影响性能的重要因素。RCU 锁减少了读操作和写操作之间的锁争用,使得系统能够更好地利用多核处理器的性能。
(3)简化代码设计
对于读多写少的场景,使用 RCU 锁可以简化代码的同步逻辑。读端代码无需复杂的锁获取和释放操作,使代码更加简洁明了,易于维护。
六、在Linux内核中使用RCU锁
在 Linux 内核中,使用 RCU 锁需要遵循特定的 API:
读端 API:
- rcu_read_lock():用于进入RCU读临界区,本质上是禁止CPU抢占。
- rcu_read_unlock():用于离开RCU读临界区,开启CPU抢占。
写端 API:
- synchronize_rcu():等待宽限期结束,确保所有已开始的 RCU 读操作完成。
- call_rcu():用于延迟执行函数,通常用于释放数据结构或对象,在宽限期结束后执行。
- kfree_rcu():call_rcu()的特殊情况,专门用于释放动态分配的内存。
RCU 示例:
#include <linux/module.h>
#include <linux/rcupdate.h>
struct my_node {
int val;
struct rcu_head rcu;
struct list_head list;
};
LIST_HEAD(my_list);
/* 添加一个节点到链表中 */
void add_node(int val)
{
struct my_node *new_node = kmalloc(sizeof(*new_node), GFP_KERNEL);
if (!new_node) {
printk(KERN_ERR "Failed to allocate memory for new node\n");
return;
}
new_node->val = val;
INIT_LIST_HEAD(&new_node->list);
/* 加入链表 */
list_add(&new_node->list, &my_list);
}
/* 删除值为 val 的节点 */
void del_node(int val)
{
struct my_node *node, *tmp;
/* 遍历链表并删除匹配节点 */
list_for_each_entry_safe(node, tmp, &my_list, list) {
if (node->val == val) {
list_del_rcu(&node->list);
call_rcu(&node->rcu, kfree);
}
}
}
/* 遍历整个链表,打印节点的值 */
void traverse_list(void)
{
struct my_node *node;
/* 进入 RCU 读取临界区 */
rcu_read_lock();
/* 遍历链表并打印节点的值 */
list_for_each_entry_rcu(node, &my_list, list) {
printk(KERN_INFO "Node value: %d\n", node->val);
}
/* 离开 RCU 读取临界区 */
rcu_read_unlock();
}
示例中,我们使用 RCU 来保护链表的访问。添加节点时,我们不需要获取锁来保护共享资源。删除节点时,我们使用了 list_del_rcu 来删除节点,并使用 call_rcu 函数来安排释放内存的回调函数。在遍历链表时,我们使用了 rcu_read_lock 和 rcu_read_unlock 来进入和离开 RCU 读取临界区。