RCU 基本概念

Linux内核的RCU(Read-Copy-Update)机制是一种用于实现高效读取和并发更新数据结构的同步机制。它在保证读操作不被阻塞的同时,也能够保证数据的一致性。

RCU的核心思想是通过延迟资源释放来实现无锁读取,并且避免了传统锁带来的争用和开销。具体而言,RCU维护了一个“回收”过程,在该过程中,当没有任何读者引用时,才会真正释放资源。这样就可以避免等待所有读者完成或者互斥锁对写者进行同步等待的情况。

在Linux内核中,RCU被广泛应用于许多数据结构的实现,如链表、哈希表等。

RCU树形结构

RCU算法是一种用于在数据读取过程中实现无锁同步的一种技术。它特别适用于读取操作远多于写入操作的场景,在内核开发中应用广泛,尤其是在Linux内核中。RCU可以提供高效的数据结构访问,并且能在多核心环境下良好地扩展性能。

发布/订阅机制的基本工作原理:

  1. 发布者(Publisher)向共享数据结构写入新的信息或事件。
  2. 订阅者(Subscriber)注册对该共享数据结构感兴趣的事件或信息类型。
  3. 当有新的信息或事件发布时,所有订阅了相关类型的订阅者将接收到通知。
  4. 订阅者根据接收到的通知执行相应的处理逻辑。

RCU的基本设计思想包括以下几个方面:

  1. 分离读写路径:RCU允许读者和写者并发执行。读者可以在无需锁的情况下安全地读取数据,而写者则通过创建数据的副本来修改数据。

  2. 延后清理机制:当数据结构的某个部分被“替换”后,RCU不会立即释放旧数据。相反,它会等到所有可能还在读取旧数据结构的“读-侧”代码路径完成后,才开始清理工作。这通常是通过一种称为"grace period"(宽限期)的机制来完成的。

  3. 同步/发布语义:在进行RCU写入操作(比如更新指针以指向新的数据结构),通常需要确保这些修改对将来的读取操作可见。这通常通过使用内存屏障或序列化指令来实现。

在Linux内核中,有几个不同的RCU API,其中一些具体API是:

  • rcu_read_lock() / rcu_read_unlock():用于进入和离开RCU读取段,保证了在这两个函数调用内的读取不会看到中间状态的数据。

  • synchronize_rcu():等待一个RCU宽限期的结束,确保之前的所有RCU读者都已经完成。

  • call_rcu():将一个回调函数传递给RCU,该函数会在RCU宽限期之后被调用,用于释放老的数据结构。

  • kfree_rcu():是call_rcu()的一种特殊情况,专门用于释放动态分配的内存。kfree_rcu()通常见于需要释放旧数据时使用,它把释放工作延迟到RCU宽限期结束后。

读侧临界区

对于RCU读者执行的区域,是从rcu_read_lock()开始,然后在rcu_read_unlock()结束。在这个临界区中,可能会包含访问受RCU保护的数据结构的函数,如rcu_dereference()。这些函数实现了依赖顺序加载的概念,也被称为memory_order_consume加载。

RCU是一种无锁并发编程机制,用于在并发环境中进行读操作而不需要加锁。它通过使用延迟回收和多版本并发控制来实现高效的读取操作。RCU的核心思想是在更新操作时,不立即删除旧版本的数据,而是等待所有正在使用旧版本数据的读者完成后再删除。这样可以避免读者被阻塞,提高并发性能。

在RCU中,读操作不需要获取锁,因此读者执行的区域是相对较轻量级的,不会阻塞其他读者。而且RCU读者之间的依赖关系是通过rcu_dereference()等函数来建立的,这些函数确保在读取数据时按照正确的顺序加载。这就是所谓的memory_order_consume加载,它确保对于依赖关系的数据,读者可以看到最新的值。

写侧临界区

RCU机制在读侧提供了无锁的读取操作,但在写侧却需要进行额外的工作来维护多个版本的数据结构以及提供有序的更新。这导致了较大的同步开销,并且编写者必须使用某种同步机制(例如锁定)来确保有序的更新。

在RCU中,写操作需要推迟销毁旧版本的数据结构,以便正在执行读操作的读者能够完成。这通过在写操作期间创建新版本的数据结构,并在合适的时机销毁旧版本来实现。这个过程称为延迟回收。

为了维护多个版本的数据结构,写侧需要进行额外的工作,例如复制数据结构、更新指针等。这样做的目的是确保正在执行读操作的读者能够访问到正确的数据版本,而不会受到写操作的干扰。

此外,为了保证有序的更新,编写者必须使用某种同步机制,如锁定,来确保在进行写操作时其他写操作不会干扰更新的顺序。这是因为在RCU中,读操作是无锁的,但写操作需要确保数据结构的一致性和正确性,因此需要使用同步机制来避免竞争条件。

静默态

在这里插入图片描述

当一个线程没有运行在读侧临界区时,它就处于静默状态。这是因为RCU机制允许读操作无锁,并且不需要等待其他读操作完成。因此,当一个线程没有进入读侧临界区时,它可以自由地执行其他任务或者处于休眠状态,而不会对其他线程造成干扰。

延长的静默态指的是一个线程在静默状态下持续相当长一段时间。在这种情况下,该线程没有参与到读侧临界区的操作,也没有进行其他需要同步的操作。延长的静默态通常发生在读操作较为稀少的场景中。

宽限期

在这里插入图片描述

宽限期是指所有的RCU读取操作都已经完成,但可能还有一些延迟删除的RCU数据结构仍然存在。在宽限期结束之前,所有正在进行RCU读取的线程都必须退出其临界区。这样可以确保所有读取操作使用的数据结构都不会在宽限期之后被删除,从而保证了系统的一致性和正确性。

在宽限期中,所有线程都应该尽快退出其RCU读取临界区,以便可以进行延迟删除操作。宽限期的长度通常是预先确定的,并与系统的需求和特性相关。如果一个线程无法在宽限期内退出其RCU读取临界区,那么它可能会阻碍延迟删除操作,从而导致系统的性能下降。

在实际应用中,不同的宽限期可能会有部分或全部重叠。这是因为在一个宽限期结束之后,可能还存在一些延迟删除的RCU数据结构,这些数据结构需要在下一个宽限期中被删除。

在Linux内核中,使用RCU机制来实现读取共享数据的并发访问。当读者在读临界区遍历RCU数据时,如果写者从该数据中移除一个元素,写者需要等待一个宽限期后才能执行回收内存操作。在此期间,所有正在进行读取操作的线程可以继续访问被写者删除的元素。宽限期的长度是由系统确定的,并且通常通过调整内核参数进行配置。

在宽限期结束之前,读者必须退出其临界区,以确保对已经删除的元素的访问不会导致错误。一旦宽限期结束,写者就可以执行回收内存操作,将已经被删除的元素的内存空间释放掉。

在这里插入图片描述
如上图所示,读者在读临界区遍历RCU数据。如果写者从该数据中移除一个元素,那么写者需要等待一个宽限期后才能执行回收内存操作。

在宽限期期间,虽然写者已经删除了这个元素,但是读者仍然可以继续访问该元素。这是因为在RCU机制中,读者使用了一种特殊的读取方式,即"读取快照"。读取快照保证了读者在临界区内始终访问到的是一致的数据快照,而不受写者删除操作的影响。

一旦宽限期结束,写者就可以执行回收内存操作,将已经被删除的元素的内存空间释放掉。此时,读者必须停止访问该元素,以确保数据的一致性和正确性。在宽限期结束之前,读者应该尽快退出读临界区,以便写者可以执行回收内存操作。

宽限期的长度通常是预先确定的,并且需要根据具体应用场景进行调整,以平衡性能和正确性的需求。在RCU机制中,读者和写者使用不同的机制来访问共享数据,从而实现高效的读取共享数据的并发访问。

RCU更新操作分为两个阶段:移除阶段和回收阶段,并且通过宽限期隔开。

  1. 移除阶段:在移除元素之前,更新者需要确保没有读取者正在引用该元素。为此,通常会使用一些锁或同步机制来避免竞争条件。一旦确认没有读取者存在,更新者可以安全地移除要更新的元素。

  2. 宽限期:在移除元素后,更新者通过调用synchronize_rcu()原语初始化一个宽限期。宽限期是一个延迟时间窗口,在此期间读取者仍然可以访问已经被移除的元素。宽限期的目的是等待已经开始的读取操作完成,而不会被新的读取操作干扰。

  3. 回收阶段:一旦宽限期结束,更新者就可以安全地回收已经被移除的元素了。在回收阶段,所有未完成的读取操作都已经完成,并且不再引用被移除的元素。因此,更新者可以安全地释放或重新利用这些资源。

一个宽限期可以用于多个删除阶段,这意味着多个更新程序可以在同一个宽限期内执行更新操作。这样做的好处是减少了开销,并且能够更高效地处理并发更新。

此外,跟踪RCU宽限期的开销通常可以均摊到现有流程调度上。这是因为宽限期的跟踪可以与其他任务或操作同时进行,不会对整体性能产生显著影响。对于一些常见的工作负载,宽限期跟踪开销可以被多个RCU更新操作共享,从而使每个RCU更新操作的平均开销接近零。

写者需要在读者完成读取后才能执行销毁操作。为了实现这一点,RCU通常会在读者离开临界区时增加一个本地计数器,并将该计数器传递给写者。

当写者收到所有读者的计数器时,就可以确定所有的读者都已经退出临界区,此时写者就可以安全地执行销毁操作了。由于增加本地计数器的操作非常快速,因此RCU的性能表现非常出色。

需要注意的是,RCU并不限制写者是否可以并行执行。写者可以选择在任何时候执行销毁操作,只要它确定所有读者都已经完成读取。这样可以提高系统的吞吐量和并发性能。

临界区

临界区是指在多线程或多进程环境下,一段访问共享资源的程序代码片段,这些共享资源在同一时间只能被一个线程或进程访问。临界区的目的是确保对共享资源的访问是互斥的,避免出现竞态条件(Race Condition)等并发访问问题。

当有一个线程或进程进入临界区时,其他线程或进程必须等待,直到当前线程或进程离开临界区后才能继续访问共享资源。为了实现临界区的互斥访问,需要使用一些同步机制,如信号量(semaphore)、互斥锁(mutex)等。

信号量是一种计数器,用于控制同时访问某个共享资源的线程或进程数量。当一个线程或进程进入临界区时,它会尝试获取信号量,如果信号量的值大于0,则表示资源可用,线程或进程可以进入临界区;如果信号量的值等于0,则表示资源已经被占用,线程或进程需要等待。

互斥锁是一种二进制标志,用于确保只有一个线程或进程可以持有锁并访问共享资源。当一个线程或进程进入临界区时,它会尝试获取互斥锁,如果锁是可用的,则表示资源未被占用,线程或进程可以获取锁并进入临界区;如果锁已经被其他线程或进程持有,则线程或进程需要等待。

这些同步机制都被用于实现临界区的互斥访问,确保共享资源的正确性和一致性。常见的例子是打印机,一次只能被一个线程或进程访问,其他线程或进程需要等待当前任务完成后才能继续使用打印机。

RCU的关键思想包括两个方面:

  1. 复制后更新(Copy-On-Write):当需要对共享数据进行更新时,不直接修改原始数据,而是复制一份副本进行修改。这样做的好处是在读操作时无需加锁或阻塞,因为读操作仍然可以继续引用原始数据。只有在所有正在引用原始数据的读操作完成后,才会将副本替换为新的更新版本。

  2. 延迟回收内存:在进行复制后更新时,原始数据仍然可能被其他线程或进程引用,因此不能立即释放其内存。相反,需要等到所有正在引用原始数据的读操作都完成之后再进行内存回收。这样可以保证不会出现悬挂指针或无效访问的问题。

典型的RCU更新时序如下:

  1. 在开始一个RCU更新之前,首先需要等待当前所有正在执行中的读操作完成。
  2. 进行复制后更新操作,即创建一个副本,并将所需的修改应用于副本。
  3. 更新完毕后,在合适的时机通知其他线程或进程切换到新版本的数据。
  4. 继续处理其他任务或操作,并允许并发读取新版本的数据。
  5. 在确认没有任何线程或进程引用旧版本数据后,进行延迟回收内存操作。

在RCU中,写者在修改数据时,会将需要修改的内容复制出一份副本,并在副本上进行修改操作。这样做的好处是,在写者进行修改操作期间,旧数据没有被更新,因此读者仍然可以并行访问旧数据。

当写者完成修改后,会使用原子操作将新数据的内存地址替换掉旧数据的内存地址。由于内存地址替换是原子操作,因此不会产生读写竞争冲突。

之后,新的读者将访问新数据,而原有读者则继续访问旧数据。当原有读者完成对旧数据的访问后进入静默期(即不再引用旧数据),旧数据才会被删除回收。

通常情况下,写者只进行更新或删除指针等操作,并不直接负责回收旧数据所占用的内存空间。

以双向链表为例,在使用RCU更新双向链表中的数据时,通常遵循以下过程:

  1. 复制更新:首先,创建一个副本或者新节点来存储需要修改的数据。这可以通过复制旧数据到新数据,并在新数据上进行修改操作来完成。

  2. 替换:当写者完成对新节点的修改后,在适当的时机将新节点插入或替换原链表中相应位置的节点。这一步需要确保替换操作是原子的,以避免并发冲突。

  3. 延时回收:旧数据的回收通常会延迟进行,直到没有读者正在访问旧数据为止。这是为了保证已经获取旧数据引用的读者能够完成它们对旧数据的访问操作。一旦所有读者都进入了静默期(不再引用旧数据),即没有任何线程正在读取旧节点,那么就可以安全地进行内存回收操作。

在这里插入图片描述
使用RCU时需要注意的重要事项,具体如下:

  1. RCU适用于多读少写场景,与读写锁相似,但RCU的读者占锁没有系统开销,而写者必须等待所有读者退出才能释放资源。

  2. RCU保护的是指针,这一点非常重要。因为指针赋值是一条单指令,也就是说是一个原子操作,更改指针指向不需要考虑同步,只需要考虑cache的影响。

  3. 读者是可以嵌套调用的,这意味着rcu_read_lock()函数可以在其它rcu_read_lock()函数中被调用。

  4. 当读者持有rcu_read_lock()时,不能发生进程上下文切换,否则写者进程会被阻塞,因为写者需要等待所有读者完成才能释放资源。

  5. 在非抢占场景中,上下文切换不能发生在RCU的读侧临界区,因此,已阻塞的任何线程必须在RCU读侧临界区之前完成。

  6. 任何没有跑在RCU读侧临界区的线程不能持有任何RCU受保护的引用。

  7. 阻塞的线程不能持有受保护的RCU数据结构,因为在RCU读侧临界区中已经存在的线程可能会持有对该数据结构的引用。

  8. 线程不能引用已经删除的RCU数据结构,否则可能会导致程序出现未定义的行为。

  9. 阻塞的线程在受保护的RCU指针被移除后,不能再引用该指针,否则可能会导致程序出现未定义的行为。

  10. 从RCU保护的数据结构中删除给定元素后,必须等待所有线程处于阻塞状态,才能确保RCU读侧临界区中的任何线程都无法持有该元素的引用,从而释放该元素所占用的内存。

核心API

下五种是常见且核心的RCU API操作,用于实现读者-写者并发模型:

  1. rcu_read_lock() / rcu_read_unlock(): 这对函数用于获取和释放读取保护。当使用RCU进行读取时,应在访问共享数据之前调用rcu_read_lock(),并在完成访问后调用rcu_read_unlock()

  2. synchronize_rcu(): 此函数等待所有已经进入临界区(即rcu_read_lock())的读者退出临界区。通常在更新数据结构时使用该函数来确保没有任何活动的读者。

  3. call_rcu(): 通过此函数注册一个回调函数,在适当的时机执行回调函数。一般用于释放旧版本数据或执行其他需要延迟处理的任务。

  4. rcu_assign_pointer(): 该宏用于替换指针,并将旧指针交给RCU负责延迟释放。可以安全地更新指针而无需额外同步措施。

  5. synchronize_sched(): 此函数类似于synchronize_rcu(),但更适合在内核中使用。它等待当前CPU上正在运行的所有内核线程退出RCU临界区域。

rcu_read_lock()

void rcu_read_lock(void);

在RCU机制中,读取共享数据可以不加锁。但是,在更新共享数据时,必须等待所有正在访问该共享数据的读者完成访问,然后才能进行更新。为了保证读和写之间的一致性,在RCU机制中需要提供一些API函数来控制读写的操作。

其中,rcu_read_lock()函数用于获取读锁,表示当前线程进入了RCU的读侧临界区。在RCU读侧临界区中,读者可以安全地访问共享数据结构,而不必担心写者会更新该数据结构。

在RCU读侧临界区中,不能阻塞当前进程,因为这可能会导致整个系统陷入死锁状态。因此,在RCU读侧临界区中,不能进行任何会阻塞当前进程的操作,包括休眠、调用I/O函数等。

当读者完成对共享数据结构的访问后,需要调用rcu_read_unlock()函数来释放读锁,表示当前线程退出了RCU的读侧临界区。

实现方式
在Linux内核中,RCU提供了多种实现方式,包括基于叶节点的RCU、基于抢占点的RCU、基于时间戳的RCU等。不同实现方式的rcu_read_lock()函数可能有所不同,下面以基于抢占点的RCU:

在基于抢占点的RCU实现中,rcu_read_lock()函数会调用preempt_disable()函数来关闭抢占,并调用rcu_start_batch()函数来开始一个新的RCU批次。这样做的目的是为了确保在RCU读侧临界区中,当前线程不会被抢占,从而保证读者能够安全地访问共享数据结构。

此外,rcu_read_lock()函数还会调用__rcu_read_lock()函数来通知回收线程当前线程已经进入了RCU的读侧临界区。在__rcu_read_lock()函数中,会将当前线程的RCU状态标记为"read",表示当前线程正在进行RCU读操作。

static inline void rcu_read_lock(void)
{
    preempt_disable();
    rcu_start_batch();
}

rcu_read_lock()函数中,首先调用preempt_disable()函数来关闭抢占,防止当前线程被抢占。然后调用rcu_start_batch()函数来开始一个新的RCU批次。这样做的目的是为了确保在RCU读侧临界区中,当前线程不会被抢占,并且可以访问所有已经发布的RCU保护的数据。

rcu_read_unlock()

void rcu_read_unlock(void);

函数rcu_read_unlock()用于释放RCU读锁,表示当前线程退出了RCU的读侧临界区。在RCU读侧临界区中,读者可以安全地访问共享数据结构,完成读操作后,需要调用rcu_read_unlock()函数来解锁并退出RCU读侧临界区。

下面是__rcu_read_unlock()函数的示例代码:

static inline void __rcu_read_unlock(void)
{
    preempt_enable(); // 开启抢占
}

__rcu_read_unlock()函数中,只有一行代码,调用preempt_enable()函数来开启抢占。这表示当前线程已经完成了对共享数据结构的读操作,并且不再需要访问RCU保护的数据,可以允许其他线程进行抢占操作。

synchronize_rcu()

void synchronize_rcu(void);

函数synchronize_rcu()是一个用于同步RCU的接口函数。它用于等待所有当前持有的RCU读者完成其临界区中的读操作,以确保在继续执行之前,所有对共享数据结构的读访问都已经完成。

下面是synchronize_rcu()函数的示例代码:

void synchronize_rcu(void)
{
    rcu_start_batch(); // 开始一个新的RCU批次
    rcu_end_batch();   // 结束当前的RCU批次
}

synchronize_rcu()函数中,首先调用rcu_start_batch()函数来开始一个新的RCU批次。这样做的目的是为了确保在调用synchronize_rcu()函数时,所有的RCU读者都能进入新的RCU批次,不会再访问旧的RCU批次的数据。

然后调用rcu_end_batch()函数来结束当前的RCU批次。这个步骤表示等待所有当前持有的RCU读者完成其临界区中的读操作。只有当所有的RCU读者都完成了读操作并退出了RCU读侧临界区,才会继续执行后续的代码。

synchronize_rcu()函数是一个阻塞式的函数,会一直等待直到所有的RCU读者完成其临界区中的读操作。因此,在调用rcu_read_unlock()函数后,如果需要等待RCU读者完成,可以使用synchronize_rcu()函数来实现同步。

call_rcu()

call_rcu() API是synchronize_rcu()的回调形式,它注册一个函数和自变量,在所有进行中的RCU读取临界区域都已经完成之后被调用。相比于阻塞等待所有读者退出临界区域,call_rcu() 可以在不阻塞其他操作的情况下延迟执行回调函数。

然 call_rcu() API 提供了更灵活的回调形式,但在实际使用中确实需要谨慎考虑。 synchronize_rcu() API 通常可以更简单地保证代码的正确性和一致性。

另外,synchronize_rcu() API 具有自动限制更新速率的属性,这可以在宽限期被延迟时帮助系统自动限制更新操作的频率。这对于抵御拒绝服务攻击是非常有用的。如果使用 call_rcu() 的代码也需要具备相同的弹性和防御机制,就需要额外限制更新速率,以避免过多回调函数堆积导致系统资源耗尽。

如果存在其他高优先级任务需要处理,并且不能等待一个宽限期结束,那么使用 call_rcu() 是更合适的选择。通过使用 call_rcu(),可以将回调函数推迟到稍后执行,从而不会阻塞当前线程或任务。

call_rcu() API的函数原型如下:

void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head));

其中,head参数是指向struct rcu_head类型的指针,这是一个包含回调函数和自变量的数据结构。func参数是一个指向回调函数的指针,该函数在所有RCU读取侧关键部分均已完成之后被调用,以执行一些特定的操作。

使用call_rcu() API时,我们需要将一个struct rcu_head类型的指针和一个指向回调函数的指针传递给call_rcu()函数。call_rcu()函数会将这个struct rcu_head类型的指针添加到RCU回调机制中,并在所有正在进行的RCU读取侧关键部分均已完成之后调用这个回调函数。

此函数在宽限期过后调用func(heda)。此调用可能发生在softirq或进程上下文中,因此不允许阻止该函数。foo结构需要添加一个rcu-head结构

这是因为RCU机制的运作方式需要在保证数据一致性的同时,尽可能地避免阻塞和竞争条件。RCU机制通过使用宽限期(grace period)来确保对被RCU保护的数据结构的延迟释放和重用,从而避免读者-写者冲突。

在使用call_rcu() API时,回调函数在RCU宽限期过后执行,并且可能是在软中断或进程上下文中执行。因此,必须避免在回调函数中执行可能会导致阻塞或竞争条件的操作。

在添加RCU保护到数据结构时,需要将struct rcu_head结构添加到该数据结构中。这使得可以使用call_rcu() API来安排回调函数的执行,并在宽限期过后释放或重用数据结构。如果没有将struct rcu_head添加到数据结构中,则无法使用call_rcu() API来安排回调函数的执行,也就无法实现RCU机制的非阻塞特性。

struct foo {
    int a;
    char b; 
    long c;
    struct rcu_head rcu; 
};

通过在foo结构中添加struct rcu_head成员,我们可以在需要进行RCU保护的数据结构上使用call_rcu() API。当宽限期结束后,RCU系统会自动调用与struct rcu_head关联的回调函数。

这样做的目的是确保在软中断(softirq)或进程上下文中调用回调函数时,不会发生阻塞。因为在这些上下文中,长时间的阻塞可能会导致系统性能问题。

以下是foo_update_a()函数的示例代码:

void foo_update_a(struct foo *f, int new_a) {
    struct foo *new_f;

    new_f = kmalloc(sizeof(struct foo), GFP_KERNEL);
    if (!new_f) {
        // 处理内存分配失败的情况
        return;
    }

    // 复制原始结构的内容到新结构
    memcpy(new_f, f, sizeof(struct foo));

    // 更新新结构的a字段
    new_f->a = new_a;

    // 将新结构替换原始结构,并在宽限期结束后释放原始结构
    rcu_assign_pointer(f->rcu, new_f);
    call_rcu(&f->rcu, foo_rcu_callback);
}

上述示例中,foo_update_a()函数接收一个指向struct foo的指针f和一个新的int类型值new_a作为参数。函数首先分配一个新的struct foo结构并将原始结构的内容复制到新结构中。然后,它更新新结构的a字段为new_a

接下来,通过调用rcu_assign_pointer()函数,将新结构指针赋值给原始结构的rcu成员。这个操作是原子的,确保在宽限期之后只有新结构被访问。

使用call_rcu()函数调度一个RCU回调函数foo_rcu_callback(),该函数在宽限期结束后执行。在回调函数中,通过调用kfree()释放原始结构的内存。

通过这种方式,foo_update_a()函数可以更新struct foo结构的a字段,并在宽限期结束后安全地释放旧结构的内存,同时避免阻塞当前上下文。

当需要从受RCU保护的数据结构中删除数据元素时,不能立即释放该元素的内存,因为可能仍然有读者持有对该元素的引用。

call_rcu() 函数就是为了处理这个问题而存在的。它允许开发者注册一个回调函数,这个回调函数会在所有现有的RCU读取侧临界区完成后被调用。只有在这个回调函数执行时,我们才真正知道没有任何老的RCU读取侧临界区可能会访问到被删除的元素了,这时才是释放该元素内存的安全时机。

kfree_rcu() 是 Linux 内核提供的一个便捷函数,专门用于处理在 RCU读侧临界区之后释放对象的场景。它的主要优势在于简化了回调管理,避免了编写单独的回调函数来只调用 kfree()

当你有一个仅需要释放内存的 RCU 回调时,通常你需要写一个函数来调用 kfree()kfree_rcu() 内部会调用 call_rcu(),并将 kfree() 作为回调,从而在适当的时间安全释放内存。

rcu_assign_pointer()

rcu_assign_pointer()宏的作用就是在进行指针赋值之前,将指向共享数据结构的旧指针保留给正在读取该数据的读者。这样,已经开始读取数据的读者仍然会看到旧指针,而新指针的赋值操作对读者是透明的。

通过使用适当的内存屏障操作,rcu_assign_pointer()确保更新操作对所有处理器可见。这样,在指针赋值操作完成后,新指针的值对于新开始的读取操作是可见的,而旧指针的值对于正在进行的读取操作仍然是有效的。

rcu_assign_pointer() 宏用于为受RCU保护的指针分配一个新值,以便安全地将更新的值更改传递给读者。它不计算rvalue,但会执行某些CPU体系结构所需的内存屏障指令,以确保在退出RCU临界区域之前更新操作对其他处理器可见。

在使用RCU时,需要记录哪些指针受到RCU保护,并确定结构中哪些点可以由其他处理器访问。这通常通过结合使用RCU列表操作原语(例如 list_add_rcu())和 rcu_assign_pointer() 来实现。

当需要向链表中添加一个节点时,可以使用 list_add_rcu() 函数进行添加操作。然后,使用 rcu_assign_pointer() 将指向链表头的指针更新为新的链表头。这样,在所有正在进行的读取操作完成之前,旧的链表头仍然可用,并且读取操作不会受到添加操作的影响。

rcu_dereference()

rcu_dereference() 是一个函数原型,用于访问受RCU保护的指针所指向的数据。

以下是 rcu_dereference() 的原型:

typeof(*ptr) rcu_dereference(const volatile typeof(ptr) *ptr);

在这个原型中,ptr 是要访问的受RCU保护的指针。

rcu_dereference() 函数的作用是安全地读取受RCU保护的指针所指向的数据。它确保在读取操作期间,指针不会被修改,并且在读取操作完成后执行必要的内存屏障来确保读取结果对其他处理器可见。

使用 rcu_dereference() 函数时,应该传递一个指向受RCU保护的指针的地址作为参数。函数将返回指针所指向的数据的值,并根据需要执行必要的内存屏障。

在使用 rcu_dereference() 函数时,它通常是通过宏来实现的。

以下是一个示例宏定义,用于实现 rcu_dereference() 函数:

#define rcu_dereference(p) \
	__rcu_dereference_check((p), __rcu)

在这个宏定义中,__rcu_dereference_check() 是一个内部函数,用于执行实际的解引用操作。__rcu 是一个标记,用于指示该指针受RCU保护。

rcu_dereference() 宏的作用是为了方便使用者,它隐藏了实际解引用操作的细节,并执行必要的内存屏障指令(如果适用)以确保读取结果对其他处理器可见。

常见的编码实践

利用 rcu_dereference() 函数来安全地读取受RCU保护的指针所指向的数据。

在第一种方式中,通过将 rcu_dereference() 的返回值赋给一个局部变量 p,然后对 p 进行解引用操作,可以确保在解引用之前 head.next 的值不会被修改。这种方式可以增加代码的可读性,同时也可以避免在解引用期间 head.next 发生变化。

    p = rcu_dereference(head.next);
    return p->data;

而在第二种方式中,直接在返回语句中使用 rcu_dereference(head.next)->data,省略了中间的局部变量赋值步骤。这种方式更为简洁,适用于只需一次解引用操作的情况。

    return rcu_dereference(head.next)->data;

无论是使用局部变量还是直接在返回语句中使用 rcu_dereference(),它们的实质是一样的:都是为了安全地读取受RCU保护的指针所指向的数据,并确保在读取过程中指针的稳定性。

rcu_dereference() 返回的值只在封闭的RCU读端临界区内有效。在使用 rcu_dereference() 时,确保将其用在适当的上下文中非常重要。通常情况下,需要使用 rcu_read_lock()rcu_read_unlock() 来创建一个封闭的RCU读端临界区,以确保读取的数据在整个访问过程中是有效的。

RCU 基础结构是一种用于实现无锁读和延迟释放的技术。在使用RCU时,需要观察以下函数调用的时间顺序:

  1. rcu_read_lock()rcu_read_unlock():这对函数用于标记一个读取临界区的开始和结束。当调用rcu_read_lock()时,它表示进入了一个RCU保护的读取区域,而调用rcu_read_unlock()则表示离开该区域。这些函数的调用应该配对,并确保在访问受RCU保护的数据之前进行。

  2. synchronize_rcu():这个函数被称为同步点,在其调用处会等待之前已经开始但尚未完成的所有RCU临界区执行完毕。只有在synchronize_rcu()返回后,可以确定之前对受RCU保护的数据所做的修改已经全部生效。

  3. call_rcu():这个函数是用于延迟释放资源的机制。通过将要释放资源的回调函数传递给call_rcu(),在稍后某个合适的时间点,系统会自动调用该回调函数来完成资源释放操作。

有效实现RCU基础结构通常会使用批处理技术来分摊开销。批处理意味着将多个操作收集到一起,并一次性处理它们,从而减少不必要的开销。例如,对于大量的读取操作,可以延迟执行实际的数据拷贝操作,而是等待合适的时机进行批量处理。

在Linux内核中,至少有三种常见的RCU用法。这些用法在更新端使用相同的原语,包括rcu_assign_pointer()synchronize_rcu()call_rcu()。但是在保护读端时,使用的原语根据不同的需求而有所不同。

  1. 基本RCU(BASIC RCU):在基本RCU用法中,在读端会使用rcu_read_lock()rcu_read_unlock()函数来标记读取临界区的开始和结束。这是最常见和最基本的RCU用法。

  2. 优化过的RCU(Optimized RCU):为了提高性能,优化过的RCU在读端使用了类似于read-copy-update (RCU)机制的方式来实现无锁读取。这种情况下,通常会使用类似于rcu_dereference()或者__rcu_dereference()函数来进行无锁访问。

  3. SRCU(Synchronous RCU):SRCU是一种特殊形式的RCU,它主要用于保护结构体或数据结构对象被释放之前仍然可以被访问。在SRCU中,在读端使用了srcu_read_lock()srcu_read_unlock()函数进行标记。

核心API使用

RCU是Linux内核中的一套机制,它的核心API包括:

  1. rcu_read_lock() rcu_read_unlock():用于创建和释放RCU读端临界区。在RCU读端临界区内,可以安全地访问受RCU保护的数据。

  2. rcu_assign_pointer():用于对受RCU保护的指针进行赋值操作,在赋值过程中会执行必要的内存屏障操作,以确保新指针的可见性。

  3. rcu_dereference():用于读取受RCU保护的指针所指向的数据,返回一个指向数据的指针。在读取过程中,会确保指针的稳定性,并执行必要的内存屏障操作以确保数据的可见性。

  4. synchronize_rcu():用于执行RCU同步操作,等待所有RCU读端临界区退出后再继续执行当前线程。这个函数通常用于释放被RCU保护的数据结构。

  5. call_rcu():用于将一个回调函数注册到RCU回调链表中,等待RCU同步操作完成后再执行回调函数。这个函数通常用于释放被RCU保护的数据结构。

  6. rcu_barrier():用于执行RCU屏障操作,等待所有RCU读端临界区退出后再继续执行当前线程。这个函数与 synchronize_rcu() 类似,但不会等待RCU回调函数执行完毕。

要使用核心RCU API来保护指向动态分配结构的全局指针,可以按照以下步骤进行简单操作:

  1. 定义数据结构:首先定义您要保护的数据结构。例如,假设有一个名为"my_struct"的结构体:
struct my_struct {
    int data;
    // 其他成员...
};
  1. 初始化RCU保护机制:在代码中初始化RCU保护机制,确保所有需要访问该全局指针的线程都能正确使用RCU。可以使用如下函数进行初始化:
rcu_init();
  1. 分配内存和更新指针:动态分配结构并更新全局指针。例如,创建一个新的my_struct对象并将其分配给全局指针global_ptr
struct my_struct *new_obj = kmalloc(sizeof(struct my_struct), GFP_KERNEL);
if (new_obj) {
    new_obj->data = 42;
    rcu_assign_pointer(global_ptr, new_obj);
}
  1. 读取全局指针:在读取该全局指针时,在读端使用适当的RCU原语来确保安全访问。例如,在读端对于无锁访问可以使用rcu_dereference()函数:
struct my_struct *local_ptr;
rcu_read_lock();
local_ptr = rcu_dereference(global_ptr);
if (local_ptr) {
    // 访问local_ptr...
}
rcu_read_unlock();
  1. 延迟释放内存:在需要释放全局指针所指向的结构之前,确保通过RCU机制延迟释放内存。可以使用call_rcu()函数来进行延迟释放:
void my_struct_free_callback(struct rcu_head *rcu)
{
    struct my_struct *obj = container_of(rcu, struct my_struct, rcu);
    kfree(obj);
}

struct my_struct *old_obj = rcu_dereference(global_ptr);
if (old_obj) {
    call_rcu(&old_obj->rcu, my_struct_free_callback);
}

常见问题

rcu_dereference()rcu_dereference_protected()都是用于在RCU保护的上下文中访问指针的函数,但它们有一些差异:

  1. rcu_dereference(): 这个函数简单地返回给定指针所指向的值。它适用于读取一个被RCU保护的全局指针,并且不提供任何额外的同步机制。因此,在使用rcu_dereference()时需要确保代码处于适当的RCU读端。

  2. rcu_dereference_protected(): 这个函数提供了额外的保护机制来确保访问指针时不存在竞争条件。它接受两个参数:指针本身和一个"condition"(条件)参数。当满足条件时,函数返回给定指针所指向的值;否则,它会返回NULL或空值,以避免潜在的竞争条件。这对于那些可能导致访问悬挂引用或无效内存地址的情况很有用。

例如,在使用rcu_dereference_protected()时,可以通过检查其他条件来确保在访问指针之前对象仍然有效。

使用rcu_dereference()在合适的RCU读端上下文是安全的,因为它不会提供额外的同步机制。在正确使用RCU机制的情况下,rcu_dereference()可以获得较低的性能损失。

然而,对于一些特殊情况,例如在检查条件之后再访问指针时,仅使用rcu_dereference()可能存在潜在的竞争条件。这种情况下,推荐使用rcu_dereference_protected()来提供额外的保护机制以避免竞争条件。

参考:Linux内核源码分析教程

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐