RCU-2——RCU初探

发布时间 2023-04-27 20:42:24作者: Hello-World3

一、RCU简介

RCU(Read-Copy-Update)的意思是读-复制-更新,是根据原理命名的。写者修改对象的逻辑是: 首先复制一个副本,然后更新这个副本,最后使用新的对象替换旧的对象。在写者执行复制更新的时候读者可以读数据。

写者删除对象,必须要等到所有读者结束访问,才能执行销毁操作。RCU的关键技术在于如何判断所有读者已经完成访问。等待所有读者访问结束的时间称为宽限期(grace period)

RCU的优点是读者没有任何同步开销:不需要获取任何锁,不需要执行原子指令(除Alpha以外),不需要执行内存屏障。但是写者的开销比较大,写者需要复制被修改的对象,延迟对象的释放。写者与写者之间必须要使用锁互斥。

RCU的第一个版本称为经典RCU,在 Linux-2.5.43 中引入。目前内核支持3种RCU: 不可抢占RCU(RCU-sched)加速版不可抢占RCU(RCU-bh,bh是bottom half的缩写,下半部)、可抢占RCU(RCU-preempt)

RCU根据数据结构可分为2种:
(1) 树型RCU(tree RCU): 也称为基于树的分层RCU,为拥有几百个或几千个处理器的大型系统设计,但小型系统也能适配的很好。配置宏是 CONFIG_TREE_RCU。
(2) 微型RCU(tiny RCU): 为不需要实时响应的单处理器系统设计。配置宏 CONFIG_TINY_RCU。


二、技术原理

1. RCU术语

(1) 读端临界区(Read-Side Criticial Section): 读者访问RCU保护对象的代码区域。

(2) 静止状态(Quiescent State): 处理器没有读RCU保护的对象,也可理解为读者静止,没有访问临界区。

(3) 候选静止状态(Candidate Quiescent State): 读端临界区以外的所有点都是候选静止状态。

(4) 可被观察的静止状态(Observed Quiescent State): 一部分候选静止状态可以很容易被观察到,因为它们与内核的某个事件相关联,通过内核事件就可以观察到。把这些容易观察到的静止状态称为可被观察的静止转状态。内核的RCU使用的静止状态是可被观察的静止状态,不可抢占RCU通过事件“调度器调度进程”观察到静止状态,加速版不可抢占RCU通过事件“执行完软中断”观察到静止状态。

(5) 宽限期(Grace Period): 等待所有处理器上的读者退出临界区是时间称为宽限期。如果所有处理器都至少经历了一次静止状态,那么当前宽限期结束,新的宽限期开始。当前宽限期结束的时候,写者在当前宽限期以及以前注册的销毁对象的回调函数就可以安全地执行了。

宽限期分为正常宽限期(Normal Grace Period) 和加速宽限期(Expedited Grace Period)。调用函数 synchronize_rcu_expedited() 等待宽限期结束的时候,会向其它处理器发送IPI中断请求,强制产生静止状态。使宽限期快速结束,我们把强制结束的宽限期称为加速宽限期。

RCU需要使用位图标记哪些处理器经历了一个静止状态。在宽限期开始的时候,在位图中为所有处理器设置对应的位,如果一个处理器经历了一个静止状态,就把自己的位从位图中清除,处理器修改位图的时候必须要使用自旋锁保护。如果处理器很多,对自旋锁的竞争很激烈,会导致系统性能很差。
为了解决性能问题,基于树(Tree)的分层RCU采用了类似淘汰赛的原理:把处理器分组,当一个处理器经历了一个静止状态时,把自己的位从分组的位图中清除,这就只需要竞争分组的自旋锁即可。当分组中的最后一个处理器经历了静止状态时,代表分组从上一层的位图中清除位,这个操作只需要竞争上一层分组的自旋锁。


三、经典RCU

若不关心使用的RCU是可抢占RCU还是不可抢占RCU,应该使用经典RCU编程接口。最初经典RCU是不可抢占RCU,后来实现了可抢占RCU,经典RCU的意思发生了变化,如果内核编译了可抢占RCU(开启了CONFIG_PREEMPT_RCU),那么经典RCU编程接口被实现为可抢占RCU,否则被实现为不可抢占RCU。

1. 读者

读者使用 rcu_read_lock() 标记进入读端临界区,使用 rcu_read_unlock() 标记退出读端临界区。读端临界区可以嵌套
在读端临界区应该使用 rcu_dereference(p) 来访问指针,这个宏封装了数据依赖的屏障,即只有Alpha处理器依赖的读内存屏障。

2. 写者

写者可以使用下面4个函数修改对象:

(1) 使用函数 synchronize_rcu() 等待宽限期结束,即所有读者退出读端临界区,然后写者执行下一步操作。这个函数可能休眠

(2) 使用函数 synchronize_rcu_expedited() 等待宽限期结束。和函数 synchronize_rcu() 的区别是:该函数会向其它处理器发送IPI中断请求,强制宽限期快速结束。我们把强制快速结束的宽限期称为加速宽限期(expedited grace period),把没有强制快速结束的宽限期称为正常宽限期(normal grace period)。

(3) 使用函数 call_rcu(head, func) 注册延后执行的回调函数,把回调函数添加到RCU回调函数链表中,立即返回,不会阻塞。函数原型:

void call_rcu(struct rcu_head *head, rcu_callback_t func) //rcu/tree.c

struct callback_head { //include/linux/types.h
    struct callback_head *next;
    void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));

#define rcu_head callback_head

(4) 使用 rcu_barrier() 等待使用 call_rcu() 注册的所有回调函数执行完。这个函数可能会休眠。######### -----------------------------------------------


四、不可抢占RCU(RCU-sched)

不可抢占RCU是指进程在读端临界区不允许被抢占。最初经典RCU是不可抢占RCU,后来引入了可抢占RCU,所以 Linux-2.6.12 为不可抢占RCU设计了一套专用变成接口。

如果我们的需求是“无论内核是否编译了可抢占RCU,都要使用不可抢占RCU”,那么应该使用不可抢占RCU专用的编程接口:

读者使用 rcu_read_lock_sched() 标记进入读端临界区,使用函数 rcu_read_unlock_sched() 标记退出读端临界区。读端临界区可以嵌套。

在读端临界区里面应该使用 rcu_dereference_sched(p) 访问指针,这个宏封装了数据依赖的屏障,即只有Alpha处理器需要的读内存屏障。

写者可以使用下面4个函数:

(1) 使用函数 synchronize_sched() 等待宽限期结束,即所有读者退出读端临界区,然后执行下一步操作,这个函数可能休眠。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被 synchronize_rcu() 给替换了。)

(2) 使用函数 synchronize_sched_expedited() 等待宽限期结束,和 synchronize_sched() 的区别是该函数会向其它处理器发送IPI中断请求,强制宽限期快速结束。(注:在5.10内核上已经不存在这个函数了,原4.19对这个函数也没有使用位置,也替换为 synchronize_rcu_expedited() ?)

(3) 使用函数 call_rcu_sched() 注册延后执行的回调函数,把回调函数添加到回调函数链表中后立即返回,不会阻塞。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被call_rcu() 给替换了。)

(4) 使用函数 rcu_barrier_sched() 等待使用 call_rcu_sched() 注册的所有回调函数执行完,可能休眠。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被rcu_barrier() 给替换了。)


五、加速版不可抢占RCU(RCU-bh)

加速版不可抢占RCU在 linux-2.6.9 中引入,是针对不可抢占RCU的改进,在软中断很多的情况下可以缩短宽限期。

内核中的网络栈在软中断中处理报文,若攻击者疯狂发送报文攻击设备,导致被攻击的设备大部分时间执行软中断,宽限期会变得很长,导致大量延后的销毁操作没有被执行,可能导致内存耗尽。为了应对这种分布式拒绝服务攻击,RCU-bh把执行完软中断看做处理器退出读端临界区的标志,缩短宽限期。

读者使用 rcu_read_lock_bh() 标记进入读端临界区,使用函数 rcu_read_unlock_bh() 标记退出读端临界区。读端临界区可以嵌套。在读端临界区里面应该使用宏 rcu_dereference_bh(p) 访问指针, 这个宏封装了数据依赖的屏障,即只有Alpha处理器需要的读内存屏障。

写者可以使用下面4个函数:

(1) 使用函数 synchronize_rcu_bh() 等待宽限期结束,即所有读者退出读端临界区,然后执行下一步操作,这个函数可能休眠。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被 synchronize_rcu() 给替换了。)

(2) 使用函数 synchronize_rcu_bh_expedited() 等待宽限期结束,和 synchronize_rcu_bh() 的区别是该函数会向其它处理器发送IPI中断请求,强制宽限期快速结束。(注:在5.10内核上已经不存在这个函数了,原4.19对这个函数也没有使用位置,也替换为 synchronize_rcu_expedited() ?)

(3) 使用函数 call_rcu_bh() 注册延后执行的回调函数,把回调函数添加到回调函数链表中后立即返回,不会阻塞。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被call_rcu() 给替换了。)

(4) 使用函数 rcu_barrier_bh() 等待使用 call_rcu_bh() 注册的所有回调函数执行完,可能休眠。(注:在5.10内核上已经不存在这个函数了,原4.19上使用这个函数的位置都被 rcu_barrier() 给替换了。)

看来这三类RCU在5.10内核上只是读端不同,写端保持一致了。


六、可抢占RCU(RCU-preempt)

可抢占RCU也称为实时RCU,在 Linux-2.6.26 中引入。可抢占RCU允许进程在读端临界区被其它进程抢占,编译时需要开启 CONFIG_PREEMPT_RCU。


七、RCU使用场景

1. RCU最常见的使用场合是保护大多数时候是读的双向链表。内核也实现了链表操作的RCU版本,这些操作封装了内存屏障。内核实现了四种双向链表:

(1) 链表 list_head,常用操作:

list_for_each_entry_rcu(pos, head, member, cond...) //宏,封装了只有Alpha处理器依赖的内存屏障
void list_add_rcu(struct list_head *new, struct list_head *head) //添加到链表首
void list_add_tail_rcu(struct list_head *new, struct list_head *head) //添加到链表尾
void list_del_rcu(struct list_head *entry)
void list_replace_rcu(struct list_head *old, struct list_head *new)

(2) 链表 hlist, 和上面 list_head 比,优势是头部节点只有一个指针,节省内存。其常用操作如下:

hlist_for_each_entry_rcu(pos, head, member, cond...)
void hlist_add_head_rcu(struct hlist_node * n, struct hlist_head * h)
void hlist_add_tail_rcu(struct hlist_node * n, struct hlist_head * h)
void hlist_del_rcu(struct hlist_node * n)
void hlist_replace_rcu(struct hlist_node * old, struct hlist_node * new)

(3) 链表 hlist_nulls 是 hlist 的变体,区别是 hlist 的结束符是个NULL指针,而练表 hlist_nulls 的结束符是 (1UL|(((long)value)<<1))), 低位是1,value 是嵌入的值,比如散列桶的索引。

hlist_nulls_for_each_entry_rcu(tpos, pos, head, member)
void hlist_nulls_add_head_rcu(struct hlist_nulls_node * n, struct hlist_nulls_head * h)
void hlist_nulls_add_tail_rcu(struct hlist_nulls_node * n, struct hlist_nulls_head * h)
void hlist_nulls_del_rcu(struct hlist_nulls_node * n)

(4) 链表 hlist_bl,它是 hlist 的变体,链表头节点的最低位作为基于位的自旋锁保护链表。其常用操作如下:

hlist_bl_for_each_entry_rcu(tpos, pos, head, member)
void hlist_bl_add_head_rcu(struct hlist_bl_node * n, struct hlist_bl_head * h)
void hlist_bl_del_rcu(struct hlist_bl_node * n)