RCU锁机制原理解析 您所在的位置:网站首页 rcu机制详解 RCU锁机制原理解析

RCU锁机制原理解析

2023-07-19 23:14| 来源: 网络整理| 查看: 265

背景

为了保护共享数据,需要一些同步机制,如自旋锁(spinlock),读写锁(rwlock),它们使用起来非常简单,而且是一种很有效的同步机制,在UNIX系统和Linux系统中得到了广泛的使用。但是随着计算机硬件的快速发展,获得这种锁的开销相对于CPU的速度在成倍地增加,原因很简单,CPU的速度与访问内存的速度差距越来越大,而这种锁使用了原子操作指令,它需要原子地访问内存,也就说获得锁的开销与访存速度相关,另外在大部分非x86架构上获取锁使用了内存栅(Memory Barrier),这会导致处理器流水线停滞或刷新,因此它的开销相对于CPU速度而言就越来越大。一些锁在多CPU情况下, 由于加锁的频度变高,性能反倒比一个CPU时性能差。正是在这种背景下,一个高性能的锁机制RCU呼之欲出,它克服了以上锁的缺点,具有很好的扩展性,但是这种锁机制的使用范围比较窄,它只适用于读多写少的情况,如网络路由表的查询更新、设备状态表的维护、数据结构的延迟释放以及多径I/O设备的维护等。

原理

Read Copy Update

读(Read):读者不需要获得任何锁就可访问RCU保护的临界区;

拷贝(Copy):写者在访问临界区时,写者“自己”将先拷贝一个临界区副本,然后对副本进行修改;

更新(Update):RCU机制将在在适当时机使用一个回调函数把指向原来临界区的指针重新指向新的被修改的临界区,锁机制中的垃圾收集器负责回调函数的调用。(时机:所有引用该共享临界区的CPU都退出对临界区的操作。即没有CPU再去操作这段被RCU保护的临界区后,这段临界区即可回收了,此时回调函数即被调用)

quiescent state(静默状态过程),它表示为CPU发生上下文切换的过程

grace period(即“适当时机”),它表示为所有CPU都经历一次quiescent state所需要的等待的时间,也即系统中所有的读者完成对共享临界区的访问

RCU的结构体定义,只有一个用于串接链表的next指针和一个函数指针,这个函数指针即是上述提及的回调函数,这个需使用RCU机制的用户向链表注册,即挂接到链表下,从而在适当时机下得到调用

示例:写者从链表中删除元素B。

写者首先遍历该链表得到指向元素B的指针

然后修改元素B的前一个元素的next指针指向元素B的next指针指向的元素C,修改元素B的next指针指向的元素C的prep指针指向元素B的prep指针指向的元素A。在此期间可能有读者访问该链表,由于修改指针指向的操作是原子的,因此这个过程不需要同步,而元素B的指针并没有去修改,因为读者可能正在使用B元素来得到链表的下一个或前一个元素,即A或C。当写者完成上述操作后便向系统注册一个回调函数func以便在 grace period之后能够删除元素B,注册完毕后写着便可认为它已经完成删除操作(实际上并未完成)。

垃圾收集器在检测到所有的CPU不在引用该链表后,即所有的CPU已经经历了一次quiescent state(即grace period),当grace period完成后,系统便会去调用先前写者注册的回调函数func,从而真正的删除了元素B。这便是RCU机制的一种使用范例。

API介绍 rcu_read_lock() & rcu_read_unlock() #define rcu_read_lock() __rcu_read_lock() #define rcu_read_unlock() __rcu_read_unlock() #define __rcu_read_lock() do { preempt_disable(); __acquire(RCU); rcu_read_acquire(); } while (0) #define __rcu_read_unlock() do { rcu_read_release(); __release(RCU); preempt_enable(); } while (0)

用来保持一个读者的RCU临界区.在该临界区内不允许发生上下文切换

rcu_dereference() #define rcu_dereference(p) rcu_dereference_check(p, 0) #define rcu_dereference_check(p, c) __rcu_dereference_check((p), rcu_read_lock_held() || (c), __rcu) #define __rcu_dereference_check(p, c, space) ({ typeof(*p) *_________p1 = (typeof(*p)*__force )ACCESS_ONCE(p); rcu_lockdep_assert(c, "suspicious rcu_dereference_check()" " usage"); rcu_dereference_sparse(p, space); smp_read_barrier_depends(); ((typeof(*p) __force __kernel *)(_________p1)); })

该宏用于在RCU读端临界区获得一个RCU保护的指针,该指针可以在以后安全地引用,内存栅只在alpha架构上才使用

rcu_assign_pointer() #define rcu_assign_pointer(p, v) __rcu_assign_pointer((p), (v), __rcu) #define __rcu_assign_pointer(p, v, space) do { smp_wmb(); (p) = (typeof(*v) __force space *)(v); } while (0)

写者使用该函数来为被RCU保护的指针分配一个新的值.这样是为了安全从写者到读者更改其值.这个函数会返回一个新值

synchronize_rcu() void synchronize_rcu(void) { struct rcu_synchronize rcu; init_completion(&rcu.completion); /* Will wake me after RCU finished */ call_rcu(&rcu.head, wakeme_after_rcu); /* Wait for it */ wait_for_completion(&rcu.completion); } static void wakeme_after_rcu(struct rcu_head *head) { struct rcu_synchronize *rcu; rcu = container_of(head, struct rcu_synchronize, head); complete(&rcu->completion); }

在RCU中是一个最核心的函数,写者用来等待之前的读者全部退出。该函数由RCU写端调用,它将阻塞写者,直到经过grace period后,即所有的读者已经完成读端临界区,写者才可以继续下一步操作。如果有多个RCU写端调用该函数,他们将在一个grace period之后全部被唤醒。

call_rcu() void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *rcu)) { unsigned long flags; struct rcu_data *rdp; head->func = func; head->next = NULL; local_irq_save(flags); rdp = &__get_cpu_var(rcu_data); *rdp->nxttail = head; rdp->nxttail = &head->next; if (unlikely(++rdp->qlen > qhimark)) { rdp->blimit = INT_MAX; force_quiescent_state(rdp, &rcu_ctrlblk); } local_irq_restore(flags); }

call_rcu()用来等待之前的读者操作完成之后,就会调用函数func,用在不可睡眠的条件中,如中断上下文。而synchronize_rcu()用在可睡眠的环境下。

链表操作 除了这些API,RCU还增加了链表操作的RCU版本,因为对于RCU,对共享数据的操作必须保证能够被没有使用同步机制的读者看到,所以内存栅是非常必要的。

static inline void list_add_rcu(struct list_head *new, struct list_head *head)

该函数把链表项new插入到RCU保护的链表head的开头。使用内存栅保证了在引用这个新插入的链表项之前,新链表项的链接指针的修改对所有读者是可见的。

static inline void list_add_tail_rcu(struct list_head *new, struct list_head *head)

该函数类似于list_add_rcu,它将把新的链表项new添加到被RCU保护的链表的末尾。

static inline void list_del_rcu(struct list_head *entry)

该函数从RCU保护的链表中移走指定的链表项entry,并且把entry的prev指针设置为LIST_POISON2,但是并没有把entry的next指针设置为LIST_POISON1,因为该指针可能仍然在被读者用于遍历该链表。

static inline void list_replace_rcu(struct list_head *old, struct list_head *new)

该函数是RCU新添加的函数,并不存在非RCU版本。它使用新的链表项new取代旧的链表项old,内存栅保证在引用新的链表项之前,它的链接指针的修正对所有读者可见。

list_for_each_rcu(pos, head)

该宏用于遍历由RCU保护的链表head,只要在读端临界区使用该函数,它就可以安全地和其它_rcu链表操作函数(如list_add_rcu)并发运行。

list_for_each_safe_rcu(pos, n, head)

该宏类似于list_for_each_rcu,但不同之处在于它允许安全地删除当前链表项pos。

list_for_each_entry_rcu(pos, head, member)

该宏类似于list_for_each_rcu,不同之处在于它用于遍历指定类型的数据结构链表,当前链表项pos为一包含struct list_head结构的特定的数据结构。

list_for_each_continue_rcu(pos, head)

该宏用于在退出点之后继续遍历由RCU保护的链表head。

static inline void hlist_del_rcu(struct hlist_node *n)

它从由RCU保护的哈希链表中移走链表项n,并设置n的ppre指针为LIST_POISON2,但并没有设置next为LIST_POISON1,因为该指针可能被读者使用用于遍利链表。

static inline void hlist_add_head_rcu(struct hlist_node *n, struct hlist_head *h)

该函数用于把链表项n插入到被RCU保护的哈希链表的开头,但同时允许读者对该哈希链表的遍历。内存栅确保在引用新链表项之前,它的指针修正对所有读者可见。

hlist_for_each_rcu(pos, head)

该宏用于遍历由RCU保护的哈希链表head,只要在读端临界区使用该函数,它就可以安全地和其它_rcu哈希链表操作函数(如hlist_add_rcu)并发运行。

hlist_for_each_entry_rcu(tpos, pos, head, member)

类似于hlist_for_each_rcu,不同之处在于它用于遍历指定类型的数据结构哈希链表,当前链表项pos为一包含struct list_head结构的特定的数据结构。

应用示例 多个写者同步 例子来源于linux kernel文档中的whatisRCU.txt。这个例子使用RCU的核心API来保护一个指向动态分配内存的全局指针。 struct foo { int a; char b; long c; }; DEFINE_SPINLOCK(foo_mutex); struct foo *gbl_foo; void foo_update_a(int new_a) { struct foo *new_fp; struct foo *old_fp; new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL); spin_lock(&foo_mutex); old_fp = gbl_foo; *new_fp = *old_fp; new_fp->a = new_a; rcu_assign_pointer(gbl_foo, new_fp); // gbl_foo = new_fp; spin_unlock(&foo_mutex); synchronize_rcu(); kfree(old_fp); } int foo_get_a(void) { int retval; foo *fp; rcu_read_lock(); //fp = gbl_foo; fp = rcu_dereference(gbl_foo); retval = fp->a; rcu_read_unlock(); return retval; }

如上代码所示,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_update_a(int new_a) { struct foo *new_fp; struct foo *old_fp; new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL); old_fp = gbl_foo; 1:------------------------- *new_fp = *old_fp; new_fp->a = new_a; rcu_assign_pointer(gbl_foo, new_fp); synchronize_rcu(); kfree(old_fp); }

假设A进程在上图1:----标识处被B进程抢点.B进程也执行了foo_update_a().等B执行完后,再切换回A进程.此时,A进程所持的old_fd实际上已经被B进程给释放掉了.此后A进程对old_fd的操作都是非法的。所以在此我们得到一个重要结论:RCU允许多个读者同时访问被保护的数据,也允许多个读者在有写者时访问被保护的数据(但是注意:是否可以有多个写者并行访问取决于写者之间使用的同步机制)。

CPU指令优化同步问题 写者删除操作 如下程序,是针对于全局变量gbl_foo的操作。假设有两个线程同时运行 foo_ read和foo_update,当foo_ read执行完赋值操作后,线程发生切换;此时另一个线程开始执行foo_update并执行完成。当foo_ read运行的进程切换回来后,运行dosomething 的时候,fp已经被删除,这将对系统造成危害。 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); }

可以通过在24行kfree函数之前插入synchronize_rcu函数,执行删除操作后,先进入grace period。下图中每行代表一个线程,最下面的一行是foo_update删除线程,当它执行完删除操作后,线程进入了宽限期。宽限期的意义是,在一个删除动作发生后,它必须等待所有在宽限期开始前已经开始的读线程结束,才可以进行销毁操作。这样做的原因是这些线程有可能读到了要删除的元素。图中的宽限期必须等待1和2结束;而读线程5在宽限期开始前已经结束,不需要考虑;而3,4,6也不需要考虑,因为在宽限期开始后的线程不可能读到已删除的元素

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); } 写者修改问题 这段代码中,我们期望的是6,7,8行的代码在第10行代码之前执行。但优化后的代码并不对执行顺序做出保证,一个写线程可能先执行第10行代码。在这种情形下,一个读线程foo_read很可能读到 new_fp,但new_fp的成员赋值还没执行完成。当读线程执行dosomething(fp->a, fp->b , fp->c ) 的时候,就有不确定的参数传入到dosomething,极有可能造成不期望的结果,甚至程序崩溃。可以通过优化屏障来解决该问题,在第9行插入屏障,保证成员赋值之后,再执行指针赋值操作。不过,RCU机制对优化屏障做了包装,提供了专用的API来解决该问题。这时候,第10行不再是直接的指针赋值,而应该改为 : rcu_assign_pointer(gbl_foo,new_fp); void foo_update( foo* new_fp ) { spin_lock(&foo_mutex); foo *old_fp = gbl_foo; new_fp->a = 1; new_fp->b = ‘b’; new_fp->c = 100; gbl_foo = new_fp; spin_unlock(&foo_mutex); synchronize_rcu(); kfee(old_fp); } 读者保护问题 如下代码,在DEC Alpha CPU机器上还有一种更强悍的优化,读线程foo_read第6行的 fp->a,fp->b,fp->c可能会在第3行还没执行的时候就预先判断运行,当foo_read和foo_update线程同时运行的时候,可能导致传入dosomething的一部分属于旧的gbl_foo,而另外的属于新的。这样导致运行结果的错误。为了避免该类问题,在第4行之后插入内存屏障,保障fp赋值之后,再执行dosomething。不过,RCU还是提供了宏来解决该问题,直接对第4行修改:fp = rcu_dereference(gbl_foo); void foo_read(void) { rcu_read_lock(); foo *fp = gbl_foo; if ( fp != NULL ) dosomething(fp->a, fp->b ,fp->c); rcu_read_unlock(); } 适用场景

适合用于同步基于指针实现的数据结构(例如链表,哈希表等)。因为指针赋值是一条单指令.也就是说是一个原子操作. 因它更改指针指向没必要考虑它的同步.只需要考虑cache的影响

适用用读操作远远大与写操作的场景。RCU实际上是一种改进的rwlock,读者几乎没有什么同步开销,它不需要锁,不使用原子指令,而且在除alpha的所有架构上也不需要内存栅(Memory Barrier),因此不会导致锁竞争,内存延迟以及流水线停滞。不需要锁也使得使用更容易,因为死锁问题就不需要考虑了。写者的同步开销比较大,它需要延迟数据结构的释放,复制被修改的数据结构,它也必须使用某种锁机制同步并行的其它写者的修改操作。

参考链接

https://www.ibm.com/developerworks/cn/linux/l-rcu/index.html

https://www.cnblogs.com/wuchanming/p/3816103.html

http://abcdxyzk.github.io/blog/2015/07/31/kernel-sched-rcu/

http://blog.jobbole.com/106856/

https://blog.csdn.net/junguo/article/details/8244530#



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有