RCU锁谈起来许多人云里雾里,听着也觉得高大上,竟然可以不加锁就实现加锁的效果。此外,网上关于RCU实现原理的完整闭环逻辑分析,比较少,大多停留在阻塞写者,等待读者退出临界区的程度。而对如何实现延迟调用,如何判定何时是合适的释放时机,由谁来执行延迟调用等实现细节没有覆盖。所以本文着重从这些方面透彻讲解RCU实现。
RCU锁的广泛运用,我认为是10年前内核社区首先发起了,当然相关技术论文比这更糟。这篇文章将从顶层设计谈起,宏观看待实现一个RCU锁,必须实现的一些基本原理与组件,并具体细聊下OVS 用户态RCU锁实现。内核态RCU锁的实现Classic RCU还会比较简单,而现在的Tree RCU锁实现的比较复杂,我也没有完整跟踪过,不过其设计上还是会比较有趣,感兴趣的可以参考文末提供的参考文档。
本文主要讨论普通RCU锁,对于睡眠RCU等变体,暂不覆盖。
RCU原理
RCU基本原理和一些概念性的东西在这里就不赘述了,可以参考LWN上的经典文章:https://lwn.net/Articles/262464/
直接说结论,RCU锁要实现几个关键组件,才能完成逻辑闭环:
- 读取时,获取指针用rcu 专用函数。其实是为了保证不受内存屏障影响,读到的指针一定是当前最新指针,因为编译器优化可能打乱指令序列,导致读取的指针为旧版本。通常可以认为是nop操作,无指令开销。
- 读临界区范围内,无睡眠(否则需要用sleeping rcu),以此满足只要发生停顿,就可以执行延迟调用释放旧版本数据的条件
- postpone 机制:rcu postpone 延迟调用,需要挂载到callback list 链表,等待后续所有读者执行线程,都经历过至少一次停顿态。本质上,其实类似高级语言的垃圾回收
- 最关键的,延迟调用时机:需要确保每个执行线程,都至少经历过一次停顿,所以本质上bitmap标记即可,每个执行线程只要对应bit位为1了,表示至少发生过一次停顿,即可执行callback list中的回调释放函数
- 谁来执行延迟调用:这个在各个实现上自行决定。
OVS RCU 实现分析
线程本地存储
在多线程程序中,所有线程共享程序中的变量。现在有一全局变量,所有线程都可以使用它,改变它的值。而如果每个线程希望能单独拥有它,那么就需要使用线程存储了。表面上看起来这是一个全局变量,所有线程都可以使用它,而它的值在每一个线程中又是单独存储的
说白了,就是给每个线程分配一个线程独有的存储空间,用于存储线程自己的东西
pthread线程库的线程本地存储使用方法
pthread_key_t key; //定义线程本地存储的载体变量,其实就是个指针
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*)); //创建,其实就是关联载体变量与清理函数,线程退出时方便释放资源
//存取线程本地存储的特定指针
int pthread_setspecific(pthread_key_t key, const void *value);
void *pthread_getspecific(pthread_key_t key);
void pthread_key_delete(pthread_key_t key); //删除存储及关联关系
OVS-RCU 的线程停顿态标记
RCU 锁里面,必须标记每个执行线程是否经历过至少一次停顿态。因为对于常规rcu锁(除开sleeping-rcu以外)而言,临界区都是没有睡眠动作的。所以只要睡眠停顿过,也就意味着其已经退出了读临界区,所以此时是可以释放旧数据的。
这类标记本质上用bit位来标记正好,只需要标记是否已经经历过。OVS 做的复杂些,用了一个结构体的指针,但是本质上也是一样的,只是判断对应指针是否位NULL。如果为NULL,就代表其经历过一次停顿了:
bool
ovsrcu_is_quiescent(void)
{
ovsrcu_init_module();
return pthread_getspecific(perthread_key) == NULL;
}
如果不为NULL,就表示其目前处在非停顿态,也就是Active活跃态:
/* Indicates the end of a quiescent state. See "Details" near the top of
* ovs-rcu.h.
*
* Quiescent states don't stack or nest, so this always ends a quiescent state
* even if ovsrcu_quiesce_start() was called multiple times in a row. */
void
ovsrcu_quiesce_end(void)
{
ovsrcu_perthread_get(); //该函数会设置perthread_key不为NULL
}
此外,注意OVS RCU的实现细节:
static struct ovsrcu_perthread *
ovsrcu_perthread_get(void)
{
struct ovsrcu_perthread *perthread;
ovsrcu_init_module();
perthread = pthread_getspecific(perthread_key);
if (!perthread) {
const char *name = get_subprogram_name();
perthread = xmalloc(sizeof *perthread);
perthread->seqno = seq_read(global_seqno); //设置当前的执行编号,只要下次
perthread->cbset = NULL;
ovs_strlcpy(perthread->name, name[0] ? name : "main",
sizeof perthread->name);
ovs_mutex_lock(&ovsrcu_threads_mutex);
ovs_list_push_back(&ovsrcu_threads, &perthread->list_node); //所有需要追踪停顿态的线程,都会push到该链表中
ovs_mutex_unlock(&ovsrcu_threads_mutex);
pthread_setspecific(perthread_key, perthread); //设置为非空,表示当前是非停顿态
}
return perthread;
}
最开始是main线程,在main线程fork出新的线程后,main线程自动转换为未停顿状态:
void
ovsrcu_quiesce_end(void)
{
ovsrcu_perthread_get();
}
pthread_t
ovs_thread_create(const char *name, void *(*start)(void *), void *arg)
{
if (ovsthread_once_start(&once)) {
//首先设置main线程的线程停顿状态
/* The first call to this function has to happen in the main thread.
* Before the process becomes multithreaded we make sure that the
* main thread is considered non quiescent.
*
* For other threads this is done in ovs_thread_wrapper(), but the
* main thread has no such wrapper.
*
* There's no reason to call ovsrcu_quiesce_end() in subsequent
* invocations of this function and it might introduce problems
* for other threads. */
ovsrcu_quiesce_end();
ovsthread_once_done(&once);
}
//接下来才是真正的线程创建
error = pthread_create(&thread, &attr, ovsthread_wrapper, aux);
}
这里ovsrcu_perthread_get
会自动设置当前线程,也就是main线程的线程本地存储指针为非NULL值,从而表征停顿状态为非停顿态。
综上:按照RCU理论,ovs_postpone发起的延迟滞后释放操作(这些操作通常挂载到了一个list上,ovs是挂到了flushed_cbsets链表上),原则上,只要所有线程的线程本地存储perthread_key都经历过至少一次NULL值,则就可以执行了。
谁,以及何时来执行延迟调用
先问谁来执行。表面看,似乎是ovsrcu_quiesce()。可以看该函数的被调用点,都是在各个OVS线程执行完自己本职任务后,在线程尾部定期调用,用来给RCU做gc。但其实不是,他只是一个弟弟。毕竟,该函数仍然是在当前线程范围内,无法保证其他持有旧版本数据指针的线程,已经经过了一次停顿态。我们来看实现:
void
ovsrcu_quiesce(void)
{
struct ovsrcu_perthread *perthread;
perthread = ovsrcu_perthread_get(); //设置当前线程为停顿态
perthread->seqno = seq_read(global_seqno); //设置当前线程的停顿编号为当前的global_seqno
if (perthread->cbset) {
ovsrcu_flush_cbset(perthread);
}
seq_change(global_seqno); // 递增全局的下一次停顿编号
ovsrcu_quiesced();
}
可以看到,这里ovsrcu_flush_cbset
将当前线程在这一次周期内发起的所有postpone延迟调用函数(在perthread->cbset中管理),提交到全局延迟调用列表flushed_cbsets中,等待经过一次全局停顿后,再执行函数。而当前线程自己,则直接可以进行下一轮的rcu周期,通过递增全局global_seqno代表全局rcu周期。
那么,**到底是谁来保证,在经过全局停顿后,执行这些回调呢?**OVS实现是用了专门的urcu线程。
static void
ovsrcu_quiesced(void)
{
if (single_threaded()) {
ovsrcu_call_postponed();
} else {
static struct ovsthread_once once = OVSTHREAD_ONCE_INITIALIZER;
if (ovsthread_once_start(&once)) {
latch_init(&postpone_exit);
ovs_barrier_init(&postpone_barrier, 2);
ovs_thread_create("urcu", ovsrcu_postpone_thread, NULL);
ovsthread_once_done(&once);
}
}
}
如果是单线程,那么当前线程执行ovsrcu_quiesce()时,就认为是其停顿期,不用再看其他不存在的线程的引用问题,直接执行释放,这种很简单。我们来看urcu 线程实现:
static void *
ovsrcu_postpone_thread(void *arg OVS_UNUSED)
{
pthread_detach(pthread_self());
while (!latch_is_set(&postpone_exit)) {
uint64_t seqno = seq_read(flushed_cbsets_seq);
if (!ovsrcu_call_postponed()) { //不停尝试释放旧版本数据
seq_wait(flushed_cbsets_seq, seqno);
latch_wait(&postpone_exit);
poll_block();
}
}
ovs_barrier_block(&postpone_barrier);
return NULL;
}
主要实现还是在ovsrcu_call_postponed
:
static bool
ovsrcu_call_postponed(void)
{
struct ovsrcu_cbset *cbset;
struct ovs_list cbsets;
guarded_list_pop_all(&flushed_cbsets, &cbsets);
if (ovs_list_is_empty(&cbsets)) {
return false;
}
ovsrcu_synchronize(); //等待所有线程seqno 大于最开始的global_seqno
LIST_FOR_EACH_POP (cbset, list_node, &cbsets) {
struct ovsrcu_cb *cb;
for (cb = cbset->cbs; cb < &cbset->cbs[cbset->n_cbs]; cb++) {
cb->function(cb->aux);
}
free(cbset->cbs);
free(cbset);
}
return true;
}
在这里可以看到,urcu 线程就是负责实际执行延迟调用的实体!他将每个使用rcu机制的线程,在每个线程各自停顿期内将自己延迟调用挂载到全局flushed_cbsets
链表的所有回调,摘取下来,挨个执行。
Ok,现在我们明白了谁来执行延迟调用。那么,如前文所述,urcu 线程需要确定全局每个使用rcu机制的线程,都经历过至少一次停顿。如何实现的呢?
在问这个问题前,我们先搞清楚,为什么需要seqno。
**不是只需要bit位标志是否经历过停顿期就可以了吗?为什么还需要seqno?**这是静态思想,我们要用发展眼光看待这个问题。因为当前经历了停顿期,且进入v2.0数据版本的线程,不能一直停止在停顿态,它还需要继续执行,再次进入非停顿态,甚至需要再次进入rcu 临界区,并再次将当前v2.0数据标记为v3.0数据版本,v4.0版本等等。所以,可能存在当前线程已经有两三个未释放数据版本挂载在全局延迟调用列表,本线程已经进入v10.0时代,而其他线程还在第一个rcu周期,处于v1.0状态,等待进入停顿态期。
所以,这里perthread->seqno其实是当前线程的数据版本号,取自线程经历停顿期时当时的global_seqno。而global_seqno代表的是全局版本号,且每个线程经历一次停顿期时,全局版本号都要被递增1。
接下来再看ovs rcu的停顿等待机制,在ovsrcu_synchronize()
内:
void
ovsrcu_synchronize(void)
{
unsigned int warning_threshold = 1000;
uint64_t target_seqno;
long long int start;
if (single_threaded()) {
return;
}
target_seqno = seq_read(global_seqno);
ovsrcu_quiesce_start();
start = time_msec();
for (;;) {
//不停循环,可能当前cur_seqno远大于target_seq,语义上等效于
//全局版本进入v10.0时代,而当前urcu线程,需要释放v4.0时代的旧版本数据
uint64_t cur_seqno = seq_read(global_seqno);
struct ovsrcu_perthread *perthread;
char stalled_thread[16];
unsigned int elapsed;
bool done = true;
ovs_mutex_lock(&ovsrcu_threads_mutex);
LIST_FOR_EACH (perthread, list_node, &ovsrcu_threads) {
if (perthread->seqno <= target_seqno) {
ovs_strlcpy_arrays(stalled_thread, perthread->name);
done = false;
break;
}
}
ovs_mutex_unlock(&ovsrcu_threads_mutex);
if (done) {
break; //如果所有线程的seqno都大于target_seqno,则我们不必再等待了,可以释放旧版本数据
}
elapsed = time_msec() - start;
if (elapsed >= warning_threshold) {
VLOG_WARN("blocked %u ms waiting for %s to quiesce",
elapsed, stalled_thread);
warning_threshold *= 2;
}
poll_timer_wait_until(start + warning_threshold);
seq_wait(global_seqno, cur_seqno);
poll_block();
}
ovsrcu_quiesce_end();
}
进函数时,读取当前周期编号global_seq,定义为目标序号target_seqno,随后for循环内持续等待,直到每个线程的seqno 都大于目标序号。其余代码实现,都是为了如果当前有线程的seqno 小于目标序号,则显然还有线程还可能在使用旧版本数据,则继续循环+poll_block等待。
所以,假设当前有3个线程,其版本号可能如图所示:
所以,如果在此时尝试释放旧版本数据,则需要等到所有线程的版本号都大于等于5后,才能执行释放。ovs urcu线程就是不停记录global_seqno为target_seqno并开始等待,等到所有线程的版本号都大于了刚进入时记录的target_seqno后,就删除一波,如此不停删除。
Ok,至此,OVS 的 RCU 实现机制厘清。
参考文档:
- 线程本地存储:https://www.jianshu.com/p/d52c1ebf808a
- lwn.net 的 RCU 介绍:https://lwn.net/Articles/262464/
- OVS RCU 文档:https://github.com/openvswitch/ovs/blob/master/lib/ovs-rcu.h
- 内核RCU实现:https://blog.csdn.net/lianhunqianr1/article/details/119259755?utm_medium=distribute.pc_relevant.none-task-blog-2defaultbaidujs_title~default-8-119259755-blog-89772926.pc_relevant_aa&spm=1001.2101.3001.4242.5&utm_relevant_index=11