SylixOS RCU

RCU 概述

RCU 是 Linux 下数据同步的一种方式。以链表为例,RCU 同一时刻允许多个线程对链表进行读操作,且读的时候允许一个线程对链表进行修改(因为只允许一个进行修改,因此修改线程需要人为加锁来互斥)。因此当读者多,更新者少时, RCU 的效率很高,在 Linux 中,有很多子系统借助 RCU 来进行数据同步,如文件系统中,由于查找目录情形比较多,修改目录则相对较少,因此就可以使用 RCU 机制。

RCU 的基本原理

RCU的基本思想是这样的:先创建一个旧数据的 copy,然后 writer 更新这个 copy,最后再用新的数据替换掉旧的数据。这样讲似乎比较抽象,那么结合一个实例来看或许会更加直观。
假设有一个单向链表,其中包含一个由指针p指向的节点:

现在,要使用 RCU 机制来更新这个节点的数据,那么首先需要分配一段新的内存空间(由指针 q 指向),用于存放这个 copy。

然后将 p 指向的节点数据,以及它和下一节点[11, 4, 8]的关系,都完整地copy到q指向的内存区域中。

接下来,writer 会修改这个 copy 中的数据(将[5, 6, 7]修改为[5, 2, 3])。

修改完成之后,writer 就可以将这个更新“发布”了(publish),对于reader 来说就“可见”了。因此,pubulish之后才开始读取操作的 reader(比如读节点[1, 2, 3]的下一个节点),得到的就是新的数据[5, 2, 3](图中红色边框表示有 reader在引用)。

而在 publish 之前就开始读取操作的 reader 则不受影响,依然使用旧的数据[5, 6, 7]。

等到所有引用旧数据区的 reader 都完成了相关操作,writer 才会释放由 p 指向的内存区域。

可见,在此期间,reader 如果读取这个节点的数据,得到的要么全是旧的数据,要么全是新的数据,反正不会是“半新半旧”的数据,数据的一致性是可以保证的。

在刚接触 RCU 时笔者一直将 RCU 当成是类似锁的机制去学习,因此在刚接触上述例子时,就产生了一个疑问:
上述的链表例子中,copy 出一个节点,这个可以理解,但是不管怎样,writer 最终还是要去更新链表,而更新链表的时候,没有任何保护,是怎么样做到不会影响到读的呢?
其实答案很简单:RCU 在对资源进行更新的时候,只有一步操作,即对一个指针赋值。比如上述例子,RCU 最终对链表造成更新影响的操作就是“将[1,2,3]的那个结点指向 copy 出来并且填充好数据的新结点”。而这种操作,可以近似看成是一个原子操作,它对读的行为产生的影响只有两种可能:读到更新前的内容和读到更新后的内容。使用了 RCU,就相当于默认这种两种情况不管哪种,都不会对自己的代码逻辑不会造成错误。因此,RCU 才可以实现读的时候,同时支持更新。

综上,笔者觉得,可以以这样一种方式去理解 RCU 的工作场景**:当数据的一致性可以保证,但存在数据引用问题时,可以使用 RCU 进行数据同步**。这里的数据一致性,就可能理解成,对数据的更新是原子性的。如上述例子,本身数据一致性就满足,更新数据时,就是一个赋值,等同于原子操作,但释放结点时,需要考虑引用问题,必须等到结点引用结束后,才能释放,因此这种场景就可以使用 RCU 来做数据同步。
数据一致性没法满足时,是没有办法使用 RCU 的。比如对一个资源进行读取和更新时,需要不止一步操作,那么即使是读多,写少的场景,也没法用 RCU 来保证数据同步,只能采用其他类似锁的方式。

使用方法

读侧

RCU 的读一侧主要使用两个 API:

  • rcu_read_lock
  • rcu_read_unlock

在访问需要保护的数据时,加上上述两个 API 即可,如下所示:

int search(long key, int *res)
{
    struct list_head *lp;
    strcut el *p;

    rcu_read_lock();

    list_for_each_entry_rcu(p, head, lp){
        if (p->key = key) {
            *res = p->data;
            rcu_read_unlock();
            return 1;
        }
    }

    rcu_read_unlock();
    return 0;
}

Linux 内核配置时,如果选择了 “CONFIG_PREEMPT”,那么这两个函数实际要做的工作仅仅是分别关闭和打开CPU的可抢占性而已,等同于preempt_disable() 和 preempt_enable()。
因此,rcu_read_lock 和 rcu_read_unlock 之间一般是不可以调度的。当然,现在 Linux 内核中也加入了可以调度的 RCU 的支持。

写侧

写一侧主要用到的也是两个接口:

  • synchronize_rcu
  • call_rcu

使用 demo 如下:

int delete(long key)
{
    struct el *p;

    spin_lock(&list_lock);
    list_for_each_entry(p, head, lp){
        list_del_rcu(&p->list);
        spin_unlock(&list_lock);
        synchronize_rcu();
        free(p);
        return 1;
    }

    spin_unlock(&list_lock);
    return 1;
}

正如上文所示,RCU 只允许一个 writer 对数据进行更新,因此使用了一个标准的 spinlock。
另外,上述代码相比正常删除时,多的就是一个函数“synchronize_rcu”,它是RCU 进行数据“同步”的关键,这里的同步就是跟引用了旧数据的 reader 同步。调用这个函数,相当于给还在引用旧数据的 reader 一个优雅退出的宽限区,因此这段同步等待的时间被成为了“Grace Period”(简称 GP)。

如图, writer 只需要等待调用 synchronize 之前的 reader ,之后的直接就能读到新的数据,不需要让 writer 再等待了。
Linux 下如果 GP 时间比较长,那么一直等着就影响更高优先级任务运行,因此这种情况下也可以用基于 callback 机制的 call_rcu()来替换 synchronize_rcu():

void call_rcu(struct rcu_head *head, rcu_callback_t func);

call_rcu() 会注册一个回调函数 “func”,当所有的 reader 都退出读的临界区后,该回调函数将被执行。第一个参数的类型是 struct rcu_head,它的定义如下:

struct callback_head {
    struct callback_head *next;
    void (*func)(struct callback_head *head);
} __attribute__((aligned(sizeof(void *))));

#define rcu_head callback_head

CPU 调用 call_rcu() 后就可以离开去做其他事情了,之后它完全可能再次调用 call_rcu(),所以它每次注册的回调函数,需要通过 “next” 指针排队串接起来,等 grace period 结束后,依次执行。如果需要处理的回调函数比较多,可能需要分批进行,详细的讨论可参考这篇文章
第二个参数就是前面讲的回调函数,其功能一般会用于释放掉“旧指针”指向的内存空间。也就是笔者提的解决引用问题。

其他

上述的 4 个 API 是读写时必备的,除此以外,如下也是使用 RCU 时常用的一些接口:

rcu_dereference:
/**
 * rcu_dereference - fetch an RCU-protected pointer in an
 * RCU read-side critical section.  This pointer may later
 * be safely dereferenced.
 *
 * Inserts memory barriers on architectures that require them
 * (currently only the Alpha), and, more importantly, documents
 * exactly which pointers are protected by RCU.
 */

#define rcu_dereference(p)     ({ \
                typeof(p) _________p1 = ACCESS_ONCE(p); \
                smp_read_barrier_depends(); \
                (_________p1); \
                })

** rcu_dereference **一般用在 RCU 的读端,用于获取一个 RCU 保护的指针的值,并且可以安全的对这个值进行引用操作,所以里面实现时加入了内存屏障;

rcu_access_pointer:
#define __rcu_access_pointer(p, space) \
    ({ \
        typeof(*p) *_________p1 = (typeof(*p) *__force)ACCESS_ONCE(p); \
        ((typeof(*p) __force __kernel *)(_________p1)); \
    })
#define rcu_access_pointer(p) __rcu_access_pointer((p), __rcu)

**rcu_access_pointer **也是用于获取一个 RCU 保护的指针的值,但其就仅仅实现这个功能,它里面没有内存屏障,因此它一般是用来判断 RCU 保护的指针是否为空等情况;

rcu_assign_pointer:
#define rcu_assign_pointer(p, v) \
    ({ \
        if (!__builtin_constant_p(v) || \
            ((v) != NULL)) \
            smp_wmb(); \
        (p) = (v); \
    })
rcu_assign_pointer 用于给一个 RCU 保护的变量赋值,它里面含有内存屏障。

RCU 实现

Linux RCU 实现

在介绍 SylixOS 的 RCU 实现时,需要先简单描述一下 Linux 下的 RCU 是如何实现的。Linux 内核的 RCU 版本有很多,有 classic RCU、Tree RCU、Tiny RCU 等,后面也加入了可抢占 RCU。这里主要以classic RCU 来介绍。
RCU 的原理上文已经介绍过了,RCU 最主要的就是解决引用问题,而其采取的方法就是 writer 在回收资源时先进行等待,等到在回收资源前已经开始读的 reader 都已经退出临界区了才回收。这个等待的时间上文也提到过,就是 GP。GP 也是 RCU 实现中最重要的部分。
下面就从 GP 入手,讲述一下 Linux RCU 的实现方式:

1、GP 何时开始

GP是由作为 writer 的 CPU 发起的,当 writer 调用 synchronize_rcu()/call_rcu() 之后,就标志着进入了一个GP。

synchronize_rcu() 
   --> wait_rcu_gp(...)
       --> wait_for_completion(...)
2、GP 何时结束

根据 rcu_read**_**lock() 的实现,对于默认的 non-preemptible 的 RCU(也就是不可抢占的 RCU),进入临界区(read section)时,reader 所在的 CPU 上的调度是关闭的,直到退出临界区后,调度才会重新打开。因此,临界区内的代码被要求是不能睡眠/阻塞的,因而不会发生线程切换。

#ifndef CONFIG_PREEMPT_RCU
static inline void __rcu_read_lock(void)
{
    preempt_disable();
}
static inline void __rcu_read_unlock(void)
{
    preempt_enable();
}
#endif

所以,如果接下来该 CPU 开始执行其他任务,那么说明发生了线程切换(开启了调度),进而可说明该 CPU 已退出了临界区。另外,由于临界区的代码一定是在内核执行的,如果判断出该 CPU 已经开始执行用户态的代码,同样可以判断临界区已退出。第三种可作为判断依据的情况是已处在 idle loop 中。
从退出临界区,即完成 read_rcu_unlock,到可以判断已经离开临界区(如发生了线程切换等3中情形)的这段时间,被称为 “Quiescent State”(以下统称 “QS”)。当所有 CPU 都经历了 QS ,GP 才能结束。

还是以本文开始的链表为例,当 writer 调用了 synchronize_rcu() 后,表示在开启了一个全局的 GP 等待。此时,所有的CPU 上,reader 调用 read_rcu_unlock 后,就进入了 QS 。此后如果发生调度或者其他上述的 3 种情形之一,都会触发对应的 QS 处理代码,也就意味着发生了这 3 种情形,就说明当前的 CPU 已经结束了 QS。当所有的 CPU 都已经度过了 QS,一个 GP 才能结束。

通过 GP 的分析,可以看出 GP 的处理主要是依赖 QS 的,下面看下 Linux 的 QS 相关的处理:

3、QS 的判定

前面列举了三种情形可作为每个 CPU QS 结束判别的依据。
1、在内核进程调度时,也就是 __schedule 函数执行时,由于发生了调度,因此就可以认为当前 CPU 完成了一次 QS,此时可以直接调用 rcu_qsctr_inc() 来表明经过了一次 QS。

asmlinkage void __sched __schedule(void)

2、在系统的时钟中断时,也会进行 QS 的判定,此时的判定就不能直接调用 rcu_qsctr_inc() 来表明经过了一次 QS。这个时候就需要对当前的 CPU 做一些判断,这里主要判断后两种,如果符合,则调用 rcu_qsctr_inc() 来记录自己经过了一次 QS。
具体标记内容见下一节。

4、QS 的标记

因为 GP 是需要每一个 CPU 都完成一次 QS,因此每个 CPU 拥有一个 “rcu_data”,该结构体专注于 QS 的记录。在上一小节里,每次 CPU 判定为经过一次 QS 时,就会调用 rcu_qsctr_inc() 将经过一次 QS 记录到 “rcu_data” 中。其实现也很简单:

/*
 * Increment the quiescent state counter.
 * The counter is a bit degenerated: We do not need to know
 * how many quiescent states passed, just if there was at least
 * one since the start of the grace period. Thus just a flag.
 */
void rcu_qsctr_inc(int cpu)
{
	struct rcu_data *rdp = &per_cpu(rcu_data, cpu);
	rdp->passed_quiesc = 1;
}
5、GP 与 QS 的关联

上文说过, 所有 CPU 都经过一次 QS 才表明一次 GP 结束。怎么知道所有的 CPU 都已经“同意”了呢?目前的 Linux 主要提供了两种版本。先来看下相对比较简单,适用于 CPU 数目比较少的 **Classic RCU **的实现。
分配一个 bitmap(在 CPU 这里一般叫做 cpumask),开始一个 GP 后,将 bitmap 中所有的 bits 置 1,当一个 CPU 退出 QS 后,就将自己对应的 bit 清零。所有 bits 都被置 0 后,就可以判断 GP 的结束了。
在此过程中,多个 CPU 可能同时操作 bitmap 对应的这个全局变量,因此需要使用一个spinlock 来保护。这就是 **Classic RCU **的实现。

在 **Classic RCU **中如果 CPU 的数目较多,对 spinlock 的争抢就可能很激烈。同时由于 RCU 在 read section 不能睡眠的要求,在 GP 内,没有争抢成功的 CPU 也不能 sleep,白白增加功耗。
这就好像员工直接向老板汇报工作,汇报的内容不能让其他员工听到,所以办公室只能容纳一人,其他等待汇报工作的员工只能在办公室外等着。几个人的小公司这样做还行,上百人的大公司也这样弄还得了?所以现代企业多采用层级管理结构,每个基层员工向中层的 leader 汇报工作,leader 汇总后,再向上级的 manager 汇报。
对应到 RCU 中 QS 的标记上来,就是每个 CPU 拥有一个 “rcu_data”,该结构体专注于 QS 的记录。多个 CPU 共同向一个上层的 “rcu_node” 汇报,该结构体负责向上传递 QS 的信息。每个 "rcu_node "有一个 “qsmask” 位图,初始时位图中所有 bits 为1,当其下属的 CPU 都标记 QS 后,“qsmask” 中所有 bits 都变为0,此时它就可以向更上一层的 rcu_node 汇报。
image.png
在实际的应用中,通常 16 个 CPU 共享一个 rcu_node,每个 rcu_node 包含一个 spinlock,以互斥这 16 个 CPU 对 “qsmask” 的更改。这种更细粒度的划分降低了 QS 标记中 CPU 之间的竞争,增强了系统的扩展性,由于采用树形结构,因而被称为 “Tree RCU”。
虽说是树形结构,但 Tree RCU 并没有采用 rbtree 或者 radix tree,而是以 array 的内存形式存在,然后通过指针的连接形成了一种逻辑上的树形关系,同时也让“广度优先遍历”变成了数组的线性搜索。
image.png
一个 rcu_node 代表多个CPU,而最顶层的 rcu_node 则代表了所有的 CPU,因此当最顶层的 rcu_node 的 “qsmask” 为 0 时,就意味着所有 CPU 都经过了 QS,那么 GP 就可以结束了。

6、GP 与 QS 的关联时机

上述 GP 与 QS 的关联的处理代码是什么时候执行的呢?其实这部分的管理代码就是一个函数,rcu_process_callbacks,这个函数是每一个 CPU 注册的软中断服务函数,如下所示:

static void __cpuinit rcu_online_cpu(int cpu)
{
	struct rcu_data *rdp = &per_cpu(rcu_data, cpu);
	struct rcu_data *bh_rdp = &per_cpu(rcu_bh_data, cpu);

	rcu_init_percpu_data(cpu, &rcu_ctrlblk, rdp);
	rcu_init_percpu_data(cpu, &rcu_bh_ctrlblk, bh_rdp);
	open_softirq(RCU_SOFTIRQ, rcu_process_callbacks);
}

这个软中断的触发时机就是时钟中断,Linux 下的时钟中断服务函数结束时会调用 **rcu_check_callbacks **进行 RCU 的检测,它检测的内容有两部分:

  • QS 的判定(上文已经提到过)
  • 触发软中断,将 QS 与 GP 关联。
/*
 * Called from the timer interrupt handler to charge one tick to the current
 * process.  user_tick is 1 if the tick is user time, 0 for system.
 */
void update_process_times(int user_tick)
{
	struct task_struct *p = current;
	int cpu = smp_processor_id();

	/* Note: this timer irq context must be accounted for as well. */
	account_process_tick(p, user_tick);
	run_local_timers();
	if (rcu_pending(cpu))
		rcu_check_callbacks(cpu, user_tick);
	printk_tick();
	scheduler_tick();
	run_posix_cpu_timers(p);
}

7、回调函数的处理

前面描述的这些内容其实都是讲解的 synchronize_rcu 和 call_rcu 接口的实现。synchronize_rcu 的本质也是调用 call_rcu 接口注册了一个唤醒 completion 的回调。那么这些回调什么时候调用的?
很明显,当 GP 结束时,就可以执行这些回调了,因此在每个 CPU 的软中断函数里,如果检测到 GP 结束了,就会执行之前注册的回调函数了。

/*
 * Invoke the completed RCU callbacks. They are expected to be in
 * a per-cpu list.
 */
static void rcu_do_batch(struct rcu_data *rdp)
{
	unsigned long flags;
	struct rcu_head *next, *list;
	int count = 0;

	list = rdp->donelist;
	while (list) {
		next = list->next;
		prefetch(next);
		list->func(list);
		list = next;
		if (++count >= rdp->blimit)
			break;
	}
	rdp->donelist = list;

	local_irq_save(flags);
	rdp->qlen -= count;
	local_irq_restore(flags);
	if (rdp->blimit == INT_MAX && rdp->qlen <= qlowmark)
		rdp->blimit = blimit;

	if (!rdp->donelist)
		rdp->donetail = &rdp->donelist;
	else
		raise_rcu_softirq();
}

SylixOS RCU 实现

由于 Linux 上很多子系统和相关内核库等内容使用到了 RCU,因此 SylixOS 如果想移植这些代码时,也需要实现 RCU 的兼容,因此本小节主要介绍一下 SylixOS 下的 RCU 的实现方案。

读测

read_lock_rcu 和 read_rcu_unlock 也是采用 Linux 下的方式,关闭调度, 在 SylixOS 下可以使用线程锁调度的方式实现。

写侧

writer 侧,实现时,按照 Linux 的思想依次来看:
1、GP 开始:
前文介绍过,synchronize_rcu 本质上是调用 call_rcu ,在 call_rcu 里注册了一个唤醒 completion 的回调函数后,就阻塞在这个 completion 上,等到 GP 结束后,唤醒 completion 的回调函数会被调用然后唤醒 writer。
SylixOS 目前的 Linux 兼容层中也有 completion 机制,其原理是借助线程挂起和互斥锁两种方式实现的。因此这里也可以借助 completion 在 SylixOS 去实现 synchronize_rcu 和 call_rcu 接口。

难点:1、synchronize_rcu 里的回调函数 wakeme_after_rcu 接口需要注意调用环境,wakeme_after_rcu 这个函数就是调用 complete() 唤醒 completion,而 SylixOS 下 complete() 的实现就是 post 互斥锁或者恢复挂起的线程,这两个操作都会进出内核,因此都会对回调函数的上下文运行环境有要求,具体看下文“回调函数的处理”小节。

2、GP 结束:
Linux 下 GP 的结束有 3 种判定条件,即所有 CPU 发生如下三种情形:

  • 发生线程调度;
  • CPU 执行用户态代码;
  • 代码运行在 idle loop 中。

这三种情形,第一种“发生了线程调度”适用于 SylixOS 系统,第二种不适用,第三种 SylixOS 上可以理解为 CPU 运行 idle 线程,本是上类同于第一种。
因此,SylixOS 的 GP 结束只有一种判定条件,即所有的 CPU 经过了一次线程切换

3、QS 判定:
Linux 中 QS 判定的两种方式:

  • 线程切换,默认为完成一次 QS;
  • 时钟中断管理服务里进行判定;

第一种方式在 SylixOS 适用,可以采用线程切换 hook 的方式实现
第二种方式,正如上述 SylixOS 下 GP 结束的判定条件所述,SylixOS 的判定条件就是进行了线程切换,因此,不需要在时钟中断里做额外工作。

难点2:SylixOS 下的 QS 判定过于单一了,且需要注意的是:SylixOS 的线程切 hook 并不是内核调用 schedule 时就会调用,而是必须发生了上下文切换。换句话时,如果进行调度时,没有发生线程切换,由于本线程优先级更高或者其他情况,还是本线程继续执行,那么就不会调用线程切换的 hook,此时就有可能导致 CPU 一直没有度过 QS,从而使得 GP 的时间变得很长,writer 一直得不到运行。

4、QS 标记:
QS 的标记也可以参考 Linux classic RCU 的实现,每一个 CPU 都对应一个 **rcu_data **,然后在线程切换的 hook 里去更新 QS 标志即可。

5、GP 与 QS 的关联
由于 SylixOS 暂时使用场景不会出现 CPU 核心非常多的情况,因此可以先使用 bitmap 的方式实现。此处实现就可以参考 classic RCU 的方式实现。

6、GP 与 QS 的关联时机
此部分 Linux 下采用的方式是时钟服务函数里触发每一个核的软中断,然后在软中断中再去处理 GP 和 QS 的联系。SylixOS 没有软中断的接口,但是可以借助 **API_KernelSmpCallAll() **核间中断的方式去实现类似的效果。
但是 **API_KernelSmpCallAll() 函数又有一个使用注意事项,那就是不能在中断中调用,因此在 SylixOS 就无法借助 tick 的 hook 去触发,所以,我们就手动注册一个 timer 来实现类似的触发效果。**​

7、回调函数的处理
如果采用上一节的实现,即通过核间中断去处理 GP 和 QS 的关联,那么回调函数也就相应的会在 SylixOS 的核间中断服务函数的被调用,此时需要主要:

/*********************************************************************************************************
** 函数名称: API_KernelSmpCallAll
** 功能描述: 所有激活的 CPU 调用指定函数
** 输 入  : pfunc         同步执行函数 (被调用函数内部不允许有锁内核操作, 否则可能产生死锁)
**           pvArg         同步参数
**           pfuncAsync    异步执行函数 (被调用函数内部不允许有锁内核操作, 否则可能产生死锁)
**           pvAsync       异步执行参数
**           iOpt          选项 IPIM_OPT_NORMAL / IPIM_OPT_NOKERN
** 输 出  : 是否成功
** 全局变量: 
** 调用模块: 
                                           API 函数
*********************************************************************************************************/
LW_API  
INT  API_KernelSmpCallAll (FUNCPTR      pfunc, 
                           PVOID        pvArg,
                           VOIDFUNCPTR  pfuncAsync,
                           PVOID        pvAsync,
                           INT          iOpt)

SylixOS 使用核间中断运行的函数是不允许有锁内核的操作的,也就是不允许存在任何进出内核的行为。这就与“synchronize_rcu ()”矛盾了,**synchronize_rcu **的阻塞和唤醒是借助 **completion **实现的,而 **completion **唤醒就是在核间中断里调用的回调函数,从目前 **completion **的实现来看,唤醒肯定会进出内核,因此这里就有可能出现死锁问题了。 所以 **synchronize_rcu **的阻塞和唤醒就需要考虑换一种实现方式了。

本方案中设计采用“变量+while(1)/sleep”的方式来实现此需求,即 synchronize_rcu 中定一个变量,然后就死循环等待此变量变化,然后在 synchronize_rcu 注册的唤醒回调函数,即 wakeme_after_rcu 中,改变此变量的值,如下所示:

struct rcu_synchronize {
    struct rcu_head head;
    bool sleep;
};

void wakeme_after_rcu(struct rcu_head  *head)
{
    struct rcu_synchronize *rcu;

    rcu = container_of(head, struct rcu_synchronize, head);
    rcu->sleep = false;
}

void synchronize_rcu(void)
{
    struct rcu_synchronize rcu;

    if (rcu_blocking_is_gp())
        return;

    /* init sleep bool */
    rcu.sleep = true;
    /* Will wake me after RCU finished. */
    call_rcu(&rcu.head, wakeme_after_rcu);
    /* Wait for it. */
    while (rcu.sleep) {
        API_TimeSleep(1);
    }
}
EXPORT_SYMBOL_GPL(synchronize_rcu);

1、这里可能一开始会考虑自旋锁,但由于 RCU 这里的逻辑是需要当前 CPU 也进行调度,才能认为完成了 QS 的,因此自旋锁并不合适。
2、直接使用一个 bool 变量,因为只存在一读一更新的情况,也是可以看成原子性的。

7、其他改动

  1. classic RCU 的回调处理里,如果回调函数过多,则会分批次调用,并且一个批次处理完后,会触发下一次软中断,而 SylixOS 下,为了防止执行回调时间过长,影响实时性,因此这里执行完一部分的回调后,会直接结束,等到下一次定时器再触发软中断时继续执行;
  2. 同样是上述场景,如果再回调函数超过一定数量的时候,还再添加,那么 Linux 下会强制去触发线程切换来让 CPU 结束 QS,但是 SylixOS 本身就是实时系统,线程切换很频繁,因此这里没有强制度过 QS 的操作;

8、难点解决
上述方案中,主要有 2 个难点:

  1. 回调函数的调用环境问题,在第 6 小节中已经讲解了解决方法;
  2. SylixOS 下的 QS 判定过于单一:

上述方案通过 swap hook 来表明经过了一次 QS,但如果存在某一个线程一直占用 CPU 的情况,这样的 QS 判定机制就会使得 GP 的时间被拉长(其实这种情况出现的可能性不大,因为当前的方案使用了 timer, SylixOS 的 timer 优先级很高,为 20,因此除非线程的优先级更高,绑核且不主动放弃 CPU 才会出现上述现象)。
此问题本方案设计了如下的解决思路:

  • 修改 read_rcu_lock 和 read_rcu_unlock,即它们除了锁调度之外,还需要在当前使用 rcu read 的 TCB 上做标记,借助此标记来做 QS 的判定;
  • swap hook 保留,hook 里记录完成一次 QS;
  • 然后在定期的核间中断处理时,除了正常的处理 QS 和 GP 的逻辑代码外,加上一个通过 TCB 的标记做QS 的判定的操作;

这样,“线程切换”和“定时检测” 2 种方式结合可以以非常小的粒度去检测出每个 CPU 是否已经完成了 QS。从而使得 RCU 效率更高。
在设计上述的 TCB 标记时,考虑与锁调度合并在一起,这样就不会产生过多的额外执行代码。所以,本方案里借助 TCB 里锁调度的成员 TCB_ulThreadLockCounter来实现:

#define RCU_READ_STEP (0xFF)

void __rcu_read_lock(void)
{
    PLW_CLASS_TCB   ptcbCur;

    LW_TCB_GET_CUR_SAFE(ptcbCur);

    if (__THREAD_LOCK_GET(ptcbCur) != __ARCH_ULONG_MAX) {
        LW_NONSCHED_MODE_PROC(ptcbCur->TCB_ulThreadLockCounter += RCU_READ_STEP;);
    }
    KN_SMP_MB();
}

void __rcu_read_unlock(void)
{
    INTREG          iregInterLevel;
    PLW_CLASS_CPU   pcpuCur;
    PLW_CLASS_TCB   ptcbCur;
    BOOL            bTrySched = LW_FALSE;

    LW_TCB_GET_CUR_SAFE(ptcbCur);

    KN_SMP_MB();
    iregInterLevel = KN_INT_DISABLE();

    if (__THREAD_LOCK_GET(ptcbCur) >= RCU_READ_STEP) {
        ptcbCur->TCB_ulThreadLockCounter -= RCU_READ_STEP;
    }

    pcpuCur = LW_CPU_GET_CUR();
    if (__COULD_SCHED(pcpuCur, 0)) {
        bTrySched = LW_TRUE;
    }

    KN_INT_ENABLE(iregInterLevel);

    if (bTrySched) {
        _ThreadSched(ptcbCur);
    }
}
总结

综合上述描述,可以对 SylixOS 的 RCU 实现进行一个概括:

  1. SylixOS 的 RCU 参考 classic RCU 方式实现;
  2. 读侧与 Linux 整体思想一致,“锁线程调度”,但锁的时候,不仅仅是 counter ++,而且加了一个 RCU 的步进值;
  3. call_rcu 实现与 classic RCU 源码保持一致;
  4. synchronize_rcu 采用 “while 循环 + sleep”方式阻塞等待,回调函数处修改 while 循环条件,线程 sleep 后自动唤醒;
  5. 注册 1 Hz 的 Timer ,如果有 CPU 存在 RCU 待处理任务,则触发核间中断,处理 QS 与 GP 之间的逻辑关系,并在核间中断中调用回调函数;
  6. 1 Hz 的核间中断,还会进行 QS 判定,判定的条件就是第二点提到的步进值;
  7. 注册 “swap hook”实现 QS 的更新。

使用说明

使用上述方案实现的 SylixOS RCU 时,需要注意如下内容:

  1. 在 reader 侧使用 RCU lock 时,临界区的代码应该不产生调度,且不应该执行过长;
  2. 系统环境中,不应该存在长时间占用 CPU 不释放的线程,否则 QS 得不到更新,GP 时间会很长;
  3. 使用 call_rcu 时需要注意,其回调函数的执行环境是核间中断,这里是不允许出现锁内核的操作的;(其实也不是一定是核间中断环境,因为触发核间中断的函数,如果是本核的就直接执行了,但为了安全可以都理解为核间中断环境)
  4. 使用 RCU 线程退出时,最好是主动完成了 RCU 调用后退出,原因就是回调函数的引用问题;
其余方案
  1. 上述的方案中,通过 timer 来触发核间中断执行 QS 和 GP 的处理,考虑也可以换成:创建 NR_CPUS 个绑核的线程,线程也是定时唤醒检测。

优点:

  1. 回调函数的执行环境就是线程上下文,因此 completion 那套机制就可以正常使用;

缺点:

  1. 增加了过多的线程;
  2. 整个 QS 和 GP 的处理,也就是“__rcu_process_callbacks”函数需要锁当前调度,从而保证每个 CPU 的 rcu 信息一致,因此虽然 callback 的环境是线程上下文,但也不能存在其余阻塞,放弃 CPU 的操作。
  3. RCU 的性能不如使用核间中断好
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值