URCU-QSBR模式
RCU基本概念
-
读侧临界区 (read-side critical sections): RCU读者执行的区域,每一个临界区开始于
rcu_read_lock()
,结束于rcu_read_unlock()
,可能包含rcu_dereference()
等访问RCU保护的数据结构的函数。这些指针函数实现了依赖顺序加载的概念,称为memory_order_consume
加载。 -
静默态(quiescent state): 当一个线程没有运行在读侧临界区时,其就处在静默状态。持续相当长一段时间的静默状态称之为延长的静默态(extended quiescent state)。
-
宽限期(Grace period): 每个线程都至少一次进入静默态的时间。宽限期前所有在读侧临界区的读者在宽限区后都会结束。不同的宽限期可能有部分或全部重叠。
读者在读临界区遍历RCU数据。如果写者从此数据中移除一个元素,需要等待一个宽限期后才能执行回收内存操作。
上述操作的示意图如下图所示,其中,标有read
的框框为一个读临界区。
上图中,每一个读者、更新者表示一个独立的线程,总共4个读线程,一个写线程。
RCU更新操作分为两个阶段:移除阶段和回收阶段。两个阶段通过宽限期隔开。更新者在移除元素后,通过synchronize_rcu()
原语,初始化一个宽限期,并等待宽限期结束后,回收移除的元素。
- 移除阶段:RCU更新通过
rcu_assign_pointer()
等函数移除或插入元素。现代CPU的指针操作都是原子的,
rcu_assign_pointer()
原语在大多数系统上编译为一个简单的指针赋值操作。移除的元素仅可被移除阶段(以灰色显示)前的读者访问。 - 回收阶段:一个宽限期后, 宽限期开始前的原有读者都完成读操作,因此,此阶段可安全释放由删除阶段删除的元素。
一个宽限期可以用于多个删除阶段,即可由多个更新程序执行更新。
此外,跟踪RCU宽限期的开销可能会均摊到现有流程调度上,因此开销较小。对于某些常见的工作负载,宽限期跟踪开销可以被多个RCU更新操作均摊,从而使每个RCU更新的平均开销接近零。
QSBR 模式
针对不同的应用场景,urcu提供了以下5种不同的flavors:1. urcu,2. QSBR(quiescent-state-based RCU), 3. Memory-barrier-base RCU,4. "Bullet-proof" RCU,5. Signal-based RCU, 其中,显示静默模式QSBR性能最好。
QSBR模式下,rcu_read_lock()
和rcu_read_unlock()
为空操作,对于读者来说负担为零,这一点是另外4种flavor不具备的。
每个读者线程必须周期性的调用rcu_quiescent_state()
来声明自己进入静默期。
注意:
- 并非每次读操作完成后都需要做此声明,考虑到读操作的性能和应用读写操作次数的不平衡性,通常的做法是每进行一定次数(如1024)的读操作之后声明进入一次静默期。
- 每个进入读侧临界区的线程都需要事先通过
rcu_register_thread()
接口进行注册,退出时调用rcu_unregister_thread()
接口取消注册。
示例1:
下图展示了Linux中的链式列表的实现中,两个读者并行读,一个更新者并行更新列表元素的场景。
图中,第一行与第二行分别展示了宽限期开始前的读者与之后的读者看到的数据结构。第三行是更新者的视角看到的数据结构。
- 第一列中,展示了一个由A B C组成的单向列表,任何在宽限期之前初始化的读者都有可能引用任何一个元素;
- 第二列中,
list_del_rcu()
将B从列表中解除,但保留了B和C之间的连接,允许已经引用了B的读者可以引用到C。 - 第三列,从第二列到第三列的转换,显示元素B从读者线程的角度消失了。 在此过渡期间,元素B从全局可见(其中任何读者都可以获取新的引用)移动到局部可见,只有已经拥有引用的读者才能看到元素B。
- 第四列,
synchronize_rcu()
原语等待宽限期期间,所有先前存在的读侧临界区都将完成, 然后可以安全地调用free()
删除,回收元素B的内存。
示例2:
示例2中,根据用户输入创建若干个writer和reader,writer不断申请释放内存资源,并用全局指针test_rcu_pointer
记录资源,reader不断读取test_rcu_pointer
指向资源的值,并且每1024次声明静默期,最后统计reader和writer的次数。
写者:
void *thr_writer(void *_count)
{
unsigned long long *count = _count;
int *new, *old;
for (;;) {
new = malloc(sizeof(int));
assert(new);
*new = 8;
old = rcu_xchg_pointer(&test_rcu_pointer, new);
synchronize_rcu();
if (old)
*old = 0;
free(old);
URCU_TLS(nr_writes)++;
}
printf_verbose("thread_end %s, tid %lu\n",
"writer", urcu_get_thread_id());
*count = URCU_TLS(nr_writes);
return ((void*)2);
}
读者:
void *thr_reader(void *_count)
{
unsigned long long *count = _count;
int *local_ptr;
rcu_register_thread();
rcu_thread_offline();
rcu_thread_online();
for (;;) {
rcu_read_lock();
local_ptr = rcu_dereference(test_rcu_pointer);
if (local_ptr)
assert(*local_ptr == 8);
rcu_read_unlock();
URCU_TLS(nr_reads)++;
/* 每读1024次,进入1次静默期 */
if (caa_unlikely((URCU_TLS(nr_reads) & ((1 << 10) - 1)) == 0))
rcu_quiescent_state();
}
rcu_unregister_thread();
*count = URCU_TLS(nr_reads);
printf_verbose("thread_end %s, tid %lu\n",
"reader", urcu_get_thread_id());
return ((void*)1);
}
不同urcu模式下性能对比:
[](_v_images/20191123163832376_23665.png =578x)
可见,qsbr 模式性能最好,在读者1024读后进入静默期的情况下,读写操作比为6000:1。
QSBR 源码分析
读者-注册/上线
注册:
qsbr rcu的实现中,reader线程必须进行显式注册, 将自己挂接在全局链表registry
上,通俗地说就是将自己置于全局管理之下,这样当writer在进行同步(synchronize)时,才能知道哪些线程需要同步(只有注册过的线程才需要)**。
上线:
线程的上线状态分为在线(online)和离线(offline)。
其中处于offline的线程虽然在registry链表上,但在synchronized时,writer会忽略这些线程。线程注册会默认置于online状态。
void rcu_register_thread(void)
{
URCU_TLS(rcu_reader).tid = pthread_self(); // rcu_reader读者的线程id
mutex_lock(&rcu_registry_lock);
URCU_TLS(rcu_reader).registered = 1; // 已注册标记
cds_list_add(&URCU_TLS(rcu_reader).node, ®istry); // 将rcu_reader加入全局链表registry
_rcu_thread_online(); // 上线,设置rcu_reader.ctr = rcu_gp.ctr
}
线程上线(online)的本质,就是将rcu_gp.ctr的值存储到本线程的ctr中
static inline void _rcu_thread_online(void)
{
_CMM_STORE_SHARED(URCU_TLS(rcu_reader).ctr, CMM_LOAD_SHARED(rcu_gp.ctr));
}
线程下线(offline),则是将本线程的ctr清零:
static inline void _rcu_thread_offline(void)
{
CMM_STORE_SHARED(URCU_TLS(rcu_reader).ctr, 0);
wake_up_gp();
}
URCU_TLS(name): 访问线程的本地变量。其产生一个C语言的本地变量,可以加载与存储。
写者-同步(synchronize)
rcu机制的一个典型场景: 全局指针gp_ptr指向内存区域A,writer在申请了一份新的内存区域B后,使全局指针gp_ptr指向B。
在多核系统中,writer在更新后并不知道有没有reader正在引用区域A的数据,所以它需要阻塞等待所有的reader线程更新本地ctr(即reader.ctr = gp.ctr),这个操作便是同步(synchronize),其简化版实现代码片段如下
void synchronize_rcu(void)
{
// 定义一个struct cds_list_head qsreaders变量
CDS_LIST_HEAD(qsreaders);
// 定义struct urcu_wait_node wait变量
DEFINE_URCU_WAIT_NODE(wait, URCU_WAIT_WAITING);
// 将wait将入gp_waitersduil,表示writer置于wait状态
urcu_wait_add(&gp_waiters, &wait)
......
/* 遍历所有reader的ctr,直到其更新到最新的gp.ctr */
wait_for_readers(registry, &cur_snap_readers, &qsreaders);
.....
}
static void wait_for_readers(struct cds_list_head *input_readers,
struct cds_list_head *cur_snap_readers,
struct cds_list_head *qsreaders)
{
unsigned int wait_loops = 0;
struct rcu_reader *index, *tmp;
/*
* Wait for each thread URCU_TLS(rcu_reader).ctr to either
* indicate quiescence (offline), or for them to observe the
* current rcu_gp.ctr value.
*/
/* 直到所有reader.ctr已经到最新才跳出循环 */
for (;;) {
uatomic_set(&rcu_gp.futex, -1);
cds_list_for_each_entry(index, input_readers, node) {
_CMM_STORE_SHARED(index->waiting, 1);
/* 遍历所有输入的reader */
cds_list_for_each_entry_safe(index, tmp, input_readers, node) {
switch(rcu_reader_state(&index->ctr)) {
case RCU_READER_ACTIVE_CURRENT: /* reader.ctr已经最新 */
case RCU_READER_INACTIVE: /* reader处于offline状态 */
cds_list_move(&index->node, qsreaders); /* 从遍历列表中移除 */
break;
case RCU_READER_ACTIVE_OLD: /* reader.ctr不是最新 */
break;
}
}
if (cds_list_empty(input_readers)) {
uatomic_set(&rcu_gp.futex, 0); /* 列表空了,表示所有reader已更新 跳出循环 */
break;
}
}
}
计数器
全局计数器:
RCU机制是用于多核系统中,保持每个核上的线程所看到的全局数据一致性的一种机制,所以需要一种手段可以判断当writer线程进行数据更新后,reader线程看到的数据是否已经最新。
为此urcu维护了一个全局的计数器rcu_gp.ctr
,每次writer进行同步操作(synchronize),都会使计数器加1,表示数据已经更新了,等待reader更新。
struct rcu_gp rcu_gp;
struct rcu_gp {
unsigned long ctr;
...
} __attribute__((aligned(CAA_CACHE_LINE_SIZE)));
读线程计数器:
每个reader线程也持有一个线程内部的计数器ctr,如果这个ctr
与rcu_gp.ctr
一致,就表明本reader线程的数据已经最新(ACTIVE_CURRENT),反之则不是最新(ACTIVE_OLD),
struct rcu_reader {
unsigned long ctr;
...
};
DECLARE_URCU_TLS(struct rcu_reader, rcu_reader)
读者-静默
读者进入静默期表示本次读操作完成。
从上面writer synchronize
的过程可知,要使writer结束阻塞状态,reader必须将其ctr更新到最新(除非它处于offline状态),更新到最新是通过reader调用rcu_quiescent_state()
接口声明静默期完成的.
static inline void _rcu_quiescent_state(void)
{
unsigned long gp_ctr;
if ((gp_ctr = CMM_LOAD_SHARED(rcu_gp.ctr)) == URCU_TLS(rcu_reader).ctr)
return;
_rcu_quiescent_state_update_and_wakeup(gp_ctr);
}
static inline void _rcu_quiescent_state_update_and_wakeup(unsigned long gp_ctr)
{
/* 将本线程ctr更新为gp_ctr */
_CMM_STORE_SHARED(URCU_TLS(rcu_reader).ctr, gp_ctr);
/* 唤醒writer */
wake_up_gp();
}
如果读者不周期性声明静默期,若写者更新了,则其一直读到的是旧数据。
写者-异步(call_rcu)
前面writer的例子中,当writer进行数据更新后需要释放旧资源,而这要在synchronize_rcu()
结束阻塞后才能进行(否则reader还在使用),但还有的时候,我们希望提高writer的效率,‘释放’过程不要阻塞,再reader进行了更新后,再进行资源释放,urcu提供了call_rcu()
接口来完成这一功能。
call_rcu()原型:
struct rcu_head {
struct cds_wfcq_node next;
void (*func)(struct rcu_head *head);
};
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head);
call_rcu()使用方法:
一般的,将要延迟释放的数据结构内嵌一个rcu_head结构,在需要延迟释放时调用:
struct global_foo {
struct rcu_head rcu_head;
......
};
struct global_foo g_foo;
在writer更新后,需要释放旧的资源时时,调用call_rcu(),之后当所有reader都更新完成后,设置的回调函数free_func被自动调用
call_rcu(&g_foo.rcu_head, free_func);
那么urcu是如何实现这个功能的呢?
既然不能阻塞将writer阻塞在synchronize_rcu(),那总得有一个线程阻塞在synchronize_rcu()等待所有reader更新,于是urcu内部创建一个线程,称为call_rcu_thread
,这个线程专门用于writer call_rcu()
(这个线程只会在第一次call_rcu()
被创建,之后的call_rcu()
均使用这个线程),以下是call_rcu_thread创建时的代码片段。
/* 第一次call_rcu()会调用到 call_rcu_data_init() */
static void call_rcu_data_init(struct call_rcu_data **crdpp,unsigned long flags,int cpu_affinity)
{
struct call_rcu_data *crdp;
int ret;
crdp = malloc(sizeof(*crdp));
if (crdp == NULL)
urcu_die(errno);
memset(crdp, '\0', sizeof(*crdp));
cds_wfcq_init(&crdp->cbs_head, &crdp->cbs_tail);
......
/* 创建call_rcu_thread */
ret = pthread_create(&crdp->tid, NULL, call_rcu_thread, crdp);
if (ret)
urcu_die(ret);
}
static void *call_rcu_thread(void *arg)
{
struct call_rcu_data *crdp = (struct call_rcu_data *) arg;
rcu_register_thread();
URCU_TLS(thread_call_rcu_data) = crdp;
for (;;) {
......
synchronize_rcu(); /* 在这里完成同步 */
rhp->func(rhp); /* 执行回调 */
......
}
rcu_unregister_thread();
return NULL;
}
参考
附录
urcu接口
userspace rcu的API相关文档在doc/
中,按API前缀可分为:
1. rcu_: Read-Copy Update (see doc/rcu-api.md)
2. cmm_: Concurrent Memory Model
3. caa_: Concurrent Architecture Abstraction
4. cds_: Concurrent Data Structures (see doc/cds-api.md)
5. uatomic_: Userspace Atomic (see doc/uatomic-api.md)
-
void rcu_init(void);
其必须在以下任何函数被调用之前被调用。 -
void ruc_read_lock(void);
RCU读侧临界区开始前被调用,这些临界区可以被嵌套。 -
void rcu_read_unlock(void);
RCU读侧临界区结束时调用。 -
void rcu_register_thread(void);
每个线程在调用rcu_read_lock()
之前,必须调用这个函数。若不会调用rcu_read_lock()
函数,则不需要调用此函数。此外,rcu bp(“bullet proof”rcu)
不需要任何线程来调用rcu register_thread()
。 -
void rcu_unregister_thread(void);
每个调用rcu_register_thread()
函数的线程在调用pthread_exit()
或返回底层函数之前,必须调用rcu_unregister_thread()
。 -
void synchronize_rcu(void);
等待,直到每个原来存在的RCU读侧临界区完成。注意,此原语不需要等待在此宽限期开始之后的读区临界区完成。 -
void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head));
注册由“head”指示的回调。这意味着func将在未来的RCU宽限期结束后被调用。head引用的rcu_head结构通常是受rcu保护的大型结构中的一个字段。func的典型实现如下:
void func(struct rcu_hed *head)
{
struct foo *p = container_of(head, struct foo, rcu); // head 在foo中的名称为rcu,通过head获得foo
free(p);
}
在给定指向封闭结构的指针p时,可以按如下方式注册此RCU回调函数:
call_rcu(&p->rcu, func);
call_rcu()
应该从注册的rcu读取侧线程调用。对于QSBR风格,调用者应该在线。
void rcu_barrier(void);
在调用rcu_barrier()
之前,确保所有的启动的所有call_rcu()
工作在rcu barrier()
返回之前完成。决不能从调用rcu()线程调用rcu barrier()。例如,此函数可用于确保在允许此共享对象的dlclose()完成之前,涉及共享对象的所有内存回收都已完成。