读前:这一章要回答什么

系统必须快的时候,怎样拿到性能,又不把前面十九章辛苦换来的简洁设计赔进去?

前面所有章节都围绕一个目标:让软件尽可能简单、好懂。很多 C/C++ 程序员读到这里会有一个疑问:这些主张是不是只适合"不在乎快慢"的代码?深模块多了一层调用,通用接口多了一层判断,系统还能快吗?

这一章正面回答这个疑问,而且答案有点出人意料:作者认为简洁和高性能并不冲突,简单的代码通常反而更快。复杂的代码慢,往往是因为它做了多余或重复的工作。全章按一条时间线展开:平时怎样选设计(20.1)→ 系统真的慢了先做什么(20.2)→ 找不到根本性修复时怎样改写一段热点代码(20.3),最后用作者自己的存储系统 RAMCloud 里的一个类做完整示范(20.4)。

它和第 21 章是一对:性能是"什么最重要"的一个具体例子。当性能真的重要时,设计就该围绕它来组织。

  1. 20.1 节那份"昂贵操作"清单:注意作者除了给绝对时间,还常常换算成"相当于多少条指令的时间"。同一节后半段有一条"什么时候值得为性能增加复杂度"的分级规则,读时把它整理成三种情况。
  2. 20.2 节的最后两句很容易被跳过:改动如果没有带来可测量的提升,就撤回,除非它让系统更简单。想想你见过多少"优化"在没测过的情况下留在了代码里。
  3. 20.3~20.4 节:先读懂"理想代码"是怎么构造出来的,再去数一数 Buffer 例子里原来的关键路径检查了几个条件,新代码又靠什么一次排除了三种特殊情况。还要留意关于 totalLength 的那段说明:作者为什么故意在关键路径上多留了一点工作。

核心观点

走中间路线

逐条语句抠性能会拖慢开发、增加复杂度,很多"优化"也没用;完全不管性能,又会让低效散落各处,最后无从下手。

选"天然高效"的设计

天然高效(naturally efficient):知道哪些操作贵,就能在同样简洁的方案里挑便宜的那个:不需要顺序时用哈希表,结构体直接放进数组而不是放指针。

先测量,改完再测

直觉不可靠,老手也一样。测量要深入到少数几个具体位置,并留下基线;改完没有可测的提升就撤回。

优先找根本性修复

根本性修复(fundamental fix):加缓存、换算法、换架构,往往比改写代码有效得多,而且可以用前面各章的方法干净地实现。

最后手段:围绕关键路径

关键路径(critical path)指最常见情况下必须执行的代码。先想象这条路径上"最少要执行的代码",即理想代码(the ideal),再找一个最接近它、结构又干净的设计。

一次判断挡住所有特殊情况

理想的关键路径开头只有一个 if;特殊情况去路径之外处理,那里写得简单就好,不必追求快。

简单通常更快

定义掉的特殊情况不需要检查;深类每次调用做的事更多,浅层越多,层间穿越的开销越多。

The most important overall lesson from this chapter is that clean design and high performance are compatible.— 第 20.5 节

哪些操作贵:数量级要心里有数

"天然高效"的前提是知道什么贵、什么便宜。下表把书中 20.1 节给出的数量级整理出来(书中数据),只有一处书中没给数字,由本站补充并标明(本站补充)。

操作书中给出的代价来源设计上的含义(本站整理)
数据中心内网络往返10–50 µs,相当于数万条指令的时间书中数据别在循环里逐个发请求;能批量就批量
广域网往返10–100 ms书中数据跨地域的交互要尽量少、尽量异步
磁盘 I/O通常 5–10 ms,相当于数百万条指令的时间书中数据关键路径上避免同步读写磁盘
闪存 I/O10–100 µs书中数据比磁盘快两个数量级,但仍是网络往返级别
新型非易失内存可能快到 1 µs,但仍约等于 2000 条指令的时间书中数据"很快的存储"也远比内存访问慢
动态内存分配书中只说分配、释放和垃圾回收都有明显开销,没有给数字本站补充本站在一台笔记本上测得逐个 make_unique 约 40 ns/个(见下文微基准,含释放和遍历)
缓存未命中从 DRAM 取数据到片上缓存,要几百条指令的时间书中数据数据要连续摆放;很多程序的性能由缓存未命中和计算一起决定

表里最有用的不是某个具体数字,而是它们之间差了多少个数量级。人对"微秒"和"毫秒"没有直觉,下面这个小工具把它们放到同一根对数轴上,还可以换成"如果一条指令花 1 秒"的人类时间来感受。

交互

延迟刻度尺

每一行是一类操作,横条覆盖书中给出的范围。点一行看它在设计上意味着什么;切到线性刻度,看看大部分操作会"消失"成什么样。
—
选中的操作
—
约等于多少条指令
—
最慢 / 最快(数量级)

换算方法:书中说 1 µs 约等于 2000 条指令的时间,本站据此取 1 条指令 ≈ 0.5 ns;"几百条指令"按 200–500 条画;动态内存分配的范围是本站粗估(书中没有数字)。这些只是建立直觉用的数量级,硬件一换数字就会变,所以作者才强调要自己写微基准去测。

这张表的大部分行,OSTEP 都用整章讲过它们为什么这么慢:磁盘驱动器里的寻道和旋转延迟,闪存 SSD 的读写特性,TLB 与缓存局部性,分布式系统里的网络往返。OSTEP 讲的是这些代价从哪来,这一章讲的是写代码时怎样提前绕开它们。

心智模型:从"选设计"到"改热点"

20.1~20.3 节其实描述了一个有先后顺序的决策过程。大多数时候你只会停在第一步;越往下走,越少见,代价也越高。

平时:在简洁的方案里挑天然高效的 知道什么贵 · 微基准 · 20.1 系统够快吗? 够 到此为止 别凭直觉"优化" 不够 先测量:定位到少数几个具体位置 只测顶层总时间不够 · 同时留下基线 · 20.2 有根本性的修复吗? 有 缓存 / 换算法 / 换架构 用前面各章的方法 把它做干净 没有 最后手段:围绕关键路径重新设计(20.3) ① 无视现有结构,写出常见情况下最少的代码(理想代码) ② 找最接近理想、结构又干净的设计;开头一个 if 分流 改完再测:和基线比 没有可测的提升 → 撤回(除非它让代码更简单)
越往下走越少见:大多数代码只需要第一步。本站自绘示意。

原创例子:一条"关闭的"日志

书中 20.4 节的例子是 RAMCloud 的 Buffer 类:在缓冲区末尾分配一小段空间是它最常见的操作,原来的路径穿过三层签名相同的浅方法,检查了 6 个条件;重写后只剩一个方法、一次判断,速度约快一倍,类的代码量反而少了 20%。建议读原书看完整过程。这里换一个 C++ 程序员几乎都写过的场景,用同样的思路走一遍。

场景:一个键值服务的 GET 路径上有一行 debug 日志,每个请求都会经过。线上 debug 级别是关闭的,所以最常见的情况是"这条日志什么都不用做"。这就是关键路径。

enum Level { kDebug, kInfo, kWarn, kError, kOff };

class Logger {
public:
    void log(Level level, const std::string& module, const std::string& msg) {
        std::string line = formatLine(level, module, msg);  // 时间戳 + 级别 + 模块 + 正文
        if (!shouldLog(level, module)) return;
        for (auto& sink : sinks_) sink->write(line);
    }
private:
    bool shouldLog(Level level, const std::string& module) const {
        if (!enabled_) return false;
        if (sinks_.empty()) return false;
        Level threshold = globalLevel_;
        auto it = moduleLevels_.find(module);               // 每次调用都按名字查一遍
        if (it != moduleLevels_.end()) threshold = it->second;
        return level >= threshold;
    }
    std::string formatLine(Level, const std::string& module, const std::string& msg) const;
    bool enabled_ = true;
    Level globalLevel_ = kInfo;
    std::map<std::string, Level> moduleLevels_;            // 按模块覆盖全局级别
    std::vector<std::unique_ptr<Sink>> sinks_;
};

// 调用处:GET 路径,每个请求都经过
logger.log(kDebug, "kv.get", "lookup key=" + key);

每一行单独看都合理,合起来却很浪费:调用处先拼好消息字符串(第 27 行),进入 log 后又读时钟、拼整行(第 6 行),然后才判断要不要输出;判断本身还要过两个开关、做一次按字符串的 map 查找(第 15 行)。在最常见的情况下,这些工作全部白做。

// 一个模块一个通道。通道在启动时创建一次,之后一直复用。
class LogChannel {
public:
    // 关键路径。线上大多数 debug/info 日志是关闭的,这时只做这一次比较。
    bool enabled(Level level) const {
        return level >= threshold_.load(std::memory_order_relaxed);
    }
    void write(Level level, std::string_view msg);   // 前提:enabled(level) 为真
private:
    friend class Logger;
    std::string name_;
    // 四个条件合成一个值:总开关关闭或没有输出目标时为 kOff;
    // 否则是本模块的覆盖级别,没有覆盖就用全局级别。
    std::atomic<Level> threshold_{kOff};
};

// 注意:expr 只在该级别打开时才求值,不要在里面写有副作用的表达式。
#define LOG(ch, level, expr) \
    do { if ((ch).enabled(level)) (ch).write((level), (expr)); } while (0)

// 关键路径之外:配置变化时(很少发生)重算所有通道。写得直白即可。
void Logger::recomputeThresholds() {
    for (auto& ch : channels_) {
        Level t = globalLevel_;
        if (auto it = moduleLevels_.find(ch->name_); it != moduleLevels_.end()) t = it->second;
        if (!enabled_ || sinks_.empty()) t = kOff;
        ch->threshold_.store(t, std::memory_order_relaxed);
    }
}

// 调用处
static LogChannel& kvGetLog = logger.channel("kv.get");
LOG(kvGetLog, kDebug, "lookup key=" + key);   // 关闭时连字符串都不拼

做法和书中的思路一致:先写出"理想代码"(关闭时只比较一次),再围绕它调整数据结构。四个条件(总开关、有无输出目标、全局级别、模块覆盖)被预先合成一个 threshold_,一次比较就能排除所有"不用输出"的情况;按名字查找挪到了启动时;格式化挪到了判断之后。特殊情况的处理全在 recomputeThresholds 里,它很少运行,所以写成最直白的样子。

关闭的 debug 日志,关键路径上的工作改之前改之后
条件判断5(开关、输出目标、查找结果、级别,外加 map 内部的字符串比较)1
非内联的函数调用log、formatLine、shouldLog、map::find、读时钟……0
堆分配(拼字符串)通常 2 次以上0
本站实测(见下文微基准)约 90 ns不到 1 ns

代价也要说清楚。第一,新增了一个必须维护的不变量:只要四个输入中的任何一个变了,就必须调用 recomputeThresholds,漏掉一处就是 bug。所以这四个输入的修改应该全部收进 Logger 内部,不能让外面直接改。第二,宏让调用处的求值规则变得不明显(expr 可能根本不被求值),这条必须写进注释(第 18 章说的"不明显的代码")。第三,调用者要先拿到通道对象。对一条每秒执行几十万次的路径,这些代价值得;对一个每分钟跑一次的管理命令,就不值得。

动手测一测:一个最小的微基准

作者说,了解什么贵,最好的办法是写微基准(micro-benchmark):单独测一个操作的代价。下面这个程序测了三件事:有序 map 和哈希表的查找、结构体数组和指针数组、以及上面日志例子的改前改后。复制下来就能编译运行。

展开 bench.cpp(约 100 行)
// bench.cpp —— 几个最小的微基准。g++ -O2 -std=c++17 bench.cpp -o bench && ./bench
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <map>
#include <memory>
#include <random>
#include <string>
#include <unordered_map>
#include <vector>

// 把 f 跑 n 次,返回平均每次的纳秒数
template <class F> double nsPerOp(long n, F f) {
    auto t0 = std::chrono::steady_clock::now();
    for (long i = 0; i < n; i++) f(i);
    std::chrono::duration<double, std::nano> d = std::chrono::steady_clock::now() - t0;
    return d.count() / n;
}

volatile long sink;  // 防止编译器把结果整个优化掉

// 3 号测试用:一条"关闭的"debug 日志,改前与改后(只保留关键路径上的工作)
enum Level { kDebug, kInfo, kWarn, kError, kOff };
struct OldLogger {
    bool enabled = true;
    Level global = kInfo;
    std::map<std::string, Level> moduleLevels{{"kv.put", kDebug}, {"net", kWarn}, {"raft", kInfo}};
    std::vector<int> sinks{1};
    bool shouldLog(Level lv, const std::string& module) const {
        if (!enabled || sinks.empty()) return false;
        Level th = global;
        auto it = moduleLevels.find(module);
        if (it != moduleLevels.end()) th = it->second;
        return lv >= th;
    }
    void log(Level lv, const std::string& module, const std::string& msg) {
        auto ts = std::chrono::system_clock::now().time_since_epoch().count();
        std::string line = std::to_string(ts) + " " + module + " " + msg;  // 先格式化
        if (!shouldLog(lv, module)) return;                                  // 再判断
        sink = (long)line.size();
    }
};
struct LogChannel {
    std::atomic<Level> threshold{kInfo};
    bool enabled(Level lv) const { return lv >= threshold.load(std::memory_order_relaxed); }
};

// 1 号测试:n 个随机键,有序 map 与哈希表各查 1,000,000 次
void lookupBench(long n) {
    std::mt19937_64 rng(42);
    std::vector<long> keys(n);
    for (auto& k : keys) k = (long)(rng() >> 1);
    std::map<long, long> tree;
    std::unordered_map<long, long> hash;
    for (long k : keys) { tree[k] = k; hash[k] = k; }
    std::shuffle(keys.begin(), keys.end(), rng);
    const long Q = 1'000'000;
    double tMap  = nsPerOp(Q, [&](long i) { sink = tree.find(keys[i % n])->second; });
    double tHash = nsPerOp(Q, [&](long i) { sink = hash.find(keys[i % n])->second; });
    std::printf("%8ld 个键:map %6.1f ns,unordered_map %5.1f ns\n", n, tMap, tHash);
}

int main() {
    // 1. 有序 map 与哈希表:随机查找。规模不同,差距也不同
    lookupBench(1'000);
    lookupBench(1'000'000);

    // 2. 结构体数组与指针数组:分配 + 遍历 N 个元素
    const long N = 1'000'000;
    struct Order { long id, qty, price; };
    double tPtr = nsPerOp(10, [&](long) {
        std::vector<std::unique_ptr<Order>> v;
        v.reserve(N);
        for (long i = 0; i < N; i++) v.push_back(std::make_unique<Order>(Order{i, 1, 2}));
        long s = 0; for (auto& o : v) s += o->qty * o->price;
        sink = s;
    }) / N;
    double tVal = nsPerOp(10, [&](long) {
        std::vector<Order> v;
        v.reserve(N);
        for (long i = 0; i < N; i++) v.push_back(Order{i, 1, 2});
        long s = 0; for (auto& o : v) s += o.qty * o.price;
        sink = s;
    }) / N;
    std::printf("vector<unique_ptr<Order>>:%5.1f ns / 元素\n", tPtr);
    std::printf("vector<Order>:            %5.1f ns / 元素\n", tVal);

    // 3. 一条关闭的 debug 日志:改前 vs 改后
    OldLogger oldLog;
    LogChannel ch;
    double tOld = nsPerOp(N, [&](long i) { oldLog.log(kDebug, "kv.get", "lookup key=" + std::to_string(i)); });
    double tNew = nsPerOp(N, [&](long i) {
        if (ch.enabled(kDebug)) sink = (long)("lookup key=" + std::to_string(i)).size();
    });
    std::printf("关闭的日志,改前:%5.1f ns\n", tOld);
    std::printf("关闭的日志,改后:%5.1f ns\n", tNew);
}
g++ -O2 -std=c++17 bench.cpp -o bench && ./bench
    1000 个键:map    7.8 ns,unordered_map   3.9 ns
 1000000 个键:map  821.5 ns,unordered_map  45.9 ns
vector<unique_ptr<Order>>: 41.5 ns / 元素
vector<Order>:              1.3 ns / 元素
关闭的日志,改前: 93.8 ns
关闭的日志,改后:  0.4 ns

示例输出,你的机器上数字会不同(本站在 AMD Ryzen 7 8745H、WSL2、g++ 14.2 上运行;多跑几次,个别数字的波动可达 20%)。

几点值得停下来想一想:

边界与反方:什么时候别生搬硬套

"过早优化是万恶之源"常被用来为"完全不管性能"辩护。作者两个极端都不赞成:逐条语句抠性能会拖慢开发、徒增复杂度,而且很多"优化"根本没用;完全不管,低效会散落在各处,系统很容易慢 5~10 倍,到时候找不到任何一个改了就见效的地方。本站补充:Knuth 那句话的原文本来也有后半句:不要错过那关键的 3%。两人的立场其实很接近。

作者给了一个分级规则:如果更快的方案只多一点复杂度,而且藏在实现里、不影响接口,可能值得(但要记住复杂性是一点点累积的);如果它会带来大量实现复杂度,或者让接口变复杂,就先用简单方案,真出了性能问题再说;如果有清楚的证据表明这里的性能至关重要,那就一开始就做快的方案。RAMCloud 一开始就决定绕过内核直接和网卡通信,依据是之前的测量已经表明走内核太慢。

作者明确说,围绕关键路径重写现有代码应当是最后手段,不该经常发生。先找根本性的修复(加缓存、换算法、换架构)。另外,"理想代码"只是目标,不是要原样照抄:为了保持干净的抽象,在理想代码上加一点东西是可以的,例如多一次对通用哈希表类的方法调用。

Buffer 的例子里,作者故意在分配路径上多维护一个总长度,因为"取总长度"也是常见操作,不能为了一条路径饿死另一条。上面日志例子里的 recomputeThresholds 也是同一类取舍:把成本挪到很少执行的路径上。本站补充:作者说的是简单的代码"往往"更快,不是定律。SIMD、无锁结构这类优化天生复杂;如果测量证明非用不可,就把它们关进一个深模块,让复杂性只出现在一个地方。

以下不在书里,来自书中给出的读者讨论组。书里有一个"逐个取出查询结果"的接口例子(13.5 节)。有读者担心:一次只取一个对象,在分布式存储上岂不是每取一个就要一次远程调用?应当改成批量接口。Ousterhout 回答说这是个误会(2021-06):这个接口在 RAMCloud 里就是这样实现的,而实现在底下做了两件事:只要可能,一次远程调用取回多个对象;同时向多台索引服务器和存储服务器并发发请求。机制不简单,但对使用者完全不可见。Ousterhout 拿带缓冲的 I/O 作类比:程序逐字符读输入时,很少真的每次都进内核。

这是本章"简单的设计通常也快"的一个补充:批量和并发是实现的事,不必长在接口上。先问"能不能在简单接口的下面把它做快",做不到再考虑把批量暴露给使用者。这也是第 8 章"把复杂性往下拉"在性能上的用法。

练一练

下面三道题来自训练场。先自己判断再看解析,答题记录会进入间隔复习。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

动手:项目任务

读完本章和第 18 章后,去做 minikv 的任务 m08。minikv 的一次 GET 命中要穿过 Server::handleGet → Database::get → Store::get → Index::get 四层。先用 make bench 测出这条路径的时间花在哪儿,再围绕它重新设计:去掉多余的工作(同一个键查两次、值被拷贝两次、昂贵的格式化),同时把带 bool 参数的调用、藏着副作用的方法改成一目了然的代码。提示:测量结果可能和你的直觉不一样,花时间最多的未必是查找,那几层透传在 -O2 下也许根本测不出开销。这正是 20.2 节说的"先测量,别靠猜"。改完再测一次,和基线比。

任务 m08:先用 make bench 找出 GET 关键路径上的时间花在哪儿,再去掉多余的工作,并把不明显的代码改清楚;改完再测。详见 任务页。

cd project/minikv && make bench

自测题

关于"平时写代码要不要考虑性能",作者的立场最接近哪一项?
作者反对两个极端:逐条优化会增加复杂度且常常无效;完全不管会导致"千刀万剐"式的整体低效,事后很难补救。中间路线是靠对昂贵操作的了解来做选择。
动手优化之前先测量,作者给出的两个目的是?
只测顶层只能告诉你"慢",不能告诉你"为什么慢"。要往下测到少数几个具体位置。基线则用来检验改动是否真的有效。
你为一个热点函数手写了一段更复杂的实现,重新测量后发现差别在噪声范围内。按本章的建议应该?
没有显著加速,就没有理由保留额外的复杂度。唯一的例外是改动让代码变简单了,那它本身就有价值。
围绕关键路径重新设计时,"理想代码"指的是什么?
理想代码是一个目标:假设可以把相关代码放进一个方法、随意选择最方便的数据结构,常见情况最少要做什么。它可能和现有类结构冲突,下一步是找一个尽量接近它、结构又干净的设计。
为什么作者说深类通常也比浅类更快?
每穿过一层都有开销,而且上层常常还要检查下层的返回值,又多出一个需要判断的情况。Buffer 例子里原来的三层浅方法正是如此。
RAMCloud 一开始就接受了绕过内核联网带来的复杂度。这说明了什么?
这是分级规则里的第三种情况。作者同时指出,把这一件大事做对之后,系统的其余部分大多可以按简洁来设计。
在关键路径上处理特殊情况,本章推荐的结构是?
常见情况只付出一次判断的代价。特殊情况本来就少见,不必为它们追求速度,可以按"最简单"来组织。实现上常常需要像 Buffer 的 availableAppendBytes 或上面日志例子的 threshold_ 那样,引入一个把多个条件合在一起的变量。

小结与读后一句话

第 21 章会把这一章当成例子推广:性能只是"重要的东西"之一。一旦确定什么最重要,设计就该围绕它来组织,并把不重要的东西藏起来。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你见过的某次"优化"。