小马的世界

读书笔记-软件设计的哲学【20】面向性能进行设计

2026-06-28 · 22 min read

到目前为止,本书对软件设计的讨论一直聚焦于 复杂性(complexity) ;我们的目标始终是让软件尽可能简单、尽可能容易理解。但如果你正在开发的是一个 必须足够快 的系统呢?性能方面的考虑应该如何影响设计过程?

本章将讨论如何在 不牺牲良好设计 的前提下,实现高性能。最重要的观点仍然是 简单性 :简单不仅能改善系统设计,而且通常也会让系统运行得更快。

如何思考性能

首先需要回答的问题是:“ 在正常的软件开发过程中,究竟应该多大程度上关注性能?

如果你试图把每一条语句都优化到最快速度,那么开发速度会明显变慢,同时还会引入大量毫无必要的复杂性。此外,许多所谓的“优化”实际上根本不会带来性能提升。

另一方面,如果完全忽略性能问题,也很容易导致整个代码中积累大量低效实现,最终系统的运行速度可能比本应达到的性能慢 5~10 倍。在这种“ 千刀万剐式死亡(death by a thousand cuts) ”的情况下,后期几乎很难再通过某一次优化把性能补救回来,因为已经不存在某一个单独的改动能够产生决定性的效果。

最好的做法介于这两个极端之间:具备一定的性能知识,在设计时优先选择那些 天然高效(naturally efficient) 、同时又保持简洁清晰的方案。关键在于培养一种意识,知道哪些操作在本质上代价昂贵。下面列举一些如今仍然属于高成本的操作:

  • 网络通信(Network communication):即使是在同一个数据中心内,一次消息往返(round-trip)的延迟通常也需要 10~50 μs,相当于执行了数万条 CPU 指令。跨广域网(WAN)的往返通信则可能需要 10~100 ms

  • 访问二级存储(I/O to secondary storage):磁盘 I/O 通常需要 5~10 ms,相当于数百万条指令的时间;Flash 存储约需 10~100 μs。新出现的一些非易失性存储器(Non-Volatile Memory)最快可以达到 1 μs 左右,但即便如此,也仍然相当于约 2000 条指令的执行时间。

  • 动态内存分配(Dynamic memory allocation):例如 C 中的 malloc,以及 C++、Java 中的 new。这类操作通常会在内存分配、释放以及垃圾回收等方面带来明显的额外开销。

  • 缓存未命中(Cache miss):从 DRAM 读取数据并加载到 CPU Cache 中,通常需要数百条指令的时间。在很多程序中,整体性能受缓存未命中的影响甚至不亚于计算本身。

了解哪些操作昂贵,最好的方式就是编写 微基准测试(micro-benchmarks) ——即只测量某一种独立操作开销的小程序。

在 RAMCloud 项目中,我们专门开发了一个简单的微基准测试框架。虽然开发这个框架花费了几天时间,但之后新增一个新的微基准测试通常只需要五到十分钟。借助这一框架,我们已经积累了数十个微基准测试,它们既帮助我们理解 RAMCloud 所依赖库的性能,也用于评估项目中新编写类的性能表现。

当你大致了解哪些操作昂贵、哪些操作便宜之后,就可以在设计时优先选择成本较低的实现方式。很多情况下,更高效的方案与较慢的方案一样简单。

例如,在保存一组需要通过键进行查找的大量对象时,你可以使用 哈希表(hash table)有序映射(ordered map) 。两者都是标准库中常见的数据结构,都易于使用且设计良好。然而,哈希表通常可以快 5~10 倍。因此,只要不需要有序映射所提供的排序特性,就应该优先使用哈希表。

再举一个例子,假设你需要在 C 或 C++ 中分配一个结构体数组。

有两种实现方式。一种方式是数组中存放的是 指向结构体的指针 ,这样就必须先为指针数组分配内存,再分别为每一个结构体单独分配内存。另一种方式则是 直接把结构体连续存储在数组中 ,这样整个数组只需要进行一次大块内存分配即可,效率要高得多。

如果提高效率的唯一办法是不断增加复杂性,那么这个选择就比较困难了。但如果更高效的设计仅仅增加了一点点复杂度,而且这些复杂性都隐藏在实现内部,并不会影响任何接口,那么通常是值得采用的(但要注意:复杂性是会不断累积的)。

如果更快的设计需要引入大量实现复杂性,或者导致接口变得更加复杂,那么更好的做法通常是先采用简单方案,等性能真正成为问题时再进行优化。

不过,如果已经有充分证据表明某个场景对性能要求非常高,那么一开始就采用性能更好的实现也是合理的。

在 RAMCloud 项目中,我们的总体目标之一就是尽可能降低客户端通过数据中心网络访问存储系统时的延迟。

因此,我们决定采用专用网络硬件,使 RAMCloud 可以绕过操作系统内核(kernel),直接与网卡(Network Interface Controller,NIC)进行通信和收发数据包。尽管这一方案增加了实现复杂性,我们仍然这样做,因为之前的测量结果已经证明,基于内核的网络通信无法满足我们的性能要求。

除了这一部分之外,RAMCloud 的其余大多数系统都能够按照简单性的原则进行设计;而把这一项关键问题彻底做好,也让其他很多地方都变得更加简单。

总体来说,简单的代码往往比复杂的代码运行得更快

如果能够消除各种特殊情况(special cases)和异常处理(exceptions),那么系统就无需再编写额外代码去检查这些情况,自然也会运行得更快。

此外, 深类(deep classes) 通常比 浅类(shallow classes) 效率更高,因为每一次方法调用能够完成更多工作。而浅类会导致更多层之间的调用,每增加一层调用都会带来额外的性能开销。

在修改之前(以及之后)都要进行测量

假设即使按照前面的原则完成了设计,你的系统仍然太慢。这时,人们很容易凭借自己的直觉,立刻开始进行各种性能优化。

不要这样做!

程序员对于性能的直觉往往并不可靠,即使经验丰富的开发者也是如此。如果你根据直觉盲目修改代码,很可能会把大量时间浪费在那些实际上不会提升性能的地方,并且还会让系统变得更加复杂。

在做任何修改之前,都应该先测量系统当前的行为。

这样做有两个目的。

第一,测量能够帮助你找出 哪些地方的性能优化最值得投入

仅仅测量整个系统的总体性能是不够的。总体性能只能告诉你系统太慢,却无法告诉你 为什么 慢。你需要进一步深入测量,详细分析哪些因素真正影响了整体性能;目标是找出 少数几个真正消耗大量时间、并且具有改进价值的具体位置

第二,测量还能提供一个 基线(baseline) ,这样在完成优化之后,你就能够重新测量系统,确认性能是否真的得到了改善。

如果修改没有带来 可测量(measurable) 的性能提升,那么就应该撤销这些修改(除非它们同时让系统变得更加简单)。如果一个优化没有带来显著的速度提升,就没有理由为了它而保留额外增加的复杂性。

围绕关键路径进行设计

现在假设你已经认真分析过系统性能,并找到了某一段足够缓慢、已经影响整个系统性能的代码。

提升性能的最佳方法,是进行一种 根本性的(fundamental) 改进,例如增加缓存,或者采用另一种算法(例如使用平衡树而不是链表)。

RAMCloud 中绕过内核进行网络通信,就是这样一种根本性的改进。

如果能够找到这样的根本解决方案,那么就可以利用前几章介绍的设计原则,把它自然地融入整个系统设计之中。

遗憾的是,有时候并不存在这样的根本性解决方案。

这就引出了本章真正关注的问题: 如何重新设计已有代码,使其运行得更快。

这种做法应该始终作为最后的手段,而不是默认选择。不过,在某些情况下,它确实能够带来非常明显的性能提升。

这里最核心的思想就是:

围绕关键路径(critical path)来设计代码。

首先,问自己一个问题:

为了完成最常见情况下的目标任务,究竟最少需要执行多少代码?

先暂时忽略现有代码,假设你正在重新编写一个全新的方法,它只负责实现 关键路径 ——也就是最常见执行场景——所需的最少功能。

现有代码中很可能充满了各种特殊情况,因此在这个思考过程中,把它们全部忽略。

当前代码也许需要经过多个方法调用才能完成任务;设想一下,如果能够把所有关键路径相关代码集中到一个方法中,会不会更好?

当前代码可能还使用了各种不同的变量和数据结构;此时只考虑关键路径真正需要的数据,并假设采用任何一种最有利于关键路径的数据结构。

例如,为了减少关键路径上必须执行的代码,也许把多个变量合并成一个值会更加合理。

假设你可以彻底重新设计整个系统,只为了让关键路径上的代码量达到最少。

我们把这段理想中的实现称为“理想代码(the ideal)”。

这种理想代码很可能与你当前的类结构发生冲突,也未必具有现实可行性,但它提供了一个很好的目标:它代表了关键路径上 最简单、最快速 的实现方式。
它所能达到的代码。

下一步,就是寻找一种新的设计,使它在保持整体结构清晰的前提下,尽可能接近这种理想实现。你可以运用本书前面章节介绍的所有设计思想,但需要额外遵循一个约束: 尽量保持理想代码(ideal code)的大部分结构不变

为了保持抽象层次的整洁,你可能不得不在理想代码中加入少量额外代码。例如,如果代码涉及哈希表查找,那么增加一次对通用哈希表类的方法调用是完全可以接受的。

根据我的经验,几乎总能够找到一种既干净、又简单,同时又非常接近理想实现的设计。

在这一过程中,最重要的工作之一,就是 将各种特殊情况(special cases)从关键路径(critical path)中移除

代码之所以变慢,往往是因为它需要处理各种不同的情况,因此代码结构逐渐演变成便于处理所有情况的形式。而每增加一种特殊情况,就意味着关键路径上多出一点代码——例如额外的条件判断(conditional statements)或方法调用(method calls)。这些额外代码都会让程序稍微慢一点。

因此,在为了性能重新设计代码时,应尽可能减少需要检查的特殊情况数量。

理想情况下,在代码开头只需要一个 if 判断,就能一次性识别所有特殊情况。在正常情况下,只需完成这一项检查,随后关键路径便可以一路执行,而无需再针对特殊情况进行任何额外判断。

如果最初的检查失败(说明出现了某种特殊情况),程序便跳转到关键路径之外的另一段代码专门处理这些情况。

由于特殊情况出现得较少,因此 特殊情况代码更重要的是保持简单,而不是追求极致性能

示例:RAMCloud Buffer

下面来看一个例子。在 RAMCloud 存储系统中,Buffer 类经过优化之后,使最常见操作的执行速度提升了约 2 倍

RAMCloud 使用 Buffer 对象来管理**可变长度(variable-length)**的内存数组,例如远程过程调用(RPC)的请求和响应消息。

Buffer 的设计目标,是减少内存复制(memory copying)和动态内存分配(dynamic storage allocation)带来的额外开销。

从逻辑上来看,一个 Buffer 表现得像是一段连续的字节数组;但为了提高效率,它允许底层存储由多个**彼此不连续(discontiguous)**的内存块(chunks)组成,如图 20.1 所示。

Buffer 是通过不断追加(append)数据块(chunks)构建出来的。每一个 chunk 都分为**外部(external)内部(internal)**两种。

如果 chunk 是 external,那么它所对应的内存由调用者拥有,Buffer 只是保存一个引用。通常,大型数据块都会采用 external chunk,以避免发生内存复制。

如果 chunk 是 internal,则这块内存由 Buffer 自己拥有;调用者提供的数据会被复制到 Buffer 的内部存储空间中。

图 20.1: Buffer 对象利用多个内存块(memory chunks)的集合,对外表现为一段连续的字节数组。内部 chunk 由 Buffer 拥有,并会在 Buffer 销毁时自动释放;外部 chunk 不属于 Buffer 所拥有。

每个 Buffer 都包含一块内置存储区(built-in allocation),用于保存内部 chunk。

如果这块空间耗尽,Buffer 就会继续申请额外的内存(extra allocations),这些内存同样会在 Buffer 被销毁时释放。

对于较小的数据块,由于复制成本几乎可以忽略,因此通常会采用内部 chunk。

图 20.1 展示了一个包含 5 个 chunkBuffer:第一个 chunk 是内部 chunk,中间两个是外部 chunk,最后两个则是内部 chunk。

Buffer 类本身就是一种根本性的优化(fundamental fix),因为如果没有它,许多昂贵的内存复制将不可避免。

例如,在组装一个响应消息时,它包含一个较小的消息头(header)以及 RAMCloud 存储系统中的一个大型对象。

RAMCloud 会构造一个由两个 chunk 组成的 Buffer

第一个 chunk 是内部 chunk,用于保存消息头;第二个 chunk 是 external chunk,它直接引用 RAMCloud 存储系统中的对象数据。

这样,整个响应消息就可以直接在 Buffer 中组织完成,而无需复制那个大型对象。

除了采用这种允许非连续 chunk 的根本性设计之外,我们最初并没有刻意优化 Buffer 类的实现。

然而,随着时间推移,我们发现 Buffer 被越来越广泛地使用。例如,每一次远程过程调用(RPC)的执行过程中,至少都会创建 4 个 Buffer

最终,我们意识到,提高 Buffer 的实现效率,很可能会对整个系统性能产生明显影响,因此决定进一步优化 Buffer 类。

Buffer 最常见的操作,是利用一个内部 chunk 为一小段新数据分配空间。

例如,在构造请求或响应消息头时,就会发生这种情况。

因此,我们决定将这一操作作为整个优化工作的关键路径(critical path)

在最简单的情况下,只需要扩展 Buffer 中最后一个已有的 chunk,就可以完成空间分配。

不过,这只有在最后一个 chunk 本身是 internal,并且其分配空间还有足够剩余来容纳新数据时才成立。

理想情况下,代码只需要进行一次检查,确认是否满足上述条件;如果满足,就直接调整最后一个 chunk 的大小即可。

使用内部 chunk 在 Buffer 尾部分配新空间的原始代码

char* Buffer::alloc(int numBytes)
{
    char* data = allocateAppend(numBytes);
    Buffer::Chunk* lastChunk = this->chunksTail;
    if ((lastChunk != NULL && lastChunk->isInternal()) &&
        (data - lastChunk->length == lastChunk->data)) {

        // 快速路径:扩展已有的 Chunk
        lastChunk->length += numBytes;
        this->totalLength += numBytes;
    } else {

        // 使用新分配的数据创建一个新的 Chunk。
        append(data, numBytes);
    }

    return data;
}

// 在 Buffer 尾部分配新的空间;
// 如果最后一个 allocation 的末尾还有可用空间,则直接使用;
// 否则创建一个新的 allocation。
// 返回指向新分配空间的指针。
char* Buffer::allocateAppend(int size) {
    void* data;
    if (this->allocations != NULL) {
        data = this->allocations->allocateAppend(size);
        if (data != NULL) {

            // 快速路径
            return data;
        }
    }

    data = newAllocation(0, size)->allocateAppend(size);
    assert(data != NULL);
    return data;
}

// 尝试在现有 allocation 的尾部分配空间。
// 如果空间不足,则返回 NULL;
// 否则返回指向新空间的指针。
char* Buffer::Allocation::allocateAppend(int size) {
    if ((this->chunkTop - this->appendTop) < size)
        return NULL;

    char *retVal = &data[this->appendTop];
    this->appendTop += size;
    return retVal;
}

优化后的关键路径代码

char* Buffer::alloc(int numBytes)
{
    if (this->availableAppendBytes >= numBytes) {

        // 当前最后一个 Chunk 后面仍然有剩余空间,
        // 因此可以直接在那里分配新的区域。
        Buffer::Chunk* chunk = this->lastChunk;
        char* result = chunk->data + chunk->length;

        chunk->length += numBytes;
        this->availableAppendBytes -= numBytes;
        this->totalLength += numBytes;

        return result;
    }

    // 需要创建一个新的 Chunk。
    ...
}

图 20.2 展示了关键路径的原始实现,它从 Buffer::alloc 方法开始。

在最快情况下,Buffer::alloc 会调用 Buffer::allocateAppend,后者又进一步调用 Buffer::Allocation::allocateAppend

从性能角度来看,这段代码存在两个问题。

第一个问题是,它会分别检查多个特殊情况,而且有些检查还是重复的。

首先,Buffer::allocateAppend 会判断 Buffer 当前是否已经存在 allocation。

随后,代码又两次检查当前 allocation 是否还有足够空间容纳新数据:第一次发生在 Buffer::Allocation::allocateAppend 中,第二次则发生在 Buffer::allocateAppend 对其返回值进行判断时。

此外,代码并没有优先尝试扩展最后一个 chunk,而是直接重新分配新的空间,完全没有考虑最后一个 chunk 是否可以继续利用。

之后,Buffer::alloc 又会检查这块新分配的空间是否恰好与最后一个 chunk 相邻;如果相邻,则再把它与最后一个 chunk 合并。

这些逻辑都会引入额外的判断。

总体来看,这段关键路径上的代码一共需要检查 6 种不同的条件

原始代码的第二个问题,是它拥有过多的层次,而且这些层次都属于浅层抽象(shallow abstractions)

这既是一个性能问题,也是一个设计问题。

关键路径除了最初调用 Buffer::alloc 之外,还需要额外经历两次方法调用。

每增加一次方法调用,都需要额外的执行时间;而且其中某些调用的返回值还必须由调用者再次检查,这又引入了新的特殊情况。

第 7 章曾讨论过:随着抽象层次不断向上传递,各层接口通常应该发生变化。

然而,图 20.2 中的三个方法几乎拥有完全相同的方法签名,并且提供的几乎也是同一种抽象,这本身就是一个危险信号(red flag)。

其中,Buffer::allocateAppend 几乎只是一个简单的透传(pass-through)方法;它唯一真正完成的工作,就是在必要时创建一个新的 allocation。

这些额外层次不仅让代码变慢,也让代码变得更加复杂。

为了解决这些问题,我们重新设计了 Buffer 类,使整个设计围绕**最重要的性能关键路径(performance-critical paths)**展开。

我们不仅重新考虑了上面介绍的内存分配路径,还分析了其他几个经常执行的重要路径,例如获取 Buffer 当前保存的总字节数。

首先,我们识别出在最常见情况下必须执行的最少代码

随后,我们围绕这些关键路径重新设计整个类。同时,我们也运用了本书介绍的设计原则,对整个类进行了简化。例如,我们消除了浅层抽象(shallow layers),构建了更加深入(deeper)的内部抽象,并减少了需要检查的特殊情况数量。

重构后的类比原始版本缩小了 20%(1476 行代码,而原来是 1886 行)。

图 20.3 展示了在 Buffer 中为内部空间分配内存的新关键路径。

新代码不仅运行更快,而且也更容易阅读,因为它避免了浅层抽象。

整个关键路径都集中在一个方法中完成,并且只使用一次判断就排除了所有特殊情况。

新代码引入了一个新的实例变量 availableAppendBytes,用于简化关键路径。

这个变量记录的是 Buffer 最后一个 chunk 后方还剩余多少可用空间

如果没有可用空间、或者 Buffer 最后一个 chunk 不是内部 chunk、或者 Buffer 根本没有任何 chunk,那么 availableAppendBytes 都会等于 0

也就是说,原本需要分别检查的三种特殊情况,现在只需要检查一次 availableAppendBytes 即可全部覆盖。

图 20.3 中的代码代表了在存在可用空间这一最常见情况下,完成任务所需的最少代码量

注意: 更新 totalLength 这一操作,本来也可以取消,而是在需要时根据各个 chunk 的长度重新计算整个 Buffer 的总长度。

然而,对于包含大量 chunk 的大型 Buffer 来说,这种重新计算的开销会很高;而获取 Buffer 总长度本身又是一项非常常见的操作。

因此,我们最终选择让 alloc 多承担一点点额外开销,以保证 Buffer 的总长度始终可以立即获得。

新代码的速度约为旧代码的 两倍

例如,使用内部存储向 Buffer 追加一个 1 字节字符串时,总耗时从 8.8 ns 降低到了 4.75 ns

由于这次修改,许多其他 Buffer 操作也获得了性能提升。

例如,创建一个新的 Buffer、向内部存储追加一个较小的数据块,以及销毁 Buffer,这些操作的耗时都从 24 ns 降低到了 12 ns

总结

本章最重要的总体结论是:

优秀的软件设计与高性能并不矛盾,它们是可以兼得的。

Buffer 类的重写,在将设计进一步简化、代码规模缩减 20% 的同时,还使性能提升了 2 倍

复杂代码往往运行缓慢,因为它会执行大量多余或重复的工作。

相反,如果你编写的是干净、简单的代码,那么系统通常已经足够快,以至于根本不需要过多担心性能问题。

而在那些确实需要进行性能优化的少数场景中,关键原则依然是 简单性(simplicity)

找到那些 对性能影响最大的关键路径(critical paths) ,然后尽可能让它们变得简单。