Skip to content

Lec 21 多核可扩展性 & RCU

[toc]

阅读资料:RCU Usage In the Linux Kernel: One Decade Later, 2013

如何在多核CPU计算机上获得好的性能,这是一个非常有趣,深入且令人着迷的话题。今天我们只会涉及这个话题的很小的一个部分,也就是在面对内核中需要频繁读但是不需要频繁写的共享数据时,如何获得更好的性能?

我们今天要看的是Linux的RCU,它对于需要频繁读的内核数据来说是一种非常成功的方法。

问题背景

一个现代的计算机,包含多个并行运行的CPU核,这些CPU核共享着内存数据。

如果你在内核中有大量的进程,在不做任何额外工作的前提下,这些进程极有可能是并行运行的。但另一方面,如果你有很多应用程序都在执行系统调用,很多时候,不同的应用程序执行的不同系统调用也应该是相互独立的。

但问题是内核中包含了大量的共享数据。出于一些其他的原因,内核共享了大量的资源,例如内存,CPU,磁盘缓存,inode缓存,这些东西都在后台被不同的进程所共享。这意味着,即使两个完全不相关的进程在执行两个系统调用,如果这两个系统调用需要分配内存或使用磁盘缓存或者涉及到线程调度决策,它们可能最终会使用内核中相同的数据结构,因此我们需要有办法能让它们在使用相同数据的同时,又互不影响。

在过去的许多年里,人们付出了巨大的努力来让内核中的这些场景能更快的运行。我们之前看过其中一种可以保证正确性的方法,也就是spinlock。spinlock很直观,它的工作就是当两个进程可能会相互影响时,阻止并行运行。所以spinlock的直接效果就是降低性能。它使得正确性有了保障,但是又绝对的阻止了并行执行。

我将使用单链表来作为主要的例子。尽管我们关注的主要是读操作,我们也需要关心写操作,我们需要保证读操作在面对写操作时是安全的。

读写锁的局限性

但是使用锁有个缺点,如果通常情况下没有修改数据的线程,那么意味着每次有一个读取数据的线程,都需要获取一个排他的锁。因此出现了读写锁,一种允许多个线程读和一个线程写。正是因为它的不足促成了对于RCU的需求。

c
// 读写锁接口
// for reader
r_lock(l);
r_unlock(l);

// for writer
w_lock(l);
w_unlock(l)

读写锁排除了某人获取了数据的写锁,同时又有别人获取读锁的可能性。你要么只有一个数据写入者,要么有多个数据 Reader ,不可能有别的可能。

实际上如果你深入细节,你会发现当你使用读写锁时,尤其对于大部分都是读取操作的数据结构,会有一些问题。为了了解实际发生了什么,我们必须看一下读写锁的代码实现。

截屏2024-07-12 03.08.45

NOTE

CAS 的原子语义

CAS接收三个参数,第一个参数是内存的某个地址,第二个参数是我们认为内存中该地址持有的数值,第三个参数是我们想设置到内存地址的数值。CAS的语义是,硬件首先会设置一个内部的锁,使得一个CAS指令针对一个内存地址原子的执行;之后硬件会检查当前内存地址的数值是否还是x;如果是的话,将其设置为第三个参数,也就是x+1,之后CAS指令会返回1;如果不是的话,并不会改变内存地址的数值,并返回0。

为什么这里要用本地变量? .e.g. x = l->n

Solution:因为n可能在任意时间被修改,所以我们需要在最开始在本地变量x中存储n的一个固定的值。▯

并行读结果

假设n从0开始,当两个r_lock同时调用时,我们希望当两个r_lock都返回时,n变成2。 因为我们希望两个数据 Reader 可以并行地使用数据。两个r_lock在最开始都将看到n为0,并且都会通过传入第二个参数0,第三个参数1来调用CAS指令,但是只有一个CAS指令能成功。CAS是一个原子操作,一次只能发生一个CAS指令。不管哪个CAS指令先执行,将会看到n等于0,并将其设置为1。另一个CAS指令将会看到n等于1,返回失败,并回到循环的最开始,这一次x可以读到1,并且接下来执行CAS的时候,第二个参数将会是1,第三个参数是2,这一次CAS指令可以执行成功。

最终, 两次r_lock都能成功获取锁,其中一次r_lock在第一次尝试就能成功,另一次r_lock会回到循环的最开始再次尝试并成功

缓存失效

在一个多核的系统中,每个CPU核都有一个关联的cache,也就是L1 cache。每当CPU核读写数据时,都会保存在cache中。除此之外,还有一些内部连接的线路使得CPU可以彼此交互,因为如果某个CPU核修改了某个数据,它需要告诉其他CPU核不要去缓存这个数据,这个过程被称为缓存失效(cache invalidation)

如果有多个数据 Reader 在多个CPU上同时调用r_lock,只有第一个调用CAS指令的CPU会修改l->n的内容。作为修改的一部分,它还需要使得其他CPU上的缓存失效。所以执行第一个CAS指令的CPU需要通过总线发送invalidate消息给其他每一个CPU核。 之后剩下的所有数据 Reader 都会回到循环的最开始,重复上面的流程,但这一次还是只有一个数据 Reader 能成功。

读锁成本

假设有n个数据 Reader ,那么每个r_lock平均需要循环n/2次,每次循环都涉及到O(n)级别的CPU消息,因为至少每次循环中所有CPU对于l->n的cache需要被设置为无效。这意味着,对于n个CPU核来说,同时获取一个锁的成本是O(n2),当你为一份数据增加CPU核时,成本以平方增加。

这是一个非常糟糕的结果,我们期望如果有10个CPU核完成一件事情,你将获得10倍的性能,尤其现在还只是读数据并没有修改数据。你期望它们能真正的并行运行。当有多个CPU核时,每个CPU核读取数据的时间应该与只有一个CPU核时读取数据的时间一致,这样并行运行才有意义,因为这样你才能同时做多件事情。但是现在,越多的CPU核尝试读取数据,每个CPU核获取锁的成本就越高。

只读数据本来可以直接从 CPU 缓存里读取,只需几十个周期。但读写锁却要求在 r_lock 时修改共享计数器 l->n,这会触发跨 CPU 的缓存一致性通信,代价可能高达数百甚至数千个周期,尤其在多核下成本呈 O(n2) 增长。结果是,把一个本来很快的读操作,变成了昂贵的写共享数据操作。

因此问题的核心是:r_lock 对共享计数器的写操作让读不再是“真正的只读”。我们需要一种读操作完全不写共享数据的机制,才能发挥缓存的优势。

RCU实现

我们接下来快速的看一下能不能让数据 Reader 在不上锁的时候直接读取链表。假设我们有个链表,链表元素中存的数据是字符串,我们将读取链表中的数据。如果没有数据的写入者,那么不会有任何问题。

接下来我们看一下存在数据写入者时的三种可能场景:

  • 首先是数据的写入者只修改了链表元素的内容,将链表元素中的字符串改成了其他的字符串。
  • 第二种场景是数据写入者插入了一个链表元素。
  • 第三种场景是数据写入者删除了一个链表元素。

因为RCU需要分别考虑这三种场景,我们将会分别审视这三种场景并看一下同时发生数据的读写会有什么问题?

  • 如果数据写入者想要修改链表元素内的字符串,而数据 Reader 可能正在读取相同字符串。如果不做任何特殊处理,数据 Reader 可能会读到部分旧的字符串和部分新的字符串。这是我们需要考虑的一个问题。
  • 如果数据写入者正在插入一个链表元素,假设要在链表头部插入一个元素,数据写入者需要将链表的头指针指向新元素,并将新元素的next指针指向之前的第一个元素。这里的问题是,数据的写入者可能在初始化新元素之前,就将头指针指向新元素,也就是说这时新元素包含的字符串是无效的并且新元素的next指针指向的是一个无效的地址。这是插入链表元素时可能出错的地方。
  • 如果数据写入者正在删除一个链表元素,我们假设删除的是第一个元素,所以需要将链表的头指针指向链表的第二个元素,之后再释放链表的第一个元素。这里的问题是,如果数据 Reader 正好在读链表的第一个元素,而数据写入者又释放了这个元素,那么数据 Reader 看到的是释放了的元素,这个链表元素可能接下来被用作其他用途,从数据 Reader 的角度来说看到的是垃圾数据。

假设数据写入者在完成任何操作前,都会使用类似spinlock的锁。我们不能直接让数据 Reader 在无锁情况下完成读取操作,但是我们可以修复上面提到的问题。RCU是一种实现并发的特殊算法,它是一种组织数据 Reader 和写入者的方法,通过RCU数据 Reader 可以不用使用任何锁。RCU的主要任务就是修复上面的三种数据 Reader 可能会陷入问题的场景,它的具体做法是让数据写入者变得更加复杂一些,所以数据写入者会更慢一些。除了锁以外它还需要遵循一些额外的规则,但是带来的好处是数据 Reader 因为可以不使用锁、不需要写内存而明显的变快。

修改场景下实现

第一个场景中,数据写入者会更新链表元素的内容。RCU将禁止这样的行为,也就是说数据写入者不允许修改链表元素的内容。假设我们有一个链表,数据写入者想要更新链表元素E2。

现在不能直接修改E2的内容,RCU会创建并初始化一个新的链表元素。所以新的内容会写到新的链表元素中,之后数据写入者会将新链表元素的next指针指向E3,之后在单个的写操作中将E1的next指针指向新的链表元素。

截屏2024-07-12 06.07.27

对于数据 Reader 来说,如果遍历到了E1并正在查看E1的next指针:

  • 要么看到的是旧的元素E2,这并没有问题,因为E2并没有被改变;
  • 要么看到的是新版本的E2,这也没有问题,因为数据写入者在更新E1的next指针之前已经完全初始化好了新版本的E2。

不管哪种情况,数据 Reader 都将通过正确的next指针指向E3。这里核心的点在于,数据 Reader 永远也不会看到一个正在被修改的链表元素内容。

旧的E2和E3之间的关系会被删除吗?

会被保留。这是个好问题,并且这也是RCU中较为复杂的主要部分,现在我们假设旧的E2被保留了。

需要释放旧的E2吗? 何时回收E2?

这里的问题是,在我们更新E1的next指针时,部分数据 Reader 通过E1的旧的next指针走到了旧的E2,所以当完成更新时,部分数据 Reader 可能正在读取旧的E2,我们最好不要释放它。至于何时释放,继续阅读。

将E1的next指针从旧的E2切换到新的E2, 我将其称为committing write。这里能工作的部分原因是,单个committing write是原子的,从数据 Reader 的角度来说更新指针要么发生要么不发生。通过这一条不可拆分的原子指令,我们将E1的next指针从旧的E2切换到的新的E2。写E1的next指针完成表明使用的是新版本的E2。

这是对于RCU来说一个非常基本同时也是非常重要的技术,它表示RCU主要能用在具备单个committing write的数据结构上。这意味着一些数据结构在使用RCU时会非常的奇怪,例如一个双向链表,其中的每个元素都有双向指针,这时就不能通过单个committing write来删除链表元素,因为在大多数机器上不能同时原子性的更改两个内存地址。所以双向链表对于RCU来说不太友好。相反的,树是一个好的数据结构。如果你有一个如下图的树:

¥

如果我们要更新图中的节点,我们可以构造树的虚线部分,然后再通过单个committing write更新树的根节点指针,切换到树的新版本。

截屏2024-07-12 06.27.28

数据写入者会创建树中更新了的那部分,同时再重用树中未被修改的部分,最后再通过单个committing write,将树的根节点更新到新版本的树的根节点。

截屏2024-07-12 06.28.20


写提交的实现

许多计算机中都不存在“之后”或者“然后”这回事,通常来说所有的编译器和许多微处理器都会重排内存操作。

c
e = alloc();
e->next = E2;
E1->next = e;

如果你测试这里的代码,它可能可以较好的运行,但是在实际中就会时不时的出错。这里的原因是编译器或者计算机可能会重排这里的写操作,也有可能编译器或者计算机会重排数据 Reader 的读操作顺序。如果我们在初始化E2’的内容之前,就将E1的next指针设置成E2‘,那么某些数据 Reader 可能就会读到垃圾数据并出错。

所以实现RCU的第二个部分就是数据 Reader 和数据写入者都需要使用memory barriers,这里背后的原因是因为我们这里没有使用锁。对于数据写入者来说,memory barrier应该放置在committing write之前

c
e = alloc();
e->next = E2;
// Barrier 
E1->next = e;

这样可以告知编译器和硬件,先完成所有在barrier之前的写操作,再完成barrier之后的写操作。所以在E1设置next指针指向E2‘的时候,E2’必然已经完全初始化完了。

对于数据 Reader ,需要先将E1的next指针加载到某个临时寄存器中,我们假设r1保存了E1的next指针,之后数据 Reader 也需要一个memory barrier,然后数据 Reader 才能查看r1中保存的指针。

c
r1 = E1->next;
// Barrier
out1 = r1->x;
out2 = r1->next;

这里的barrier表明的意思是,在完成E1的next指针读取之前,不要执行其他的数据读取,这样数据 Reader 从E1的next指针要么可以读到旧的E2,要么可以读到新的E2‘。通过barrier的保障,我们可以确保成功在r1中加载了E1的next指针之后,再读取r1中指针对应的内容。


何时才能释放旧数据?

你可以想到好几种方法来实现这里的等待。

例如,我们可以为每个链表元素设置一个引用计数,并让数据 Reader 在开始使用链表元素时对引用计数加1,用完之后对引用计数减1,然后让数据写入者等待引用计数为0。但是我们会第一时间就否定这个方案,因为RCU的核心思想就是在读数据的时候不引入任何的写操作,因为我们前面看过了,如果有大量的数据 Reader 同时更新引用计数,相应的代价将十分高。所以我们绝对不会想要有引用计数的存在。

另一种可能是使用自带垃圾回收(Garbage Collect)的编程语言。在带有GC的编程语言中,你不用释放任何对象,相应的GC会记住是否有任何线程或者任何数据结构对于某个对象还存在引用。如果GC发现对象不可能再被使用时,就会释放对象。这也是一种可能且合理的用来释放链表元素的方法。但是使用了RCU的Linux系统,并不是由带有GC的编程语言编写,并且我们也不确定GC能不能提升性能,所以这里我们也不能使用一个标准GC来释放E2。

RCU使用的是另一种方法,数据 Reader 和写入者都需要遵循一些规则,使得数据写入者可以在稍后再释放链表元素。规则如下:

  1. 数据 Reader 不允许在context switch时持有一个被RCU保护的数据的指针(注,是指线程切换的context switch)。所以数据 Reader 不能在RCU critical 区域内yield CPU。
  2. 对于数据写入者,它会在每一个CPU核都执行过至少一次context switch之后再释放链表元素。

这里的第一条规则也是针对spin lock的规则,在spin lock的加锁区域内是不能出让CPU的。第二条规则更加复杂点,但是相对来说也更清晰,因为每个CPU核都知道自己有没有发生context switch,所以第二条规则是数据写入者需要等待的一个明确条件。数据写入者或许要在第二条规则上等待几个毫秒的时间才能确保没有数据 Reader 还在使用链表元素,进而释放链表元素

人们创造了多种技术来实现上面第二条规则中的等待,论文里面讨论的最简单的一种方法是通过调整线程调度器,使得写入线程简短的在操作系统的每个CPU核上都运行一下,这个过程中每个CPU核必然完成了一次context switching。因为数据 Reader 不能在context switch的时候持有数据的引用,所以经过这个过程,数据写入者可以确保没有数据 Reader 还在持有数据。

所以数据写入者的代码实际上看起来是这样的:

  • 首先完成任何对于数据的修改
  • 之后调用实现了上面第二条规则synchronize_rcu函数
  • 最后才是释放旧的链表元素
C
...;
E1 ->next = e;
synchronize_rcu();
free(old);

synchronize_rcu迫使每个CPU核都发生一次context switch,所以在synchronize_rcu函数调用之后,由于前面的规则1,任何一个可能持有旧的E1 next指针的CPU核,都不可能再持有指向旧数据的指针,这意味着我们可以释放旧的链表元素。

你们可能会觉得synchronize_rcu要花费不少时间,可能要将近1个毫秒,这是事实并且不太好。其中一种辩解的方法是:对于RCU保护的数据来说,写操作相对来说较少,写操作多花费点时间对于整体性能来说不会太有影响。

c
...;
E1 ->next = e;
call_rcu(old, cb);

对于数据写入者不想等待的场景,可以调用另一个函数call_rcu,将你想释放的对象和一个执行释放的回调函数作为参数传入,RCU系统会将这两个参数存储到一个列表中,并立刻返回。之后在后台,RCU系统会检查每个CPU核的context switch计数,如果每个CPU核都发生过context switch,RCU系统会调用刚刚传入的回调函数,并将想要释放的对象作为参数传递给回调函数。这是一种避免等待的方法,因为call_rcu会立即返回。

但是另一方面不推荐使用call_rcu,因为如果内核大量的调用call_rcu,那么保存call_rcu参数的列表就会很长,这意味着需要占用大量的内存,因为每个列表元素都包含了一个本该释放的指针。在一个极端情况下,如果你不够小心,大量的调用call_rcu可能会导致系统OOM,因为所有的内存都消耗在这里的列表上了。所以如果不是必须的话,人们一般不会使用call_rcu

上面提到的条件1,是不是意味着我们必须关注在RCU read crtical区域内的代码执行时间,因为它限制了CPU核在这个区域内不能context switch?

是的,在RCU区域内,数据 Reader 会阻止CPU发生context switch,所以你会想要让这个区域变得较短,这是个需要考虑的地方。RCU使用的方式是,在Linux中本来有一些被普通锁或者读写锁保护的代码,然后某人会觉得锁会带来糟糕的性能问题,他会将Locking区域替换成RCU区域,尽管实际中会更加复杂一些。Locking区域已经尽可能的短了,因为当你持有锁的时候,可能有很多个CPU核在等待锁,所以普通锁保护的区域会尽量的短。因为RCU区域通常是用来替代Lock区域,它也趋向于简短,所以通常情况下不用担心RCU区域的长短。这里实际的限制是,数据 Reader 不能在context switch时持有指针指向被RCU保护的数据,这意味着你不能读磁盘,然后在等读磁盘返回的过程中又持有指针指向被RCU保护的数据。所以通常的问题不是RCU区域的长短,而是禁止出让CPU。


RCU用例代码

c
// list reader;
rcu_read_lock();
e = head;
while(p) {
  e = rcu_dereference(e);
  lock at e->x ...;
  e = e->next;
}
rcu_read_unlock();

// replace the first list element
acquire(lock);
old = head;
e = alloc();
e->x = ...;
e->next = head->next;
rcu_assign_pointer(&head, e);
release(lock);
syncronize_rcu();
free(old);

数据读取位于rcu_read_lock和rcu_read_unlock之间,这两个函数几乎不做任何事情。rcu_read_lock会设置一个标志位,表明如果发生了定时器中断,请不要执行context switch,因为接下来要进入RCU critical区域。所以rcu_read_lock会设置一个标志位来阻止定时器中断导致的context switch,中断或许还会发生,但是不会导致context switch(注,也就是线程切换)。rcu_read_unlock会取消该标志位。所以这是一个集成在RCU critical区域的计数器。rcu_read_lock和rcu_read_unlock因为几乎不做任何工作所以极其的快(注,这里有个问题,23.2中描述的读写锁慢的原因是因为在读数据的时候引入了写计数器的操作,这里同样也是需要额外的写操作,为什么这里不会有问题?这是因为读写锁的计数器是所有CPU共享的,而这里的标志位是针对每个CPU的,所以修改这里的标志位并不会引起CPU之间的缓存一致消息)。

其中的while循环会扫描链表,rcu_dereference函数会插入memory barrier,它首先会从内存中拷贝e,触发一个memory barrier,之后返回指向e的指针。之后我们就可以读取e指针指向的数据内容,并走向下一个链表元素。数据读取部分非常简单。

数据写入部分更复杂点。

  • RCU并不能帮助数据写入者之间避免相互干扰,所以必须有一种方法能确保一次只能有一个数据写入者更新链表。这里我们假设我们将使用普通的spinlock,所以最开始数据写入者获取锁。
  • 如果我们要替换链表的第一个元素,我们需要保存先保存链表第一个元素的拷贝,因为最后我们需要释放它,所以有old=head。
  • 接下来的代码执行的是之前介绍的内容,首先是分配一个全新的链表元素,之后是设置该链表元素的内容,设置该链表元素的next指针指向旧元素的next指针。
  • 之后的rcu_assign_pointer函数会设置一个memory barrier,以确保之前的所有写操作都执行完,再将head指向新分配的链表元素e。
  • 之后就是释放锁。
  • 之后调用synchronize_rcu确保任何一个可能持有了旧的链表元素的CPU都执行一次context switch,因此这些CPU会放弃指向旧链表元素的指针。
  • 最后是释放旧的链表元素。

这里有件事情需要注意,在数据读取代码中,我们可以在循环中查看链表元素,但是我们不能将链表元素返回。例如,我们使用RCU的时候,不能写一个list_lookup函数来返回链表元素,也不能返回指向链表元素中数据的指针,也就是不能返回嵌入在链表元素中的字符串。我们必须只在RCU critical区域内查看被RCU保护的数据,如果我们写了一个通用的函数返回链表元素,或许我们能要求这个函数的调用者也遵循一些规则,但是函数的调用者还是可能会触发context switch。如果我们在函数的调用者返回之前调用了rcu_read_unlock,这将会违反23.5中的规则1,因为现在定时器中断可以迫使context switch,而被RCU保护的数据指针仍然被持有着。所以使用RCU的确会向数据 Reader 增加一些之前并不存在的限制。

这样是不是说我们不可能返回下标是i的元素所包含的内容?

可以返回一个拷贝,如果e->x是个字符串,那么我们可以返回一个该字符串的拷贝,这是没有问题的。但是如果我们直接返回一个指针指向e->x,那就违反了RCU规则。实际上返回e中的任何指针都是错误的,因为我们不能在持有指向RCU保护数据的指针时,发生context switch。通常的习惯是直接在RCU critical区域内使用这些数据。

性能

接下来我将再简短的介绍性能。如果你使用RCU,数据读取会非常的快,除了读取数据本身的开销之外就几乎没有别的额外的开销了。如果你的链表有10亿个元素,读取链表本身就要很长的时间,但是这里的时间消耗并不是因为同步(注,也就是类似加锁等操作)引起的。所以你几乎可以认为RCU对于数据 Reader 来说没有额外的负担。唯一额外的工作就是在rcu_read_lock和rcu_read_unlock里面设置好不要触发context switch,并且在rcu_dereference中设置memory barrier,这些可能会消耗几十个CPU cycle,但是相比锁来说代价要小的多。

对于数据写入者,性能会更加的糟糕。首先之前使用锁的时候所有的工作仍然需要做,例如获取锁和释放锁。其次,现在还有了一个可能非常耗时的synchronize_rcu函数调用。实际上在synchronize_rcu内部会出让CPU,所以代码在这不会通过消耗CPU来实现等待,但是它可能会消耗大量时间来等待其他所有的CPU核完成context switch。所以基于数据写入时的多种原因,和数据读取时的工作量,数据写入者需要消耗更多的时间完成操作。如果数据读取区域很短(注,这样就可以很快可以恢复context switch),并且数据写入并没有很多,那么数据写入慢一些也没关系。所以当人们将RCU应用到内核中时,必须要做一些性能测试来确认使用RCU是否能带来好处,因为这取决于实际的工作负载。

使用场景

你们应该已经看到了RCU并不是广泛通用的,你不能把所有使用spinlock并且性能很差的场景转化成使用 RCU,并获得更好的性能。主要的原因是RCU完全帮不到写操作,甚至会让写操作更慢,只有当读操作远远多于写操作时才有可能应用RCU。因为RCU有这样的限制:代码不能在sleep的时候持有指针指向被RCU保护的数据,所以这会使得一些代码非常奇怪。当一定要sleep的时候,在sleep结束之后需要重新进入RCU critical区域再次查找之前已经看过的数据,前提是这些数据还存在。所以RCU使得代码稍微复杂了一些。

另一方面可以直接应用RCU的数据结构在更新时,需要能支持单个操作的committing write。你不能在原地更新数据,而是必须创建一个新的链表元素对象来替代之前的元素对象。所以单链表,树是可以应用RCU的数据结构,但是一些复杂的数据结构不能直接使用RCU。论文里面提到了一些更复杂的方法,例如sequence lock,可以在允许原地更新数据的同时,又不用数据 Reader 使用锁。但是这些方法要复杂一些,并且能够提升性能的场景也是受限的。

总结

另一个小问题是,RCU并没有一种机制能保证数据 Reader 一定看到的是新的数据。因为如果某些数据 Reader 在数据写入者替换链表元素之前,获取了一个指针指向被RCU保护的旧数据,数据 Reader 可能会在较长的时间内持有这个旧数据。大部分时候这都无所谓,但是论文提到了在一些场景中,人们可能会因为读到旧数据而感到意外。

作为一个独立的话题,你们或许会想知道对于一个写操作频繁的数据该如何提升性能。RCU只关心读操作频繁的数据,但是这类数据只代表了一种场景。在一些特殊场景中,写操作频繁的数据也可以获取好的性能,但是我还不知道存在类似RCU这样通用的方法能优化写操作频繁的数据。不过仍然有一些思路可以值得借鉴。

  • 最有效的方法就是重新构造你的数据结构,这样它就不是共享的。有的时候共享数据完全是没必要的,一旦你发现数据共享是个问题,你可以尝试让数据不共享。
  • 但是某些时候你又的确需要共享的数据,而这些共享数据并没有必要被不同的CPU写入。实际上你们已经在lab中见过这样的数据,在locking lab的kalloc部分,你们重构了free list使得每个CPU核都有了一个专属的free list,这实际上就是将一个频繁写入的数据转换成了每个CPU核的半私有数据。大部分时候CPU核不会与其他CPU核的数据有冲突,因为它们都有属于自己的free list。唯一的需要查看其他CPU核的free list的场景是自己的free list用光了。有很多类似的例子用来处理内核中需要频繁写入的数据,例如Linux中的内存分配,线程调度列表。对于每个CPU核都有一套独立的线程对象以供线程调度器查看(注,详见11.8,线程对象存储在struct cpu中)。CPU核只有在自己所有工作都完成的时候才会查看其他CPU核的线程调度列表。另一个例子是统计计数,如果你在对某个行为计数,但是计数变化的很频繁,同时又很少被读出,你可以重构你的计数器,使得每个CPU核都有一个独立的计数器,这样每个CPU核只需要更新属于自己的计数器。当你需要读取计数值时,你只需要通过加锁读出每个CPU核的计数器,然后再加在一起。这些都是可以让写操作变得更快的方法,因为数据写入者只需要更新当前CPU核的计数器,但是数据 Reader 现在变得更慢了。如果你的计数器需要频繁写入,实际上通常的计数器都需要频繁写入,通过将更多的工作转换到数据读取操作上,这将会是一个巨大的收益。

论文阅读: Linux内核的RCU的使用

RCU(Read-Only update)是Linux内核实现的一个可扩展性的高性能同步机制。RCU新颖的性质包括读写并发,和极大优化了CPU同步的性能。今天,很多内核子系统都用了RCU,这篇论文讲推动RCU的开发必要性, API的设计,以及开发者如何使用RCU。

1. 介绍

过去用一把大锁进行同步,内核即时应用在过去的多处理器机器上表现很差,需要可扩展性好,且性能好的同步机制,在多核处理器上运行。内核开发者用了很多各种技术,包括细粒度锁,无锁结构,每个CPU一个副本的数据结构(Per-CPU),以及RCU。 RCU并不是Linux独有的,但Linux的适用范围和方式与众不同,这使得RCU称为理解Linux内核机器性能的前提条件。

RCU的成功源于高性能的并发reader和updater的出现,且尽利用了简单的原语,reader在RCU的读者端的临界区内访问数据,而updater使用RCU的同步来等待所有之前的RCU读者端的临界区完成。这样一结合,这些原语允许线程并发读数据结构,即便其他线程在更新他们。

3. RCU 设计

RCU是一个给Linux内核的库,允许内核子系统以高效的方式同步和访问共享数据。RCU的两个原语,简单说就是,RCU临界区和RCU同步。一个线程进入RCU临界区通过调用rcu_read_lock,完成临界区用rcu_read_unlock,一个线程进行RCU同步通过synchronize_rcu,他可以保证直到所有的RCU临界区都完成后才会返回,并且Synchronize_rcu 不会阻止新的 RCU 临界区开始,也不会等待在调用 synchronize_rcu 后启动的 RCU 临界区完成

下面阐述了一种可能的使用方式。假设需要在应用程序删除文件时,安全地释放与一个目录项(dentry)相关联的内存。一种实现方式是,一个线程只从目录缓存中获取dentry的指针,并在操作目录项时始终处在RCU临界区中执行。当一个应用删除一个文件时,内核需要在其所在目录缓存中去除这个文件,调用call_synchronize_rcu来等待所有线程完成临界区。当调用返回时,内核已经安全释放了这个目录项。

RCU基于调度器上下文的切换来完成低成本的执行和存储开销。如果RCU关闭了线程抢占(thread preemption),则synchronize_rcu只需要等待直到所有的CPU都执行了上下文切换。在 RCU 临界区和 synchronize_rcu 之间,不需要额外的显式通信。

image-20241128133450313

图2展示了Linux RCU 简化实现的版本。lock关闭抢占,unlock打开抢占。preempy_disable是一个CPU本地变量,因此线程在更改它时不会造成竞争。为了确保每个CPU执行了上下文切换,执行sync的线程会对在每个CPU上执行。注意,sync的成本与执行lock和unlock的次数无关。

实际中Linux 实现 Snyc 通过等待所有的CPU完成上下文切换,而不是调度一个线程在每个CPU上执行。这个设计优化了低成本的RCU临界区,但代价是 synchronize_rcu 的调用者需要等待的时间可能比实际必要的更长。Linux的实现,本质上是批处理了读者到写着的通信,通过等待上下文切换实现。尽可能地,读者使用异步版本的sync,叫call_rcu,在所有CPU至少完成一次上下文切换后,能够异步调用指定的回调。

Linux的RCU实现,尽可能摊销了许多在sync和call_rcu探测上下文切换的成本。探测上下文切换需要维护CPU之间的共享状态。一个 CPU 必须更新其状态,而其他 CPU 会读取该状态,以表明它执行了上下文切换。更新共享状态代价太高了,因为他会导致其他CPU的cache miss。RCU 通过每个调度时钟周期大约报告一次per-CPU 的状态来降低成本。 如果内核在这段时间内多次调用sync和call_rcu,RCU会通过批处理方式降低每次调用的平均成本,但是代价是更高的延迟。Linux 能够在一次批处理中满足超过 1,000 次 sync和call_rcu 的调用需求。对于对延迟敏感的内核子系统,RCU 提供了加速的同步功能,这些功能无需等待所有 CPU 执行多次上下文切换即可完成。

另外一个Linux RCU实现的考量是,内存排序的问题。由于 RCU 的读取和更新操作是并发运行的,需要特别注意编译器和内存的重排序问题。(如果处理不当,读取线程访问脏数据, 这可能是由于更新线程在并发初始化数据项并往单链表中插入时,可能会看到该数据项初始化之前的值。)

因此 RCU 提供了原语 rcu_dereferencercu_assign_pointer。reader用derefer来表示他们准备读取RCU临界区的指针,更新者用assign来更改这些指针。这两个原语包含了与架构有关内存屏障指令和编译器指令来确保排序正确。derefer原语是一个是一个 volatile 访问操作,意思是它告诉编译器不要对该操作进行优化,因为它必须确保每次都从内存中读取最新的值。下图总结用法:

image-20241128152809417

4. RCU使用场景

我们将会展示最典型的用法和场景

4.1 等待完成

RCU最简单的用法就是等待之前的活动完成。这个例子,等待着用sync,或者异步的call_rcu。Linux NMI(Non-Maskable Interrupt)系统用RCU来注销NMI的handlers。在注销之前,内核必须保证没有CPU在执行handler,否则,某个 CPU 可能会尝试执行无效或已释放内存中的代码.比如,当内核卸载一个注册到NMI handler的模块,内核会释放掉包含那块代码的NMI handler。

image-20241128195110185

图4为伪代码,nmi_list是handler的单链表,获取时需要自旋锁来避免并发更新,rcu_list_for_each会调用每个handler的rcu_dereference,而rcu_list_addrcu_list_remove调用rcu_assign_pointer来更改链表。 为了移除一个handler,NMI系统从链表中移除该handler,然后调用synchronize_rcu,当返回时,所有的对handler的调用都已经返回。

在NMI系统使用RCU有3个好的性质,首先是高性能,因为CPU能够频繁执行NMI的handler,不会造成Cache Miss,或者跑到其他CPU上。其次,对实时应用很重要,能够在确定数量的指令进入和完成RCU临界区。最后, 这种实现能够实现动态的注册和注销NMI handler。

4.2 引用计数

相比采用显式的对一个数据项进行引用计数,RCU采用的是在RCU临界区执行数据更新。为了释放数据项, 一个线程必须阻止其他线程获取对这个数据项的指针,然后调用call_rcu在释放内存。