浅析从同步到RCU的引入

描述

一、从同步开始

1.1 同步的产生

在阅读或者编写内核代码的时候,总是需要带着一个默认的前提条件:任意的一条执行流,都可能在任意一条指令之后被中断执行,然后在并不确定的时间后再次回来执行。

因此,常常需要考虑一个问题:指令在被中断到回到断点继续执行的这个过程中,原本所依赖的执行环境是不是会发生变化,对应的问题是,指令执行所依赖的环境是独享的还是共享的。如果它的独享的,那就是安全的,而如果是共享的,那就可能存在被意外修改的问题,由此引发一些同步问题。处理同步问题,通常是通过原子变量、加锁这些同步机制来解决。

而大部分工程师对于是否使用同步机制的判断基于一个朴素的观念:全局变量的操作需要加锁,而局部变量并不需要。

在绝大多数的情况下,这句话是适用的。之所以我将全局和局部替换为共享与独享,是因为在特定情况下,局部变量并不等于独享资源,而全局变量也同样如此,是否要引入同步机制这个问题也并不是一成不变的,比如下面的几种情况:

(1) 需要注意的一个问题是,我们通常毫不思索地把返回指向栈上资源的指针这种行为视为绝对的 bug,但是却忽略了这个 bug 产生的原因:函数返回之后栈上的数据会被覆盖。但是如果函数没有返回呢?内核中能看到这样的代码:在栈上初始化一些资源,然后将其链接到全局链表上,随后陷入睡眠,栈上数据的生命周期也就保持到了被唤醒之后。既然栈上的数据可以导出到其它地方,自然也就由独享变成了共享,也就需要考虑同步问题。

(2) 当我们把视线全部聚焦在数据上时,或者像第一点提到的理所当然地认为栈就应该是独占的时候,其实我们忽略了它们本身存在的形式:不论是指令、亦或者是数据,栈区也好,堆区也罢,它们都是存在于内存当中,而内存本身的硬件属性是可读可写的。而诸如代码段只读,栈独立于每个进程只是操作系统赋予它们的属性,如果我们只是操作系统的使用者,自然可以默认这些规律,但是如果我们是开发者,是不是存在修改代码段或者其它非数据部分的需求?而这些修改又会不会存在同步问题呢?所以有时候同步问题并不仅仅局限于数据。

(3) 有些全局变量的定义可能仅仅是为了扩大访问范围,或者虽然它是共享资源,但是在特定场景下并没有并发产生,(比如 per-task 的变量,percpu 变量)。因此,对于产生同步问题来说,共享只是其中一个必要条件,它还有另一个必要条件:同时操作。也就是多条执行流同时访问同一个共享资源。

同时操作中的同时如何理解?时间刻度下的同一时刻?如果是这样判定,那从来就不存在真正意义上的同时,我们所定义的同时是在 A 还没完成某项工作的情况下,B 也参与进来,这种情况就视为 A 与 B 同时做这项工作。

比如,在单核环境下并不存在代码执行的同时,所有代码都是串行执行的,但是依旧会产生同步问题,比如 i++。这是因为,C 语言的最小粒度并非指令执行的最小粒度,一个 i++ 操作实际上由 load/modified/store 指令组成,如果在执行 load 指令完成之后被打断,在其它执行流再操作 i,从 C 语言的执行角度来说,i++ 是没有被执行完的,由此而带来的一种”同时”。而这样的概念同样可以引申到复合结构。

(4)多个执行流对共享的数据进行同时操作,当这个场景出现时,许多工程师会毫不犹豫地增加同步机制来避免出现问题。不知道各位盆友有没有想过,如果不加锁,是不是一定会造成程序上的 bug?要弄清楚这个问题,我们需要知道 CPU 在执行时的一些行为。

首先就是要区分读和写,通常我们所说的问题其实就是执行读操作时读取到不符合预料之中的值,随后的逻辑判断或者随后的写操作就会出现逻辑问题,而这种问题就是同时写共享资源带来的。

另一方面,程序在执行时如果不做同步,会遇到几种乱序:

编译器会对程序执行优化操作,编译器会假定所编译的程序都是在单执行流环境下运行的,在此基础上进行优化,比如将代码乱序,将计算结果使用寄存器缓存而不写回内存等等,自然地,如果你不希望编译器这么做,就需要通过 volatile 来禁用激进的优化项,对于内核而言,通常是使用 WRITE_ONCE/READ_ONCE 接口,或者试用 barrie 屏障防止特定代码段的乱序行为。

为了进一步提高并发性能,CPU 也会对代码进行乱序排列,通常 CPU 只会保证有逻辑依赖的指令按照顺序执行,比如一条指令依赖于上一条指令的执行结果,就会按照顺序执行。而对于其它不存在逻辑依赖的指令,则不能保证顺序,至于会怎么乱序,这个并不能做任何假设,而在多线程多核环境下,这种乱序会带来问题,可以通过 CPU 的屏障来禁止这种行为。

现代 CPU 弱序的内存模型,这和处理器架构相关,在较弱的内存模型中,写操作并不会按照执行顺序提交到内存,一个 CPU 写完之后,另一个 CPU 在下次访问时并不一定能立刻访问到新值,而且另一个 CPU 的看到写的顺序也是不一定的,只有通过数据屏障或者特定内存屏障来禁止这种行为。

因此,当我们了解了程序在不使用同步机制会带来什么问题的情况下,就可以具体问题具体分析,即使是针对同一共享数据的同时操作,比如出现下面的情况,即使不加锁也不会出现问题:

针对共享数据的只读

即使存在同时读写,也不一定会产生 bug,最常见的例子是对 /proc/ 目录下的某些节点进行设置操作,该设置对应一个全局变量,而内核代码中只会读取该变量,这种情况不加同步措施(或者只加编译器屏障),通常也只会造成非常短的时间周期内读者读到旧值,通常不会产生逻辑问题。

而对于同时的写,在特定应用场景下也可以不加锁。参考下图: 

 处理器

A 线程和 B 线程同时操作变量 cnt,尽管 A 和 B 都执行了 cnt++ 操作,但是 B 的操作被覆盖了,两次 ++ 操作最终只有一次产生了效果,看起来这肯定是有问题的。但是在诸如网络数据包统计的时候,这种情况发生的概率非常低,和所有路径加锁带来巨大的性能损失相比,统计值稍微有一点点误差也不是不能接受,这种情况下我们可以只加一个 WRITE_ONCE 来限制编译器优化。因此,当我们了解了不同的同步措施所带来的性能损失以及它实际能解决什么问题的时候,更多的是做性能与准确性(也可以是吞吐量、功耗之类的指标)之间的权衡,而并不是不由分说地加锁。

1.2 同步机制

聊到同步问题,那自然就离不开它的解决方案:同步机制,当然,我们讨论最多的同步机制就是锁。既然同步问题产生的必要条件是 "共享" 和 "同时",那只需要破坏其中的某一个条件,就可以解决同步问题。

最简单也是最经典的方案就是 spinlock 和 mutex,只要接触过 linux,对这两者基本上就不会陌生,这两者所实现的逻辑就是建立一个临界区来保护某一个共享资源的操作,一次只允许一个访问者进入,等该访问者退出的时候,下一个再进来,破坏了 "同时" 这个条件,也就能解决同步问题。

spinlock 在无法获取锁的时候会选择自旋等待,而 mutex 无法获取锁的时候会选择睡眠,它们应用在不同的场景,对于一个操作系统而言,这两者是必要的。但是很多朋友在看待它们之间的差异的时候,往往只注意到尝试持锁但失败这个场景下的不同,而忽略了持锁之后的差异:

(1) spinlock 持锁是关抢占的,但是并不一定关中断,也就是说在 spinlock 临界区中,不会出现调度,但是可能出现进程环境的切换,比如中断、软中断,这在某些场景下是需要考虑的。

(2) 而 mutex 是不能应用在中断环境下的,所以可以不用考虑进程运行环境的切换,但是 mutex 并不关抢占,所以 mutex 也会带来嵌套持锁的复杂问题。

如果要深入研究代码实现甚至对它们进行优化,这两个问题是无法回避的。而如果只是使用 spinlock、mutex 作为同步措施保护特定全局数据,这两个问题并不需要过多考虑,而且如果没有其它方面诸如性能的需求,你只需要知道 mutex 和 spinlock 这两个简单的接口就能应付工作。

当然,如果你是一个对事物本源有好奇心的朋友,可以再深入思考 spinlock(mutex) 的实现原理,就会发现一个矛盾点:spinlock 的作用是对全局数据的同时操作做互斥保护,让一个访问者进入而其它访问者等待,而让一个访问者进入而其它访问者等待,做这件事本身就需要线程之间的通信。

换句话说,在 spinlock 的实现中,等待线程要知道锁已经被占用,而占用者在尝试持锁之初要知道自己已经占用锁,它们也必须通过访问某个全局的资源才能获得这个信息,这是不是又产生了对某一个共享资源的同时访问?那谁又来保护 spinlock 本身的实现呢?

软件无法解决,那就需要借助硬件来实现,因此每个不同的硬件架构都至少需要实现对单字长变量的原子操作指令,比如 64 位平台下,硬件必须支持一类或一组指令,该指令保证对一个 long 型变量执行诸如 ++ 操作时,保证它的原子性。

借助于这个原子性的硬件操作,spinlock 就可以这样实现:先请求锁的,就可以通过原子操作获取到某个全局变量的所有权,而后请求锁的,必须等待之前的 owner 释放其所有权,也就是 unlock,而上面说到的某个全局变量,就是锁变量。

因此可以看到,对共享的全局数据的操作,变成了对锁的竞争,而对锁的竞争实际上就是对锁变量的竞争,本质上就是将复合数据的保护收敛成单字长变量的保护,然后使用硬件的原子指令来解决这个问题。

举个例子,多个执行流对某个 struct foo 结构实例有读写操作,为了防止同步问题,这些执行流从竞争 struct foo 变成竞争 foo->spinlock,而 spinlock 是基于锁变量lock->val实现的,所以,实际上所有执行流竞争的是锁变量。基于此,不难发现,锁的实现并非消除了竞争,它只是将竞争缩小到单变量的范围。

而在随后的发展中,因为需要平衡延迟、吞吐量、功耗等因素,渐渐地对锁又加入了更多的逻辑,比如 mutex 最开始实现了排队机制,后续为了减少上下文切换引入乐观自旋,又为了解决乐观自旋带来的公平性问题引入 handoff 机制。而它的表兄弟 rwsem 在 mutex 的基础上进一步区分读写,实现则更加复杂。

俗话说,是药三分毒,锁是解决同步问题的一剂猛药,但是它带来的问题也不容小觑:

死锁、饿死这些常见且直接导致系统死机。

锁只是缓解竞争,而不是根治,所以竞争依旧存在,在激烈条件下开销依旧不小。

锁实现越来越趋于复杂,也会消耗指令周期和 cache 空间,而且这种复杂度让量化分析变得越来越难。

具体的锁有具体的问题,比如spinlock 机制会带来 cpu 的空转,在某些竞争激烈的场景下,8 个 cpu 同时竞争同一个 spinlock 锁,因为关抢占的原因,这 8 个 CPU 上除了处理中断,再也做不了任何事,只是空转浪费 CPU 资源。而 mutex 所带来的无效唤醒以及本身的进程切换也是有不小的开销,比如 mutex 的环境下,当多个 cpu 在竞争同一把锁时,不良的锁使用或者不合时宜的设计会导致很多的无效唤醒,也就是很多进程被唤醒之后却无法获取锁,只能再次睡下,而这些开销都是一种资源的浪费,在重载环境下这种浪费程度是非常大的.

这些都是锁本身的实现问题,来自于性能、公平性、吞吐量之间不可调和的矛盾,而锁竞争所花费的时间完全是产生不了任何效益的。

当解决方案本身成为最大的问题,当屠龙的少年即将成为新的恶龙,我们不得不转而去寻找更合适的同步方案。

1.3 其它同步解决方案

当通用的锁方案无法更进一步时,另一个方向是拆分使用场景,寻找特定场景下的针对性解决方案。

一种想法是继续沿用锁的形式,但是区分读写,因为读写的性质是完全不一样的。

在上面的描述中,对共享资源进行操作的角色我们统称为访问者,但是从实际的硬件角度出发,发现读和写对于共享数据的操作有根本性的不同,而写操作通常才是带来同步问题的罪魁祸首,针对多读少写的场景,在 spinlock、mutex 的基础上衍生出了 rwlock、rwsem(rwsem 其实并没有信号量的语义,它更像是睡眠版的 rwlock)。

另一种是无锁设计,无锁设计也分成很多种类型,第一种是干脆不使用同步机制,或者最小化使用同步机制,因为某些场景下,全局数据的不同步是可以接受的。这一点在上面已经论证过了。

另外的较常见的无锁方案,通过结合应用场景采用更细化的设计,只使用硬件提供的原子操作,而不引入诸如 spinlock 这一类复杂的锁逻辑,从而避免在锁上消耗太多 CPU 资源。

除此之外,一种二次确认的机制也比较常用,因为某些共享数据的操作可能会带来同步问题,如果产生并发条件的概率足够低,直接采用锁有时候并没有必要,我们大可以直接使用无锁下的操作,然后对操作结果进行检查,如果结果不符合我们的预期,那就重新执行一遍操作,以保证数据被正常更新。

还有很多的无锁方案都是针对”共享”的条件来做的,比如使用额外的内存来避免竞争的产生,其中使用最多的就是内核中的 percpu 机制,尽管大多数工程师通常并不会将它看成是一种同步的解决方案,但是它却实际上地解决了同步问题产生的"共享"这个条件,也就是将原本 cpu 之间共享的全局数据分散到每个 cpu 一份,这样虽然依旧存在进程环境与中断环境的同步问题,但是却极大地降低了多 cpu 之间的同步问题,从多核收敛到单核的环境。

同时,针对一些特定的场景还涌现出一些无锁方案,最常见的是在特定场景下存在冷热路径,通过增大冷路径下的开销这种设计来实现热路径下的无锁方案,而冷热路径的定义完全是取决于应用场景的,因此这些优化都不能作为通用方案,因为它们的实现是一些特殊情况下的权衡。

而我们今天要聊到的,RCU,也正是在特定场景下的一种无锁方案。

二、RCU 是什么?

2.1 RCU 的基本概念

RCU,read-copy-update,也就是读-拷贝-更新,其基本思想在于,当我们需要对一个共享数据进行操作的时候,我们可以先复制一份原有的数据 B,将需要更新的部分在 B 上实现,然后再使用 B 替换掉 A,这也是 RCU 最典型的使用场景。

很显然,这种无锁方案所针对的是 "共享" 这个特性,毕竟并不直接对目标数据进行操作。

从这个理念出发,其实我们可以非常直观地感受到 RCU 的第一个特点:RCU 是针对多读少写的使用场景的,毕竟这种形式的实现明显加大了写端的开销。

RCU 的设计理念简单到任何人在第一次听到时就能够理解它,但是当我们尝试像 spinlock 那样通过它的 lock/unlock 接口来阅读它的代码实现时,居然惊奇地发现它的 lock/unlock 实现仅仅只是开关一下抢占,在多次确认内核配置没有问题之后,发现事实确实是如此,从而产生一种很荒谬的感觉:仅仅通过开关抢占是怎么实现读-拷贝-更新的?

2.2 RCU 实现的核心问题是什么?

很多对 RCU 感兴趣的朋友其实在网上也看过不少 RCU 相关的文章,知道了 RCU 的操作形式:读-拷贝-更新,并自然而然地觉得这是个很好的想法,而且它好像确实不需要锁来实现,因为更新操作和原本读者读到的是不同的数据,不满足共享的条件,随后再执行替换就好了。而且,这三个操作步骤完全就可以由使用者自己完成,实在是想不到有什么地方需要操作系统来插手的。

如果一个问题想不明白,那么我们就代入到真实的场景下来考虑这个问题:假设现在有 3 个读者和 1 个更新者需要对共享数据进行访问,读者不间断地对数据发起读操作,而更新者需要更新时,拷贝出一份新的数据,操作完之后然后再替换旧的数据,这样就完成了数据的更新,而读者就可以读到新的数据。

看起来非常合理,效率也很高,但是这里有一个最大的问题在于,我们默认了一个并不存在的前提条件:新数据的替换立马就对所有的读者生效,替换之后就可以立刻删除旧数据,而读者也可以立刻读到新的数据。

处理器

我们可以通过上图来了解这个流程,其中存在三个读者,读者的两个箭头标定读操作的开始点和结束点,中间的表示共享数据。

从图中可以看到,writer 先是从 D1 copy 出一份 D2,接着对D2 执行修改,紧接着将 D2 更新为新的共享数据,这个过程就可以理解为一次 Read-Copy-Update 操作。

而在整个过程中,reader1 始终读到 D1 数据,而reader3 始终读到 D2 的新数据,但是 reader2 就比较麻烦了,它的读操作跨越了 D1 到 D2 的更新过程,那么它读到的是 D1 还是 D2,又或者说读到一半 D1 的数据和另一半 D2 的数据?

按照传统的同步锁做法,这时候需要写者等所有旧读者退出,然后读者等待写者更新完,才继续进读临界区,对应上图就是 writer 必须等 reader2 先读完再执行更新。你有没有意识到,写者等读者退出,读者再等写者更新完,这个操作实际上就是 rwlock 的实现,难道 RCU 操作要基于 rwlock 来实现?那为什么我不直接使用 rwlock 呢?显然让替换立刻生效来实现 RCU 的方式,只能说你创造了一个新的同步机制,但它永远也不会有人用。

那么为了能超过 rwlock 的性能,一方面不能做读者与写者之间的锁同步,从而让 RCU 能在特定场景下有性能优势,另一方面,如果不做锁同步,那就意味着读者不知道写者什么时候更新,写者也不知道更新时是否存在读者,唯一的方案是:即使 writer 在更新完之后,reader2 读取的依旧是 D1 的旧数据(因为 reader2 不知道数据有更新),而更新完之后新来的读者读到的自然是新数据。

在这种情况下,也就意味着 RCU 不能像普通锁一样保护复合结构的实例,而只能是针对指向动态资源的数据指针,稍微深入地想一想,就能发现,如果D1 和 D2是同一个结构实例,D2 会覆盖掉 D1 的数据,就会产生reader2 读到一半D1,更新后读到另一半D2的错误结果。而如果在更新完之后reader2依旧需要能够读取到 D1,那D1和D2必须是独立的两片内存。

之前的问题是如何处理跨越更新点的读者,在确定这类读者依旧读取旧数据之后,现在剩下的问题变成了:判断什么时候这些读者读完了旧数据,从而可以回收旧数据的资源?

这就是内核中 RCU 实现所需要解决的问题:如何低成本地实现等待依旧正在访问旧数据的读者退出?而读-拷贝-更新操作,完全可以留给用户自己做,所以,RCU 在内核中的实现实际上并不是 Read-Copy-Update 操作,而是实现一种等待读者退出临界区的机制。

同时,由于通常情况下,RCU 等待所有旧读者退出之后,主要的操作就是释放旧数据,所以它的实现也很像一种垃圾回收机制。

结合上面的两点问题,也就引出 RCU 的另外几个特点:

即使是在写者更新完之后,依旧允许读者读到旧数据。而内核的 RCU 实现需要保证所有能读到旧数据的 RCU 退出,才删除旧数据。

RCU 同步机制所保护的对象不能直接是复合结构,只能保护动态分配数据对应的指针

追求读端的极限性能,这是 RCU 在内核中的立足之本。

2.3 RCU 的实现讨论

如果我上一小节已经将 RCU 在内核中实现的逻辑表达清楚,且你也已经看明白,那下一步需要讨论的就是:如何低成本地实现等待旧的读者退出临界区。也就是从现在开始,我们才真正进入到 RCU 的实现中。

等待一件事情结束,最常用的也是容易想到的解决方案就是在开始的时候做一个标记,凭票入场,出场退票,这样只需要通过判断出入的记录是否成对就能判断是否还存在没有退出者,当然,这个想法在上面已经被证实效率过低,记录读端的起始意味着需要执行全局的写操作,而读端临界区一旦需要执行全局的写操作,在多核上并发时就会产生同步问题,这并不好解决,而且开销并不小,当然这个全局写操作可以换成 percpu 类型的,从而减少一些性能的损失,不过这种方式总归是治标不治本,而我们的理想状态是读端没有同步开销,也就是不记录读临界区的进入。

另一个思路是,我们是否可以借用其它的事件来完成这个等待操作?也就是说是否能通过一些既有事件来判断我们需要等待的条件已经满足,而不需要进行针对该事件直接的记录行为。

在内核中,RCU 的实现就使用了一种非常巧妙的方式:简单地通过关-开抢占来实现一个读临界区,读者进入临界区将会关抢占,而退出临界区时再将抢占打开,而进程的调度只会在抢占开的时候发生,因此,写者等待之前所有的读者退出,只需要等待所有 cpu 上都执行完一次调度就行了。

这里有必要进一步解释一下,上一段文字中非常重要的几个字是:之前所有的读者。

处理器

参考上图,在writer更新之前,reader1 和 reader2 依旧引用的是 D1 数据,而 reader3 已经读取到新的数据了,所以只需要等待 reader1 和 reader2 完成读操作,就可以释放 D1 了。

而在 reader1 整个读的过程中,是处于关抢占的状态,如果 reader1 运行在 cpu0 上,那 writer 更新完之后,只需要判断 cpu0 上一旦发生了调度,就能判断 reader1 已经退出临界区,毕竟发生调度的前提是 cpu0 上开了抢占,也就意味着 reader1 已经读完了。

而更新者更新完数据之后,等待所有读者退出临界区这个过程,被命名为宽限期(grance period),也就是宽限期一过,也就意味着数据的更新以及所有读者退出这个过程已经完成,这时候就可以释放旧数据了,如果是单纯的 add 操作,那自然就不需要删除旧数据,只需要确认更新已经完成就好。

当然,等待所有之前的读者退出临界区这个过程可能会比较长,甚至到几十毫秒。因此,在决定是否使用 RCU 作为同步之前需要考虑到这一点。

这也就引出 RCU 的另外两个特点:

Linux 实现下的RCU 读端临界区就是通过关-开抢占来实现的,性能以及多核扩展性非常好,但是很明显读端临界区不支持抢占和睡眠。

写端具有一定的延迟。读端在一定的时间周期内会获取到新或者旧数据。

处理器

上图是一个简单的示例,更新端在 CPU1 上对 gptr 执行了置 NULL 操作,然后调用 synchronize_rcu 阻塞等待所有之前的读者退出临界区,synchronize_rcu 会立刻触发一次调度,接着 CPU2 上在执行完浅蓝长条对应的读端临界区之后,执行了一次调度,同时也意味着 CPU2 已经渡过了临界区,而在 CPU3 上,实际上经历了三次进入-退出读临界区的阶段,但是因为没有触发进程切换,RCU core 是无法判断 CPU3 渡过了临界区的,直到最后 CPU3 执行了一次调度,整个系统也就渡过了一个完整的宽限期,CPU1 上阻塞的 task 得以继续运行,free 对应的内存。

同时,再整体总结一下 RCU 的特点:

RCU 是针对多读少写的使用场景

写端具有一定的延迟。读端在一定的时间周期内会获取到新或者旧数据

即使是在写者更新完之后,依旧允许读者读到旧数据。而内核的 RCU 实现需要保证所有能读到旧数据的读者退出,才删除旧数据

RCU 同步机制所保护的对象不能直接是复合结构,只能是指针

RCU 追求读端的极限性能,这是 RCU 在内核中的立足之本

Linux 实现下的经典RCU 读端临界区就是通过关-开抢占来实现的,性能以及多核扩展性非常好,但是很明显读端临界区不支持抢占和睡眠

三、结语

其实对于 RCU ,还有很多东西要讲,包括RCU的使用、实现、RCU的变种、RCU的发展以及源代码分析之类的,整个 RCU 是一个非常庞大的体系。

按照我以往的经验,这种大段的文字且没有什么趣味性的东西,篇幅还是不宜过长,如果各位对 RCU 真的感兴趣,下次咱们再一起走进 RCU 的使用和实现。






审核编辑:刘清

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分