20.4 一个例子:RAMCloud缓冲区
Last updated
Last updated
让我们考虑一个例子,在这个例子中,RAMCloud存储系统的Buffer类经过优化,在最常见的操作中实现了2倍左右的速度提升。
RAMCloud使用Buffer对象来管理可变长度的内存数组,例如远程过程调用的请求和响应信息。Buffer的设计是为了减少内存复制和动态存储分配的开销。Buffer存储的似乎是一个线性的字节数组,但为了提高效率,它允许将底层存储划分为多个不连续的内存块,如图20.1所示。Buffer是通过添加数据块来创建的。每个块都是外部或内部的。如果一个块是外部的,它的存储空间是由调用者拥有的;Buffer保持对这个存储空间的引用。外部块通常用于大型块,以避免内存拷贝。如果一个块是内部的,Buffer拥有该块的存储空间;由调用者提供的数据被复制到Buffer的内部存储中。每个Buffer包含一个小的内置分配,这是一个可用于存储内部块的内存块。如果这个空间用完了,那么Buffer就会创建额外的分配,这些分配必须在Buffer被销毁时释放。内部块对于小块来说是很方便的,因为内存复制的成本可以忽略不计。图20.1显示了一个有5个块的Buffer:第一个块是内部的,后面两个是外部的,最后两个块是内部的。
Buffer类本身代表了一种“根本性的修复”,因为它消除了昂贵的内存拷贝,而如果没有它的话,就需要进行拷贝。例如,当在RAMCloud存储系统中构建一个由短header和大对象内容组成的响应消息时,RAMCloud使用一个有两个块的Buffer。第一个块是内部块,包含头;第二个块是外部块,指向RAMCloud存储系统中的对象内容。响应可以被收集在Buffer中,而不需要复制大的对象。
除了允许不连续的块的根本方法以外,在最初的实现中,我们并没有试图优化Buffer类的代码。然而,随着时间的推移,我们注意到Buffer在越来越多的情况下被使用;例如,在执行每个远程过程调用时,至少有四个Buffer被创建。最终,我们发现,加快Buffer的实现速度会对整个系统的性能产生明显的影响。我们决定看看我们是否能提高Buffer类的性能。
Buffer最常见的操作是使用一个内部块为少量的新数据分配空间。例如,在为请求和响应信息创建header信息时,就会发生这种情况。我们决定将这个操作作为优化的关键路径。在最简单的情况下,可以通过扩大Buffer中最后一个现有的块来分配空间。然而,这只有在最后一个现有的块是内部的,并且在其分配中有足够的空间来容纳新数据的情况下才有可能。理想的代码会进行一次检查以确认简单的方法是可行的,然后它将调整现有块的大小。
图20.2显示了关键路径的原始代码,它以Buffer::alloc
方法开始。在最快的情况下,Buffer::alloc
调用Buffer::allocateAppend
,后者调用Buffer::Allocation::allocateAppend
。从性能的角度来看,这段代码有两个问题。第一个问题是,众多的特殊情况被单独检查:
Buffer::allocateAppend
会检查Buffer当前是否有任何分配。
该代码检查了两次,看当前的分配是否有足够的空间容纳新的数据:一次是在Buffer::Allocation::allocateAppend
中,另一次是在Buffer::allocateAppend
检测其返回值时。
Buffer::alloc
测试Buffer::allocAppend
的返回值以再一次确认分配成功了。
此外,该代码没有尝试直接扩展最后一个块,而是在不考虑最后一个块的情况下分配新的空间。然后Buffer::alloc
检查该空间是否恰好与最后一个块相邻,在这种情况下,它将新空间与现有块合并。这导致了额外的检查。总的来说,这段代码在关键路径上测试了6个不同的条件。
原始代码的第二个问题是,它有太多的层,而且全都是浅的。这既是一个性能问题,也是一个设计问题。除了对Buffer::alloc
的原始调用,关键路径还进行了两个额外的方法调用。每个方法调用都需要额外的时间,而且每个调用的结果都必须由其调用者检查,这就导致了更多需要考虑的特殊情况。第7章讨论了当你从一层传递到另一层时,抽象通常应该发生变化,但是图20.2中的三个方法都有相同的签名,它们提供的抽象基本上是一样的;这是一个危险信号。Buffer::allocateAppend
几乎是一个传递式的方法;它唯一的贡献是在需要时创建一个新的分配。额外的层次使代码变得更慢、更复杂。
为了解决这些问题,我们重构了Buffer类,使其设计以性能最关键的路径为中心。我们不仅考虑了上面的分配代码,还考虑了其他几个通常执行的路径,比如检索当前存储在Buffer中的数据的总字节数。对于这些关键路径中的每一个,我们试图找出在普通情况下必须执行的最小的代码量。然后,我们围绕这些关键路径设计了该类的其他部分。我们还应用了本书的设计原则来简化该类的总体结构。例如,我们消除了浅层,创建了更深的内部抽象。重构后的类比原来的版本小了20%(1476行代码,而原来的版本是1886行)。
图20.3显示了在Buffer中分配内部空间的新关键路径。新的代码不仅更快,而且更容易阅读,因为它避免了浅层抽象。整个路径由一个方法处理,它使用一个测试来排除所有的特殊情况。新代码引入了一个新的实例变量extraAppendBytes
,以简化关键路径。这个变量记录了在缓冲区的最后一个数据块之后还有多少未使用的空间。如果没有可用空间,或者Buffer中的最后一个块不是内部块,或者Buffer中根本不包含任何块,那么extraAppendBytes
就是零。图20.3中的代码代表了处理这种常见情况的尽可能少的代码量。
注意:对totalLength
的更新可以通过在需要时从独立的块中重新计算Buffer的总长度来消除。然而,这种方法对于有很多块的大型Buffer来说开销是很大的,而且获取Buffer的总长度是另一种常见的操作。因此,我们选择为alloc
增加少量的额外开销,以确保Buffer
的长度总是立即可用。
新代码的速度是旧代码的两倍:使用内部存储向Buffer追加一个1字节的字符串的总时间从8.8 ns下降到4.75 ns。许多其他的Buffer操作也因为修订而加快了速度。例如,构造一个新的Buffer,在内部存储中追加一个小块,然后销毁Buffer的时间从24ns下降到12ns。