Linux rcu主要由两部分功能:
- 作为一种类锁功能,避免读、写竞争资源,功能上类似读写锁;
- rcu也是一个垃圾回收器;可以利用这个特性debug kernel, 著名的log: "RCU stall ..."
注:这篇文章直接讲解代码,讲解RCU的设计。 rcu基础概念需自行了解。
Tiny rcu 基础
内核有针对单核mcu开发tiny rcu, 是非常基础的版本,适合学习rcu。
代码:kernel/rcu/tiny.c
核心结构体:
c
31 struct rcu_ctrlblk {
| 32 struct rcu_head *rcucblist; /* List of pending callbacks (CBs). */
| 33 struct rcu_head **donetail; /* ->next pointer of last "done" CB. */
| 34 struct rcu_head **curtail; /* ->next pointer of last CB. */
~| 35 unsigned long gp_seq; /* Grace-period counter. */
~| 36 };
~| 37
~| 38 /* Definition for rcupdate control block. */
~| 39 static struct rcu_ctrlblk rcu_ctrlblk = {
~| 40 .donetail = &rcu_ctrlblk.rcucblist,
~| 41 .curtail = &rcu_ctrlblk.rcucblist,
~| 42 .gp_seq = 0 - 300UL,
~| 43 };
~| 44
- rcucblist: 链表的头,指向第一个回调。
- donetail: 指向"已完成宽限期"的回调段的末尾指针的地址(即最后一个已完成 CB 的 next 指针)。
- curtail: 指向"整个链表"的末尾指针的地址(即链表最后一个 CB 的 next 指针)。
- 链表初始状态:
c
链表是空的,donetail 和 curtail 都指向头指针 rcucblist 的地址。
donetail == &rcucblist
curtail == &rcucblist
结果:donetail == curtail(没有待处理的回调)。
tiny rcu 回收
rcu 作为一个垃圾回收器,会定期回收 old value 占用的内存,而且回收方式有两种:同步、异步。
- 同步回收:synchronize_rcu:阻塞只到所有cpu上报qs;
因为synchronize_rcu同步操作是不允许在rcu_read_lock() 内部调用的,所以在单cpu时,调用synchronize_rcu的位置一定在qs阶段,所以synchronize_rcu 本身不用做任何事;多cpu时synchronize_rcu要阻塞操作,等待所有cpu上报qs。
- 典型场景:
c
rcu_dereference(global_ptr);
rcu_assign_pointer(global_ptr, new_obj); // 1. 指针指向新数据
synchronize_rcu(); // 2. 等待(在 UP 上直接返回)
kfree(old_obj); // 3. 直接手动释放内存!
同步回收是谁调用synchronize_rcu, 谁去主动kfree旧值,rcu不负责垃圾回收。
- 异步回收:rcu_call
在某些场景下, 代码是不能阻塞的(比如网络协议栈、vfs查找等频繁快操作),也就是说旧值不是立刻回收,回收动作是异步的。
- 典型场景:
c
void writer_delete_rcu(struct my_data __rcu **pptr)
{
struct my_data *old_p;
// 假设写者持有某种锁(如 my_lock)
spin_lock(&my_lock);
// rcu_replace_pointer(目标指针, 新值, 检查条件)
// 它是 RCU 语义下的"替换并返回旧值"
old_p = rcu_replace_pointer(*pptr, NULL, lockdep_is_held(&my_lock));
spin_unlock(&my_lock);
if (old_p)
call_rcu(&old_p->rcu, my_data_reclaim_callback); // 调用call_rcu注册hook, 异步等待所有cpu上报qs后,调用my_data_reclaim_callback 释放旧值
return; //直接return, 不阻塞
}
看一下tiny RCU 是如何实现call_rcu的:
c
|171 void call_rcu(struct rcu_head *head, rcu_callback_t func)
|172 {
|173 static atomic_t doublefrees;
|174 unsigned long flags;
|175
|176 if (debug_rcu_head_queue(head)) {
|177 if (atomic_inc_return(&doublefrees) < 4) {
|178 pr_err("%s(): Double-freed CB %p->%pS()!!! ", __func__, head, head->func);
|179 mem_dump_obj(head);
|180 }
|181
|182 if (!__is_kvfree_rcu_offset((unsigned long)head->func))
|183 WRITE_ONCE(head->func, tiny_rcu_leak_callback);
|184 return;
|185 }
|186
~ |187 head->func = func;
~ |188 head->next = NULL;
~ |189
~ |190 local_irq_save(flags);
~ |191 *rcu_ctrlblk.curtail = head;
~ |192 rcu_ctrlblk.curtail = &head->next;
~ |193 local_irq_restore(flags);
~ |194
~ |195 if (unlikely(is_idle_task(current))) {
~ |196 /* force scheduling for rcu_qs() */
~ |197 resched_cpu(0);
~ |198 }
~ |199 }
~ |200 EXPORT_SYMBOL_GPL(call_rcu);
核心代码:187~191:将要释放的内存,放入链表,当所有cpu 上报qs后,遍历列表释放内存。
c
当我们调用 call_rcu 加入一个新的回调 A 时:
A 被挂在 curtail 指向的位置。
curtail 指向 A 的 next 指针
注意:此时 donetail 还没有动,依然指向 rcucblist。
结果:donetail != curtail。这说明有回调(A)新加入,但还没经过宽限期。
195:如果此时是idle task,直接resched, 因为处于idle task本身就代表在qs,可以立刻回收旧值了。
tiny rcu 上报qs
Rcu 会在某些时刻,上报qs(quiescent state),代表着此时cpu已经脱离了rcu read context, 如果每个cpu都是上报了qs状态 ,意味着所有cpu都脱离了rcu read context,那么此时释放旧内存是安全的。
rcu上报qs的点:
- 进程切换
- 软中断退出rcu_softirq_qs
- CPU 进入idle task
- tick到来时发现cpu在用户态el0
以上四点中,任意一点发生时,此cpu一定是脱离rcu read context的,可以上报qs,
所以这就是为什么rcu会被用来debug task, 如果发生 "RCU stall log" 一定是cpu长时间没上报qs,一定是cpu上时间没调度、或者没tick等。
看一下tiny rcu 如何实现上报qs的,拿tick举例:
timer中断到来
->update_process_times
->rcu_sched_clock_irq
c
71 void rcu_sched_clock_irq(int user)
72 {
73 if (user) {
74 rcu_qs();
75 } else if (rcu_ctrlblk.donetail != rcu_ctrlblk.curtail) {
76 set_tsk_need_resched(current);
77 set_preempt_need_resched();
78 }
79 }
如果是cpu 处于user el0, 此cpu一定是脱离rcu read context的,直接调用rcu_qs上报qs;
如果处于el1,且rcu_ctrlblk.donetail != rcu_ctrlblk.curtail, 意味之前有人调用过call_rcu,有旧值待释放,要set_tsk_need_resched,设置重新调度标记。当然cpu能不能重新调度是另外一回事。
rcu_qs:
c
51 /* Record an rcu quiescent state. */
52 void rcu_qs(void)
53 {
54 unsigned long flags;
55
56 local_irq_save(flags);
57 if (rcu_ctrlblk.donetail != rcu_ctrlblk.curtail) {
58 rcu_ctrlblk.donetail = rcu_ctrlblk.curtail;
59 raise_softirq_irqoff(RCU_SOFTIRQ);
60 }
61 WRITE_ONCE(rcu_ctrlblk.gp_seq, rcu_ctrlblk.gp_seq + 2);
62 local_irq_restore(flags);
63 }
57~58:判断是否有旧值待释放,如果有就激活软中断(rcu软中断初始化时已经注册好hook)
RCU hook 函数如下:
bash
107 /* Invoke the RCU callbacks whose grace period has elapsed. */
108 static __latent_entropy void rcu_process_callbacks(struct softirq_action *unused)
109 {
110 struct rcu_head *next, *list;
111 unsigned long flags;
112
113 /* Move the ready-to-invoke callbacks to a local list. */
114 local_irq_save(flags);
115 if (rcu_ctrlblk.donetail == &rcu_ctrlblk.rcucblist) {
116 /* No callbacks ready, so just leave. */
117 local_irq_restore(flags);
118 return;
119 }
120 list = rcu_ctrlblk.rcucblist;
121 rcu_ctrlblk.rcucblist = *rcu_ctrlblk.donetail;
122 *rcu_ctrlblk.donetail = NULL;
123 if (rcu_ctrlblk.curtail == rcu_ctrlblk.donetail)
124 rcu_ctrlblk.curtail = &rcu_ctrlblk.rcucblist;
125 rcu_ctrlblk.donetail = &rcu_ctrlblk.rcucblist;
126 local_irq_restore(flags);
127
128 /* Invoke the callbacks on the local list. */
129 while (list) {
130 next = list->next;
131 prefetch(next);
132 debug_rcu_head_unqueue(list);
133 local_bh_disable();
134 rcu_reclaim_tiny(list);
135 local_bh_enable();
136 list = next;
137 }
138 }
120~126:
c
// 1. 摘取已处理段
list = rcu_ctrlblk.rcucblist; // list 指向链表头
rcu_ctrlblk.rcucblist = *rcu_ctrlblk.donetail; // 头指针跳过已处理段,指向第一个"未处理"节点
*rcu_ctrlblk.donetail = NULL; // 断开 list 和剩余链表的联系,使其成为独立链表
// 2. 修正指针
if (rcu_ctrlblk.curtail == rcu_ctrlblk.donetail)
rcu_ctrlblk.curtail = &rcu_ctrlblk.rcucblist; // 如果整个链表都被取走了,重置 curtail
rcu_ctrlblk.donetail = &rcu_ctrlblk.rcucblist; // 重置 donetail 到头部
local_irq_restore(flags);
// 3. 执行回调
while (list) { ... f(list); ... }
假设链表中有回调 A -\> B -\> C -\> D,其中 A, B 已过宽限期(donetail 指向 B->next),C, D 是新加入的。
- 摘取:
- list 拿到 A。
- rcucblist 被设置为 *donetail,即 B->next,也就是 C。现在 rcucblist 指向 C。
- *donetail = NULL 即 B->next = NULL。现在 list 变成了 A -\> B -\> NULL。
- 重置:
- donetail 回到 &rcucblist(现在指向 C 的前驱)。
- 如果刚才把 D 也取走了(即 curtail == donetail),则 curtail 也会回到 &rcucblist。
- 总结链表分段模型:
- [rcucblist ... *donetail) : 已成熟区(宽限期已过,等待软中断执行)。
- [*donetail ... *curtail) : 孵化区(刚 call_rcu 加入,正在等静止状态)。
129~136:循环调用rcu_reclaim_tiny
c
85 static inline bool rcu_reclaim_tiny(struct rcu_head *head)
86 {
87 ....
102 f(head);
103 ...
105 }
102:这个函数非常简单,就是调用之前注册的f函数,释放内存。
为什么 gp_seq 要 + 2
在tiny代码中,rcu_qs\synchronize_rcu函数中会执行:
c
WRITE_ONCE(rcu_ctrlblk.gp_seq, rcu_ctrlblk.gp_seq + 2);
这是配合start_poll_synchronize_rcu、poll_state_synchronize_rcu使用的。
典型场景:
c
cookie = start_poll_synchronize_rcu(); // "登记"一下,拿到当前版本号
while (!poll_state_synchronize_rcu(cookie)) {
// 宽限期还没过,但我不想睡觉
do_something_else();
}
// 执行到这里,说明 gp_seq != cookie,即宽限期已过
free_my_data();
当判断出gp已经变化,代表宽限期结束。才会退出循环。
Q: 为什么需要"典型场景", 看上去rcu_call完全可以代替?
A: 主要是为了性能,RCU_CALL的性能比较差,而且链表占用内存多,且回收内存的hook函数是软中断的callback, 不能休眠、不能获取复杂的锁,且运行时间不能太长。start_poll_synchronize_rcu + poll_state_synchronize_rcu 解决了这件事。
rcu_barrier
确保在调用 rcu_barrier() 之前所有已提交的 RCU 回调函数(callbacks)都已经执行完毕。
在tiny rcu里,rcu_barrier的实现非常简洁:
c
void call_rcu_hurry(struct rcu_head *head, rcu_callback_t func)
{
__call_rcu_common(head, func, false);
}
void rcu_barrier(void)
{
wait_rcu_gp(call_rcu_hurry);
}
- wait_rcu_gp 是一个宏,最终会调用 __wait_rcu_gp。它会在栈上创建一个 struct rcu_synchronize(包含一个 rcu_head 和一个 completion 信号量),然后:
- 调用传入的函数(这里是 call_rcu_hurry,在 Tiny RCU 中通常等同于 call_rcu)。
- 将该 rcu_head 注册到 RCU 链表的末尾,回调函数设为 wakeme_after_rcu(该函数仅负责 complete() 信号量)。
- 当前进程进入睡眠,等待 completion 信号量。
- 在 Tiny RCU 中,所有的回调函数都排在唯一的单向链表 rcucblist 中。
- 由于 RCU 回调是在同一个 CPU 上按顺序执行的。
- rcu_barrier 注册的"屏障回调"被放在了链表的最后。
- 因此,当 RCU 软中断(rcu_process_callbacks)执行到这个"屏障回调"并唤醒调用者时,排在它之前的每一个回调函数(即在 rcu_barrier 调用前提交的所有回调)都已经执行完毕了。